mobile-contract-form.test.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. import assert from 'node:assert/strict'
  2. import { existsSync, readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { computed, ref } from 'vue'
  5. import { parse, compileScript, compileTemplate } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import {
  8. mobileContractMoneyLabels,
  9. validateMobileContractMoney
  10. } from '../src/views/m/crm/contract/mobileFormState'
  11. const file = new URL('../src/views/crm/contract/components/ContractFormDialog.vue', import.meta.url)
  12. const source = readFileSync(file, 'utf8')
  13. const descriptor = parse(source).descriptor
  14. const ast = ts.createSourceFile(
  15. 'contract.ts',
  16. descriptor.scriptSetup!.content,
  17. ts.ScriptTarget.Latest,
  18. true
  19. )
  20. function declaration(name: string) {
  21. const node = ast.statements.find(
  22. (node) =>
  23. ts.isVariableStatement(node) &&
  24. node.declarationList.declarations.some((d) => d.name.getText(ast) === name)
  25. )
  26. assert.ok(node, `Production declaration ${name} exists`)
  27. return node.getText(ast)
  28. }
  29. function evaluate(names: string[], bindings: Record<string, any>) {
  30. const code = ts.transpile(names.map(declaration).join('\n'), { target: ts.ScriptTarget.ESNext })
  31. return new Function(...Object.keys(bindings), `${code}; return { ${names.join(',')} }`)(
  32. ...Object.values(bindings)
  33. )
  34. }
  35. function saveHarness(page: boolean, ready: boolean, uploading = false) {
  36. const writes: any[] = []
  37. const warnings: string[] = []
  38. const bindings = {
  39. isPageVariant: ref(page),
  40. mobileReady: ref(ready),
  41. mobileProductMetaError: ref(''),
  42. mobileContractMoneyLabels,
  43. validateMobileContractMoney,
  44. syncMobileFieldValidation: async () => {},
  45. mobileActionPending: ref(false),
  46. mobileBusy: ref(false),
  47. mobileUploading: ref(uploading),
  48. formLoading: ref(false),
  49. saveDraftLoading: ref(false),
  50. submitLoading: ref(false),
  51. form: ref({ customerId: 'c1', supplierId: 's1', products: [], partyA: '' }),
  52. attachmentList: ref([]),
  53. $modal: { msgWarning: (m: string) => warnings.push(m), msgSuccess: () => {} },
  54. confirmZeroPricingIfNeeded: async () => true,
  55. confirmContractZeroPricing: async () => true,
  56. isFrameworkContractType: () => false,
  57. rebuildContractName: () => {},
  58. serializeContractAttachments: () => '[]',
  59. omitEmptyExceptPartyFields: () => {},
  60. updateContract: async (p: any) => writes.push(p),
  61. addContract: async (p: any) => {
  62. writes.push(p)
  63. return { data: 'new-id' }
  64. },
  65. handleClose: () => {},
  66. emit: () => {}
  67. }
  68. const api = evaluate(['handleSaveDraft'], bindings)
  69. return { ...bindings, ...api, writes, warnings }
  70. }
  71. test('mobile initialization failure cannot save a seemingly valid draft', async () => {
  72. const h = saveHarness(true, false)
  73. await h.handleSaveDraft()
  74. assert.equal(h.writes.length, 0)
  75. })
  76. test('mobile attachment upload blocks draft save at the handler', async () => {
  77. const h = saveHarness(true, true, true)
  78. await h.handleSaveDraft()
  79. assert.equal(h.writes.length, 0)
  80. })
  81. test('desktop draft keeps its original weak validation and save behavior', async () => {
  82. const h = saveHarness(false, false, true)
  83. await h.handleSaveDraft()
  84. assert.equal(h.writes.length, 1)
  85. assert.equal(h.writes[0].status, 'draft')
  86. })
  87. test('page is a real component branch; desktop dialog remains its separate default branch', () => {
  88. const roots = descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
  89. assert.equal(roots[0].tag, 'MobileContractForm')
  90. assert.match(roots[0].loc.source, /v-if="isPageVariant"/)
  91. assert.equal(roots[1].tag, 'el-dialog')
  92. assert.match(roots[1].loc.source, /v-else/)
  93. })
  94. test('mobile required fields remain registered with the original Element form validator', () => {
  95. const path = new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url)
  96. assert.ok(existsSync(path), 'Mobile form exists')
  97. const template = parse(readFileSync(path, 'utf8')).descriptor.template!.content
  98. assert.match(template, /<el-form\b[^>]*:rules="c.rules"/)
  99. for (const prop of [
  100. 'customerId',
  101. 'contactId',
  102. 'signDate',
  103. 'ownerUserId',
  104. 'supplierId',
  105. 'settlementMethod',
  106. 'startDate'
  107. ]) {
  108. assert.match(template, new RegExp(`prop="${prop}"`), `register ${prop}`)
  109. }
  110. })
  111. test('changed SFCs compile without syntax or template errors', () => {
  112. for (const path of [
  113. file,
  114. new URL('../src/views/m/crm/contract/MobileContractForm.vue', import.meta.url),
  115. new URL('../src/views/m/crm/contract/ContractPickerSheet.vue', import.meta.url),
  116. new URL('../src/views/m/crm/contract/ContractSubmitSheet.vue', import.meta.url),
  117. new URL('../src/views/m/crm/contract/ContractAuxiliarySheets.vue', import.meta.url),
  118. new URL('../src/views/m/crm/contract/form.vue', import.meta.url)
  119. ]) {
  120. assert.ok(existsSync(path))
  121. const { descriptor, errors } = parse(readFileSync(path, 'utf8'))
  122. assert.deepEqual(errors, [])
  123. compileScript(descriptor, { id: 'test' })
  124. const result = compileTemplate({
  125. source: descriptor.template!.content,
  126. filename: path.pathname,
  127. id: 'test'
  128. })
  129. assert.deepEqual(result.errors, [])
  130. }
  131. })
  132. test('copy bootstrap failure clears the skeleton and exposes a retryable error', async () => {
  133. const parent = parse(
  134. readFileSync(new URL('../src/views/m/crm/contract/form.vue', import.meta.url), 'utf8')
  135. ).descriptor.scriptSetup!.content
  136. const tree = ts.createSourceFile('page.ts', parent, ts.ScriptTarget.Latest, true)
  137. const fn = tree.statements.find(
  138. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'bootForm'
  139. )!
  140. const bindings: Record<string, any> = {
  141. shellReady: ref(true),
  142. pageMode: ref('copy'),
  143. copyFromId: ref('missing'),
  144. formReady: ref(false),
  145. bootError: ref(''),
  146. copySource: ref(null),
  147. contentLoading: ref(true),
  148. bootLoading: ref(false),
  149. bootGeneration: 0,
  150. getContract: async () => {
  151. throw new Error('来源不存在')
  152. }
  153. }
  154. const boot = new Function(
  155. ...Object.keys(bindings),
  156. ts.transpile(fn.getText(tree), { target: ts.ScriptTarget.ESNext }) + '; return bootForm'
  157. )(...Object.values(bindings))
  158. await boot()
  159. assert.equal(bindings.contentLoading.value, false)
  160. assert.match(bindings.bootError.value, /来源不存在/)
  161. assert.equal(bindings.formReady.value, false)
  162. })
  163. test('double-tap draft save cannot create duplicate mobile contracts', async () => {
  164. const h = saveHarness(true, true)
  165. await Promise.all([h.handleSaveDraft(), h.handleSaveDraft()])
  166. assert.equal(h.writes.length, 1)
  167. })
  168. import { execFileSync } from 'node:child_process'
  169. import * as pricing from '../src/views/crm/contract/frameworkProductPricing'
  170. import { isFrameworkContractType } from '../src/views/crm/contract/contractType'
  171. import {
  172. buildPackageSpecsPayload,
  173. normalizeContractProductPackageFields,
  174. validateContractProductPackageSpecs
  175. } from '../src/utils/crm-package-spec'
  176. import {
  177. prepareLineAliasAndBrandForSubmit,
  178. initLineBrandMetaFromProduct
  179. } from '../src/utils/crm-product-brand'
  180. import * as mobileState from '../src/views/m/crm/contract/mobileFormState'
  181. import { omitEmptyExceptPartyFields } from '../src/views/crm/contract/partyFields'
  182. test('desktop dialog template is byte-for-byte unchanged apart from the branch directive', () => {
  183. const old = execFileSync(
  184. 'git',
  185. ['show', 'HEAD:web-admin/src/views/crm/contract/components/ContractFormDialog.vue'],
  186. { encoding: 'utf8' }
  187. )
  188. const before = parse(old).descriptor.template!.content.trim()
  189. const current = (
  190. descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
  191. )[1].loc.source.replace(' v-else\n', '')
  192. assert.equal(current, before)
  193. })
  194. function productHarness(page = false) {
  195. const bindings = {
  196. ...pricing,
  197. ...mobileState,
  198. isPageVariant: ref(page),
  199. isPartiesSwapped: ref(false),
  200. initLineBrandMetaFromProduct,
  201. enrichContractProductBrandMeta: async () => {},
  202. assertMobileCurrent: () => {},
  203. showCrmConfirmDialog: async () => {},
  204. form: ref<any>({ products: [], contractType: 'framework_sale' }),
  205. normalizeContractProductPackageFields,
  206. buildPackageSpecsPayload,
  207. prepareLineAliasAndBrandForSubmit,
  208. isFrameworkContractType
  209. }
  210. return {
  211. ...bindings,
  212. ...evaluate(
  213. [
  214. 'mapContractProductsFromSource',
  215. 'buildProductsPayload',
  216. 'calculateProductAmount',
  217. 'calculateTotalAmount',
  218. 'handlePriceChange',
  219. 'handleQuantityChange',
  220. 'handleRemoveProduct',
  221. 'handleProductSelected',
  222. 'normalizeTaxRateDisplay',
  223. 'handleEnableFrameworkQuantityFixed',
  224. 'handleResetFrameworkQuantity',
  225. 'handleEnableFrameworkPriceFixed',
  226. 'handleResetFrameworkPrice',
  227. 'applyContractProductsToForm',
  228. 'handleQuoteConfirm'
  229. ],
  230. bindings
  231. )
  232. }
  233. }
  234. test('copy clears contract/product/pricing identity and prices while preserving package and price-letter dates', () => {
  235. const h = productHarness()
  236. const [row] = h.mapContractProductsFromSource(
  237. [
  238. {
  239. id: 'line-id',
  240. contractId: 'old-id',
  241. productId: 'p1',
  242. productName: '甲醇',
  243. quantity: 4,
  244. price: 9,
  245. subtotal: 36,
  246. taxAmount: 1,
  247. remark: 'old remark',
  248. packageSpec: '桶',
  249. quantityMode: 'fixed',
  250. priceMode: 'fixed',
  251. priceTiers: [
  252. {
  253. id: 'tier-id',
  254. contractId: 'old-id',
  255. contractProductId: 'line-id',
  256. startDate: '2026-01-01',
  257. endDate: '2026-02-01',
  258. price: 100,
  259. letterOssId: 'minio/letter.png'
  260. }
  261. ]
  262. }
  263. ],
  264. true
  265. )
  266. assert.equal(row.id, undefined)
  267. assert.equal(row.contractId, undefined)
  268. assert.equal(row.productId, 'p1')
  269. assert.deepEqual(
  270. [row.quantity, row.price, row.subtotal, row.taxAmount, row.remark],
  271. [0, 0, 0, 0, '']
  272. )
  273. assert.deepEqual(row.packageSpecs, ['桶'])
  274. assert.equal(row.priceTiers[0].id, undefined)
  275. assert.equal(row.priceTiers[0].price, undefined)
  276. assert.equal(row.priceTiers[0].startDate, '2026-01-01')
  277. assert.deepEqual(row.priceTiers[0].letterOssIds, ['minio/letter.png'])
  278. })
  279. test('framework fixed quantity and price retain a nonzero amount; tiers do not alter it', () => {
  280. const h = productHarness()
  281. const row: any = {
  282. productId: 'p1',
  283. productName: '甲醇',
  284. quantity: 2,
  285. quantityMode: 'fixed',
  286. price: 100,
  287. priceMode: 'fixed',
  288. taxRate: 13,
  289. packageSpecs: ['桶', '槽罐车'],
  290. priceTiers: [
  291. {
  292. startDate: '2026-01-01',
  293. endDate: '2026-02-01',
  294. price: 999,
  295. letterOssIds: ['letter-1', 'letter-2']
  296. }
  297. ]
  298. }
  299. h.form.value.products = [row]
  300. h.calculateProductAmount(row)
  301. h.calculateTotalAmount()
  302. const [saved] = h.buildProductsPayload()
  303. assert.equal(h.form.value.amount, 200)
  304. assert.equal(saved.subtotal, 200)
  305. assert.deepEqual(saved.packageSpecs, ['桶', '槽罐车'])
  306. assert.deepEqual(saved.priceTiers[0].letterOssIds, ['letter-1', 'letter-2'])
  307. pricing.resetFrameworkPriceToMarket(row)
  308. const [market] = h.buildProductsPayload()
  309. assert.equal(market.quantity, 2)
  310. assert.equal(market.price, 0)
  311. assert.equal(market.quantityMode, 'fixed')
  312. assert.equal(market.priceMode, 'market')
  313. })
  314. test('legacy framework copy carries only type and product content into fresh defaults', async () => {
  315. const h = productHarness()
  316. const bindings = {
  317. ...h,
  318. isPageVariant: ref(true),
  319. assertMobileCurrent: () => {},
  320. initNewContractForm: async () => {
  321. h.form.value = {
  322. customerId: undefined,
  323. contactId: undefined,
  324. supplierId: 'default',
  325. products: []
  326. }
  327. },
  328. applyContractProductsToForm: async (products: any, type: string) => {
  329. h.form.value.products = products
  330. h.form.value.contractType = type
  331. }
  332. }
  333. const { loadContractForCopy } = evaluate(['loadContractForCopy'], bindings)
  334. await loadContractForCopy({
  335. id: 'old',
  336. customerId: 'old-customer',
  337. contactId: 'old-contact',
  338. supplierId: 'old-supplier',
  339. contractType: 'framework',
  340. products: [{ productId: 'p', productName: '产品', packageSpecs: ['桶'] }]
  341. })
  342. assert.equal(h.form.value.customerId, undefined)
  343. assert.equal(h.form.value.contactId, undefined)
  344. assert.equal(h.form.value.supplierId, 'default')
  345. assert.equal(h.form.value.contractType, 'framework')
  346. })
  347. function submitHarness() {
  348. const h = productHarness()
  349. const writes: any[] = []
  350. const approvals: any[] = []
  351. const warnings: any[] = []
  352. const bindings: any = {
  353. ...h,
  354. isPageVariant: ref(true),
  355. mobileReady: ref(true),
  356. mobileProductMetaError: ref(''),
  357. mobileContractMoneyLabels,
  358. validateMobileContractMoney,
  359. syncMobileFieldValidation: async () => {},
  360. mobileBusy: ref(false),
  361. mobileActionPending: ref(false),
  362. mobileUploading: ref(false),
  363. formRef: ref({ validate: async () => true }),
  364. attachmentList: ref([]),
  365. submitLoading: ref(false),
  366. $modal: {
  367. msgWarning: (value: any) => warnings.push(value),
  368. msgSuccess: () => {},
  369. msgError: (value: any) => warnings.push(value)
  370. },
  371. ElMessage: { warning: (value: any) => warnings.push(value) },
  372. expandPartiesForInvalidFields: () => {},
  373. validateContractProductPackageSpecs,
  374. confirmContractZeroPricing: async () => true,
  375. rebuildContractName: () => {},
  376. serializeContractAttachments: () => '',
  377. omitEmptyExceptPartyFields,
  378. addContract: async (data: any) => {
  379. writes.push(data)
  380. return { data: 'saved-id' }
  381. },
  382. updateContract: async (data: any) => {
  383. writes.push(data)
  384. },
  385. mobileFormRef: ref({ openApproval: async (id: any) => approvals.push(id) }),
  386. approvalDialogRef: ref(),
  387. emit: () => {}
  388. }
  389. h.form.value = {
  390. customerId: 'c',
  391. supplierId: 's',
  392. contractType: 'sale',
  393. contactId: 'ct',
  394. partyA: '',
  395. partyB: '',
  396. products: [
  397. {
  398. productId: 'p',
  399. productName: '甲醇',
  400. quantity: 2,
  401. price: 100,
  402. subtotal: 200,
  403. taxAmount: 23.01,
  404. taxRate: 13,
  405. deliveryMethod: '送货',
  406. packageSpecs: ['桶']
  407. }
  408. ]
  409. }
  410. return { ...bindings, ...evaluate(['handleSubmit'], bindings), writes, approvals, warnings }
  411. }
  412. test('submit invokes registered validation and does not save rejected required fields', async () => {
  413. const h = submitHarness()
  414. h.formRef.value.validate = async () => {
  415. throw { contactId: 'missing' }
  416. }
  417. await h.handleSubmit()
  418. assert.equal(h.writes.length, 0)
  419. assert.equal(h.mobileActionPending.value, false)
  420. })
  421. test('customer-template attachment requirement applies to submit only and uses the actual customer value', async () => {
  422. const h = submitHarness()
  423. h.form.value.contractTemplate = 'customer'
  424. await h.handleSubmit()
  425. assert.equal(h.writes.length, 0)
  426. assert.match(JSON.stringify(h.warnings), /合同附件/)
  427. })
  428. test('submit after canceling approval updates the saved ID rather than creating again', async () => {
  429. const h = submitHarness()
  430. await h.handleSubmit()
  431. assert.equal(h.form.value.id, 'saved-id')
  432. await h.handleSubmit()
  433. assert.equal(h.writes.length, 2)
  434. assert.equal(h.writes[0].id, undefined)
  435. assert.equal(h.writes[1].id, 'saved-id')
  436. assert.deepEqual(h.approvals, ['saved-id', 'saved-id'])
  437. assert.equal(h.writes[0].partyA, '')
  438. })
  439. test('upload gate covers submission even if invoked without the disabled button', async () => {
  440. const h = submitHarness()
  441. h.mobileUploading.value = true
  442. await h.handleSubmit()
  443. assert.equal(h.writes.length, 0)
  444. })
  445. test('framework tier validation requires dates, positive price and letters; no tiers is valid', async () => {
  446. const h = submitHarness()
  447. h.form.value.contractType = 'framework_sale'
  448. const row = h.form.value.products[0]
  449. Object.assign(row, {
  450. quantityMode: 'fixed',
  451. priceMode: 'fixed',
  452. priceTiers: [{ startDate: '2026-01-01', endDate: '2026-02-01', price: 100, letterOssIds: [] }]
  453. })
  454. await h.handleSubmit()
  455. assert.equal(h.writes.length, 0)
  456. assert.match(JSON.stringify(h.warnings), /调价函/)
  457. row.priceTiers = []
  458. await h.handleSubmit()
  459. assert.equal(h.writes.length, 1)
  460. })
  461. test('mobile validation retains required rules and rejects reversed dates', async () => {
  462. const form = ref({ startDate: '2026-10-02', endDate: '2026-10-01' })
  463. const contractDateRange = ref(['2026-10-02', '2026-10-01'])
  464. const { rules, mobileRules } = evaluate(['rules', 'mobileMoneyRules', 'mobileRules'], {
  465. mobileContractMoneyLabels,
  466. validateMobileContractMoney,
  467. ref,
  468. computed,
  469. form,
  470. contractDateRange
  471. })
  472. assert.equal(mobileRules.value.customerId, rules.value.customerId)
  473. let error: any
  474. mobileRules.value.startDate[1].validator(null, null, (value: any) => {
  475. error = value
  476. })
  477. assert.match(error.message, /开始日期/)
  478. contractDateRange.value = []
  479. rules.value.startDate[0].validator(null, null, (value: any) => {
  480. error = value
  481. })
  482. assert.match(error.message, /不能为空/)
  483. })
  484. function deferredValue() {
  485. let resolve: any
  486. let reject: any
  487. const promise = new Promise((a, b) => {
  488. resolve = a
  489. reject = b
  490. })
  491. return { resolve, reject, promise }
  492. }
  493. function bootHarness() {
  494. const parent = parse(
  495. readFileSync(new URL('../src/views/m/crm/contract/form.vue', import.meta.url), 'utf8')
  496. ).descriptor.scriptSetup!.content
  497. const tree = ts.createSourceFile('page.ts', parent, ts.ScriptTarget.Latest, true)
  498. const fn = tree.statements.find(
  499. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'bootForm'
  500. )!
  501. const calls: any[] = []
  502. const bindings: any = {
  503. shellReady: ref(true),
  504. pageMode: ref('copy'),
  505. copyFromId: ref('first'),
  506. formReady: ref(false),
  507. bootError: ref(''),
  508. copySource: ref(null),
  509. contentLoading: ref(true),
  510. bootLoading: ref(false),
  511. bootGeneration: 0,
  512. getContract: (id: string) => {
  513. const d = deferredValue()
  514. calls.push({ id, ...d })
  515. return d.promise
  516. }
  517. }
  518. const bootForm = new Function(
  519. ...Object.keys(bindings),
  520. ts.transpile(fn.getText(tree), { target: ts.ScriptTarget.ESNext }) + ';return bootForm'
  521. )(...Object.values(bindings))
  522. return { ...bindings, bootForm, calls }
  523. }
  524. test('copy requests wait for shell authentication and ignore old success/failure after navigation', async () => {
  525. const h = bootHarness()
  526. h.shellReady.value = false
  527. await h.bootForm()
  528. assert.equal(h.calls.length, 0)
  529. h.shellReady.value = true
  530. const first = h.bootForm()
  531. h.copyFromId.value = 'second'
  532. const second = h.bootForm()
  533. h.calls[1].resolve({ data: { id: 'second' } })
  534. await second
  535. h.calls[0].reject(new Error('stale'))
  536. await first
  537. assert.equal(h.copySource.value.id, 'second')
  538. assert.equal(h.bootError.value, '')
  539. assert.equal(h.bootLoading.value, false)
  540. assert.equal(h.formReady.value, true)
  541. })
  542. test('mobile initialization failure leaves the form unsavable and retry can become ready', async () => {
  543. let fail = true
  544. const bindings: any = {
  545. mobileGeneration: 0,
  546. mobileReady: ref(false),
  547. mobileError: ref(''),
  548. mobileContactError: ref(''),
  549. mobileProductMetaError: ref(''),
  550. mobileProductMetaRetry: ref(null),
  551. mobileProductMetaCanDiscard: ref(false),
  552. formLoading: ref(false),
  553. emit: () => {},
  554. resetForm: () => {},
  555. props: { editId: null, copySource: null },
  556. loadSupplierList: async () => {
  557. if (fail) throw new Error('供应商加载失败')
  558. },
  559. loadUserList: async () => {},
  560. assertMobileCurrent: () => {},
  561. initNewContractForm: async () => {}
  562. }
  563. const { initializeMobileForm } = evaluate(['initializeMobileForm'], bindings)
  564. await initializeMobileForm()
  565. assert.equal(bindings.mobileReady.value, false)
  566. assert.equal(bindings.formLoading.value, false)
  567. assert.match(bindings.mobileError.value, /供应商/)
  568. fail = false
  569. await initializeMobileForm()
  570. assert.equal(bindings.mobileReady.value, true)
  571. assert.equal(bindings.mobileError.value, '')
  572. })
  573. test('old detail response cannot overwrite a newly initialized mobile form', async () => {
  574. const pending = deferredValue()
  575. const form = ref({ contractName: 'fresh' })
  576. const bindings: any = {
  577. getContract: () => pending.promise,
  578. assertMobileCurrent: () => {
  579. throw new Error('stale')
  580. },
  581. form,
  582. isPageVariant: ref(true)
  583. }
  584. const { loadContractDetail } = evaluate(['loadContractDetail'], bindings)
  585. const request = loadContractDetail('old', 1)
  586. pending.resolve({ data: { id: 'old', contractName: 'stale' } })
  587. await assert.rejects(request, /stale/)
  588. assert.equal(form.value.contractName, 'fresh')
  589. })
  590. test('customer change clears related quote/opportunity amounts, replaces party snapshot and selects primary contact', async () => {
  591. const form = ref<any>({
  592. customerId: 'old',
  593. contactId: 'old-contact',
  594. quoteId: 'old-quote',
  595. quoteNo: 'old-no',
  596. opportunityId: 'old-opp',
  597. freight: 9,
  598. barrelDeposit: 8,
  599. partyB: 'old-party',
  600. partyBBank: 'old-bank'
  601. })
  602. const picked: any[] = []
  603. let rebuilt = 0
  604. const bindings: any = {
  605. form,
  606. ensureCustomerOption: () => {},
  607. confirmedCustomerId: ref('old'),
  608. contactOptions: ref([]),
  609. contactEmptyPrompted: ref(false),
  610. customerShortName: ref(''),
  611. customerInvoiceName: ref(''),
  612. isPartiesSwapped: ref(false),
  613. isPageVariant: ref(true),
  614. mobileContactError: ref(''),
  615. getDefaultInvoiceInfoByCustomerId: async () => ({
  616. data: { companyName: '新开票公司', bankName: '新银行', bankAccount: '123' }
  617. }),
  618. fetchCustomerDetail: async () => ({ data: { customerName: '新客户', shortName: '新' } }),
  619. loadContactOptions: async () => [
  620. { id: 'first', contactName: '普通' },
  621. { id: 'primary', contactName: '主要', isPrimary: 1 }
  622. ],
  623. handleContactConfirm: (row: any) => {
  624. picked.push(row)
  625. form.value.contactId = row.id
  626. },
  627. rebuildContractName: () => {
  628. rebuilt++
  629. },
  630. ElMessage: { warning: () => {} },
  631. nextTick: () => {},
  632. openContactFormDialog: () => {}
  633. }
  634. const { handleCustomerConfirm } = evaluate(['handleCustomerConfirm'], bindings)
  635. await handleCustomerConfirm({
  636. id: 'new',
  637. customerName: '新客户',
  638. shortName: '新',
  639. mobile: '13800000000'
  640. })
  641. assert.deepEqual(
  642. [form.value.quoteId, form.value.opportunityId, form.value.freight, form.value.barrelDeposit],
  643. [undefined, undefined, 0, 0]
  644. )
  645. assert.equal(form.value.partyB, '新开票公司')
  646. assert.equal(form.value.partyBBank, '新银行')
  647. assert.equal(form.value.contactId, 'primary')
  648. assert.equal(rebuilt, 1)
  649. })
  650. test('failed contact fetch remains an error state instead of a successful empty list', async () => {
  651. const form = ref<any>({ customerId: 'c' })
  652. const bindings: any = {
  653. isPageVariant: ref(true),
  654. mobileContactError: ref(''),
  655. mobileGeneration: 0,
  656. contactOptions: ref([]),
  657. contactOptionsLoading: ref(false),
  658. form,
  659. fetchContactsByCustomerId: async () => {
  660. throw new Error('offline')
  661. },
  662. console: { error: () => {} }
  663. }
  664. const { loadContactOptions } = evaluate(['loadContactOptions'], bindings)
  665. assert.deepEqual(await loadContactOptions('c'), [])
  666. assert.match(bindings.mobileContactError.value, /加载失败/)
  667. assert.equal(bindings.contactOptionsLoading.value, false)
  668. })
  669. function approvalHarness() {
  670. const text = readFileSync(
  671. new URL('../src/views/m/crm/contract/ContractSubmitSheet.vue', import.meta.url),
  672. 'utf8'
  673. )
  674. const script = parse(text).descriptor.scriptSetup!.content
  675. const tree = ts.createSourceFile('submit-sheet.ts', script, ts.ScriptTarget.Latest, true)
  676. const writes: any[] = []
  677. let fail = false
  678. let defaults: any = { status: '0', approverIds: ['disabled', 'u2', 'u1'] }
  679. const bindings: any = {
  680. ref,
  681. computed,
  682. props: {
  683. users: [
  684. { userId: 'u1', nickName: '甲' },
  685. { userId: 'u2', nickName: '乙' }
  686. ]
  687. },
  688. getContractApproverSetting: async () => ({ data: defaults }),
  689. submitContract: async (...args: any[]) => {
  690. writes.push(args)
  691. if (fail) throw new Error('提交失败')
  692. },
  693. showSuccessToast: () => {},
  694. emit: () => {}
  695. }
  696. const statements = tree.statements.filter(
  697. (node) =>
  698. (ts.isVariableStatement(node) &&
  699. !['props', 'emit'].includes(node.declarationList.declarations[0].name.getText(tree))) ||
  700. ts.isFunctionDeclaration(node)
  701. )
  702. const code = ts.transpile(statements.map((node) => node.getText(tree)).join('\n'), {
  703. target: ts.ScriptTarget.ESNext
  704. })
  705. const api = new Function(
  706. ...Object.keys(bindings),
  707. code +
  708. ';return {open,confirm,visible,loading,submitting,error,approverIds,contractId,remark,approvalType,selectedNames}'
  709. )(...Object.values(bindings))
  710. return {
  711. ...api,
  712. writes,
  713. setFailure: (value: boolean) => {
  714. fail = value
  715. },
  716. setDefaults: (value: any) => {
  717. defaults = value
  718. }
  719. }
  720. }
  721. test('approval defaults only retain available users in their saved order', async () => {
  722. const h = approvalHarness()
  723. await h.open('saved')
  724. assert.deepEqual(h.approverIds.value, ['u2', 'u1'])
  725. assert.equal(h.selectedNames.value, '乙 → 甲')
  726. h.setDefaults({ status: '1', approverIds: ['u1'] })
  727. await h.open('saved')
  728. assert.deepEqual(h.approverIds.value, [])
  729. })
  730. test('approval failure keeps the saved ID and retry sends only the existing API arguments', async () => {
  731. const h = approvalHarness()
  732. await h.open('saved')
  733. h.remark.value = '提交说明'
  734. h.approvalType.value = 'parallel'
  735. h.setFailure(true)
  736. await h.confirm()
  737. assert.equal(h.visible.value, true)
  738. assert.equal(h.contractId.value, 'saved')
  739. assert.match(h.error.value, /提交失败/)
  740. h.setFailure(false)
  741. await h.confirm()
  742. assert.equal(h.visible.value, false)
  743. assert.deepEqual(h.writes, [
  744. ['saved', ['u2', 'u1'], '提交说明'],
  745. ['saved', ['u2', 'u1'], '提交说明']
  746. ])
  747. })
  748. test('metadata errors block draft and approval handlers before snapshot serialization', async () => {
  749. for (const h of [saveHarness(true, true), submitHarness()]) {
  750. h.mobileProductMetaError.value = '产品加载失败'
  751. if ('handleSaveDraft' in h) await h.handleSaveDraft()
  752. else await h.handleSubmit()
  753. assert.equal(h.writes.length, 0)
  754. }
  755. })
  756. test('mobile draft and approval reject invalid money without strengthening draft required fields', async () => {
  757. for (const key of ['amount', 'freight', 'barrelDeposit']) {
  758. for (const value of [-1, 1.234]) {
  759. for (const h of [saveHarness(true, true), submitHarness()]) {
  760. h.form.value[key] = value
  761. if ('handleSaveDraft' in h) await h.handleSaveDraft()
  762. else await h.handleSubmit()
  763. assert.equal(h.writes.length, 0)
  764. assert.match(String(h.warnings[0]), /非负|2位小数/)
  765. }
  766. }
  767. }
  768. const draft = saveHarness(true, true)
  769. draft.form.value.freight = 0.29
  770. await draft.handleSaveDraft()
  771. assert.equal(draft.writes.length, 1)
  772. })
  773. test('actual mobile price-change totals normalize IEEE tails before display and draft save', async () => {
  774. for (const price of [29.9, 0.1]) {
  775. const h = productHarness(true)
  776. h.form.value.contractType = 'sale'
  777. const row = { productId: 'p', productName: '甲醇', quantity: 3, price, taxRate: 13 }
  778. h.form.value.products = [row]
  779. h.handlePriceChange(row)
  780. assert.equal(h.form.value.amount, price === 29.9 ? 89.7 : 0.3)
  781. assert.equal(validateMobileContractMoney(h.form.value), '')
  782. const draft = saveHarness(true, true)
  783. draft.form.value.amount = h.form.value.amount
  784. await draft.handleSaveDraft()
  785. assert.equal(draft.writes.length, 1)
  786. }
  787. })
  788. test('actual third decimal money remains invalid despite float tolerance', () => {
  789. for (const amount of [1.234, 1.001, 0.001, 1e-20]) {
  790. assert.match(validateMobileContractMoney({ amount }), /2位小数/)
  791. }
  792. })
  793. test('mobile automatic totals round decimal quantities and positive half cents while desktop retains its arithmetic', () => {
  794. for (const [quantity, price, expected] of [
  795. [0.123, 1.23, 0.15],
  796. [1.5, 29.99, 44.99],
  797. [1.5, 0.67, 1.01]
  798. ]) {
  799. for (const page of [true, false]) {
  800. const h = productHarness(page)
  801. h.form.value.contractType = 'sale'
  802. const row = { productId: 'p', quantity, price, taxRate: 13 }
  803. h.form.value.products = [row]
  804. h.handlePriceChange(row)
  805. assert.equal(h.form.value.amount, page ? expected : quantity * price)
  806. if (page) {
  807. assert.equal(h.form.value.products[0].subtotal, expected)
  808. assert.equal(Number(h.form.value.products[0].subtotal.toFixed(2)), expected)
  809. assert.equal(validateMobileContractMoney(h.form.value), '')
  810. }
  811. }
  812. }
  813. })
  814. test('mobile automatic total boundary covers add, remove, quantity, framework modes, loading and quote import', async () => {
  815. const h = productHarness(true)
  816. h.form.value.contractType = 'sale'
  817. const row = () => ({
  818. productId: 'p',
  819. productName: '甲醇',
  820. quantity: 1.5,
  821. price: 29.99,
  822. taxRate: 13
  823. })
  824. h.form.value.products = [row()]
  825. h.handleProductSelected([{ id: 'new', productName: '新产品', price: 1 }])
  826. assert.equal(h.form.value.amount, 45.99)
  827. h.handleRemoveProduct(1)
  828. assert.equal(h.form.value.amount, 44.99)
  829. h.form.value.products[0].quantity = 0.123
  830. h.form.value.products[0].price = 1.23
  831. h.handleQuantityChange(h.form.value.products[0])
  832. assert.equal(h.form.value.amount, 0.15)
  833. for (const type of ['framework_sale', 'framework_purchase', 'framework']) {
  834. h.form.value.contractType = type
  835. h.form.value.products = [{ ...row(), quantityMode: 'fixed', priceMode: 'fixed' }]
  836. h.handleEnableFrameworkQuantityFixed(h.form.value.products[0])
  837. assert.equal(h.form.value.amount, 44.99)
  838. h.handleEnableFrameworkPriceFixed(h.form.value.products[0])
  839. assert.equal(h.form.value.amount, 44.99)
  840. h.handleResetFrameworkPrice(h.form.value.products[0])
  841. assert.equal(h.form.value.amount, 0)
  842. h.handleResetFrameworkQuantity(h.form.value.products[0])
  843. assert.equal(h.form.value.amount, 0)
  844. }
  845. h.form.value.contractType = 'sale'
  846. await h.applyContractProductsToForm([row()], 'sale')
  847. assert.equal(h.form.value.amount, 44.99)
  848. await h.handleQuoteConfirm({ id: 'quote', productList: [row()] })
  849. assert.equal(h.form.value.amount, 44.99)
  850. h.form.value.amount = '1.001'
  851. assert.match(validateMobileContractMoney(h.form.value), /2位小数/)
  852. })
  853. test('desktop calculation source remains intact outside the explicit mobile total boundary', () => {
  854. const baseline = parse(
  855. execFileSync(
  856. 'git',
  857. ['show', 'HEAD:web-admin/src/views/crm/contract/components/ContractFormDialog.vue'],
  858. { encoding: 'utf8' }
  859. )
  860. ).descriptor
  861. const tree = ts.createSourceFile(
  862. 'baseline.ts',
  863. baseline.scriptSetup!.content,
  864. ts.ScriptTarget.Latest,
  865. true
  866. )
  867. for (const name of ['calculateProductAmount', 'calculateTotalAmount']) {
  868. const node = tree.statements.find(
  869. (item) =>
  870. ts.isVariableStatement(item) &&
  871. item.declarationList.declarations.some((d) => d.name.getText(tree) === name)
  872. )!
  873. const current = declaration(name).replace(
  874. ' if (isPageVariant.value) normalizeMobileContractAmounts(form.value)\n',
  875. ''
  876. )
  877. assert.equal(current, node.getText(tree))
  878. }
  879. })
  880. test('mobile total equals the sum of rounded line subtotals and payload uses those same cents', () => {
  881. const h = productHarness(true)
  882. h.form.value.contractType = 'sale'
  883. h.form.value.products = [
  884. { productId: 'p1', productName: '甲醇', quantity: 1.5, price: 29.99 },
  885. { productId: 'p2', productName: '乙醇', quantity: 1.5, price: 29.99 }
  886. ]
  887. h.handlePriceChange(h.form.value.products[0])
  888. assert.deepEqual(
  889. h.form.value.products.map((row) => row.subtotal),
  890. [44.99, 44.99]
  891. )
  892. assert.equal(h.form.value.amount, 89.98)
  893. assert.deepEqual(
  894. h.buildProductsPayload().map((row) => row.subtotal),
  895. [44.99, 44.99]
  896. )
  897. })