mobile-pl-plan-detail.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import {
  4. actualQuantity,
  5. matchStopLinks,
  6. stopFeedbacks,
  7. freightFields,
  8. loadMobileDetailReads
  9. } from '../src/views/m/pl/plan/mobileDetailDisplay'
  10. test('actual quantities use segment actuals including zero, never estimates', () => {
  11. assert.equal(
  12. actualQuantity({ inboundQty: 33.97, outboundQty: 34, planQty: 30 } as any, 'load'),
  13. '33.97'
  14. )
  15. assert.equal(actualQuantity({ inboundQty: 33.97, outboundQty: 34 } as any, 'delivery'), '34')
  16. assert.equal(actualQuantity({ inboundQty: 0 } as any, 'load'), '0')
  17. assert.equal(actualQuantity({ planQty: 30 } as any, 'load'), '—')
  18. })
  19. test('exact stop identity, legacy only plan product identity, never catalog identity', () => {
  20. const stop = {
  21. id: 'a',
  22. products: [
  23. { id: 'line1', productId: 'same', planProductId: 'p1' },
  24. { id: 'line2', productId: 'same', planProductId: 'p2' }
  25. ]
  26. }
  27. const links = [
  28. { stopType: 'delivery', stopId: 'b', planProductId: 'p1' },
  29. { stopType: 'load', stopId: 'a', planProductId: 'p2' },
  30. { planProductId: 'p1' },
  31. { productId: 'same' }
  32. ]
  33. assert.deepEqual(matchStopLinks(links, stop, 'load'), [links[1], links[2]])
  34. assert.equal(
  35. stopFeedbacks(
  36. {
  37. driverStopFeedbacks: [
  38. { stopType: 'delivery', stopId: 'b' },
  39. { stopType: 'load', stopId: 'a' }
  40. ]
  41. } as any,
  42. stop,
  43. 'load'
  44. ).length,
  45. 1
  46. )
  47. })
  48. test('legacy driver feedback remains distinct and keeps zero', () => {
  49. const f = stopFeedbacks(
  50. { feedbackQty: 0, feedbackImages: '["a.pdf"]', feedbackRemark: '旧记录' } as any,
  51. {},
  52. 'load'
  53. )
  54. assert.equal(f[0].quantity, 0)
  55. assert.deepEqual(f[0].attachmentUrls, ['a.pdf'])
  56. assert.equal(stopFeedbacks({ feedbackQty: 3 } as any, {}, 'delivery').length, 0)
  57. })
  58. test('eight freight sources, trip hides quantity and unit price, no freight blanks', () => {
  59. const fields = freightFields(
  60. { hasFreight: 1, freightAmount: 500, products: [{ actualQty: 34 }] } as any,
  61. { freightType: 'by_trip', freightAmount: 999, transportQty: 0, hasInvoice: 0 } as any,
  62. { invoiceAmount: 10 } as any
  63. )
  64. assert.equal(fields.length, 8)
  65. assert.equal(fields[3].value, '—')
  66. assert.equal(fields[4].value, '—')
  67. assert.equal(fields[5].value, '500.00')
  68. assert.equal(fields[6].value, '否')
  69. assert.ok(
  70. freightFields({ hasFreight: 0 } as any, {} as any, null)
  71. .slice(1)
  72. .every((f) => f.value === '—')
  73. )
  74. assert.equal(
  75. freightFields({ hasFreight: 1 } as any, { transportQty: 0 } as any, null)[3].value,
  76. '0'
  77. )
  78. })
  79. test('partial failures are visible and retry performs only declared reads; masked prices untouched', async () => {
  80. let failed = true
  81. const plan: any = {
  82. id: 'p',
  83. transportId: 't',
  84. transportStatus: 'arranged',
  85. products: [{ inboundPrice: null }]
  86. }
  87. const reads: any = {
  88. transport: async () => {
  89. if (failed) throw Error()
  90. return { id: 't' }
  91. },
  92. freight: async () => null,
  93. arrange: async () => ({ inventoryRecords: [] }),
  94. inventory: async () => null,
  95. related: async () => null,
  96. io: async () => null,
  97. vehicle: async () => null
  98. }
  99. const first = await loadMobileDetailReads(plan, reads)
  100. assert.deepEqual(first.errors, ['运输反馈'])
  101. failed = false
  102. const next = await loadMobileDetailReads(plan, reads)
  103. assert.deepEqual(next.errors, [])
  104. assert.equal(plan.products[0].inboundPrice, null)
  105. })
  106. test('production page controller rejects late results from another route', async () => {
  107. const { readFileSync } = await import('node:fs')
  108. const { parse } = await import('@vue/compiler-sfc')
  109. const ts = await import('typescript')
  110. const { ref } = await import('vue')
  111. const source = parse(
  112. readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
  113. ).descriptor.scriptSetup!.content
  114. const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
  115. const fn = ast.statements.find(
  116. (n) => ts.isFunctionDeclaration(n) && n.name?.text === 'loadDetail'
  117. )!
  118. let release!: (value: any) => void
  119. const older = new Promise((resolve) => {
  120. release = resolve
  121. })
  122. const plan = ref<any>(null),
  123. recordId = ref('older'),
  124. readDetail = ref<any>(null),
  125. partialErrors = ref([])
  126. const planReads: unknown[][] = []
  127. const bindings: any = {
  128. plan,
  129. recordId,
  130. readDetail,
  131. partialErrors,
  132. loading: ref(false),
  133. errorText: ref(''),
  134. siteRecords: ref([]),
  135. inventoryRecords: ref([]),
  136. planApi: {
  137. get: async (id: string, includeMobileProductMeta?: boolean) => {
  138. planReads.push([id, includeMobileProductMeta])
  139. return id === 'older' ? older : { id, planType: 'transport', products: [] }
  140. }
  141. },
  142. loadArrangeActions: async () => {},
  143. resetArrangeActions: () => {},
  144. loadMobileDetailReads: async () => ({ errors: [], transport: null }),
  145. transportApi: {},
  146. freightApi: {},
  147. inventoryApi: {},
  148. inboundOrderApi: {},
  149. outboundOrderApi: {},
  150. vehicleApi: {},
  151. personnelApi: {}
  152. }
  153. const load = new Function(
  154. ...Object.keys(bindings),
  155. `let shellReady=true, disposed=false, loadVersion=0; function invalidateRecord(){loadVersion++;plan.value=null;readDetail.value=null} ${ts.transpile(fn.getText(ast), { target: ts.ScriptTarget.ESNext })};return loadDetail`
  156. )(...Object.values(bindings))
  157. const first = load()
  158. recordId.value = 'newer'
  159. await load()
  160. release({ id: 'older' })
  161. await first
  162. assert.equal(plan.value.id, 'newer')
  163. assert.deepEqual(planReads, [
  164. ['older', true],
  165. ['newer', true]
  166. ])
  167. })
  168. test('prefetched arrange and historical snapshots avoid redundant reads and retain stop attachments', async () => {
  169. const snapshot = { licensePlate: '历史车号', personnelList: [{ personnelName: '历史司机' }] }
  170. const feedback: any = {
  171. driverStopFeedbacks: [
  172. {
  173. stopType: 'load',
  174. stopId: 'a',
  175. attachmentUrls: ['load.pdf'],
  176. products: [
  177. { stopProductId: 'r1', planProductId: 'p1', actualQty: 1 },
  178. { stopProductId: 'r2', planProductId: 'p2', actualQty: 2 }
  179. ]
  180. },
  181. { stopType: 'delivery', stopId: 'b', attachmentUrls: ['delivery.mp4'] }
  182. ]
  183. }
  184. let forbidden = 0
  185. const reads: any = {
  186. transport: async () => feedback,
  187. freight: async () => null,
  188. arrange: async () => {
  189. forbidden++
  190. throw Error()
  191. },
  192. vehicle: async () => {
  193. forbidden++
  194. throw Error()
  195. },
  196. inventory: async () => ({ planNo: 'CRK' }),
  197. related: async () => ({ weighing: { netWeight: 34 } })
  198. }
  199. const detail = await loadMobileDetailReads(
  200. {
  201. id: 'p',
  202. planType: 'transport',
  203. transportStatus: 'arranged',
  204. transportId: 't',
  205. vehicleId: 'v',
  206. resourceSnapshot: snapshot
  207. } as any,
  208. reads,
  209. {
  210. inventoryRecords: [
  211. { stopType: 'load', stopId: 'a', dbInventoryId: 'db', planProductId: 'p1' }
  212. ]
  213. }
  214. )
  215. assert.equal(forbidden, 0)
  216. assert.equal(detail.snapshot, snapshot)
  217. const load = stopFeedbacks(detail.transport, { id: 'a' }, 'load')
  218. assert.deepEqual(load[0].attachmentUrls, ['load.pdf'])
  219. assert.deepEqual(
  220. load[0].products?.map((p) => p.stopProductId),
  221. ['r1', 'r2']
  222. )
  223. assert.equal(detail.dibangRows[0].weighing.netWeight, 34)
  224. })
  225. test('segment metadata overrides parent values including explicit empty values', async () => {
  226. const mod: any = await import('../src/views/m/pl/plan/mobileDetailDisplay')
  227. assert.equal(typeof mod.stopProductMetadata, 'function')
  228. const parent: any = [{ id: 'p', aliasName: '装货别名', brandName: '装货品牌' }]
  229. const row: any = {
  230. planProductId: 'p',
  231. mobileProductMeta: { aliasName: '送货别名', brandMode: '', brandId: '', brandName: '' }
  232. }
  233. assert.deepEqual(mod.stopProductMetadata(row, parent), row.mobileProductMeta)
  234. assert.equal(
  235. mod.stopProductMetadata({ ...row, mobileProductMeta: { aliasName: '', brandName: '' } }, parent)
  236. .aliasName,
  237. ''
  238. )
  239. })
  240. test('legacy vehicle personnel certificate failures stay visible and retryable', async () => {
  241. let failed = true
  242. const reads: any = {
  243. vehicle: async () => ({
  244. data: {
  245. licensePlate: '旧车',
  246. personnelList: [{ personnelId: 'driver', personnelName: '司机' }]
  247. }
  248. }),
  249. personnel: async () => {
  250. if (failed) throw Error('cert failed')
  251. return { certList: [{ certName: '从业证', attachmentUrls: 'cert.pdf' }] }
  252. }
  253. }
  254. const plan: any = { id: 'p', planType: 'transport', vehicleId: 'v' }
  255. const result = await loadMobileDetailReads(plan, reads)
  256. assert.deepEqual(result.errors, ['人员证书'])
  257. assert.equal(result.vehicle.personnelList[0].personnelName, '司机')
  258. assert.equal(result.vehicle.personnelList[0].certLoadFailed, true)
  259. failed = false
  260. const retry = await loadMobileDetailReads(plan, reads)
  261. assert.deepEqual(retry.errors, [])
  262. assert.equal(retry.vehicle.personnelList[0].certList[0].attachmentUrls, 'cert.pdf')
  263. })
  264. async function statusHarness(options: { failRead?: boolean; write?: () => Promise<any> } = {}) {
  265. const { readFileSync } = await import('node:fs')
  266. const { parse } = await import('@vue/compiler-sfc')
  267. const ts = await import('typescript')
  268. const { ref } = await import('vue')
  269. const source = parse(
  270. readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
  271. ).descriptor.scriptSetup!.content
  272. const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
  273. const names = ['invalidateRecord', 'loadDetail', 'changePlanStatus']
  274. const code = names
  275. .map((name) =>
  276. ast.statements.find((n) => ts.isFunctionDeclaration(n) && n.name?.text === name)!.getText(ast)
  277. )
  278. .join('\n')
  279. const calls: any[] = []
  280. const full: any = {
  281. id: 'p',
  282. products: [{ id: 'product' }],
  283. load: { products: [{ mobileProductMeta: { aliasName: '分段别名', brandName: '' } }] }
  284. }
  285. const b: any = {
  286. recordId: ref('p'),
  287. plan: ref(full),
  288. readDetail: ref(null),
  289. partialErrors: ref([]),
  290. errorText: ref(''),
  291. loading: ref(false),
  292. acting: ref(false),
  293. deliveryIndex: ref(0),
  294. activeTab: ref('base'),
  295. moreOpen: ref(false),
  296. voidOpen: ref(false),
  297. voidReason: ref(''),
  298. vehicleOpen: ref(false),
  299. siteRecords: ref([]),
  300. inventoryRecords: ref([]),
  301. resetArrangeActions: () => {},
  302. loadArrangeActions: async () => {},
  303. showCrmConfirmDialog: async () => {},
  304. loadMobileDetailReads: async () => ({ errors: [], transport: { id: 'transport' } }),
  305. transportApi: {},
  306. freightApi: {},
  307. inventoryApi: {},
  308. inboundOrderApi: {},
  309. outboundOrderApi: {},
  310. vehicleApi: {},
  311. personnelApi: {},
  312. ElMessage: {
  313. success: (s: string) => calls.push(['success', s]),
  314. warning: (s: string) => calls.push(['warning', s]),
  315. error: (s: string) => calls.push(['error', s])
  316. }
  317. }
  318. const write = async () => {
  319. calls.push(['write'])
  320. return options.write ? options.write() : { id: 'p', followStatus: 'wait_notice' }
  321. }
  322. b.planApi = {
  323. waitNotice: write,
  324. cancelWaitNotice: write,
  325. cancel: write,
  326. get: async (id: string, mobile: boolean) => {
  327. calls.push(['get', id, mobile])
  328. if (options.failRead) throw Error('offline')
  329. return { ...full, id }
  330. }
  331. }
  332. const f = new Function(
  333. ...Object.keys(b),
  334. `let shellReady=true,disposed=false,loadVersion=0,actionVersion=0;${ts.transpile(code, { target: ts.ScriptTarget.ESNext })};return {${names.join(',')}}`
  335. )(...Object.values(b))
  336. return { b, f, calls, full, options }
  337. }
  338. test('status success with scalar PlanRead reloads complete mobile detail for all three actions', async () => {
  339. for (const kind of ['wait', 'cancel-wait', 'void']) {
  340. const h = await statusHarness()
  341. await h.f.changePlanStatus(kind, h.full)
  342. assert.deepEqual(h.b.plan.value.load, h.full.load)
  343. assert.equal(h.b.readDetail.value.transport.id, 'transport')
  344. assert.deepEqual(
  345. h.calls.filter((c) => c[0] === 'get'),
  346. [['get', 'p', true]]
  347. )
  348. assert.equal(h.calls.filter((c) => c[0] === 'write').length, 1)
  349. }
  350. })
  351. test('status write success and failed refresh reports saved and retries only GET', async () => {
  352. const h = await statusHarness({ failRead: true })
  353. await h.f.changePlanStatus('wait', h.full)
  354. assert.match(h.b.errorText.value, /状态已保存.*刷新失败/)
  355. assert.equal(h.b.plan.value, null)
  356. h.options.failRead = false
  357. await h.f.loadDetail()
  358. assert.equal(h.calls.filter((c) => c[0] === 'write').length, 1)
  359. assert.deepEqual(h.b.plan.value.load, h.full.load)
  360. })
  361. test('late status write cannot refresh or replace a new route', async () => {
  362. let release!: (v: any) => void
  363. const waiting = new Promise((resolve) => {
  364. release = resolve
  365. })
  366. const h = await statusHarness({ write: () => waiting })
  367. const action = h.f.changePlanStatus('wait', h.full)
  368. await Promise.resolve()
  369. h.b.recordId.value = 'other'
  370. h.f.invalidateRecord()
  371. await h.f.loadDetail()
  372. release({ id: 'p' })
  373. await action
  374. assert.equal(h.b.plan.value.id, 'other')
  375. assert.deepEqual(
  376. h.calls.filter((c) => c[0] === 'get'),
  377. [['get', 'other', true]]
  378. )
  379. })
  380. test('plan total uses root quantity while stop totals use planQty', async () => {
  381. const { readFileSync } = await import('node:fs')
  382. const { parse } = await import('@vue/compiler-sfc')
  383. const ts = await import('typescript')
  384. const { computed, ref } = await import('vue')
  385. const source = parse(
  386. readFileSync(new URL('../src/views/m/pl/plan/detail.vue', import.meta.url), 'utf8')
  387. ).descriptor.scriptSetup!.content
  388. const ast = ts.createSourceFile('page.ts', source, ts.ScriptTarget.Latest, true)
  389. const names = ['sumProducts', 'loadQty', 'deliveryQty', 'qtyUnitLabel', 'planTotalQtyText']
  390. const code = ast.statements
  391. .filter((node) =>
  392. ts.isFunctionDeclaration(node)
  393. ? names.includes(node.name!.text)
  394. : ts.isVariableStatement(node) &&
  395. node.declarationList.declarations.some((d) => names.includes(d.name.getText(ast)))
  396. )
  397. .map((node) => node.getText(ast))
  398. .join('\n')
  399. const state = {
  400. computed,
  401. plan: ref({
  402. products: [
  403. { quantity: 1, unit: 'pcs' },
  404. { quantity: 2, unit: 'pcs' }
  405. ]
  406. }),
  407. load: ref({ products: [{ planQty: 1 }, { planQty: 2 }] }),
  408. deliveries: ref([{ products: [{ planQty: 1 }] }, { products: [{ planQty: 2 }] }]),
  409. resolveMpProductUnitLabel: () => '个'
  410. }
  411. const values = new Function(
  412. ...Object.keys(state),
  413. ts.transpile(code, { target: ts.ScriptTarget.ESNext }) +
  414. ';return {planTotalQtyText,loadQty,deliveryQty}'
  415. )(...Object.values(state))
  416. assert.equal(values.planTotalQtyText.value, '3个')
  417. assert.equal(values.loadQty.value, 3)
  418. assert.equal(values.deliveryQty.value, 3)
  419. state.plan.value.products[0].quantity = 0
  420. assert.equal(values.planTotalQtyText.value, '2个')
  421. })