mobile-crm-detail-filters.test.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { compile, computed, createSSRApp, reactive, ref } from 'vue'
  5. import { renderToString } from 'vue/server-renderer'
  6. import { parse } from '@vue/compiler-sfc'
  7. import ts from 'typescript'
  8. import { isFrameworkContractType } from '../src/views/crm/contract/contractType'
  9. import { displayFrameworkMoney } from '../src/views/crm/contract/frameworkProductPricing'
  10. const root = new URL('../src/views/m/crm/', import.meta.url)
  11. const detail = 'contract/detail.vue'
  12. const lists = ['quote/MpQuoteList.vue', 'contract/MpContractList.vue']
  13. function source(file: string) {
  14. const { descriptor } = parse(readFileSync(new URL(file, root), 'utf8'))
  15. return {
  16. descriptor,
  17. ast: ts.createSourceFile(file, descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true)
  18. }
  19. }
  20. function functions(file: string, names: string[], bindings: Record<string, any>, prelude = '') {
  21. const { ast } = source(file)
  22. const declarations = ast.statements.filter(
  23. (item) => ts.isFunctionDeclaration(item) && names.includes(item.name?.text || '')
  24. )
  25. const code = ts.transpile(declarations.map((item) => item.getText(ast)).join('\n'), {
  26. target: ts.ScriptTarget.ESNext
  27. })
  28. return new Function(
  29. ...Object.keys(bindings),
  30. `${prelude}\n${code}\nreturn typeof ${names[0]} === "undefined" ? undefined : ${names[0]}`
  31. )(...Object.values(bindings))
  32. }
  33. function value(file: string, name: string, bindings: Record<string, any>) {
  34. const { ast } = source(file)
  35. const declaration = ast.statements
  36. .filter(ts.isVariableStatement)
  37. .flatMap((item) => item.declarationList.declarations)
  38. .find((item) => item.name.getText(ast) === name)
  39. assert.ok(declaration, `Missing ${name}`)
  40. const code = ts.transpile(`const result = ${declaration.initializer!.getText(ast)}`, {
  41. target: ts.ScriptTarget.ESNext
  42. })
  43. return new Function(...Object.keys(bindings), `${code}\nreturn result`)(
  44. ...Object.values(bindings)
  45. )
  46. }
  47. async function render(fragment: string, bindings: Record<string, any>) {
  48. const compiled = compile(fragment)
  49. return renderToString(createSSRApp({ render: () => compiled.call(bindings, bindings, []) }))
  50. }
  51. function deferred<T>() {
  52. let resolve!: (result: T) => void
  53. let reject!: (error: Error) => void
  54. const promise = new Promise<T>((a, b) => {
  55. resolve = a
  56. reject = b
  57. })
  58. return { promise, resolve, reject }
  59. }
  60. // Execute the installed Vant click handler so identical-value suppression stays covered.
  61. function clickAmountOption(
  62. modelValue: string,
  63. nextValue: string,
  64. emit: (event: string, value: string) => void
  65. ) {
  66. const file = readFileSync(
  67. new URL('../node_modules/vant/es/dropdown-item/DropdownItem.mjs', import.meta.url),
  68. 'utf8'
  69. )
  70. const ast = ts.createSourceFile('DropdownItem.mjs', file, ts.ScriptTarget.Latest, true)
  71. let clickExpression = ''
  72. const visit = (node: ts.Node) => {
  73. if (ts.isVariableDeclaration(node) && node.name.getText(ast) === 'onClick') {
  74. clickExpression = node.initializer!.getText(ast)
  75. }
  76. ts.forEachChild(node, visit)
  77. }
  78. visit(ast)
  79. assert.ok(clickExpression)
  80. new Function('props', 'option', 'emit', 'state', 'disabled', `return ${clickExpression}`)(
  81. { modelValue },
  82. { value: nextValue },
  83. emit,
  84. {},
  85. false
  86. )()
  87. }
  88. function productBindings(contract: Record<string, any>, product: Record<string, any>) {
  89. const formatContractMoney = (amount: any) => `¥${Number(amount || 0).toFixed(2)}`
  90. const productMoney = functions(detail, ['productMoney'], {
  91. contract: ref(contract),
  92. isFrameworkContractType,
  93. displayFrameworkMoney,
  94. formatContractMoney
  95. })
  96. return {
  97. props: { embedded: false, hideProductPrice: false },
  98. contract,
  99. products: [product],
  100. productMoney,
  101. displayCategory: functions(detail, ['displayCategory'], { categoryPaths: ref(new Map()) }),
  102. formatContractMoney,
  103. unitLabel: () => '吨',
  104. formatBrandConstraintLabel: () => ''
  105. }
  106. }
  107. test('contract anchors follow the visible section order', () => {
  108. const tabs = value(detail, 'detailTabs', {})
  109. const anchors = [
  110. ...source(detail).descriptor.template!.content.matchAll(/<section id="([^"]+)"/g)
  111. ]
  112. .map((match) => match[1])
  113. .filter((id) => tabs.some((tab: any) => tab.anchor === id))
  114. assert.deepEqual(
  115. tabs.map((tab: any) => tab.anchor),
  116. anchors
  117. )
  118. })
  119. test('contract scroll highlights the closest section even when anchor declarations are unsorted', () => {
  120. const activeTab = ref('base')
  121. const top: Record<string, number> = { base: -600, products: 50, parties: -300 }
  122. const sync = functions(detail, ['syncActiveTabFromScroll'], {
  123. // Geometry cases assume a loaded current record; identity is covered by navigation tests.
  124. navigationReady: () => true,
  125. activeTab,
  126. contract: ref({ id: '1' }),
  127. scrollMargin: ref(100),
  128. scrollLockUntil: 0,
  129. detailTabs: ['base', 'products', 'parties'].map((key) => ({ key, anchor: key })),
  130. window: { scrollY: 500, innerHeight: 812 },
  131. document: {
  132. scrollingElement: { scrollHeight: 3000 },
  133. getElementById: (id: string) => ({ getBoundingClientRect: () => ({ top: top[id] }) })
  134. }
  135. })
  136. sync()
  137. assert.equal(activeTab.value, 'products')
  138. })
  139. for (const initial of ['products', 'attachments']) {
  140. test(`contract bottom scroll selects the final reachable anchor from ${initial}`, () => {
  141. const activeTab = ref(initial)
  142. const tabs = value(detail, 'detailTabs', {})
  143. const tops: Record<string, number> = {
  144. 'contract-sec-base': -2200,
  145. 'contract-sec-amount': -1600,
  146. 'contract-sec-parties': -1000,
  147. 'contract-sec-products': -300,
  148. 'contract-sec-approval': 400,
  149. 'contract-sec-attachments': 513.0625
  150. }
  151. const viewport = { scrollY: 2270.5, innerHeight: 812 }
  152. const document = {
  153. scrollingElement: { scrollHeight: 3083 },
  154. getElementById: (id: string) => ({ getBoundingClientRect: () => ({ top: tops[id] }) })
  155. }
  156. const sync = functions(detail, ['syncActiveTabFromScroll'], {
  157. // Geometry cases assume a loaded current record; identity is covered by navigation tests.
  158. navigationReady: () => true,
  159. activeTab,
  160. contract: ref({ id: '1' }),
  161. scrollMargin: ref(100),
  162. scrollLockUntil: 0,
  163. detailTabs: tabs,
  164. window: viewport,
  165. document
  166. })
  167. sync()
  168. assert.equal(activeTab.value, 'attachments')
  169. tops['contract-sec-attachments'] = 900
  170. sync()
  171. assert.equal(activeTab.value, 'products', 'an anchor outside the viewport is not forced active')
  172. tops['contract-sec-attachments'] = 513.0625
  173. // Scrolling up must immediately restore the normal closest-probe calculation.
  174. viewport.scrollY = 2200
  175. sync()
  176. assert.equal(activeTab.value, 'products')
  177. // Home must never be treated as the bottom of a short/unscrollable document.
  178. viewport.scrollY = 0
  179. document.scrollingElement.scrollHeight = 812
  180. Object.keys(tops).forEach((id, index) => { tops[id] = index * 100 + 100 })
  181. sync()
  182. assert.equal(activeTab.value, 'base')
  183. })
  184. }
  185. test('contract bottom detection respects the 700ms anchor click lock before updating', () => {
  186. let now = 100
  187. const activeTab = ref('approval')
  188. const sync = functions(detail, ['syncActiveTabFromScroll'], {
  189. // Geometry cases assume a loaded current record; identity is covered by navigation tests.
  190. navigationReady: () => true,
  191. activeTab,
  192. contract: ref({ id: '1' }),
  193. scrollMargin: ref(100),
  194. scrollLockUntil: now + 700,
  195. detailTabs: value(detail, 'detailTabs', {}),
  196. Date: { now: () => now },
  197. window: { scrollY: 2270.5, innerHeight: 812 },
  198. document: {
  199. scrollingElement: { scrollHeight: 3083 },
  200. getElementById: () => ({ getBoundingClientRect: () => ({ top: 513 }) })
  201. }
  202. })
  203. sync()
  204. assert.equal(activeTab.value, 'approval')
  205. now += 700
  206. sync()
  207. assert.equal(activeTab.value, 'attachments')
  208. })
  209. function approvalState(overrides: Record<string, any> = {}) {
  210. const contract = ref({ status: 'approving', ...overrides })
  211. const approvalList = ref<any[]>([
  212. {
  213. id: 'a',
  214. step: 1,
  215. approverId: 'other',
  216. approvalResult: 'approved',
  217. isCurrent: 0,
  218. createTime: '2026-10-21 10:00:00'
  219. },
  220. {
  221. id: 'b',
  222. step: 2,
  223. approverId: 'me',
  224. approvalResult: 'pending',
  225. isCurrent: 1,
  226. createTime: '2026-10-21 10:00:00'
  227. }
  228. ])
  229. const pending = value(detail, 'pendingApproval', {
  230. computed,
  231. contract,
  232. approvalList,
  233. resolveCurrentUserId: () => 'me'
  234. })
  235. return { contract, approvalList, pending }
  236. }
  237. test('contract approval allows the current pending node after all earlier steps passed', () => {
  238. assert.equal(approvalState().pending.value?.id, 'b')
  239. })
  240. for (const status of ['draft', 'approved', 'executing', 'finished', 'invalid', '']) {
  241. test(`contract approval denies status ${status || '(empty)'}`, () => {
  242. assert.equal(approvalState({ status }).pending.value, null)
  243. })
  244. }
  245. for (const prior of ['rejected', 'pending', undefined]) {
  246. test(`contract approval denies earlier ${prior ?? '(missing)'} result`, () => {
  247. const state = approvalState()
  248. state.approvalList.value[0].approvalResult = prior
  249. assert.equal(state.pending.value, null)
  250. })
  251. }
  252. for (const current of [0, undefined]) {
  253. test(`contract approval denies isCurrent=${current}`, () => {
  254. const state = approvalState()
  255. state.approvalList.value[1].isCurrent = current
  256. assert.equal(state.pending.value, null)
  257. })
  258. }
  259. for (const result of ['approved', 'rejected', undefined]) {
  260. test(`contract approval denies own ${result ?? '(missing)'} result`, () => {
  261. const state = approvalState()
  262. state.approvalList.value[1].approvalResult = result
  263. assert.equal(state.pending.value, null)
  264. })
  265. }
  266. test('contract approval denies a node belonging to another user', () => {
  267. const state = approvalState()
  268. state.approvalList.value[1].approverId = 'someone'
  269. assert.equal(state.pending.value, null)
  270. })
  271. test('contract approval ignores previous submission rounds after rejection and resubmission', () => {
  272. const state = approvalState()
  273. state.approvalList.value = [
  274. {
  275. id: 'old',
  276. step: 1,
  277. approverId: 'me',
  278. approvalResult: 'rejected',
  279. isCurrent: 0,
  280. createTime: '2026-10-20 10:00:00'
  281. },
  282. {
  283. id: 'first',
  284. step: 1,
  285. approverId: 'other',
  286. approvalResult: 'approved',
  287. isCurrent: 0,
  288. createTime: '2026-10-21 10:00:00'
  289. },
  290. {
  291. id: 'current',
  292. step: 2,
  293. approverId: 'me',
  294. approvalResult: 'pending',
  295. isCurrent: 1,
  296. createTime: '2026-10-21 10:00:00'
  297. }
  298. ]
  299. assert.equal(state.pending.value?.id, 'current')
  300. state.approvalList.value[1].approvalResult = 'rejected'
  301. assert.equal(state.pending.value, null, 'a rejection in the current round still blocks approval')
  302. })
  303. for (const createTime of ['', undefined]) {
  304. test(`contract approval without pending timestamps (${createTime === '' ? 'empty API value' : 'absent field'}) matches the backend fallback`, () => {
  305. const state = approvalState()
  306. state.approvalList.value = [
  307. {
  308. id: 'old',
  309. step: 1,
  310. approverId: 'other',
  311. approvalResult: 'rejected',
  312. isCurrent: 0,
  313. createTime
  314. },
  315. {
  316. id: 'new-first',
  317. step: 1,
  318. approverId: 'other',
  319. approvalResult: 'approved',
  320. isCurrent: 0,
  321. createTime
  322. },
  323. {
  324. id: 'current',
  325. step: 2,
  326. approverId: 'me',
  327. approvalResult: 'pending',
  328. isCurrent: 1,
  329. createTime
  330. }
  331. ]
  332. assert.equal(state.pending.value?.id, 'current')
  333. state.approvalList.value[1].approvalResult = 'pending'
  334. assert.equal(
  335. state.pending.value,
  336. null,
  337. 'an earlier pending step still blocks approval without timestamps'
  338. )
  339. })
  340. }
  341. function detailLoader(api: Record<string, any> = {}) {
  342. const bindings = {
  343. props: { embedded: false },
  344. route: reactive({ params: { id: 'A' } }),
  345. contract: ref<any>(null),
  346. products: ref<any[]>([]),
  347. approvalList: ref<any[]>([]),
  348. loading: ref(false),
  349. errorText: ref(''),
  350. getContract: async (id: string) => ({
  351. data: { id, status: 'approving', quoteId: 'Q', products: [] }
  352. }),
  353. getQuote: async () => ({ data: { quoteNo: 'SXDD-20261009005' } }),
  354. listContractApproval: async () => ({ data: [{ id: 'approval-A' }] }),
  355. runRouteAction: async () => {},
  356. categoryPaths: ref(new Map()),
  357. loadCategoryPaths: async () => {},
  358. ...api
  359. }
  360. const load = functions(
  361. detail,
  362. ['loadDetail', 'isCurrentLoad', 'currentRecordId'],
  363. bindings,
  364. 'let detailRequestSeq = 0; const shellReady = true; const disposed = false'
  365. )
  366. return { ...bindings, load }
  367. }
  368. test('contract detail loads the linked quote number using quoteId', async () => {
  369. const calls: string[] = []
  370. const state = detailLoader({
  371. getQuote: async (id: string) => {
  372. calls.push(id)
  373. return { data: { quoteNo: 'SXDD-20261009005' } }
  374. }
  375. })
  376. await state.load()
  377. assert.deepEqual(calls, ['Q'])
  378. assert.equal(state.contract.value.quoteNo, 'SXDD-20261009005')
  379. })
  380. test('a failed quote lookup preserves contract and approval details', async () => {
  381. const state = detailLoader({
  382. getQuote: async () => {
  383. throw new Error('quote unavailable')
  384. }
  385. })
  386. await state.load()
  387. assert.equal(state.contract.value.id, 'A')
  388. assert.equal(state.approvalList.value[0].id, 'approval-A')
  389. assert.equal(state.errorText.value, '')
  390. })
  391. test('a failed approval lookup still shows the linked quote and main detail', async () => {
  392. const state = detailLoader({
  393. listContractApproval: async () => {
  394. throw new Error('approval unavailable')
  395. }
  396. })
  397. await state.load()
  398. assert.equal(state.contract.value.quoteNo, 'SXDD-20261009005')
  399. assert.deepEqual(state.approvalList.value, [])
  400. assert.equal(state.errorText.value, '')
  401. })
  402. test('older main detail responses cannot replace a newer route', async () => {
  403. const old = deferred<any>()
  404. const state = detailLoader({
  405. getContract: (id: string) => (id === 'A' ? old.promise : Promise.resolve({ data: { id } }))
  406. })
  407. const first = state.load()
  408. state.route.params.id = 'B'
  409. await state.load()
  410. old.resolve({ data: { id: 'A', products: [{ id: 'old' }] } })
  411. await first
  412. assert.equal(state.contract.value.id, 'B')
  413. assert.deepEqual(state.products.value, [])
  414. })
  415. test('older quote and approval responses cannot alter the newer route', async () => {
  416. const oldQuote = deferred<any>()
  417. const oldApproval = deferred<any>()
  418. const state = detailLoader({
  419. getContract: async (id: string) => ({ data: { id, quoteId: `Q-${id}` } }),
  420. getQuote: (id: string) => {
  421. if (id === 'Q-A') return oldQuote.promise
  422. return Promise.resolve({ data: { quoteNo: 'quote-B' } })
  423. },
  424. listContractApproval: (id: string) =>
  425. id === 'A' ? oldApproval.promise : Promise.resolve({ data: [{ id: 'approval-B' }] })
  426. })
  427. const first = state.load()
  428. // Flush microtasks without waiting forever when the old implementation never fetches a quote.
  429. await Promise.resolve()
  430. await Promise.resolve()
  431. state.route.params.id = 'B'
  432. await state.load()
  433. oldQuote.resolve({ data: { quoteNo: 'quote-A' } })
  434. oldApproval.resolve({ data: [{ id: 'approval-A' }] })
  435. await first
  436. assert.equal(state.contract.value.quoteNo, 'quote-B')
  437. assert.equal(state.approvalList.value[0].id, 'approval-B')
  438. })
  439. test('contract mobile rendering matches quote number, unit dictionary, tax and payable total', async () => {
  440. const template = source(detail).descriptor.template!.content
  441. const section = (id: string) =>
  442. template.match(new RegExp(`<section id="contract-sec-${id}"[\\s\\S]*?<\\/section>`))![0]
  443. const contract = { amount: 750, freight: 20, barrelDeposit: 30, amountWithTax: 795 }
  444. const dictStore = {
  445. getLabel: (type: string, unit: string) =>
  446. type === 'crm_product_unit' && unit === 'dun' ? '吨' : unit
  447. }
  448. const unitLabel = functions(detail, ['unitLabel'], { dictStore }) || ((unit: string) => unit)
  449. const bindings = {
  450. ...productBindings(contract, {}),
  451. contract,
  452. products: [{ id: 'p', unit: 'dun', taxAmount: 86.28, subtotal: 750 }],
  453. formatContractMoney: (amount: any) => `¥${Number(amount || 0).toFixed(2)}`,
  454. formatBrandConstraintLabel: () => '',
  455. unitLabel,
  456. dictStore
  457. }
  458. // Read the amount expression from the real template; no mirrored total calculation in this test.
  459. const html = await render(section('amount') + section('products'), bindings)
  460. assert.match(html, /吨/)
  461. assert.match(html, /税费[\s\S]*¥86\.28/)
  462. assert.match(html, /合计金额[\s\S]*¥800\.00/)
  463. assert.doesNotMatch(html, /¥795\.00/)
  464. })
  465. for (const contractType of [
  466. 'framework_sale',
  467. 'framework_purchase',
  468. 'framework',
  469. 'sales',
  470. 'purchase'
  471. ]) {
  472. for (const priceMode of ['market', 'fixed']) {
  473. test(`contract ${contractType}/${priceMode} renders all three monetary fields consistently`, async () => {
  474. const template = source(detail).descriptor.template!.content
  475. const products = template.match(/<section id="contract-sec-products"[\s\S]*?<\/section>/)![0]
  476. const row = { priceMode, price: 100, subtotal: 750, taxAmount: 86.28 }
  477. const html = await render(products, productBindings({ contractType }, row))
  478. const market = contractType.startsWith('framework') && priceMode === 'market'
  479. for (const [label, money] of [
  480. ['含税单价(元)', '¥100.00'],
  481. ['含税总价(元)', '¥750.00'],
  482. ['税费(元)', '¥86.28']
  483. ]) {
  484. assert.ok(
  485. html.includes(`<span>${label}</span><span>${market ? '随行就市' : money}</span>`),
  486. `${label} must follow ${contractType}/${priceMode}`
  487. )
  488. }
  489. if (market) assert.doesNotMatch(html, /¥(?:100\.00|750\.00|86\.28)/)
  490. })
  491. }
  492. }
  493. test('framework market zero and legacy snake_case modes use the same Web semantics', async () => {
  494. const products = source(detail).descriptor.template!.content.match(
  495. /<section id="contract-sec-products"[\s\S]*?<\/section>/
  496. )![0]
  497. for (const row of [
  498. { priceMode: 'market', price: 0, subtotal: 0, taxAmount: 0 },
  499. { price_mode: 'market', price: 15, subtotal: 100, taxAmount: 13 },
  500. { price: 0, subtotal: 0, taxAmount: 0 }
  501. ]) {
  502. const html = await render(products, productBindings({ contractType: 'framework_sale' }, row))
  503. assert.equal(html.match(/随行就市/g)?.length, 3)
  504. assert.doesNotMatch(html, /¥0\.00/)
  505. }
  506. })
  507. test('fixed zero pricing remains numeric for framework contracts', async () => {
  508. const products = source(detail).descriptor.template!.content.match(
  509. /<section id="contract-sec-products"[\s\S]*?<\/section>/
  510. )![0]
  511. const html = await render(
  512. products,
  513. productBindings(
  514. { contractType: 'framework_purchase' },
  515. { priceMode: 'fixed', price: 0, subtotal: 0, taxAmount: 0 }
  516. )
  517. )
  518. assert.equal(html.match(/¥0\.00/g)?.length, 3)
  519. assert.doesNotMatch(html, /随行就市/)
  520. })
  521. for (const page of lists) {
  522. for (const [min, max, expected] of [
  523. ['10000', '100000', '1-10w'],
  524. ['10000.00', '100000.00', '1-10w'],
  525. ['', '10000', 'lt1w'],
  526. ['100000', '', 'gt10w'],
  527. ['12000', '23000', 'custom'],
  528. ['', '', '']
  529. ]) {
  530. test(`${page}: more filters preserve preset ${min}..${max}`, () => {
  531. const state: Record<string, any> = {
  532. keyword: ref(''),
  533. view: ref('all'),
  534. contractType: ref(''),
  535. quoteDateBegin: ref(''),
  536. quoteDateEnd: ref(''),
  537. signDateBegin: ref(''),
  538. signDateEnd: ref(''),
  539. minAmount: ref(''),
  540. maxAmount: ref(''),
  541. amountRange: ref(''),
  542. moreOpen: ref(true),
  543. moreDraft: { keyword: 'test', minAmount: min, maxAmount: max },
  544. reload() {}
  545. }
  546. functions(page, ['applyMoreFilters', 'resolveAmountRange'], state)()
  547. assert.equal(state.amountRange.value, expected)
  548. assert.equal(state.minAmount.value, min)
  549. assert.equal(state.maxAmount.value, max)
  550. assert.equal(state.moreOpen.value, false)
  551. })
  552. }
  553. test(`${page}: a custom range is visibly labeled instead of all amounts`, async () => {
  554. const template = source(page).descriptor.template!.content
  555. const dropdown = template.match(/<van-dropdown-item[^>]*v-model="amountRange"[^>]*\/>/)![0]
  556. const title = dropdown.match(/:title="([^"]+)"/)?.[1]
  557. const bindings = { amountRange: 'custom', minAmount: '12000', maxAmount: '23000' }
  558. const label = title
  559. ? new Function(...Object.keys(bindings), `return ${title}`)(...Object.values(bindings))
  560. : ''
  561. assert.equal(label, '自定义金额')
  562. assert.equal(new Function(...Object.keys(bindings), `return ${title}`)('', '', ''), undefined)
  563. })
  564. test(`${page}: selecting all in Vant clears a custom amount range and reloads`, () => {
  565. let reloads = 0
  566. const state = {
  567. keyword: ref(''),
  568. view: ref('all'),
  569. contractType: ref(''),
  570. quoteDateBegin: ref(''),
  571. quoteDateEnd: ref(''),
  572. signDateBegin: ref(''),
  573. signDateEnd: ref(''),
  574. minAmount: ref(''),
  575. maxAmount: ref(''),
  576. amountRange: ref(''),
  577. moreOpen: ref(true),
  578. moreDraft: { keyword: '', minAmount: '12000', maxAmount: '23000' },
  579. reload() {
  580. reloads++
  581. }
  582. }
  583. functions(page, ['applyMoreFilters', 'resolveAmountRange'], state)()
  584. reloads = 0
  585. const changed = functions(page, ['onAmountRangeChange', 'applyAmountRange'], state)
  586. clickAmountOption(state.amountRange.value, '', (event, value) => {
  587. if (event === 'update:modelValue') state.amountRange.value = value
  588. if (event === 'change') changed()
  589. })
  590. assert.equal(state.minAmount.value, '')
  591. assert.equal(state.maxAmount.value, '')
  592. assert.equal(state.amountRange.value, '')
  593. assert.equal(reloads, 1)
  594. })
  595. test(`${page}: custom amounts mark more filters active while presets do not`, () => {
  596. const state = {
  597. computed,
  598. amountRange: ref('custom'),
  599. minAmount: ref('12000'),
  600. maxAmount: ref('23000'),
  601. quoteDateBegin: ref(''),
  602. quoteDateEnd: ref(''),
  603. signDateBegin: ref(''),
  604. signDateEnd: ref('')
  605. }
  606. const active = value(page, 'moreActive', state)
  607. assert.equal(active.value, true)
  608. state.amountRange.value = '1-10w'
  609. assert.equal(active.value, false)
  610. state.amountRange.value = ''
  611. state.minAmount.value = ''
  612. state.maxAmount.value = ''
  613. assert.equal(active.value, false)
  614. })
  615. test(`${page}: detail return restores the custom sentinel together with amount bounds`, async () => {
  616. const path = page.startsWith('quote') ? '/m/crm/quote' : '/m/crm/contract'
  617. let leave!: (target: { path: string }) => void
  618. const runtime = {
  619. ref,
  620. nextTick: async () => {},
  621. useRoute: () => ({ path }),
  622. useRouter: () => ({ afterEach: () => () => {} }),
  623. useUserStore: () => ({ accessToken: 'session', getUserInfo: { id: 'me' } }),
  624. onBeforeRouteLeave: (hook: typeof leave) => {
  625. leave = hook
  626. },
  627. onBeforeUnmount() {},
  628. window: { scrollY: 0, scrollTo() {} }
  629. }
  630. const helper = readFileSync(new URL('useListReturnState.ts', root), 'utf8')
  631. .replace(/^import .*\n/gm, '')
  632. .replace('export function', 'function')
  633. const code = ts.transpile(helper, { target: ts.ScriptTarget.ESNext })
  634. const useListReturnState = new Function(
  635. ...Object.keys(runtime),
  636. `${code}; return useListReturnState`
  637. )(...Object.values(runtime))
  638. const create = () => {
  639. const fields: Record<string, any> = {}
  640. for (const key of [
  641. 'keyword',
  642. 'view',
  643. 'quoteType',
  644. 'contractType',
  645. 'amountRange',
  646. 'minAmount',
  647. 'maxAmount',
  648. 'quoteDateBegin',
  649. 'quoteDateEnd',
  650. 'signDateBegin',
  651. 'signDateEnd'
  652. ])
  653. fields[key] = ref('')
  654. const state = value(page, 'listReturnState', {
  655. ...fields,
  656. useListReturnState,
  657. pageNum: ref(1),
  658. expandedIds: ref(new Set()),
  659. hasMore: ref(false),
  660. requestSnapshot: () => JSON.stringify([fields.minAmount.value, fields.maxAmount.value]),
  661. fetchPage: async () => true
  662. })
  663. return { fields, state }
  664. }
  665. const first = create()
  666. functions(page, ['applyMoreFilters', 'resolveAmountRange'], {
  667. ...first.fields,
  668. moreOpen: ref(true),
  669. moreDraft: { keyword: '', minAmount: '12000', maxAmount: '23000' },
  670. reload() {}
  671. })()
  672. leave({ path: `${path}/detail-id` })
  673. const returned = create()
  674. await returned.state.restore()
  675. assert.equal(returned.fields.amountRange.value, 'custom')
  676. assert.equal(returned.fields.minAmount.value, '12000')
  677. assert.equal(returned.fields.maxAmount.value, '23000')
  678. })
  679. test(`${page}: reset clears bounds and restores all amounts`, () => {
  680. const state: Record<string, any> = {
  681. props: { mode: 'manage' },
  682. keyword: ref('old'),
  683. view: ref('all'),
  684. contractType: ref(''),
  685. quoteDateBegin: ref(''),
  686. quoteDateEnd: ref(''),
  687. signDateBegin: ref(''),
  688. signDateEnd: ref(''),
  689. minAmount: ref('10000'),
  690. maxAmount: ref('100000'),
  691. amountRange: ref('1-10w'),
  692. moreOpen: ref(true),
  693. moreDraft: { keyword: 'old', minAmount: '10000', maxAmount: '100000' },
  694. reload() {}
  695. }
  696. functions(
  697. page,
  698. ['resetAndApplyMore', 'resetMoreFilters', 'applyMoreFilters', 'resolveAmountRange'],
  699. state
  700. )()
  701. assert.equal(state.minAmount.value, '')
  702. assert.equal(state.maxAmount.value, '')
  703. assert.equal(state.amountRange.value, '')
  704. })
  705. }
  706. for (const view of ['approved', 'submitted']) {
  707. test(`contract batch buttons reject ineligible selected rows in ${view}`, async () => {
  708. const file = 'contract/MpContractList.vue'
  709. const template = source(file).descriptor.template!.content
  710. const button = template.match(
  711. /<button[^>]*@click="openBatchApprove\('approved'\)"[^>]*>[\s\S]*?<\/button>/
  712. )![0]
  713. const selectedIds = ref(new Set(['1']))
  714. const contracts = ref([{ id: '1', canApprove: false, status: 'approved' }])
  715. const bindings = {
  716. computed,
  717. props: { mode: 'approve' },
  718. view: ref(view),
  719. selectedIds,
  720. contracts
  721. }
  722. const selectedRows = functions(file, ['selectedRows'], bindings)
  723. const ast = source(file).ast
  724. const hasComputed = ast.statements.some(
  725. (s) =>
  726. ts.isVariableStatement(s) &&
  727. s.declarationList.declarations.some((d) => d.name.getText(ast) === 'canApproveBatch')
  728. )
  729. const canApproveBatch = hasComputed
  730. ? value(file, 'canApproveBatch', { ...bindings, selectedRows }).value
  731. : undefined
  732. const html = await render(button, {
  733. selectedIds: selectedIds.value,
  734. acting: false,
  735. canApproveBatch,
  736. openBatchApprove() {}
  737. })
  738. assert.match(html, /disabled/)
  739. })
  740. }
  741. test('contract batch eligibility follows the active view and at least one selected eligible row', () => {
  742. const file = 'contract/MpContractList.vue'
  743. const bindings = {
  744. computed,
  745. props: { mode: 'approve' },
  746. view: ref('submitted'),
  747. selectedIds: ref(new Set(['yes', 'no'])),
  748. contracts: ref([
  749. { id: 'yes', status: 'approving', canApprove: true },
  750. { id: 'no', status: 'approved', canApprove: false }
  751. ])
  752. }
  753. const selectedRows = functions(file, ['selectedRows'], bindings)
  754. const eligible = value(file, 'canApproveBatch', { ...bindings, selectedRows })
  755. assert.equal(eligible.value, true)
  756. bindings.view.value = 'approved'
  757. assert.equal(eligible.value, false)
  758. bindings.view.value = 'submitted'
  759. bindings.selectedIds.value = new Set(['no'])
  760. assert.equal(eligible.value, false)
  761. bindings.selectedIds.value = new Set()
  762. assert.equal(eligible.value, false)
  763. })
  764. test('contract batch sheets open only for eligible targets and preserve the skipped count', () => {
  765. const file = 'contract/MpContractList.vue'
  766. const state = {
  767. canApproveBatch: ref(false),
  768. selectedRows: () => [
  769. { id: 'yes', status: 'approving', canApprove: true },
  770. { id: 'no', status: 'approved', canApprove: false }
  771. ],
  772. batchTargetIds: ref<string[]>([]),
  773. batchSkipped: ref(0),
  774. approveResult: ref(''),
  775. approveOpinion: ref(''),
  776. approveOpen: ref(false),
  777. ElMessage: { warning() {} }
  778. }
  779. const open = functions(file, ['openBatchApprove'], state)
  780. open('approved')
  781. assert.equal(state.approveOpen.value, false)
  782. state.canApproveBatch.value = true
  783. open('approved')
  784. assert.equal(state.approveOpen.value, true)
  785. assert.deepEqual(state.batchTargetIds.value, ['yes'])
  786. assert.equal(state.batchSkipped.value, 1)
  787. })
  788. test('contract batch approval keeps mixed results and refreshes after partial success', async () => {
  789. let reloads = 0
  790. const warnings: string[] = []
  791. const state = {
  792. approveResult: ref('approved'),
  793. approveOpinion: ref(''),
  794. batchTargetIds: ref(['a', 'b']),
  795. batchSkipped: ref(1),
  796. acting: ref(false),
  797. approveOpen: ref(true),
  798. selectMode: ref(true),
  799. approveContract: async (id: string) => {
  800. if (id === 'b') throw new Error('changed')
  801. },
  802. rejectContract: async () => {},
  803. clearSelection() {},
  804. reload() {
  805. reloads++
  806. },
  807. ElMessage: { warning: (message: string) => warnings.push(message), error() {}, success() {} }
  808. }
  809. await functions('contract/MpContractList.vue', ['confirmBatchApprove'], state)()
  810. assert.match(warnings[0], /成功通过 1 条,失败 1 条,跳过 1 条/)
  811. assert.equal(reloads, 1)
  812. assert.equal(state.approveOpen.value, false)
  813. assert.equal(state.acting.value, false)
  814. })