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

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