crm-child-identity.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { parse } from '@vue/compiler-sfc'
  5. import ts from 'typescript'
  6. import {
  7. buildPackageSpecsPayload,
  8. normalizeContractProductPackageFields
  9. } from '../src/utils/crm-package-spec'
  10. import { partitionQuoteLinesForCopy } from '../src/utils/crm-quote-copy-products'
  11. // Execute the actual form handlers, with UI/network dependencies replaced at the boundary.
  12. function handler(file: string, name: string, bindings: Record<string, any>) {
  13. const source = parse(readFileSync(new URL(`../src/${file}`, import.meta.url), 'utf8')).descriptor
  14. .scriptSetup!.content
  15. const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true)
  16. const statement = ast.statements.find(
  17. (node) =>
  18. (ts.isVariableStatement(node) &&
  19. node.declarationList.declarations.some((d) => d.name.getText(ast) === name)) ||
  20. (ts.isFunctionDeclaration(node) && node.name?.getText(ast) === name)
  21. )
  22. assert.ok(statement, name)
  23. return new Function(
  24. ...Object.keys(bindings),
  25. ts.transpile(statement.getText(ast), { target: ts.ScriptTarget.ESNext }) + `;return ${name}`
  26. )(...Object.values(bindings))
  27. }
  28. const contractFile = 'views/crm/contract/components/ContractFormDialog.vue'
  29. const quoteFile = 'views/crm/quote/components/QuoteFormPanel.vue'
  30. const product = {
  31. id: 'detail-id',
  32. productId: 'catalog-id',
  33. productName: 'Product',
  34. unit: '吨',
  35. packageSpec: '桶',
  36. quantity: 2,
  37. price: 3,
  38. priceTiers: [
  39. {
  40. id: 'tier-id',
  41. contractProductId: 'detail-id',
  42. startDate: '2026-01-01',
  43. endDate: '2026-02-01',
  44. price: 4,
  45. letterOssIds: ['file-id']
  46. }
  47. ]
  48. }
  49. test('desktop and mobile contract mapper retain edit IDs and clear copied line/tier IDs', () => {
  50. const map = handler(contractFile, 'mapContractProductsFromSource', {
  51. normalizeContractProductPackageFields
  52. })
  53. const edit = map([product])[0]
  54. assert.equal(edit.id, 'detail-id')
  55. assert.equal(edit.productId, 'catalog-id')
  56. assert.equal(edit.priceTiers[0].id, 'tier-id')
  57. const copy = map([product], true)[0]
  58. assert.equal(copy.id, undefined)
  59. assert.equal(copy.productId, 'catalog-id')
  60. assert.equal(copy.priceTiers[0].id, undefined)
  61. assert.equal(copy.priceTiers[0].contractProductId, undefined)
  62. assert.equal(product.id, 'detail-id')
  63. })
  64. test('shared desktop and H5 product editor preserves brand and alias IDs on save', async () => {
  65. const file = 'components/business/product-form-dialog/index.vue'
  66. const formData: Record<string, any> = {}
  67. const source = {
  68. id: 'product',
  69. productName: 'Product',
  70. brands: [{ id: 'brand', brandName: 'Brand', status: 'off' }],
  71. aliases: [{ id: 'alias', aliasName: 'Alias' }]
  72. }
  73. const open = handler(file, 'handleOpen', {
  74. formData,
  75. dialogLoading: {},
  76. resetFormData: () => {},
  77. loadCategoryOptions: async () => {},
  78. props: { productId: 'product' },
  79. fetchProductDetail: async () => ({ data: source }),
  80. isPageVariant: { value: false },
  81. ElMessage: { error: assert.fail },
  82. emit: () => {}
  83. })
  84. await open()
  85. formData.brands.push({ brandName: 'New' })
  86. formData.aliases.push({ aliasName: 'New' })
  87. let sent: any
  88. const submit = handler(file, 'submitForm', {
  89. formData,
  90. productImagesUploadRef: {},
  91. formRef: { value: { validate: async () => true } },
  92. submitLoading: {},
  93. isEdit: { value: true },
  94. fetchUpdateProduct: async (data: any) => {
  95. sent = data
  96. },
  97. ElMessage: { success: () => {} },
  98. visible: {},
  99. emit: () => {}
  100. })
  101. await submit()
  102. assert.deepEqual(
  103. sent.brands.map((row: any) => row.id),
  104. ['brand', undefined]
  105. )
  106. assert.deepEqual(
  107. sent.aliases.map((row: any) => row.id),
  108. ['alias', undefined]
  109. )
  110. assert.equal(sent.brands[0].status, 'off')
  111. assert.ok(
  112. readFileSync(new URL('../src/views/m/crm/product/form.vue', import.meta.url), 'utf8').includes(
  113. '@/components/business/product-form-dialog/index.vue'
  114. )
  115. )
  116. assert.ok(
  117. readFileSync(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8').includes(
  118. "encodeURIComponent('/m/crm/product')"
  119. )
  120. )
  121. })
  122. for (const file of [contractFile, 'views/crm/contract/form.vue']) {
  123. test(`${file} sends detail and tier identities independently of catalog ID`, () => {
  124. const form = { value: { id: 'contract', contractType: 'framework_sale', products: [product] } }
  125. const build = handler(file, 'buildProductsPayload', {
  126. form,
  127. buildPackageSpecsPayload,
  128. prepareLineAliasAndBrandForSubmit: () => {},
  129. isFrameworkContractType: () => true,
  130. prepareFrameworkProductForSubmit: (p: any) => ({ quantity: p.quantity, price: p.price })
  131. })
  132. const payload = build()[0]
  133. assert.equal(payload.id, 'detail-id')
  134. assert.equal(payload.productId, 'catalog-id')
  135. assert.equal(payload.priceTiers[0].id, 'tier-id')
  136. })
  137. }
  138. for (const mobile of [false, true]) {
  139. test(`quote ${mobile ? 'mobile' : 'desktop'} load/copy retain only the correct identities`, async () => {
  140. const form = { value: {} as any }
  141. const bindings = {
  142. form,
  143. getQuote: async () => ({ data: { id: 'quote', productList: [product] } }),
  144. isPageVariant: { value: mobile },
  145. customerShortName: { value: '' },
  146. confirmedCustomerId: { value: '' },
  147. opportunityDisplayName: { value: '' },
  148. attachmentList: { value: [] },
  149. ensureCustomerOption: () => {},
  150. ensureContactOption: () => {},
  151. loadContactOptions: async () => {},
  152. enrichQuoteProductBrandMeta: async () => {},
  153. updateQuoteTitleAndNo: () => {},
  154. fetchProductDetail: async () => ({ data: { id: 'catalog-id', status: 'on' } }),
  155. partitionQuoteLinesForCopy,
  156. finishClose: () => {},
  157. $modal: { msgError: assert.fail, msgWarning: assert.fail }
  158. }
  159. await handler(quoteFile, 'getQuoteInfo', bindings)('quote')
  160. assert.equal(form.value.products[0].id, 'detail-id')
  161. assert.equal(form.value.products[0].productId, 'catalog-id')
  162. await handler(quoteFile, 'getQuoteInfoForCopy', bindings)('quote')
  163. assert.equal(form.value.products[0].id, undefined)
  164. assert.equal(form.value.products[0].productId, 'catalog-id')
  165. })
  166. }
  167. test('catalog picker does not put catalog IDs into child IDs', () => {
  168. for (const [file, name, quote] of [
  169. [quoteFile, 'handleProductConfirm', true],
  170. [contractFile, 'handleProductSelected', false]
  171. ] as const) {
  172. const form = { value: { products: [] as any[], contractType: 'sale' } }
  173. const pick = handler(file, name, {
  174. form,
  175. isPageVariant: { value: false },
  176. initLineBrandMetaFromProduct: () => ({}),
  177. normalizeTaxRateDisplay: () => 13,
  178. isFrameworkContractType: () => false,
  179. calculateProductAmount: () => {},
  180. calculateTotalAmount: () => {}
  181. })
  182. pick([{ id: 'catalog-id', productName: 'Product' }])
  183. assert.equal(form.value.products[0].productId, 'catalog-id')
  184. assert.equal(form.value.products[0].id, undefined, `${quote ? 'quote' : 'contract'} new row`)
  185. }
  186. })
  187. test('native mini program uses the same verified CRM mobile forms', () => {
  188. const config = readFileSync(new URL('../../miniprogram/config/api.js', import.meta.url), 'utf8')
  189. for (const domain of ['quote', 'contract']) {
  190. assert.match(
  191. config,
  192. new RegExp(`'crm-${domain}-webview':[\\s\\S]*?encodeURIComponent\\('/m/crm/${domain}'\\)`)
  193. )
  194. const mobile = readFileSync(
  195. new URL(`../src/views/m/crm/${domain}/form.vue`, import.meta.url),
  196. 'utf8'
  197. )
  198. assert.ok(mobile.includes(`@/views/crm/${domain}/components/`))
  199. }
  200. })