mobile-contract-form.test.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  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!.ast.children as any[])
  189. .find((node: any) => node.type === 1 && node.tag === 'el-dialog')
  190. .loc.source.replace(' v-else\n', '')
  191. const current = (
  192. descriptor.template!.ast.children.filter((n: any) => n.type === 1) as any[]
  193. )[1].loc.source.replace(' v-else\n', '')
  194. assert.equal(current, before)
  195. })
  196. function productHarness(page = false) {
  197. const bindings = {
  198. ...pricing,
  199. ...mobileState,
  200. isPageVariant: ref(page),
  201. isPartiesSwapped: ref(false),
  202. initLineBrandMetaFromProduct,
  203. enrichContractProductBrandMeta: async () => {},
  204. assertMobileCurrent: () => {},
  205. showCrmConfirmDialog: async () => {},
  206. form: ref<any>({ products: [], contractType: 'framework_sale' }),
  207. normalizeContractProductPackageFields,
  208. buildPackageSpecsPayload,
  209. prepareLineAliasAndBrandForSubmit,
  210. isFrameworkContractType
  211. }
  212. return {
  213. ...bindings,
  214. ...evaluate(
  215. [
  216. 'mapContractProductsFromSource',
  217. 'buildProductsPayload',
  218. 'calculateProductAmount',
  219. 'calculateTotalAmount',
  220. 'handlePriceChange',
  221. 'handleQuantityChange',
  222. 'handleRemoveProduct',
  223. 'handleProductSelected',
  224. 'normalizeTaxRateDisplay',
  225. 'handleEnableFrameworkQuantityFixed',
  226. 'handleResetFrameworkQuantity',
  227. 'handleEnableFrameworkPriceFixed',
  228. 'handleResetFrameworkPrice',
  229. 'applyContractProductsToForm',
  230. 'handleQuoteConfirm'
  231. ],
  232. bindings
  233. )
  234. }
  235. }
  236. test('copy clears contract/product/pricing identity and prices while preserving package and price-letter dates', () => {
  237. const h = productHarness()
  238. const [row] = h.mapContractProductsFromSource(
  239. [
  240. {
  241. id: 'line-id',
  242. contractId: 'old-id',
  243. productId: 'p1',
  244. productName: '甲醇',
  245. quantity: 4,
  246. price: 9,
  247. subtotal: 36,
  248. taxAmount: 1,
  249. remark: 'old remark',
  250. packageSpec: '桶',
  251. quantityMode: 'fixed',
  252. priceMode: 'fixed',
  253. priceTiers: [
  254. {
  255. id: 'tier-id',
  256. contractId: 'old-id',
  257. contractProductId: 'line-id',
  258. startDate: '2026-01-01',
  259. endDate: '2026-02-01',
  260. price: 100,
  261. letterOssId: 'minio/letter.png'
  262. }
  263. ]
  264. }
  265. ],
  266. true
  267. )
  268. assert.equal(row.id, undefined)
  269. assert.equal(row.contractId, undefined)
  270. assert.equal(row.productId, 'p1')
  271. assert.deepEqual(
  272. [row.quantity, row.price, row.subtotal, row.taxAmount, row.remark],
  273. [0, 0, 0, 0, '']
  274. )
  275. assert.deepEqual(row.packageSpecs, ['桶'])
  276. assert.equal(row.priceTiers[0].id, undefined)
  277. assert.equal(row.priceTiers[0].price, undefined)
  278. assert.equal(row.priceTiers[0].startDate, '2026-01-01')
  279. assert.deepEqual(row.priceTiers[0].letterOssIds, ['minio/letter.png'])
  280. })
  281. test('framework fixed quantity and price retain a nonzero amount; tiers do not alter it', () => {
  282. const h = productHarness()
  283. const row: any = {
  284. productId: 'p1',
  285. productName: '甲醇',
  286. quantity: 2,
  287. quantityMode: 'fixed',
  288. price: 100,
  289. priceMode: 'fixed',
  290. taxRate: 13,
  291. packageSpecs: ['桶', '槽罐车'],
  292. priceTiers: [
  293. {
  294. startDate: '2026-01-01',
  295. endDate: '2026-02-01',
  296. price: 999,
  297. letterOssIds: ['letter-1', 'letter-2']
  298. }
  299. ]
  300. }
  301. h.form.value.products = [row]
  302. h.calculateProductAmount(row)
  303. h.calculateTotalAmount()
  304. const [saved] = h.buildProductsPayload()
  305. assert.equal(h.form.value.amount, 200)
  306. assert.equal(saved.subtotal, 200)
  307. assert.deepEqual(saved.packageSpecs, ['桶', '槽罐车'])
  308. assert.deepEqual(saved.priceTiers[0].letterOssIds, ['letter-1', 'letter-2'])
  309. pricing.resetFrameworkPriceToMarket(row)
  310. const [market] = h.buildProductsPayload()
  311. assert.equal(market.quantity, 2)
  312. assert.equal(market.price, 0)
  313. assert.equal(market.quantityMode, 'fixed')
  314. assert.equal(market.priceMode, 'market')
  315. })
  316. test('legacy framework copy carries only type and product content into fresh defaults', async () => {
  317. const h = productHarness()
  318. const bindings = {
  319. ...h,
  320. isPageVariant: ref(true),
  321. assertMobileCurrent: () => {},
  322. initNewContractForm: async () => {
  323. h.form.value = {
  324. customerId: undefined,
  325. contactId: undefined,
  326. supplierId: 'default',
  327. products: []
  328. }
  329. },
  330. applyContractProductsToForm: async (products: any, type: string) => {
  331. h.form.value.products = products
  332. h.form.value.contractType = type
  333. }
  334. }
  335. const { loadContractForCopy } = evaluate(['loadContractForCopy'], bindings)
  336. await loadContractForCopy({
  337. id: 'old',
  338. customerId: 'old-customer',
  339. contactId: 'old-contact',
  340. supplierId: 'old-supplier',
  341. contractType: 'framework',
  342. products: [{ productId: 'p', productName: '产品', packageSpecs: ['桶'] }]
  343. })
  344. assert.equal(h.form.value.customerId, undefined)
  345. assert.equal(h.form.value.contactId, undefined)
  346. assert.equal(h.form.value.supplierId, 'default')
  347. assert.equal(h.form.value.contractType, 'framework')
  348. })
  349. function submitHarness() {
  350. const h = productHarness()
  351. const writes: any[] = []
  352. const approvals: any[] = []
  353. const warnings: any[] = []
  354. const bindings: any = {
  355. ...h,
  356. isPageVariant: ref(true),
  357. mobileReady: ref(true),
  358. mobileProductMetaError: ref(''),
  359. mobileContractMoneyLabels,
  360. validateMobileContractMoney,
  361. syncMobileFieldValidation: async () => {},
  362. mobileBusy: ref(false),
  363. mobileActionPending: ref(false),
  364. mobileUploading: ref(false),
  365. formRef: ref({ validate: async () => true }),
  366. attachmentList: ref([]),
  367. submitLoading: ref(false),
  368. $modal: {
  369. msgWarning: (value: any) => warnings.push(value),
  370. msgSuccess: () => {},
  371. msgError: (value: any) => warnings.push(value)
  372. },
  373. ElMessage: { warning: (value: any) => warnings.push(value) },
  374. expandPartiesForInvalidFields: () => {},
  375. validateContractProductPackageSpecs,
  376. confirmContractZeroPricing: async () => true,
  377. rebuildContractName: () => {},
  378. serializeContractAttachments: () => '',
  379. omitEmptyExceptPartyFields,
  380. addContract: async (data: any) => {
  381. writes.push(data)
  382. return { data: 'saved-id' }
  383. },
  384. updateContract: async (data: any) => {
  385. writes.push(data)
  386. },
  387. mobileFormRef: ref({ openApproval: async (id: any) => approvals.push(id) }),
  388. approvalDialogRef: ref(),
  389. emit: () => {}
  390. }
  391. h.form.value = {
  392. customerId: 'c',
  393. supplierId: 's',
  394. contractType: 'sale',
  395. contactId: 'ct',
  396. partyA: '',
  397. partyB: '',
  398. products: [
  399. {
  400. productId: 'p',
  401. productName: '甲醇',
  402. quantity: 2,
  403. price: 100,
  404. subtotal: 200,
  405. taxAmount: 23.01,
  406. taxRate: 13,
  407. deliveryMethod: '送货',
  408. packageSpecs: ['桶']
  409. }
  410. ]
  411. }
  412. return { ...bindings, ...evaluate(['handleSubmit'], bindings), writes, approvals, warnings }
  413. }
  414. test('submit invokes registered validation and does not save rejected required fields', async () => {
  415. const h = submitHarness()
  416. h.formRef.value.validate = async () => {
  417. throw { contactId: 'missing' }
  418. }
  419. await h.handleSubmit()
  420. assert.equal(h.writes.length, 0)
  421. assert.equal(h.mobileActionPending.value, false)
  422. })
  423. test('customer-template attachment requirement applies to submit only and uses the actual customer value', async () => {
  424. const h = submitHarness()
  425. h.form.value.contractTemplate = 'customer'
  426. await h.handleSubmit()
  427. assert.equal(h.writes.length, 0)
  428. assert.match(JSON.stringify(h.warnings), /合同附件/)
  429. })
  430. test('submit after canceling approval updates the saved ID rather than creating again', async () => {
  431. const h = submitHarness()
  432. await h.handleSubmit()
  433. assert.equal(h.form.value.id, 'saved-id')
  434. await h.handleSubmit()
  435. assert.equal(h.writes.length, 2)
  436. assert.equal(h.writes[0].id, undefined)
  437. assert.equal(h.writes[1].id, 'saved-id')
  438. assert.deepEqual(h.approvals, ['saved-id', 'saved-id'])
  439. assert.equal(h.writes[0].partyA, '')
  440. })
  441. test('upload gate covers submission even if invoked without the disabled button', async () => {
  442. const h = submitHarness()
  443. h.mobileUploading.value = true
  444. await h.handleSubmit()
  445. assert.equal(h.writes.length, 0)
  446. })
  447. test('framework tier validation requires dates, positive price and letters; no tiers is valid', async () => {
  448. const h = submitHarness()
  449. h.form.value.contractType = 'framework_sale'
  450. const row = h.form.value.products[0]
  451. Object.assign(row, {
  452. quantityMode: 'fixed',
  453. priceMode: 'fixed',
  454. priceTiers: [{ startDate: '2026-01-01', endDate: '2026-02-01', price: 100, letterOssIds: [] }]
  455. })
  456. await h.handleSubmit()
  457. assert.equal(h.writes.length, 0)
  458. assert.match(JSON.stringify(h.warnings), /调价函/)
  459. row.priceTiers = []
  460. await h.handleSubmit()
  461. assert.equal(h.writes.length, 1)
  462. })
  463. test('mobile validation retains required rules and rejects reversed dates', async () => {
  464. const form = ref({ startDate: '2026-10-02', endDate: '2026-10-01' })
  465. const contractDateRange = ref(['2026-10-02', '2026-10-01'])
  466. const { rules, mobileRules } = evaluate(['rules', 'mobileMoneyRules', 'mobileRules'], {
  467. mobileContractMoneyLabels,
  468. validateMobileContractMoney,
  469. ref,
  470. computed,
  471. form,
  472. contractDateRange
  473. })
  474. assert.equal(mobileRules.value.customerId, rules.value.customerId)
  475. let error: any
  476. mobileRules.value.startDate[1].validator(null, null, (value: any) => {
  477. error = value
  478. })
  479. assert.match(error.message, /开始日期/)
  480. contractDateRange.value = []
  481. rules.value.startDate[0].validator(null, null, (value: any) => {
  482. error = value
  483. })
  484. assert.match(error.message, /不能为空/)
  485. })
  486. function deferredValue() {
  487. let resolve: any
  488. let reject: any
  489. const promise = new Promise((a, b) => {
  490. resolve = a
  491. reject = b
  492. })
  493. return { resolve, reject, promise }
  494. }
  495. function bootHarness() {
  496. const parent = parse(
  497. readFileSync(new URL('../src/views/m/crm/contract/form.vue', import.meta.url), 'utf8')
  498. ).descriptor.scriptSetup!.content
  499. const tree = ts.createSourceFile('page.ts', parent, ts.ScriptTarget.Latest, true)
  500. const fn = tree.statements.find(
  501. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'bootForm'
  502. )!
  503. const calls: any[] = []
  504. const bindings: any = {
  505. shellReady: ref(true),
  506. pageMode: ref('copy'),
  507. copyFromId: ref('first'),
  508. formReady: ref(false),
  509. bootError: ref(''),
  510. copySource: ref(null),
  511. contentLoading: ref(true),
  512. bootLoading: ref(false),
  513. bootGeneration: 0,
  514. getContract: (id: string) => {
  515. const d = deferredValue()
  516. calls.push({ id, ...d })
  517. return d.promise
  518. }
  519. }
  520. const bootForm = new Function(
  521. ...Object.keys(bindings),
  522. ts.transpile(fn.getText(tree), { target: ts.ScriptTarget.ESNext }) + ';return bootForm'
  523. )(...Object.values(bindings))
  524. return { ...bindings, bootForm, calls }
  525. }
  526. test('copy requests wait for shell authentication and ignore old success/failure after navigation', async () => {
  527. const h = bootHarness()
  528. h.shellReady.value = false
  529. await h.bootForm()
  530. assert.equal(h.calls.length, 0)
  531. h.shellReady.value = true
  532. const first = h.bootForm()
  533. h.copyFromId.value = 'second'
  534. const second = h.bootForm()
  535. h.calls[1].resolve({ data: { id: 'second' } })
  536. await second
  537. h.calls[0].reject(new Error('stale'))
  538. await first
  539. assert.equal(h.copySource.value.id, 'second')
  540. assert.equal(h.bootError.value, '')
  541. assert.equal(h.bootLoading.value, false)
  542. assert.equal(h.formReady.value, true)
  543. })
  544. test('mobile initialization failure leaves the form unsavable and retry can become ready', async () => {
  545. let fail = true
  546. const bindings: any = {
  547. mobileGeneration: 0,
  548. mobileReady: ref(false),
  549. mobileError: ref(''),
  550. mobileContactError: ref(''),
  551. mobileProductMetaError: ref(''),
  552. mobileProductMetaRetry: ref(null),
  553. mobileProductMetaCanDiscard: ref(false),
  554. formLoading: ref(false),
  555. emit: () => {},
  556. resetForm: () => {},
  557. props: { editId: null, copySource: null },
  558. loadSupplierList: async () => {
  559. if (fail) throw new Error('供应商加载失败')
  560. },
  561. loadUserList: async () => {},
  562. assertMobileCurrent: () => {},
  563. initNewContractForm: async () => {}
  564. }
  565. const { initializeMobileForm } = evaluate(['initializeMobileForm'], bindings)
  566. await initializeMobileForm()
  567. assert.equal(bindings.mobileReady.value, false)
  568. assert.equal(bindings.formLoading.value, false)
  569. assert.match(bindings.mobileError.value, /供应商/)
  570. fail = false
  571. await initializeMobileForm()
  572. assert.equal(bindings.mobileReady.value, true)
  573. assert.equal(bindings.mobileError.value, '')
  574. })
  575. test('old detail response cannot overwrite a newly initialized mobile form', async () => {
  576. const pending = deferredValue()
  577. const form = ref({ contractName: 'fresh' })
  578. const bindings: any = {
  579. getContract: () => pending.promise,
  580. assertMobileCurrent: () => {
  581. throw new Error('stale')
  582. },
  583. form,
  584. isPageVariant: ref(true)
  585. }
  586. const { loadContractDetail } = evaluate(['loadContractDetail'], bindings)
  587. const request = loadContractDetail('old', 1)
  588. pending.resolve({ data: { id: 'old', contractName: 'stale' } })
  589. await assert.rejects(request, /stale/)
  590. assert.equal(form.value.contractName, 'fresh')
  591. })
  592. test('customer change clears related quote/opportunity amounts, replaces party snapshot and selects primary contact', async () => {
  593. const form = ref<any>({
  594. customerId: 'old',
  595. contactId: 'old-contact',
  596. quoteId: 'old-quote',
  597. quoteNo: 'old-no',
  598. opportunityId: 'old-opp',
  599. freight: 9,
  600. barrelDeposit: 8,
  601. partyB: 'old-party',
  602. partyBBank: 'old-bank'
  603. })
  604. const picked: any[] = []
  605. let rebuilt = 0
  606. const bindings: any = {
  607. form,
  608. ensureCustomerOption: () => {},
  609. confirmedCustomerId: ref('old'),
  610. contactOptions: ref([]),
  611. contactEmptyPrompted: ref(false),
  612. customerShortName: ref(''),
  613. customerInvoiceName: ref(''),
  614. isPartiesSwapped: ref(false),
  615. isPageVariant: ref(true),
  616. mobileContactError: ref(''),
  617. getDefaultInvoiceInfoByCustomerId: async () => ({
  618. data: { companyName: '新开票公司', bankName: '新银行', bankAccount: '123' }
  619. }),
  620. fetchCustomerDetail: async () => ({ data: { customerName: '新客户', shortName: '新' } }),
  621. loadContactOptions: async () => [
  622. { id: 'first', contactName: '普通' },
  623. { id: 'primary', contactName: '主要', isPrimary: 1 }
  624. ],
  625. handleContactConfirm: (row: any) => {
  626. picked.push(row)
  627. form.value.contactId = row.id
  628. },
  629. rebuildContractName: () => {
  630. rebuilt++
  631. },
  632. ElMessage: { warning: () => {} },
  633. nextTick: () => {},
  634. openContactFormDialog: () => {}
  635. }
  636. const { handleCustomerConfirm } = evaluate(['handleCustomerConfirm'], bindings)
  637. await handleCustomerConfirm({
  638. id: 'new',
  639. customerName: '新客户',
  640. shortName: '新',
  641. mobile: '13800000000'
  642. })
  643. assert.deepEqual(
  644. [form.value.quoteId, form.value.opportunityId, form.value.freight, form.value.barrelDeposit],
  645. [undefined, undefined, 0, 0]
  646. )
  647. assert.equal(form.value.partyB, '新开票公司')
  648. assert.equal(form.value.partyBBank, '新银行')
  649. assert.equal(form.value.contactId, 'primary')
  650. assert.equal(rebuilt, 1)
  651. })
  652. test('failed contact fetch remains an error state instead of a successful empty list', async () => {
  653. const form = ref<any>({ customerId: 'c' })
  654. const bindings: any = {
  655. isPageVariant: ref(true),
  656. mobileContactError: ref(''),
  657. mobileGeneration: 0,
  658. contactOptions: ref([]),
  659. contactOptionsLoading: ref(false),
  660. form,
  661. fetchContactsByCustomerId: async () => {
  662. throw new Error('offline')
  663. },
  664. console: { error: () => {} }
  665. }
  666. const { loadContactOptions } = evaluate(['loadContactOptions'], bindings)
  667. assert.deepEqual(await loadContactOptions('c'), [])
  668. assert.match(bindings.mobileContactError.value, /加载失败/)
  669. assert.equal(bindings.contactOptionsLoading.value, false)
  670. })
  671. function approvalHarness() {
  672. const text = readFileSync(
  673. new URL('../src/views/m/crm/contract/ContractSubmitSheet.vue', import.meta.url),
  674. 'utf8'
  675. )
  676. const script = parse(text).descriptor.scriptSetup!.content
  677. const tree = ts.createSourceFile('submit-sheet.ts', script, ts.ScriptTarget.Latest, true)
  678. const writes: any[] = []
  679. let fail = false
  680. let defaults: any = { status: '0', approverIds: ['disabled', 'u2', 'u1'] }
  681. const bindings: any = {
  682. ref,
  683. computed,
  684. props: {
  685. users: [
  686. { userId: 'u1', nickName: '甲' },
  687. { userId: 'u2', nickName: '乙' }
  688. ]
  689. },
  690. getContractApproverSetting: async () => ({ data: defaults }),
  691. submitContract: async (...args: any[]) => {
  692. writes.push(args)
  693. if (fail) throw new Error('提交失败')
  694. },
  695. showSuccessToast: () => {},
  696. emit: () => {}
  697. }
  698. const statements = tree.statements.filter(
  699. (node) =>
  700. (ts.isVariableStatement(node) &&
  701. !['props', 'emit'].includes(node.declarationList.declarations[0].name.getText(tree))) ||
  702. ts.isFunctionDeclaration(node)
  703. )
  704. const code = ts.transpile(statements.map((node) => node.getText(tree)).join('\n'), {
  705. target: ts.ScriptTarget.ESNext
  706. })
  707. const api = new Function(
  708. ...Object.keys(bindings),
  709. code +
  710. ';return {open,confirm,visible,loading,submitting,error,approverIds,contractId,remark,approvalType,selectedNames}'
  711. )(...Object.values(bindings))
  712. return {
  713. ...api,
  714. writes,
  715. setFailure: (value: boolean) => {
  716. fail = value
  717. },
  718. setDefaults: (value: any) => {
  719. defaults = value
  720. }
  721. }
  722. }
  723. test('approval defaults only retain available users in their saved order', async () => {
  724. const h = approvalHarness()
  725. await h.open('saved')
  726. assert.deepEqual(h.approverIds.value, ['u2', 'u1'])
  727. assert.equal(h.selectedNames.value, '乙 → 甲')
  728. h.setDefaults({ status: '1', approverIds: ['u1'] })
  729. await h.open('saved')
  730. assert.deepEqual(h.approverIds.value, [])
  731. })
  732. test('approval failure keeps the saved ID and retry sends only the existing API arguments', async () => {
  733. const h = approvalHarness()
  734. await h.open('saved')
  735. h.remark.value = '提交说明'
  736. h.approvalType.value = 'parallel'
  737. h.setFailure(true)
  738. await h.confirm()
  739. assert.equal(h.visible.value, true)
  740. assert.equal(h.contractId.value, 'saved')
  741. assert.match(h.error.value, /提交失败/)
  742. h.setFailure(false)
  743. await h.confirm()
  744. assert.equal(h.visible.value, false)
  745. assert.deepEqual(h.writes, [
  746. ['saved', ['u2', 'u1'], '提交说明'],
  747. ['saved', ['u2', 'u1'], '提交说明']
  748. ])
  749. })
  750. test('metadata errors block draft and approval handlers before snapshot serialization', async () => {
  751. for (const h of [saveHarness(true, true), submitHarness()]) {
  752. h.mobileProductMetaError.value = '产品加载失败'
  753. if ('handleSaveDraft' in h) await h.handleSaveDraft()
  754. else await h.handleSubmit()
  755. assert.equal(h.writes.length, 0)
  756. }
  757. })
  758. test('mobile draft and approval reject invalid money without strengthening draft required fields', async () => {
  759. for (const key of ['amount', 'freight', 'barrelDeposit']) {
  760. for (const value of [-1, 1.234]) {
  761. for (const h of [saveHarness(true, true), submitHarness()]) {
  762. h.form.value[key] = value
  763. if ('handleSaveDraft' in h) await h.handleSaveDraft()
  764. else await h.handleSubmit()
  765. assert.equal(h.writes.length, 0)
  766. assert.match(String(h.warnings[0]), /非负|2位小数/)
  767. }
  768. }
  769. }
  770. const draft = saveHarness(true, true)
  771. draft.form.value.freight = 0.29
  772. await draft.handleSaveDraft()
  773. assert.equal(draft.writes.length, 1)
  774. })
  775. test('actual mobile price-change totals normalize IEEE tails before display and draft save', async () => {
  776. for (const price of [29.9, 0.1]) {
  777. const h = productHarness(true)
  778. h.form.value.contractType = 'sale'
  779. const row = { productId: 'p', productName: '甲醇', quantity: 3, price, taxRate: 13 }
  780. h.form.value.products = [row]
  781. h.handlePriceChange(row)
  782. assert.equal(h.form.value.amount, price === 29.9 ? 89.7 : 0.3)
  783. assert.equal(validateMobileContractMoney(h.form.value), '')
  784. const draft = saveHarness(true, true)
  785. draft.form.value.amount = h.form.value.amount
  786. await draft.handleSaveDraft()
  787. assert.equal(draft.writes.length, 1)
  788. }
  789. })
  790. test('actual third decimal money remains invalid despite float tolerance', () => {
  791. for (const amount of [1.234, 1.001, 0.001, 1e-20]) {
  792. assert.match(validateMobileContractMoney({ amount }), /2位小数/)
  793. }
  794. })
  795. test('mobile automatic totals round decimal quantities and positive half cents while desktop retains its arithmetic', () => {
  796. for (const [quantity, price, expected] of [
  797. [0.123, 1.23, 0.15],
  798. [1.5, 29.99, 44.99],
  799. [1.5, 0.67, 1.01]
  800. ]) {
  801. for (const page of [true, false]) {
  802. const h = productHarness(page)
  803. h.form.value.contractType = 'sale'
  804. const row = { productId: 'p', quantity, price, taxRate: 13 }
  805. h.form.value.products = [row]
  806. h.handlePriceChange(row)
  807. assert.equal(h.form.value.amount, page ? expected : quantity * price)
  808. if (page) {
  809. assert.equal(h.form.value.products[0].subtotal, expected)
  810. assert.equal(Number(h.form.value.products[0].subtotal.toFixed(2)), expected)
  811. assert.equal(validateMobileContractMoney(h.form.value), '')
  812. }
  813. }
  814. }
  815. })
  816. test('mobile automatic total boundary covers add, remove, quantity, framework modes, loading and quote import', async () => {
  817. const h = productHarness(true)
  818. h.form.value.contractType = 'sale'
  819. const row = () => ({
  820. productId: 'p',
  821. productName: '甲醇',
  822. quantity: 1.5,
  823. price: 29.99,
  824. taxRate: 13
  825. })
  826. h.form.value.products = [row()]
  827. h.handleProductSelected([{ id: 'new', productName: '新产品', price: 1 }])
  828. assert.equal(h.form.value.amount, 45.99)
  829. h.handleRemoveProduct(1)
  830. assert.equal(h.form.value.amount, 44.99)
  831. h.form.value.products[0].quantity = 0.123
  832. h.form.value.products[0].price = 1.23
  833. h.handleQuantityChange(h.form.value.products[0])
  834. assert.equal(h.form.value.amount, 0.15)
  835. for (const type of ['framework_sale', 'framework_purchase', 'framework']) {
  836. h.form.value.contractType = type
  837. h.form.value.products = [{ ...row(), quantityMode: 'fixed', priceMode: 'fixed' }]
  838. h.handleEnableFrameworkQuantityFixed(h.form.value.products[0])
  839. assert.equal(h.form.value.amount, 44.99)
  840. h.handleEnableFrameworkPriceFixed(h.form.value.products[0])
  841. assert.equal(h.form.value.amount, 44.99)
  842. h.handleResetFrameworkPrice(h.form.value.products[0])
  843. assert.equal(h.form.value.amount, 0)
  844. h.handleResetFrameworkQuantity(h.form.value.products[0])
  845. assert.equal(h.form.value.amount, 0)
  846. }
  847. h.form.value.contractType = 'sale'
  848. await h.applyContractProductsToForm([row()], 'sale')
  849. assert.equal(h.form.value.amount, 44.99)
  850. await h.handleQuoteConfirm({ id: 'quote', productList: [row()] })
  851. assert.equal(h.form.value.amount, 44.99)
  852. h.form.value.amount = '1.001'
  853. assert.match(validateMobileContractMoney(h.form.value), /2位小数/)
  854. })
  855. test('desktop calculation source remains intact outside the explicit mobile total boundary', () => {
  856. const baseline = parse(
  857. execFileSync(
  858. 'git',
  859. ['show', 'HEAD:web-admin/src/views/crm/contract/components/ContractFormDialog.vue'],
  860. { encoding: 'utf8' }
  861. )
  862. ).descriptor
  863. const tree = ts.createSourceFile(
  864. 'baseline.ts',
  865. baseline.scriptSetup!.content,
  866. ts.ScriptTarget.Latest,
  867. true
  868. )
  869. for (const name of ['calculateProductAmount', 'calculateTotalAmount']) {
  870. const node = tree.statements.find(
  871. (item) =>
  872. ts.isVariableStatement(item) &&
  873. item.declarationList.declarations.some((d) => d.name.getText(tree) === name)
  874. )!
  875. const current = declaration(name).replace(
  876. ' if (isPageVariant.value) normalizeMobileContractAmounts(form.value)\n',
  877. ''
  878. )
  879. assert.equal(
  880. current,
  881. node
  882. .getText(tree)
  883. .replace(' if (isPageVariant.value) normalizeMobileContractAmounts(form.value)\n', '')
  884. )
  885. }
  886. })
  887. test('mobile total equals the sum of rounded line subtotals and payload uses those same cents', () => {
  888. const h = productHarness(true)
  889. h.form.value.contractType = 'sale'
  890. h.form.value.products = [
  891. { productId: 'p1', productName: '甲醇', quantity: 1.5, price: 29.99 },
  892. { productId: 'p2', productName: '乙醇', quantity: 1.5, price: 29.99 }
  893. ]
  894. h.handlePriceChange(h.form.value.products[0])
  895. assert.deepEqual(
  896. h.form.value.products.map((row) => row.subtotal),
  897. [44.99, 44.99]
  898. )
  899. assert.equal(h.form.value.amount, 89.98)
  900. assert.deepEqual(
  901. h.buildProductsPayload().map((row) => row.subtotal),
  902. [44.99, 44.99]
  903. )
  904. })
  905. test('mobile edit initialization preserves saved contract name while desktop keeps rebuilding', async () => {
  906. for (const page of [true, false]) {
  907. const form = ref<any>({})
  908. const bindings = {
  909. isPageVariant: ref(page),
  910. form,
  911. getContract: async () => ({ data: { id: 'A', contractName: '约定名称' } }),
  912. assertMobileCurrent() {},
  913. originalTemplateContent: ref(''),
  914. handleAttachmentData: async () => {},
  915. customerShortName: ref(''),
  916. customerInvoiceName: ref(''),
  917. confirmedCustomerId: ref(''),
  918. contactEmptyPrompted: ref(false),
  919. customerOptions: ref([]),
  920. contactOptions: ref([]),
  921. syncPartiesSwappedFlagFromPanels() {},
  922. rebuildContractName: () => {
  923. form.value.contractName = '自动生成'
  924. }
  925. }
  926. await evaluate(['loadContractDetail'], bindings).loadContractDetail('A')
  927. assert.equal(form.value.contractName, page ? '约定名称' : '自动生成')
  928. }
  929. })
  930. test('mobile saves preserve entered names; blank names and desktop saves still rebuild', async () => {
  931. for (const kind of ['draft', 'submit']) {
  932. for (const page of [true, false]) {
  933. for (const name of ['约定名称', '']) {
  934. const h: any = kind === 'draft' ? saveHarness(page, true) : submitHarness()
  935. h.isPageVariant.value = page
  936. h.form.value.contractName = name
  937. const method = kind === 'draft' ? 'handleSaveDraft' : 'handleSubmit'
  938. const bindings = { ...h }
  939. delete bindings[method]
  940. const api = evaluate([method], {
  941. ...bindings,
  942. confirmZeroPricingIfNeeded: async () => true,
  943. rebuildContractName: () => {
  944. h.form.value.contractName = '自动生成'
  945. }
  946. })
  947. await api[method]()
  948. assert.equal(h.writes[0].contractName, page && name ? name : '自动生成')
  949. }
  950. }
  951. }
  952. })