mobile-customer-opportunity-readonly.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFileSync } from 'node:fs'
  4. import { parse, compileTemplate } from '@vue/compiler-sfc'
  5. import * as vue from 'vue'
  6. import ts from 'typescript'
  7. import * as labels from '../src/utils/crm-opportunity-labels'
  8. import { formatViewValue } from '../src/components/business/system-view-dialog/types'
  9. const mobile = parse(
  10. readFileSync(new URL('../src/views/m/crm/customer/RelatedPanel.vue', import.meta.url), 'utf8')
  11. ).descriptor
  12. const web = parse(
  13. readFileSync(
  14. new URL('../src/views/crm/opportunity/components/OpportunityDetailDrawer.vue', import.meta.url),
  15. 'utf8'
  16. )
  17. ).descriptor
  18. function declarations(source: string, names: string[]) {
  19. const ast = ts.createSourceFile('component.ts', source, ts.ScriptTarget.Latest, true)
  20. return ast.statements
  21. .filter((node) =>
  22. ts.isFunctionDeclaration(node)
  23. ? names.includes(node.name!.text)
  24. : ts.isVariableStatement(node) &&
  25. node.declarationList.declarations.some((d) => names.includes(d.name.getText(ast)))
  26. )
  27. .map((node) => node.getText(ast))
  28. .join('\n')
  29. }
  30. function evaluate(code: string, bindings: Record<string, unknown>, result: string) {
  31. const entries = Object.entries(bindings).filter(
  32. ([key]) => /^[a-zA-Z_$][\w$]*$/.test(key) && key !== 'default'
  33. )
  34. return new Function(
  35. ...entries.map(([key]) => key),
  36. ts.transpile(code, { target: ts.ScriptTarget.ESNext }) + ';return ' + result
  37. )(...entries.map(([, value]) => value))
  38. }
  39. function harness(row: Record<string, any> = {}, kind = 'opportunity') {
  40. const detail = vue.ref(row)
  41. const dictMap = vue.reactive<Record<string, any[]>>({
  42. crm_opportunity_type: [{ value: 'new', label: '租户自定义新业务', tagType: 'danger' }],
  43. crm_clue_source: [{ value: 'phone', label: '热线获客', tagType: 'warning' }],
  44. crm_opportunity_stage: [{ value: 'initial', label: '接洽', tagType: 'success' }],
  45. crm_opportunity_status: [{ value: 'following', label: '跟进中', tagType: 'primary' }],
  46. crm_follow_type: [{ value: 'phone', label: '电话回访', tagType: 'danger' }]
  47. })
  48. const dict = {
  49. dictMap,
  50. getOptions: (code: string) => dictMap[code] || [],
  51. getTagType: (code: string, value: string) =>
  52. dictMap[code]?.find((v) => v.value === value)?.tagType || 'info'
  53. }
  54. const names = [
  55. 'fieldsFrom',
  56. 'opportunityFields',
  57. 'contractFields',
  58. 'logFields',
  59. 'detailFields',
  60. 'formatOpportunityMoney',
  61. 'formatOpportunityDateTime'
  62. ]
  63. const api = evaluate(
  64. declarations(mobile.scriptSetup!.content, names),
  65. {
  66. ...labels,
  67. ...vue,
  68. formatViewValue,
  69. props: { kind },
  70. detail,
  71. dict
  72. },
  73. '{detailFields, date: typeof formatOpportunityDateTime === "function" ? formatOpportunityDateTime : undefined, money: typeof formatOpportunityMoney === "function" ? formatOpportunityMoney : undefined}'
  74. )
  75. return { ...api, detail, dict }
  76. }
  77. test('业务字段按Web整组条件出现,非单项过滤;lost和备注严格遵守条件', () => {
  78. const h = harness({ id: '1', status: 'following' })
  79. const keys = () => h.detailFields.value.map((f: any) => f.key)
  80. const business = ['competitors', 'keyFactors', 'description']
  81. for (const key of [...business, 'lostReason', 'remark']) assert.ok(!keys().includes(key), key)
  82. for (const key of business) {
  83. h.detail.value = { id: '1', [key]: '内容' }
  84. for (const included of business) assert.ok(keys().includes(included), included)
  85. }
  86. for (const value of ['', null, undefined, 0, false]) {
  87. h.detail.value = {
  88. id: '1',
  89. competitors: value,
  90. keyFactors: value,
  91. description: value,
  92. status: 'lost',
  93. lostReason: value,
  94. remark: value
  95. }
  96. for (const key of [...business, 'lostReason', 'remark']) assert.ok(!keys().includes(key), key)
  97. }
  98. h.detail.value = {
  99. id: '1',
  100. description: ' ',
  101. status: 'following',
  102. lostReason: '旧原因',
  103. remark: ' '
  104. }
  105. assert.ok(keys().includes('description'), 'Web使用truthy而非trim')
  106. assert.ok(keys().includes('remark'))
  107. assert.ok(!keys().includes('lostReason'))
  108. h.detail.value.status = 'lost'
  109. assert.ok(keys().includes('lostReason'))
  110. })
  111. test('只读金额、计数、百分比、时间、字典和Tag与Web语义对应', () => {
  112. const h = harness({
  113. id: '1',
  114. amount: 1234.5,
  115. stagePercent: 0,
  116. winRate: 25.2,
  117. type: 'new',
  118. source: 'phone',
  119. stage: 'initial',
  120. status: 'following',
  121. nextTime: '2000-01-02T03:04:05.000Z'
  122. })
  123. const field = (key: string) => h.detailFields.value.find((f: any) => f.key === key)
  124. assert.equal(field('amount').value, '¥1,234.50')
  125. assert.equal(field('followCount').value, '0 次')
  126. assert.equal(field('productCount').value, '0 个')
  127. assert.equal(field('stagePercent').value, '0%')
  128. assert.equal(field('winRate').value, '25.2%')
  129. assert.equal(field('type').value, '租户自定义新业务', '字典优先历史fallback')
  130. assert.equal(field('type').tagType, 'primary', 'Web类型固定primary')
  131. assert.equal(field('source').value, '热线获客')
  132. assert.equal(field('source').tagType, 'warning')
  133. assert.equal(field('stage').tagType, 'success')
  134. assert.equal(field('status').tagType, 'primary')
  135. assert.equal(field('nextTime').value, '2000-01-02 03:04:05')
  136. assert.equal(field('nextTime').tagType, undefined, '商机不沿用客户逾期色')
  137. h.detail.value = {
  138. id: '1',
  139. type: 'upgrade',
  140. source: 'referral',
  141. amount: 0,
  142. followCount: 3,
  143. productCount: 4
  144. }
  145. assert.equal(field('type').value, '升级')
  146. assert.equal(field('source').value, '客户推荐')
  147. assert.equal(field('amount').value, '¥0.00')
  148. assert.equal(field('followCount').value, '3 次')
  149. assert.equal(field('productCount').value, '4 个')
  150. assert.equal(field('stagePercent').value, '—')
  151. h.dict.dictMap.crm_opportunity_type = [
  152. { value: 'upgrade', label: '更新后的字典', tagType: 'warning' }
  153. ]
  154. assert.equal(field('type').value, '更新后的字典', '字典晚加载保持响应式')
  155. })
  156. test('金额日期格式直接对照Web实际函数,覆盖零值、空值、非法值与ISO输入', () => {
  157. const baseline = evaluate(
  158. declarations(web.scriptSetup!.content, ['formatMoney', 'formatDateTime']),
  159. {},
  160. '{formatMoney,formatDateTime}'
  161. )
  162. const h = harness()
  163. assert.equal(typeof h.money, 'function')
  164. assert.equal(typeof h.date, 'function')
  165. for (const input of [null, undefined, '', 0, -1234.567, '1234.50', 'bad', '12.3元'])
  166. assert.equal(h.money(input), baseline.formatMoney(input))
  167. for (const input of [
  168. null,
  169. undefined,
  170. '',
  171. '2026-10-23',
  172. '2026-10-23 12:13:14',
  173. '2026-10-23T12:13:14.000Z',
  174. 'bad',
  175. '2026-10-23T12:13:14+08:00'
  176. ])
  177. assert.equal(h.date(input), baseline.formatDateTime(input))
  178. })
  179. test('商机只读模板独立消费格式值及Tag,不套用客户nextTime规则', () => {
  180. const start = mobile.template!.content.indexOf('<section class="mp-wv-section">')
  181. const end = mobile.template!.content.indexOf('</section>', start) + '</section>'.length
  182. const section = mobile.template!.content.slice(start, end)
  183. const { code, errors } = compileTemplate({
  184. source: section,
  185. filename: 'section.vue',
  186. id: 'section'
  187. })
  188. assert.deepEqual(errors, [])
  189. const exports: any = {}
  190. new Function(
  191. 'require',
  192. 'exports',
  193. ts.transpile(code, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext })
  194. )(() => ({ ...vue, resolveComponent: (name: string) => name }), exports)
  195. const h = harness({ id: '1', type: 'new', nextTime: '2000-01-01' })
  196. const node = exports.render(
  197. {
  198. kind: 'opportunity',
  199. detailFields: h.detailFields.value,
  200. detail: h.detail.value,
  201. detailHeading: '商机'
  202. },
  203. []
  204. )
  205. const nodes: any[] = []
  206. function walk(n: any) {
  207. if (!n || typeof n !== 'object') return
  208. if (Array.isArray(n)) return n.forEach(walk)
  209. nodes.push(n)
  210. if (Array.isArray(n.children)) walk(n.children)
  211. else if (n.children && typeof n.children === 'object')
  212. for (const v of Object.values(n.children)) if (typeof v === 'function') walk(v())
  213. }
  214. walk(node)
  215. assert.ok(!nodes.some((n) => n.type === 'CustomerFields'))
  216. assert.ok(nodes.some((n) => n.type === 'span' && n.props?.class?.includes('status-tag--primary')))
  217. assert.ok(nodes.some((n) => n.type === 'van-cell' && n.props?.value === '2000-01-01 00:00:00'))
  218. })
  219. test('合同和日志详情字段列表不受商机显示修复影响', () => {
  220. assert.deepEqual(
  221. harness({}, 'contract').detailFields.value.map((f: any) => f.key),
  222. ['contractName', 'contractNo', 'amount', 'signDate', 'ownerUserName', 'contractType', 'status']
  223. )
  224. assert.deepEqual(
  225. harness({}, 'log').detailFields.value.map((f: any) => f.key),
  226. [
  227. 'title',
  228. 'operName',
  229. 'operTime',
  230. 'operIp',
  231. 'costTime',
  232. 'businessType',
  233. 'status',
  234. 'operUrl',
  235. 'errorMsg'
  236. ]
  237. )
  238. })
  239. test('商机跟进和阶段模板的时间、标签及说明条件对齐Web', () => {
  240. const source = mobile.template!.content
  241. const h = harness()
  242. const expressions = [...source.matchAll(/{{([\s\S]*?)}}/g)].map((m) => m[1].trim())
  243. const row = {
  244. followTime: '2026-10-23T12:13:14.000Z',
  245. createTime: '2026-10-23',
  246. nextTime: '2026-10-24',
  247. followType: 'phone'
  248. }
  249. const valueOf = (needle: string) => {
  250. const expression = expressions.find((v) => v.includes(needle))
  251. assert.ok(expression, needle)
  252. return evaluate('', { row, formatOpportunityDateTime: h.date }, expression)
  253. }
  254. assert.ok(String(valueOf('row.followTime')).includes('2026-10-23 12:13:14'))
  255. assert.equal(valueOf('row.nextTime'), '2026-10-24 00:00:00')
  256. const follow = source.slice(
  257. source.indexOf('v-for="row in follows"'),
  258. source.indexOf('v-for="row in stages"')
  259. )
  260. const tagExpression = follow.match(/:class="([^"]*crm_follow_type[^"]*)"/)?.[1]
  261. assert.ok(tagExpression, 'follow label carries the Web semantic tag type')
  262. assert.equal(evaluate('', { row, dict: h.dict, ...labels }, tagExpression), 'status-tag--danger')
  263. const changeRow = source.match(/<div([^>]*)>\s*<span>变更原因<\/span>/)?.[1]
  264. assert.ok(changeRow?.includes('v-if="row.changeReason"'))
  265. assert.match(
  266. source,
  267. /<span[^>]*class="[^"]*status-tag--danger[^"]*"[^>]*>{{\s*row.lostDetail\s*}}<\/span>/
  268. )
  269. assert.match(
  270. source,
  271. /<span[^>]*class="[^"]*status-tag--success[^"]*"[^>]*>{{\s*row.wonRemark\s*}}<\/span>/
  272. )
  273. })