mobile-quote-persistence.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { computed, ref } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import { getMpQuoteAmountSummary } from '../src/utils/mp-crm-quote-card'
  8. function component(path: string) {
  9. return parse(readFileSync(new URL(path, import.meta.url), 'utf8')).descriptor
  10. }
  11. const formDescriptor = component('../src/views/crm/quote/components/QuoteFormPanel.vue')
  12. const detailDescriptor = component('../src/views/m/crm/quote/detail.vue')
  13. // Execute actual production declarations. Only transport/UI side effects are stubbed.
  14. function declarations(source: string, names: string[], bindings: Record<string, any>) {
  15. const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true)
  16. const selected = ast.statements.filter((node) =>
  17. ts.isVariableStatement(node)
  18. ? names.includes(node.declarationList.declarations[0].name.getText(ast))
  19. : ts.isFunctionDeclaration(node) && names.includes(node.name?.text || '')
  20. )
  21. const code = ts
  22. .transpile(
  23. selected
  24. .map((node) => node.getText(ast))
  25. .join('\n')
  26. .replace(/export /g, ''),
  27. {
  28. target: ts.ScriptTarget.ESNext,
  29. module: ts.ModuleKind.None
  30. }
  31. )
  32. .replace(/export /g, '')
  33. const found = names.filter((name) =>
  34. selected.some((node) =>
  35. ts.isVariableStatement(node)
  36. ? node.declarationList.declarations[0].name.getText(ast) === name
  37. : (node as ts.FunctionDeclaration).name?.text === name
  38. )
  39. )
  40. return new Function(...Object.keys(bindings), `${code}\nreturn { ${found.join(', ')} }`)(
  41. ...Object.values(bindings)
  42. )
  43. }
  44. function formHarness(page: boolean, prepare: () => boolean | void = () => {}) {
  45. const calls: Array<{ kind: string; payload: any }> = []
  46. let validation: Promise<void> | undefined
  47. const form = ref({
  48. id: 'quote-1',
  49. customerId: 'customer-new',
  50. contactId: 'contact-new',
  51. contactName: '新姓名',
  52. finalAmount: 25,
  53. freight: 5,
  54. barrelDeposit: 3,
  55. products: [
  56. {
  57. productId: 'product-1',
  58. productName: '产品',
  59. quantity: 2,
  60. price: 12.5,
  61. subtotal: 25,
  62. packageSpec: '桶',
  63. deliveryMethod: '送货'
  64. }
  65. ]
  66. })
  67. const transport = (kind: string) => async (payload: any) => {
  68. calls.push({ kind, payload: JSON.parse(JSON.stringify(payload)) })
  69. return {}
  70. }
  71. const bindings = {
  72. ref,
  73. computed,
  74. form,
  75. isPageVariant: ref(page),
  76. formRef: ref({
  77. validate: (fn: (valid: boolean) => Promise<void>) => {
  78. validation = fn(true)
  79. }
  80. }),
  81. submitFormRef: ref({ validate: async () => true }),
  82. submitForm: ref({ approverIds: ['approver-1'], remark: '' }),
  83. submitDialog: ref({ loading: false, visible: true }),
  84. saveLoading: ref(false),
  85. attachmentList: ref([]),
  86. pageMissingMessage: () => '',
  87. syncQuoteProductsForSubmit: prepare,
  88. showFailToast: () => {},
  89. ElMessage: { warning: () => {} },
  90. $modal: { msgSuccess: () => {}, msgError: () => {}, msgWarning: () => {} },
  91. updateQuote: transport('web-update'),
  92. updateMobileQuote: transport('mobile-update'),
  93. submitQuote: transport('submit'),
  94. addQuote: transport('add'),
  95. finishSuccess: () => {},
  96. updateQuoteTitleAndNo: () => {}
  97. }
  98. const api = declarations(
  99. formDescriptor.scriptSetup!.content,
  100. ['calculatedAmount', 'mobileFinalAmount', 'handleSaveDraft', 'handleConfirmApproval'],
  101. bindings
  102. )
  103. return { ...bindings, ...api, calls, saved: () => validation }
  104. }
  105. for (const page of [true, false]) {
  106. test(`${page ? 'mobile' : 'desktop'} draft save dispatches through its own endpoint`, async () => {
  107. const h = formHarness(page)
  108. h.handleSaveDraft()
  109. await h.saved()
  110. assert.deepEqual(
  111. h.calls.map((call) => call.kind),
  112. [page ? 'mobile-update' : 'web-update']
  113. )
  114. assert.equal(h.calls[0].payload.finalAmount, 25)
  115. assert.equal(h.calls[0].payload.contactId, 'contact-new')
  116. })
  117. test(`${page ? 'mobile' : 'desktop'} approval saves via its own endpoint before submitting`, async () => {
  118. const h = formHarness(page)
  119. await h.handleConfirmApproval()
  120. assert.deepEqual(
  121. h.calls.map((call) => call.kind),
  122. [page ? 'mobile-update' : 'web-update', 'submit']
  123. )
  124. assert.equal(h.calls[0].payload.finalAmount, 25)
  125. assert.equal(h.calls[1].payload.id, 'quote-1')
  126. })
  127. }
  128. test('mobile amount includes freight and deposit while desktop and payload keep product amount', () => {
  129. const h = formHarness(true)
  130. // Evaluate the actual mobile display expression, so the baseline displays 25 and fails.
  131. const mobileExpression = formDescriptor.template!.content.match(
  132. /<strong class="money-display">¥\{\{ (.*?) \}\}/
  133. )![1]
  134. const display = new Function(
  135. 'calculatedAmount',
  136. 'mobileFinalAmount',
  137. `return ${mobileExpression}`
  138. )(h.calculatedAmount.value, h.mobileFinalAmount?.value)
  139. assert.equal(display, '33.00')
  140. assert.equal(h.calculatedAmount.value.finalAmount, '25.00')
  141. assert.equal(h.form.value.finalAmount, 25)
  142. assert.match(
  143. formDescriptor.template!.content,
  144. /<span class="money-display" style="font-size: 20px">¥\{\{ calculatedAmount.finalAmount \}\}/
  145. )
  146. h.form.value.freight = 0
  147. h.form.value.barrelDeposit = 0
  148. assert.equal(h.mobileFinalAmount.value, '25.00')
  149. })
  150. test('mobile form display agrees with the detail amount and avoids adding fees to stored finalAmount', () => {
  151. const h = formHarness(true)
  152. const detail = declarations(detailDescriptor.scriptSetup!.content, ['amountSummary'], {
  153. computed,
  154. getMpQuoteAmountSummary,
  155. quote: ref({ totalAmount: 25, finalAmount: 25, freight: 5, barrelDeposit: 3 })
  156. })
  157. assert.equal(Number(h.mobileFinalAmount?.value), detail.amountSummary.value.finalAmount)
  158. })
  159. test('mobile API normalizes contact IDs without changing the original desktop serializer', async () => {
  160. const source = readFileSync(new URL('../src/api/crm/quote/index.ts', import.meta.url), 'utf8')
  161. const calls: any[] = []
  162. const api = declarations(source, ['stringifyBody', 'updateQuote', 'updateMobileQuote'], {
  163. BASE: '/web/crm/quote',
  164. request: { post: async (payload: any) => calls.push(payload) }
  165. })
  166. assert.equal(typeof api.updateMobileQuote, 'function', 'mobile API wrapper is missing')
  167. for (const id of [42, '9007199254740993', '', null, undefined]) {
  168. const input = { id: 1, customerId: 2, contactId: id }
  169. await api.updateMobileQuote(input)
  170. assert.equal(calls.at(-1).url, '/web/crm/quote/mobile-update')
  171. assert.equal(calls.at(-1).data.contactId, id == null ? id : String(id))
  172. assert.equal(input.contactId, id)
  173. }
  174. await api.updateQuote({ id: 1, customerId: 2, contactId: 42 })
  175. assert.equal(calls.at(-1).url, '/web/crm/quote/update')
  176. assert.equal(calls.at(-1).data.contactId, 42)
  177. })
  178. test('rejection confirmation describes editing back to draft and retains approval behavior', async () => {
  179. const dialogs: any[] = []
  180. const requests: any[] = []
  181. const bindings = {
  182. // This confirmation test operates on a valid current approval session.
  183. captureOperation: () => ({ id: 'quote-1', epoch: 1 }),
  184. isCurrentOperation: () => true,
  185. disposed: false,
  186. operationEpoch: 1,
  187. pendingApproval: ref({ id: 'approval-1' }),
  188. approveResult: ref('rejected'),
  189. approveOpinion: ref(' 请补充资料 '),
  190. acting: ref(false),
  191. approveOpen: ref(true),
  192. showFailToast: () => {},
  193. showSuccessToast: () => {},
  194. loadDetail: async () => {},
  195. showCrmConfirmDialog: async (dialog: any) => {
  196. dialogs.push(dialog)
  197. },
  198. approveQuote: async (payload: any) => {
  199. requests.push(payload)
  200. }
  201. }
  202. const h = declarations(detailDescriptor.scriptSetup!.content, ['confirmApprove'], bindings)
  203. await h.confirmApprove()
  204. assert.doesNotMatch(dialogs[0].message, /无法再次提交审批/)
  205. assert.match(dialogs[0].message, /编辑.*草稿.*重新提交审批/)
  206. assert.deepEqual(requests, [
  207. { id: 'approval-1', approvalResult: 'rejected', approvalOpinion: '请补充资料' }
  208. ])
  209. assert.equal(bindings.acting.value, false)
  210. assert.equal(bindings.approveOpen.value, false)
  211. })
  212. for (const action of ['draft', 'approval']) {
  213. test(`mobile ${action} refuses writes when metadata preparation is blocked`, async () => {
  214. const h = formHarness(true, () => false)
  215. if (action === 'draft') {
  216. h.handleSaveDraft()
  217. await h.saved()
  218. } else await h.handleConfirmApproval()
  219. assert.equal(h.calls.length, 0)
  220. })
  221. }