mobile-pl-plan-load-state.test.ts 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175
  1. import assert from 'node:assert/strict'
  2. import { readFileSync } from 'node:fs'
  3. import test from 'node:test'
  4. import { effectScope, nextTick, ref, watch } from 'vue'
  5. import { parse } from '@vue/compiler-sfc'
  6. import ts from 'typescript'
  7. import { preserveMobileCopyAssociations, assertMobilePersistenceCapability } from '../src/views/m/pl/plan/mobilePlanPersistence'
  8. import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
  9. import * as brand from '../src/utils/crm-product-brand'
  10. import { collectStopContractIds, filterProductsByAllowedProductIds } from '../src/utils/pl-plan-contract-product'
  11. import { retainStopProductsForContracts } from '../src/utils/pl-plan-stop-form'
  12. const source = readFileSync(
  13. new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
  14. 'utf8'
  15. )
  16. const descriptor = parse(source).descriptor
  17. const ast = ts.createSourceFile(
  18. 'form.ts',
  19. descriptor.scriptSetup!.content,
  20. ts.ScriptTarget.Latest,
  21. true
  22. )
  23. function declaration(name: string) {
  24. return ast.statements
  25. .find(
  26. (node) =>
  27. (ts.isFunctionDeclaration(node) && node.name?.text === name) ||
  28. (ts.isVariableStatement(node) &&
  29. node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
  30. )
  31. ?.getText(ast)
  32. }
  33. function deferred<T = any>() {
  34. let resolve!: (value: T) => void
  35. let reject!: (reason: any) => void
  36. const promise = new Promise<T>((yes, no) => {
  37. resolve = yes
  38. reject = no
  39. })
  40. return { promise, resolve, reject }
  41. }
  42. function harness(
  43. options: {
  44. page?: boolean
  45. copy?: boolean
  46. newPlan?: boolean
  47. get?: any
  48. master?: any
  49. contract?: any
  50. enrich?: any
  51. realDependencies?: boolean
  52. realArrange?: boolean
  53. overrides?: Record<string, any>
  54. } = {}
  55. ) {
  56. const writes: any[] = []
  57. const events: any[] = []
  58. const invalidations: string[] = []
  59. const noop = () => {}
  60. const bindings: Record<string, any> = {
  61. mobileStopIdentity,
  62. preserveMobileCopyAssociations,
  63. buildMobileProductLinks: () => ({}),
  64. assertMobilePersistenceCapability: () => {},
  65. ref,
  66. Error,
  67. String,
  68. Object,
  69. Promise,
  70. Set,
  71. Number,
  72. props: {
  73. planId: options.copy || options.newPlan ? undefined : 'plan-1',
  74. copyFromPlanId: options.copy ? 'plan-1' : undefined
  75. },
  76. isPageVariant: ref(options.page !== false),
  77. isEdit: ref(!options.copy && !options.newPlan),
  78. detailLoading: ref(false),
  79. submitLoading: ref(false),
  80. suppressAddressMutex: ref(false),
  81. suppressStopProductSync: ref(false),
  82. formData: { belongsCompany: 'shanxia', createDate: '2026-10-21', products: [] },
  83. loadForm: ref({ products: [], contracts: [] }),
  84. deliveryForms: ref([]),
  85. loadedPlanSnapshot: ref(null),
  86. loadedTransportStatus: ref('pending'),
  87. loadedFollowStatus: ref(''),
  88. selectedDriverId: ref(''),
  89. selectedEscortId: ref(''),
  90. vehicleBundle: ref(null),
  91. contractProductIdsCache: ref({}),
  92. contractProductRowsCache: ref({}),
  93. contractLinesCache: ref({}),
  94. loadContractProductIds: ref([]),
  95. vehicleList: ref([]),
  96. carrierList: ref([]),
  97. driverList: ref([]),
  98. escortList: ref([]),
  99. customerOptions: ref([]),
  100. customerOptionsLoading: ref(false),
  101. vehicleLoading: ref(false),
  102. footerMoreOpen: ref(false),
  103. planApi: {
  104. mobileProductCapability: async () => ({ mobileProductMetaVersion: 1 }),
  105. get:
  106. options.get ||
  107. (async () => ({
  108. data: { id: 'plan-1', belongsCompany: 'shanxia', remark: '原计划', products: [] }
  109. })),
  110. update: async (...args: any[]) => {
  111. writes.push(['update', ...args])
  112. return { id: 'plan-1' }
  113. },
  114. createFull: async (...args: any[]) => {
  115. writes.push(['create', ...args])
  116. return { id: 'new-plan' }
  117. }
  118. },
  119. invalidateContractDetailCache: (id: string) => invalidations.push(id),
  120. getContractDetailCached: options.contract || (async () => ({ id: 'c1', products: [] })),
  121. enrichPlanProductsHazardFromCrm: options.enrich || (async (rows: any) => rows),
  122. enrichPlanProductLinesAlias: async (rows: any) => rows,
  123. mapPlanProductToFormRow: (row: any) => ({ ...row }),
  124. mapPlanProductToCopyFormRow: (row: any) => ({ ...row, id: undefined }),
  125. collectStopContractIds,
  126. retainStopProductsForContracts,
  127. collectContractProductRows: (rows: any) => rows,
  128. collectContractProductIds: (rows: any[]) => rows.map((row) => row.productId),
  129. unionContractProductIds: (cache: any, ids: string[]) => ids.flatMap((id) => cache[id] || []),
  130. resetForm: () => {
  131. bindings.formData.remark = ''
  132. bindings.formData.products = []
  133. bindings.loadedPlanSnapshot.value = null
  134. },
  135. applyStopsFromPlan: (data: any) => {
  136. bindings.loadForm.value = data.load || { products: [], contracts: [] }
  137. bindings.deliveryForms.value = data.deliveries || []
  138. },
  139. syncCommittedPartyIds: noop,
  140. bootstrapStopsFromPlanProducts: noop,
  141. syncSelectedCustomerOptions: noop,
  142. todayYmd: () => '2026-10-21',
  143. formatDate: () => '',
  144. resolveCurrentUserId: () => 'user',
  145. resolveCurrentUserName: () => '用户',
  146. freightCalc: { resetFreightCalc: noop, freightAmount: ref(null) },
  147. ensureSelectedVehicleOption: async () => {},
  148. ensureSelectedDriverOption: async () => {},
  149. ensureSelectedEscortOption: async () => {},
  150. ensureSelectedCarrierOption: async () => {},
  151. handleVehicleChange: async () => {},
  152. restoreFreightFromTransport: async () => {},
  153. reapplyStopDibangByVehiclePlate: noop,
  154. nextTick: async () => {},
  155. mobileMetadata: { ready: () => true },
  156. ElMessage: { error: noop, warning: noop, success: noop },
  157. emit: (...args: any[]) => events.push(args),
  158. innerVisible: ref(true),
  159. basicFormRef: ref({ validate: async () => true, validateField: async () => {} }),
  160. mirrorPlanProductsFromLoad: noop,
  161. buildPlanCommonPayload: () => ({
  162. remark: bindings.formData.remark,
  163. products: bindings.formData.products
  164. }),
  165. pickBxData: (data: any) => data.data ?? data,
  166. validateBusinessRules: () => true,
  167. validateSingleContractPerStop: () => true,
  168. validateContractPackageSpecs: () => true,
  169. validateStopContractsRequired: () => true,
  170. getArrangePrerequisiteGaps: () => [],
  171. validateStopProductsForArrange: () => true,
  172. showPlanSaveWarnings: noop,
  173. performArrangeAfterSave: async (id: string) => {
  174. writes.push(['arrange', id])
  175. return true
  176. },
  177. loadMasterData: options.master || (async () => {}),
  178. vehicleApi: { list: async () => [], get: async (id: string) => ({ id }) },
  179. carrierApi: { list: async () => [], get: async (id: string) => ({ id }) },
  180. personnelApi: { list: async () => [], get: async (id: string) => ({ id }) },
  181. fetchCustomerOptions: async () => [],
  182. extractCustomerOptions: (data: any) => data,
  183. pickBxList: (data: any) => data.data ?? data,
  184. searchVehicles: async () => {},
  185. searchCarriers: async () => {},
  186. searchDrivers: async () => {},
  187. searchEscorts: async () => {},
  188. loadDefaultCustomerOptions: async () => {},
  189. loadVehicleViewBundle: async () => ({ detail: {}, personnelList: [] }),
  190. transportApi: { get: async () => ({}) },
  191. resolveDefaultDriver: noop,
  192. resolveDefaultEscort: noop,
  193. buildMissingHazardCertReadyMessage: () => '',
  194. ElMessageBox: { alert: async () => {} },
  195. createArrangePresenter: () => async () => {},
  196. planHasAnyDibangSelected: () => false,
  197. loadArrangeHistoricalFlags: async () => ({}),
  198. confirmBeforePlanArrange: async () => {},
  199. buildArrangePayloadFromPlanForm: () => ({}),
  200. submitPlanArrange: async (id: string) => {
  201. writes.push(['arrange', id])
  202. },
  203. getUnitLabel: () => '',
  204. ...options.overrides
  205. }
  206. const names = [
  207. 'mobileSavedPlanId',
  208. 'mobileCreateUncertain',
  209. 'pageReady',
  210. 'pageLoadError',
  211. 'pageLoadGeneration',
  212. 'pageDisposed',
  213. 'isPageLoadCurrent',
  214. 'assertPageLoadCurrent',
  215. 'failPageLoad',
  216. 'invalidatePageLoad',
  217. 'validateLoadedPagePlan',
  218. 'handleOpened',
  219. 'ensureMobilePersistenceCapability',
  220. 'loadPlanForEdit',
  221. 'loadPlanForCopy',
  222. 'fetchContractProductIds',
  223. 'prefetchStopContractProducts',
  224. 'handleSubmit',
  225. 'isPageSubmitCurrent',
  226. 'pageLoadErrorMessage'
  227. ]
  228. names.push('ensurePageSelectedOption')
  229. if (options.realDependencies) {
  230. names.push('loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport')
  231. for (const name of ['loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport'])
  232. delete bindings[name]
  233. }
  234. if (options.realArrange) {
  235. names.push('performArrangeAfterSave')
  236. delete bindings.performArrangeAfterSave
  237. }
  238. const present = names.filter((name) => declaration(name))
  239. const code = ts.transpile(present.map((name) => declaration(name)).join('\n'), {
  240. target: ts.ScriptTarget.ESNext
  241. })
  242. const result = new Function(
  243. ...Object.keys(bindings),
  244. code + '; return {' + present.join(',') + '}'
  245. )(...Object.values(bindings))
  246. return { ...bindings, ...result, writes, events, invalidations }
  247. }
  248. async function blocked(h: ReturnType<typeof harness>) {
  249. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) await h.handleSubmit(mode)
  250. assert.deepEqual(h.writes, [], 'failed initialization must never write or arrange')
  251. }
  252. for (const status of [404, 500])
  253. test(`mobile edit GET ${status} stays blocked`, async () => {
  254. const h = harness({
  255. get: async () => {
  256. throw new Error(`HTTP ${status}`)
  257. }
  258. })
  259. await h.handleOpened().catch(() => {})
  260. await blocked(h)
  261. assert.equal(h.pageReady.value, false)
  262. assert.match(h.pageLoadError.value, /404|500/)
  263. assert.equal(h.detailLoading.value, false)
  264. })
  265. test('mobile copy GET failure remains visible and blocked', async () => {
  266. const h = harness({
  267. copy: true,
  268. get: async () => {
  269. throw new Error('copy failed')
  270. }
  271. })
  272. await h.handleOpened().catch(() => {})
  273. await blocked(h)
  274. assert.ok(h.pageLoadError.value)
  275. assert.equal(
  276. h.events.some((event) => event[0] === 'update:visible'),
  277. false
  278. )
  279. })
  280. for (const data of [{}, { id: 'different' }, null])
  281. test(`mobile rejects invalid plan ${JSON.stringify(data)}`, async () => {
  282. const h = harness({ get: async () => ({ data }) })
  283. await h.handleOpened().catch(() => {})
  284. await blocked(h)
  285. assert.ok(h.pageLoadError.value)
  286. })
  287. test('new mobile base dependency failure cannot create', async () => {
  288. const h = harness({
  289. newPlan: true,
  290. master: async () => {
  291. throw new Error('base failed')
  292. }
  293. })
  294. await h.handleOpened().catch(() => {})
  295. await blocked(h)
  296. })
  297. test('contract failure preserves historical products, does not cache empty, retry recovers and saves original payload', async () => {
  298. let failed = true
  299. const h = harness({
  300. get: async () => ({
  301. id: 'plan-1',
  302. belongsCompany: 'shanxia',
  303. remark: '原计划',
  304. products: [{ productId: 'p1' }],
  305. load: { contracts: [{ contractId: 'c1' }], products: [{ productId: 'p1', planQty: 12 }] }
  306. }),
  307. contract: async () => {
  308. if (failed) throw new Error('contract failed')
  309. return { id: 'c1', products: [{ productId: 'p1' }] }
  310. }
  311. })
  312. await h.handleOpened().catch(() => {})
  313. await blocked(h)
  314. assert.deepEqual(h.contractProductIdsCache.value, {})
  315. assert.equal(h.loadForm.value.products[0].planQty, 12)
  316. failed = false
  317. await h.handleOpened()
  318. assert.equal(h.pageReady.value, true)
  319. await h.handleSubmit('save')
  320. assert.deepEqual(h.writes, [
  321. ['update', 'plan-1', { remark: '原计划', products: [{ productId: 'p1' }], saveMode: 'formal' }]
  322. ])
  323. })
  324. for (const lateError of [false, true])
  325. test(`retry ignores old ${lateError ? 'error' : 'success'} and finally`, async () => {
  326. const old = deferred()
  327. const fresh = deferred()
  328. let count = 0
  329. const h = harness({ get: () => (++count === 1 ? old.promise : fresh.promise) })
  330. const first = h.handleOpened().catch(() => {})
  331. while (count < 1) await Promise.resolve()
  332. const second = h.handleOpened()
  333. while (count < 2) await Promise.resolve()
  334. if (lateError) old.reject(new Error('old error'))
  335. else old.resolve({ id: 'plan-1', remark: 'stale' })
  336. await first
  337. assert.equal(h.detailLoading.value, true)
  338. assert.equal(h.formData.remark, '')
  339. assert.equal(h.pageLoadError.value, '')
  340. fresh.resolve({ id: 'plan-1', remark: 'fresh', products: [] })
  341. await second
  342. assert.equal(h.formData.remark, 'fresh')
  343. assert.equal(h.pageReady.value, true)
  344. })
  345. test('unmount invalidates pending detail without late ref writes or loading events', async () => {
  346. const request = deferred()
  347. const h = harness({ get: () => request.promise })
  348. const work = h.handleOpened()
  349. await Promise.resolve()
  350. await Promise.resolve()
  351. h.invalidatePageLoad()
  352. const before = JSON.stringify(h.formData)
  353. request.resolve({ id: 'plan-1', remark: 'stale' })
  354. await work
  355. assert.equal(JSON.stringify(h.formData), before)
  356. assert.equal(h.pageReady.value, false)
  357. })
  358. test('desktop retains copy failure close, failed contract empty cache and draft save behavior', async () => {
  359. const h = harness({
  360. page: false,
  361. copy: true,
  362. get: async () => {
  363. throw new Error('404')
  364. },
  365. contract: async () => {
  366. throw new Error('offline')
  367. }
  368. })
  369. await h.handleOpened()
  370. assert.ok(h.events.some((event) => event[0] === 'update:visible' && event[1] === false))
  371. assert.deepEqual(await h.fetchContractProductIds('c1'), [])
  372. assert.deepEqual(h.contractProductIdsCache.value, { c1: [] })
  373. await h.handleSubmit('draft')
  374. assert.equal(h.writes[0][0], 'create')
  375. })
  376. test('page template gates form and write actions on readiness and exposes retry/return', () => {
  377. assert.match(descriptor.template!.content, /v-if="isPageVariant && pageLoadError"/)
  378. assert.match(descriptor.template!.content, /v-show="!isPageVariant \|\| pageReady"/)
  379. assert.match(descriptor.template!.content, /v-if="isPageVariant && pageReady"/)
  380. assert.match(descriptor.template!.content, /重新加载/)
  381. assert.match(source, /onBeforeUnmount\(invalidatePageLoad\)/)
  382. })
  383. for (const dependency of ['vehicleApi', 'carrierApi', 'personnelApi', 'fetchCustomerOptions']) {
  384. test(`real mobile base loader fails closed for ${dependency}`, async () => {
  385. const fail = async () => {
  386. throw new Error(`${dependency} failed`)
  387. }
  388. const overrides =
  389. dependency === 'fetchCustomerOptions'
  390. ? { fetchCustomerOptions: fail }
  391. : { [dependency]: { list: fail } }
  392. const h = harness({ newPlan: true, realDependencies: true, overrides })
  393. await h.handleOpened()
  394. await blocked(h)
  395. assert.match(h.pageLoadError.value, /failed/)
  396. })
  397. }
  398. test('late base dependency cannot replace new option lists', async () => {
  399. const old = deferred()
  400. let count = 0
  401. const h = harness({
  402. newPlan: true,
  403. realDependencies: true,
  404. overrides: {
  405. vehicleApi: { list: () => (++count === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }
  406. }
  407. })
  408. const first = h.handleOpened()
  409. while (count < 1) await Promise.resolve()
  410. await h.handleOpened()
  411. old.resolve([{ id: 'old' }])
  412. await first
  413. assert.deepEqual(h.vehicleList.value, [{ id: 'fresh' }])
  414. assert.equal(h.pageReady.value, true)
  415. })
  416. test('late product enrichment cannot overwrite retry products or release new suppression flags', async () => {
  417. const old = deferred()
  418. const fresh = deferred()
  419. let count = 0
  420. const h = harness({ enrich: () => (++count === 1 ? old.promise : fresh.promise) })
  421. const first = h.handleOpened()
  422. while (count < 1) await Promise.resolve()
  423. const second = h.handleOpened()
  424. while (count < 2) await Promise.resolve()
  425. old.resolve([{ productId: 'old' }])
  426. await first
  427. assert.equal(h.suppressStopProductSync.value, true)
  428. assert.deepEqual(h.formData.products, [])
  429. fresh.resolve([{ productId: 'fresh' }])
  430. await second
  431. assert.deepEqual(h.formData.products, [{ productId: 'fresh' }])
  432. })
  433. test('late vehicle bundle cannot overwrite retry refs or clear the new vehicle loading flag', async () => {
  434. const old = deferred()
  435. const fresh = deferred()
  436. let count = 0
  437. const h = harness({
  438. realDependencies: true,
  439. get: async () => ({ id: 'plan-1', vehicleId: 'v1' }),
  440. overrides: { loadVehicleViewBundle: () => (++count === 1 ? old.promise : fresh.promise) }
  441. })
  442. const first = h.handleOpened()
  443. while (count < 1) await Promise.resolve()
  444. const second = h.handleOpened()
  445. while (count < 2) await Promise.resolve()
  446. old.resolve({ detail: { carrierId: 'old' }, personnelList: [] })
  447. await first
  448. assert.equal(h.vehicleLoading.value, true)
  449. assert.equal(h.vehicleBundle.value, null)
  450. fresh.resolve({ detail: { carrierId: 'fresh' }, personnelList: [] })
  451. await second
  452. assert.equal(h.formData.carrierId, 'fresh')
  453. })
  454. test('late contract success cannot populate the current generation cache', async () => {
  455. const old = deferred()
  456. const h = harness({ contract: () => old.promise })
  457. const fetch = h.fetchContractProductIds('c1').catch(() => {})
  458. await h.handleOpened()
  459. old.resolve({ products: [{ productId: 'old' }] })
  460. await fetch
  461. assert.deepEqual(h.contractProductIdsCache.value, {})
  462. assert.equal(h.pageLoadError.value, '')
  463. })
  464. test('successful mobile copy clears source ids and transport people before creating', async () => {
  465. const original = {
  466. id: 'plan-1',
  467. belongsCompany: 'shanxia',
  468. vehicleId: 'v1',
  469. driverId: 'd1',
  470. escortId: 'e1',
  471. load: {
  472. id: 'load-1',
  473. contracts: [],
  474. products: [{ id: 'line-1', planProductId: 'product-row-1' }]
  475. },
  476. deliveries: [
  477. { id: 'stop-1', contracts: [], products: [{ id: 'line-2', planProductId: 'product-row-2' }] }
  478. ]
  479. }
  480. const h = harness({ copy: true, get: async () => structuredClone(original) })
  481. await h.handleOpened()
  482. assert.equal(h.pageReady.value, true)
  483. assert.equal(h.loadForm.value.id, undefined)
  484. assert.equal(h.loadForm.value.products[0].planProductId, undefined)
  485. assert.equal(h.deliveryForms.value[0].id, undefined)
  486. assert.equal(h.deliveryForms.value[0].products[0].id, undefined)
  487. assert.equal(h.formData.vehicleId, '')
  488. assert.equal(h.selectedDriverId.value, '')
  489. assert.equal(h.selectedEscortId.value, '')
  490. await h.handleSubmit('draft')
  491. assert.equal(h.writes[0][0], 'create')
  492. assert.equal(
  493. h.writes.some((row) => row[0] === 'update'),
  494. false
  495. )
  496. })
  497. test('draft keeps minimal fields and formal save still requires original validation', async () => {
  498. const h = harness({ newPlan: true })
  499. await h.handleOpened()
  500. h.basicFormRef.value.validate = async () => false
  501. await h.handleSubmit('save')
  502. assert.deepEqual(h.writes, [])
  503. await h.handleSubmit('draft')
  504. assert.equal(h.writes[0][0], 'create')
  505. })
  506. test('desktop edit GET error still propagates and clears the loading state', async () => {
  507. const h = harness({
  508. page: false,
  509. get: async () => {
  510. throw new Error('desktop failure')
  511. }
  512. })
  513. await assert.rejects(h.handleOpened(), /desktop failure/)
  514. assert.equal(h.detailLoading.value, false)
  515. assert.equal(h.pageLoadError.value, '')
  516. })
  517. test('empty dependency error still provides a visible retry state', async () => {
  518. const h = harness({
  519. master: async () => {
  520. throw new Error('')
  521. }
  522. })
  523. await h.handleOpened()
  524. assert.ok(h.pageLoadError.value)
  525. })
  526. test('new initialization resets vehicle loading left by an invalidated generation', async () => {
  527. const h = harness({ newPlan: true })
  528. h.vehicleLoading.value = true
  529. await h.handleOpened()
  530. assert.equal(h.vehicleLoading.value, false)
  531. })
  532. for (const kind of ['driver', 'transport'])
  533. test(`${kind} refill failure prevents saving mobile details`, async () => {
  534. const fail = async () => {
  535. throw new Error(`${kind} failed`)
  536. }
  537. const h = harness({
  538. realDependencies: true,
  539. get: async () => ({
  540. id: 'plan-1',
  541. driverId: kind === 'driver' ? 'd1' : '',
  542. transportId: kind === 'transport' ? 't1' : ''
  543. }),
  544. overrides:
  545. kind === 'driver'
  546. ? { personnelApi: { list: async () => [], get: fail } }
  547. : { transportApi: { get: fail } }
  548. })
  549. await h.handleOpened()
  550. await blocked(h)
  551. assert.match(h.pageLoadError.value, /failed/)
  552. })
  553. function deliveryWatcher(h: ReturnType<typeof harness>) {
  554. const watcher = ast.statements.find(
  555. (node) =>
  556. ts.isExpressionStatement(node) &&
  557. node.getText(ast).startsWith('watch(') &&
  558. node.getText(ast).includes('contractKey: collectStopContractIds')
  559. )
  560. assert.ok(watcher)
  561. let callback!: (...args: any[]) => Promise<void>
  562. const bindings = {
  563. mobileStopIdentity, ...h,
  564. pageLoadGeneration: 1,
  565. deliveryContractFetchSeqByIndex: {},
  566. watch: (_source: any, cb: any) => {
  567. callback = cb
  568. }
  569. }
  570. new Function(
  571. ...Object.keys(bindings),
  572. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  573. )(...Object.values(bindings))
  574. return callback
  575. }
  576. test('mobile removing a delivery contract still clears its products', async () => {
  577. const h = harness()
  578. await h.handleOpened()
  579. h.deliveryForms.value = [{ products: [{ productId: 'p1', contractId: 'c1' }], contracts: [] }]
  580. await deliveryWatcher(h)([{ contractKey: '' }], [{ contractKey: 'c1' }])
  581. assert.deepEqual(h.deliveryForms.value[0].products, [])
  582. })
  583. test('delivery contract metadata failure preserves historical products', async () => {
  584. const h = harness({
  585. contract: async () => {
  586. throw new Error('offline')
  587. }
  588. })
  589. await h.handleOpened()
  590. h.deliveryForms.value = [{ products: [{ productId: 'p1' }], contracts: [{ contractId: 'c1' }] }]
  591. await deliveryWatcher(h)([{ contractKey: 'c1' }], [{ contractKey: 'old' }])
  592. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'p1' }])
  593. assert.equal(h.pageReady.value, false)
  594. })
  595. function enrichmentHelper(file: string, name: string, fetchProductDetail: any) {
  596. const text = readFileSync(new URL(`../src/composables/${file}.ts`, import.meta.url), 'utf8')
  597. const source = ts.createSourceFile('helper.ts', text, ts.ScriptTarget.Latest, true)
  598. const declaration = source.statements.find(
  599. (node) => ts.isFunctionDeclaration(node) && node.name?.text === name
  600. )
  601. assert.ok(declaration)
  602. const code = ts.transpile(declaration.getText(source).replace(/^export\s+/, ''), {
  603. target: ts.ScriptTarget.ESNext
  604. })
  605. return new Function('fetchProductDetail', ...Object.keys(brand), code + `;return ${name}`)(
  606. fetchProductDetail,
  607. ...Object.values(brand)
  608. )
  609. }
  610. for (const [file, name] of [
  611. ['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
  612. ['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
  613. ]) {
  614. test(`${name} strict mode rejects failed product metadata while default preserves existing behavior`, async () => {
  615. const helper = enrichmentHelper(file, name, async () => {
  616. throw new Error('product offline')
  617. })
  618. const rows = [
  619. {
  620. productId: 'p1',
  621. productName: '历史产品',
  622. isToxic: 1,
  623. brandMode: 'required',
  624. brandId: 'b1',
  625. brandName: '品牌'
  626. }
  627. ]
  628. const before = structuredClone(rows)
  629. await assert.rejects(helper(rows, true), /product offline/)
  630. assert.deepEqual(rows, before)
  631. const fallback = await helper(structuredClone(rows))
  632. assert.equal(fallback[0].brandId, 'b1')
  633. assert.equal(fallback[0].isToxic, 1)
  634. })
  635. }
  636. for (const copy of [false, true])
  637. test(`mobile ${copy ? 'copy' : 'edit'} opts into strict product initialization`, async () => {
  638. const strictArgs: unknown[] = []
  639. const h = harness({
  640. copy,
  641. enrich: async (rows: any, strict: boolean) => {
  642. strictArgs.push(strict)
  643. return rows
  644. },
  645. overrides: {
  646. enrichPlanProductLinesAlias: async (rows: any, strict: boolean) => {
  647. strictArgs.push(strict)
  648. return rows
  649. }
  650. }
  651. })
  652. await h.handleOpened()
  653. assert.deepEqual(strictArgs, [true, true])
  654. })
  655. test('delivery slow contract completion cannot clear products selected after fast contract completion', async () => {
  656. const slow = deferred()
  657. const h = harness({
  658. contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
  659. })
  660. await h.handleOpened()
  661. const watchDelivery = deliveryWatcher(h)
  662. h.deliveryForms.value = [
  663. { contracts: [{ contractId: 'slow' }], products: [{ productId: 'original' }] }
  664. ]
  665. const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
  666. h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
  667. await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
  668. h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
  669. slow.resolve({ id: 'slow', products: [] })
  670. await first
  671. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
  672. })
  673. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) {
  674. for (const newPlan of [false, true]) {
  675. test(`late ${newPlan ? 'create' : 'update'} ${mode} cannot emit, arrange or clear a new session busy state`, async () => {
  676. const write = deferred()
  677. let started = false
  678. const h = harness({ newPlan })
  679. await h.handleOpened()
  680. h.formData.vehicleId = 'v1'
  681. const apiMethod = newPlan ? 'createFull' : 'update'
  682. h.planApi[apiMethod] = async () => {
  683. started = true
  684. return write.promise
  685. }
  686. const saving = h.handleSubmit(mode)
  687. while (!started) await Promise.resolve()
  688. await h.handleOpened()
  689. h.submitLoading.value = true
  690. h.events.splice(0)
  691. write.resolve({ id: 'new-plan' })
  692. await saving
  693. assert.equal(h.submitLoading.value, true)
  694. assert.deepEqual(h.events, [])
  695. assert.deepEqual(h.writes, [])
  696. })
  697. }
  698. }
  699. for (const stage of ['detail', 'history', 'confirm', 'arrange']) {
  700. test(`arrange ${stage} completion after unmount cannot continue or emit`, async () => {
  701. const pending = deferred()
  702. let started = false
  703. const pause = async () => {
  704. started = true
  705. return pending.promise
  706. }
  707. const h = harness({
  708. realArrange: true,
  709. overrides:
  710. stage === 'confirm'
  711. ? { confirmBeforePlanArrange: pause }
  712. : stage === 'history'
  713. ? { loadArrangeHistoricalFlags: pause }
  714. : stage === 'arrange'
  715. ? { submitPlanArrange: pause }
  716. : {}
  717. })
  718. await h.handleOpened()
  719. h.formData.vehicleId = 'v1'
  720. if (stage === 'detail') h.planApi.get = pause
  721. else
  722. h.planApi.get = async () => ({
  723. id: 'plan-1',
  724. transportStatus: stage === 'history' ? 'arranged' : 'pending',
  725. products: []
  726. })
  727. const work = h.performArrangeAfterSave('plan-1')
  728. while (!started) await Promise.resolve()
  729. h.invalidatePageLoad()
  730. h.events.splice(0)
  731. pending.resolve(stage === 'detail' ? { id: 'plan-1', products: [] } : {})
  732. await work
  733. assert.deepEqual(h.events, [])
  734. assert.deepEqual(h.writes, [])
  735. })
  736. }
  737. for (const [file, name] of [
  738. ['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
  739. ['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
  740. ]) {
  741. for (const response of [{}, { data: null }, { id: 'other' }]) {
  742. test(`${name} strict rejects malformed 200 ${JSON.stringify(response)} and preserves snapshot`, async () => {
  743. const helper = enrichmentHelper(file, name, async () => response)
  744. const rows = [
  745. {
  746. productId: 'p1',
  747. productName: '历史产品',
  748. isToxic: 1,
  749. brandMode: 'required',
  750. brandId: 'brand-1'
  751. }
  752. ]
  753. const before = structuredClone(rows)
  754. await assert.rejects(helper(rows, true), /产品/)
  755. assert.deepEqual(rows, before)
  756. await assert.doesNotReject(helper(structuredClone(rows)))
  757. })
  758. }
  759. }
  760. for (const response of [
  761. {},
  762. null,
  763. { id: 'other', products: [] },
  764. { id: 'c1' },
  765. { id: 'c1', products: {} }
  766. ]) {
  767. test(`mobile contract malformed 200 ${JSON.stringify(response)} cannot cache an empty list`, async () => {
  768. const h = harness({ contract: async () => response })
  769. await assert.rejects(h.fetchContractProductIds('c1'), /合同/)
  770. assert.deepEqual(h.contractProductIdsCache.value, {})
  771. assert.deepEqual(h.invalidations, ['c1'])
  772. assert.equal(h.pageReady.value, false)
  773. })
  774. }
  775. for (const copy of [false, true])
  776. test(`mobile ${copy ? 'copy' : 'edit'} HTTP 404 error is actionable Chinese`, async () => {
  777. const error = Object.assign(new Error('Request failed with status code 404'), {
  778. response: { status: 404 }
  779. })
  780. const h = harness({
  781. copy,
  782. get: async () => {
  783. throw error
  784. }
  785. })
  786. await h.handleOpened()
  787. assert.equal(h.pageLoadError.value, copy ? '复制来源不存在或已删除' : '运输计划不存在或已删除')
  788. })
  789. test('mobile network error is Chinese while backend business message is preserved', async () => {
  790. for (const [error, expected] of [
  791. [new Error('Network Error'), '网络连接失败,请检查网络后重新加载'],
  792. [
  793. Object.assign(new Error('Request failed with status code 500'), {
  794. response: { data: { message: '当前租户无权查看此计划' } }
  795. }),
  796. '当前租户无权查看此计划'
  797. ]
  798. ]) {
  799. const h = harness({
  800. get: async () => {
  801. throw error
  802. }
  803. })
  804. await h.handleOpened()
  805. assert.equal(h.pageLoadError.value, expected)
  806. }
  807. })
  808. test('mobile error actions meet the 44px minimum tap target', () => {
  809. const errorStyle = descriptor.styles[0].content.split('.mp-plan-form-page')[0]
  810. assert.match(errorStyle, /\.mp-plan-load-error[\s\S]*?\.el-button[\s\S]*?min-height:\s*44px/)
  811. })
  812. test('obsolete delivery contract error does not close the ready fast selection', async () => {
  813. const slow = deferred()
  814. const h = harness({
  815. contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
  816. })
  817. await h.handleOpened()
  818. const watchDelivery = deliveryWatcher(h)
  819. h.deliveryForms.value = [{ contracts: [{ contractId: 'slow' }], products: [] }]
  820. const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
  821. h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
  822. await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
  823. h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
  824. slow.reject(new Error('old contract error'))
  825. await first
  826. assert.equal(h.pageReady.value, true)
  827. assert.equal(h.pageLoadError.value, '')
  828. assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
  829. })
  830. test('arrange hazard completion after unmount does not continue to confirmation', async () => {
  831. const pending = deferred()
  832. let calls = 0
  833. let started = false
  834. let confirmed = false
  835. const h = harness({
  836. realArrange: true,
  837. enrich: async (rows: any) => {
  838. calls += 1
  839. if (calls === 1) return rows
  840. started = true
  841. return pending.promise
  842. },
  843. overrides: {
  844. confirmBeforePlanArrange: async () => {
  845. confirmed = true
  846. }
  847. }
  848. })
  849. await h.handleOpened()
  850. h.formData.vehicleId = 'v1'
  851. const work = h.performArrangeAfterSave('plan-1')
  852. while (!started) await Promise.resolve()
  853. h.invalidatePageLoad()
  854. pending.resolve([])
  855. await work
  856. assert.equal(confirmed, false)
  857. assert.deepEqual(h.events, [])
  858. assert.deepEqual(h.writes, [])
  859. })
  860. function loadWatcher(h: ReturnType<typeof harness>) {
  861. const watcher = ast.statements.find(
  862. (node) =>
  863. ts.isExpressionStatement(node) &&
  864. node.getText(ast).startsWith('watch(') &&
  865. node.getText(ast).includes('() => loadForm.value.contracts')
  866. )
  867. assert.ok(watcher)
  868. let callback!: (...args: any[]) => Promise<void>
  869. const bindings = {
  870. mobileStopIdentity, ...h,
  871. pageLoadGeneration: 1,
  872. loadContractFetchSeq: 0,
  873. filterProductsByAllowedProductIds,
  874. syncFromLoadProducts: () => {},
  875. watch: (_source: any, cb: any) => {
  876. callback = cb
  877. }
  878. }
  879. new Function(
  880. ...Object.keys(bindings),
  881. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  882. )(...Object.values(bindings))
  883. return callback
  884. }
  885. for (const rejects of [false, true])
  886. test(`load contract old ${rejects ? 'error' : 'success'} cannot affect current selection`, async () => {
  887. const slow = deferred()
  888. const h = harness({
  889. contract: (id: string) =>
  890. id === 'slow'
  891. ? slow.promise
  892. : Promise.resolve({ id, products: [{ productId: 'fast-product' }] })
  893. })
  894. await h.handleOpened()
  895. const watchLoad = loadWatcher(h)
  896. h.loadForm.value = { contracts: [{ contractId: 'slow' }], products: [] }
  897. const first = watchLoad([{ contractId: 'slow' }], [{ contractId: 'original' }])
  898. h.loadForm.value.contracts = [{ contractId: 'fast' }]
  899. await watchLoad([{ contractId: 'fast' }], [{ contractId: 'slow' }])
  900. h.loadForm.value.products = [{ productId: 'fast-product' }]
  901. if (rejects) slow.reject(new Error('obsolete'))
  902. else slow.resolve({ id: 'slow', products: [{ productId: 'slow-product' }] })
  903. await first
  904. assert.deepEqual(h.loadForm.value.products, [{ productId: 'fast-product' }])
  905. assert.deepEqual(h.loadContractProductIds.value, ['fast-product'])
  906. assert.equal(h.pageReady.value, true)
  907. assert.equal(h.pageLoadError.value, '')
  908. })
  909. for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange'])
  910. test(`desktop ${mode} keeps its save and arrange completion`, async () => {
  911. const h = harness({ page: false, realArrange: true })
  912. await h.handleOpened()
  913. h.formData.vehicleId = 'v1'
  914. h.invalidatePageLoad()
  915. await h.handleSubmit(mode)
  916. assert.equal(h.writes[0][0], 'update')
  917. assert.equal(
  918. h.writes.some((row) => row[0] === 'arrange'),
  919. mode === 'saveAndArrange' || mode === 'rearrange'
  920. )
  921. assert.equal(h.events.filter((event) => event[0] === 'success').length, 1)
  922. assert.equal(h.submitLoading.value, false)
  923. })
  924. // Execute the production watcher through Vue's scheduler, together with the real
  925. // fetchContractProductIds closure from harness(). Only the contract API is stubbed.
  926. function mountDeliveryWatcher(h: ReturnType<typeof harness>) {
  927. const watcher = ast.statements.find(
  928. (node) =>
  929. ts.isExpressionStatement(node) &&
  930. node.getText(ast).startsWith('watch(') &&
  931. node.getText(ast).includes('contractKey: collectStopContractIds')
  932. )
  933. assert.ok(watcher)
  934. const bindings = { ...h, mobileStopIdentity, watch, pageLoadGeneration: 1, deliveryContractFetchSeqByIndex: {} }
  935. const scope = effectScope()
  936. scope.run(() =>
  937. new Function(
  938. ...Object.keys(bindings),
  939. ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
  940. )(...Object.values(bindings))
  941. )
  942. return () => scope.stop()
  943. }
  944. async function flushDeliveryWatchers() {
  945. for (let tick = 0; tick < 30; tick += 1) await nextTick()
  946. }
  947. for (const page of [true, false])
  948. test(`real Vue delivery watcher ${page ? 'mobile' : 'desktop'} clears both changed segments despite crossed requests`, async () => {
  949. const slow = deferred()
  950. const h = harness({
  951. page,
  952. contract: async (id: string) => {
  953. if (id === 'new-0') await slow.promise
  954. return { id, products: [] }
  955. }
  956. })
  957. await h.handleOpened()
  958. h.deliveryForms.value = [
  959. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  960. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  961. ]
  962. const stop = mountDeliveryWatcher(h)
  963. try {
  964. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  965. await nextTick()
  966. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  967. await nextTick()
  968. slow.resolve({})
  969. await flushDeliveryWatchers()
  970. assert.deepEqual(
  971. h.deliveryForms.value.map((row: any) => row.products),
  972. [[], []]
  973. )
  974. assert.equal(h.pageLoadError.value, '')
  975. } finally {
  976. stop()
  977. }
  978. })
  979. test('real Vue watcher does not let an earlier batched callback supersede a later segment selection', async () => {
  980. const slow = deferred()
  981. const fast = deferred()
  982. const requests: string[] = []
  983. const h = harness({
  984. contract: async (id: string) => {
  985. requests.push(id)
  986. if (id === 'new-0') await slow.promise
  987. if (id === 'latest-1') await fast.promise
  988. return { id, products: [] }
  989. }
  990. })
  991. await h.handleOpened()
  992. h.deliveryForms.value = [
  993. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  994. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  995. ]
  996. const stop = mountDeliveryWatcher(h)
  997. try {
  998. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  999. h.deliveryForms.value[1].contracts = [{ contractId: 'intermediate-1' }]
  1000. await nextTick()
  1001. h.deliveryForms.value[1].contracts = [{ contractId: 'latest-1' }]
  1002. await nextTick()
  1003. slow.resolve({})
  1004. await flushDeliveryWatchers()
  1005. fast.resolve({})
  1006. await flushDeliveryWatchers()
  1007. assert.deepEqual(
  1008. h.deliveryForms.value.map((row: any) => row.products),
  1009. [[], []]
  1010. )
  1011. assert.equal(requests.includes('intermediate-1'), false)
  1012. h.deliveryForms.value[1].products = [{ productId: 'latest-product-1' }]
  1013. await flushDeliveryWatchers()
  1014. assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'latest-product-1' }])
  1015. } finally {
  1016. stop()
  1017. }
  1018. })
  1019. test('real Vue watcher preserves newly selected products while another segment finishes later', async () => {
  1020. const slow = deferred()
  1021. const h = harness({
  1022. contract: async (id: string) => {
  1023. if (id === 'new-0') await slow.promise
  1024. return { id, products: [] }
  1025. }
  1026. })
  1027. await h.handleOpened()
  1028. h.deliveryForms.value = [
  1029. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  1030. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  1031. ]
  1032. const stop = mountDeliveryWatcher(h)
  1033. try {
  1034. h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
  1035. await nextTick()
  1036. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  1037. await flushDeliveryWatchers()
  1038. h.deliveryForms.value[1].products = [{ productId: 'new-selection' }]
  1039. slow.resolve({})
  1040. await flushDeliveryWatchers()
  1041. assert.deepEqual(h.deliveryForms.value[0].products, [])
  1042. assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'new-selection' }])
  1043. } finally {
  1044. stop()
  1045. }
  1046. })
  1047. for (const rejects of [false, true])
  1048. test(`real Vue batched delivery changes still process later segments after obsolete first ${rejects ? 'error' : 'success'}`, async () => {
  1049. const slow = deferred()
  1050. const requests: string[] = []
  1051. const h = harness({
  1052. contract: async (id: string) => {
  1053. requests.push(id)
  1054. if (id === 'slow-0') await slow.promise
  1055. return { id, products: [] }
  1056. }
  1057. })
  1058. await h.handleOpened()
  1059. h.deliveryForms.value = [
  1060. { contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
  1061. { contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
  1062. ]
  1063. const stop = mountDeliveryWatcher(h)
  1064. try {
  1065. h.deliveryForms.value[0].contracts = [{ contractId: 'slow-0' }]
  1066. h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
  1067. await nextTick()
  1068. h.deliveryForms.value[0].contracts = [{ contractId: 'latest-0' }]
  1069. await flushDeliveryWatchers()
  1070. if (rejects) slow.reject(new Error('obsolete first segment'))
  1071. else slow.resolve({})
  1072. await flushDeliveryWatchers()
  1073. assert.ok(
  1074. requests.includes('new-1'),
  1075. 'the batched later segment must still request its contract'
  1076. )
  1077. assert.deepEqual(
  1078. h.deliveryForms.value.map((row: any) => row.products),
  1079. [[], []]
  1080. )
  1081. assert.equal(h.pageReady.value, true)
  1082. assert.equal(h.pageLoadError.value, '')
  1083. } finally {
  1084. stop()
  1085. }
  1086. })
  1087. test('mobile delivery watcher preserves segment products and notes during reorder and clears only changed contract target', async () => {
  1088. const slow=deferred()
  1089. const h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
  1090. await h.handleOpened()
  1091. h.deliveryForms.value=[
  1092. {customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a',contractId:'old-a'}],notes:'A'},
  1093. {customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b',contractId:'old-b'}],notes:'B'}
  1094. ]
  1095. const stop=mountDeliveryWatcher(h)
  1096. try {
  1097. h.deliveryForms.value[0].contracts=[{contractId:'new-a'}]
  1098. await nextTick()
  1099. h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1},{...h.deliveryForms.value[0],seq:2}]
  1100. await flushDeliveryWatchers()
  1101. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[{productId:'a',contractId:'old-a'}]])
  1102. slow.resolve({}); await flushDeliveryWatchers()
  1103. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[]])
  1104. assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.notes),['B','A'])
  1105. } finally {stop()}
  1106. })
  1107. test('mobile removed delivery response cannot clear surviving same-customer segment', async () => {
  1108. const slow=deferred(), h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
  1109. await h.handleOpened()
  1110. h.deliveryForms.value=[{customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a'}]},{customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b'}]}]
  1111. const stop=mountDeliveryWatcher(h)
  1112. try {
  1113. h.deliveryForms.value[0].contracts=[{contractId:'new-a'}];await nextTick()
  1114. h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1}];await flushDeliveryWatchers()
  1115. slow.resolve({});await flushDeliveryWatchers()
  1116. assert.deepEqual(h.deliveryForms.value[0].products,[{productId:'b'}])
  1117. } finally {stop()}
  1118. })
  1119. for(const removed of [false,true]) test(`mobile load contract ${removed?'removal':'replacement with same product'} clears only its products`,async()=>{
  1120. const h=harness({contract:async(id:string)=>({id,products:[{productId:'p'}]})})
  1121. await h.handleOpened()
  1122. h.loadForm.value={contracts:removed?[]:[{contractId:'new'}],products:[{productId:'p',contractProductId:'old-line',contractId:'old'}]}
  1123. const run=loadWatcher(h)
  1124. await run(h.loadForm.value.contracts,[{contractId:'old'}])
  1125. assert.deepEqual(h.loadForm.value.products,[])
  1126. })
  1127. for (const newPlan of [false, true]) test(`old server without capability blocks ${newPlan ? 'new' : 'edit'} page readiness and all writes`, async () => {
  1128. const h = harness({ newPlan, overrides: { assertMobilePersistenceCapability, planApi: { mobileProductCapability: async () => ({}) } } })
  1129. await h.handleOpened()
  1130. assert.equal(h.pageReady.value, false)
  1131. assert.match(h.pageLoadError.value, /服务.*更新/)
  1132. await h.handleSubmit('draft')
  1133. assert.deepEqual(h.writes, [])
  1134. })
  1135. test('successful capability handshake still rejects an old detail response without the explicit marker', async () => {
  1136. const h = harness({ overrides: { assertMobilePersistenceCapability } })
  1137. await h.handleOpened()
  1138. assert.equal(h.pageReady.value, false)
  1139. assert.match(h.pageLoadError.value, /服务.*更新/)
  1140. })
  1141. test('actual copy mapper preserves same-packaging second-load and shared delivery association while clearing source ids', async () => {
  1142. const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
  1143. const product = (id: string, planProductId: string) => ({ id, planProductId, productId: 'p', productNo: 'P', productName: '标准名', packageSpec: '20kg/袋', model: 'M', unit: '吨' })
  1144. const sourcePlan = { id: 'plan-1', load: { products: [product('l1', 'pp1'), product('l2', 'pp2')], contracts: [] }, deliveries: [{ id: 'd1', products: [product('dl1', 'pp2')], contracts: [] }, { id: 'd2', products: [product('dl2', 'pp2')], contracts: [] }], products: [] }
  1145. const h = harness({ copy: true, get: async () => sourcePlan })
  1146. await h.handleOpened()
  1147. assert.equal(h.pageReady.value, true)
  1148. const key = mobileLoadProductKey(h.loadForm.value.products[1])
  1149. assert.deepEqual(h.deliveryForms.value.map((row: any) => row.products[0].mobileLoadProductKey), [key, key])
  1150. assert.ok(h.loadForm.value.products.every((row: any) => !row.id && !row.planProductId))
  1151. assert.ok(h.deliveryForms.value.every((row: any) => !row.id && !row.products[0].id && !row.products[0].planProductId))
  1152. })
  1153. test('capability endpoint 404 explains service upgrade rather than a missing plan', async () => {
  1154. const h = harness({newPlan: true, overrides: {planApi: {mobileProductCapability: async () => {throw Object.assign(new Error('Request failed with status code 404'), {response:{status:404}})}}}})
  1155. await h.handleOpened()
  1156. assert.equal(h.pageReady.value,false)
  1157. assert.match(h.pageLoadError.value,/服务.*更新/)
  1158. assert.doesNotMatch(h.pageLoadError.value,/计划不存在/)
  1159. })