Files
shanxia2.0/web-admin/scripts/mobile-pl-plan-load-state.test.ts
wuzilongandCursor bde19c0644 feat: 锁定执行中计划并按装卸明细差量归档库存,补齐派车日历数量与酸碱类型。
执行中的运输计划不再随意改单;库存流水记到装卸产品明细;手工派车日历区分酸碱并记录数量。同时收紧合同附件与子表的差量保存,并清理已废弃的 bak 备份目录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-29 07:59:53 +08:00

1184 lines
45 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { effectScope, nextTick, ref, watch } from 'vue'
import { parse } from '@vue/compiler-sfc'
import ts from 'typescript'
import { preserveMobileCopyAssociations, assertMobilePersistenceCapability } from '../src/views/m/pl/plan/mobilePlanPersistence'
import { mobileStopIdentity } from '../src/views/m/pl/plan/mobileStopIdentity'
import * as brand from '../src/utils/crm-product-brand'
import { collectStopContractIds, filterProductsByAllowedProductIds } from '../src/utils/pl-plan-contract-product'
import {
describeUntaggedExternalStopProducts,
retainStopProductsForContracts
} from '../src/utils/pl-plan-stop-form'
const source = readFileSync(
new URL('../src/components/business/pl-plan-form-dialog/index.vue', import.meta.url),
'utf8'
)
const descriptor = parse(source).descriptor
const ast = ts.createSourceFile(
'form.ts',
descriptor.scriptSetup!.content,
ts.ScriptTarget.Latest,
true
)
function declaration(name: string) {
return ast.statements
.find(
(node) =>
(ts.isFunctionDeclaration(node) && node.name?.text === name) ||
(ts.isVariableStatement(node) &&
node.declarationList.declarations.some((d) => d.name.getText(ast) === name))
)
?.getText(ast)
}
function deferred<T = any>() {
let resolve!: (value: T) => void
let reject!: (reason: any) => void
const promise = new Promise<T>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function harness(
options: {
page?: boolean
copy?: boolean
newPlan?: boolean
get?: any
master?: any
contract?: any
enrich?: any
realDependencies?: boolean
realArrange?: boolean
overrides?: Record<string, any>
} = {}
) {
const writes: any[] = []
const events: any[] = []
const invalidations: string[] = []
const noop = () => {}
const bindings: Record<string, any> = {
mobileStopIdentity,
preserveMobileCopyAssociations,
buildMobileProductLinks: () => ({}),
assertMobilePersistenceCapability: () => {},
ref,
Error,
String,
Object,
Promise,
Set,
Number,
props: {
planId: options.copy || options.newPlan ? undefined : 'plan-1',
copyFromPlanId: options.copy ? 'plan-1' : undefined
},
isPageVariant: ref(options.page !== false),
isEdit: ref(!options.copy && !options.newPlan),
detailLoading: ref(false),
submitLoading: ref(false),
suppressAddressMutex: ref(false),
suppressStopProductSync: ref(false),
formData: { belongsCompany: 'shanxia', createDate: '2026-10-21', products: [] },
loadForm: ref({ products: [], contracts: [] }),
deliveryForms: ref([]),
loadedPlanSnapshot: ref(null),
loadedTransportStatus: ref('pending'),
loadedFollowStatus: ref(''),
selectedDriverId: ref(''),
selectedEscortId: ref(''),
vehicleBundle: ref(null),
contractProductIdsCache: ref({}),
contractProductRowsCache: ref({}),
contractLinesCache: ref({}),
loadContractProductIds: ref([]),
vehicleList: ref([]),
carrierList: ref([]),
driverList: ref([]),
escortList: ref([]),
customerOptions: ref([]),
customerOptionsLoading: ref(false),
vehicleLoading: ref(false),
footerMoreOpen: ref(false),
planApi: {
mobileProductCapability: async () => ({ mobileProductMetaVersion: 1 }),
get:
options.get ||
(async () => ({
data: { id: 'plan-1', belongsCompany: 'shanxia', remark: '原计划', products: [] }
})),
update: async (...args: any[]) => {
writes.push(['update', ...args])
return { id: 'plan-1' }
},
createFull: async (...args: any[]) => {
writes.push(['create', ...args])
return { id: 'new-plan' }
}
},
invalidateContractDetailCache: (id: string) => invalidations.push(id),
getContractDetailCached: options.contract || (async () => ({ id: 'c1', products: [] })),
enrichPlanProductsHazardFromCrm: options.enrich || (async (rows: any) => rows),
enrichPlanProductLinesAlias: async (rows: any) => rows,
mapPlanProductToFormRow: (row: any) => ({ ...row }),
mapPlanProductToCopyFormRow: (row: any) => ({ ...row, id: undefined }),
collectStopContractIds,
describeUntaggedExternalStopProducts,
retainStopProductsForContracts,
collectContractProductRows: (rows: any) => rows,
collectContractProductIds: (rows: any[]) => rows.map((row) => row.productId),
unionContractProductIds: (cache: any, ids: string[]) => ids.flatMap((id) => cache[id] || []),
resetForm: () => {
bindings.formData.remark = ''
bindings.formData.products = []
bindings.loadedPlanSnapshot.value = null
},
applyStopsFromPlan: (data: any) => {
bindings.loadForm.value = data.load || { products: [], contracts: [] }
bindings.deliveryForms.value = data.deliveries || []
},
syncCommittedPartyIds: noop,
bootstrapStopsFromPlanProducts: noop,
syncSelectedCustomerOptions: noop,
todayYmd: () => '2026-10-21',
formatDate: () => '',
resolveCurrentUserId: () => 'user',
resolveCurrentUserName: () => '用户',
freightCalc: { resetFreightCalc: noop, freightAmount: ref(null) },
ensureSelectedVehicleOption: async () => {},
ensureSelectedDriverOption: async () => {},
ensureSelectedEscortOption: async () => {},
ensureSelectedCarrierOption: async () => {},
handleVehicleChange: async () => {},
restoreFreightFromTransport: async () => {},
reapplyStopDibangByVehiclePlate: noop,
nextTick: async () => {},
mobileMetadata: { ready: () => true },
ElMessage: { error: noop, warning: noop, success: noop },
emit: (...args: any[]) => events.push(args),
innerVisible: ref(true),
basicFormRef: ref({ validate: async () => true, validateField: async () => {} }),
mirrorPlanProductsFromLoad: noop,
buildPlanCommonPayload: () => ({
remark: bindings.formData.remark,
products: bindings.formData.products
}),
pickBxData: (data: any) => data.data ?? data,
validateBusinessRules: () => true,
validateSingleContractPerStop: () => true,
validateContractPackageSpecs: () => true,
validateStopContractsRequired: () => true,
getArrangePrerequisiteGaps: () => [],
validateStopProductsForArrange: () => true,
showPlanSaveWarnings: noop,
performArrangeAfterSave: async (id: string) => {
writes.push(['arrange', id])
return true
},
loadMasterData: options.master || (async () => {}),
vehicleApi: { list: async () => [], get: async (id: string) => ({ id }) },
carrierApi: { list: async () => [], get: async (id: string) => ({ id }) },
personnelApi: { list: async () => [], get: async (id: string) => ({ id }) },
fetchCustomerOptions: async () => [],
extractCustomerOptions: (data: any) => data,
pickBxList: (data: any) => data.data ?? data,
searchVehicles: async () => {},
searchCarriers: async () => {},
searchDrivers: async () => {},
searchEscorts: async () => {},
loadDefaultCustomerOptions: async () => {},
loadVehicleViewBundle: async () => ({ detail: {}, personnelList: [] }),
transportApi: { get: async () => ({}) },
resolveDefaultDriver: noop,
resolveDefaultEscort: noop,
buildMissingHazardCertReadyMessage: () => '',
ElMessageBox: { alert: async () => {} },
createArrangePresenter: () => async () => {},
planHasAnyDibangSelected: () => false,
loadArrangeHistoricalFlags: async () => ({}),
confirmBeforePlanArrange: async () => {},
buildArrangePayloadFromPlanForm: () => ({}),
submitPlanArrange: async (id: string) => {
writes.push(['arrange', id])
},
getUnitLabel: () => '',
...options.overrides
}
const names = [
'mobileSavedPlanId',
'mobileCreateUncertain',
'pageReady',
'pageLoadError',
'pageLoadGeneration',
'pageDisposed',
'isPageLoadCurrent',
'assertPageLoadCurrent',
'failPageLoad',
'invalidatePageLoad',
'validateLoadedPagePlan',
'handleOpened',
'ensureMobilePersistenceCapability',
'loadPlanForEdit',
'loadPlanForCopy',
'fetchContractProductIds',
'prefetchStopContractProducts',
'handleSubmit',
'isPageSubmitCurrent',
'pageLoadErrorMessage'
]
names.push('ensurePageSelectedOption')
if (options.realDependencies) {
names.push('loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport')
for (const name of ['loadMasterData', 'handleVehicleChange', 'restoreFreightFromTransport'])
delete bindings[name]
}
if (options.realArrange) {
names.push('performArrangeAfterSave')
delete bindings.performArrangeAfterSave
}
const present = names.filter((name) => declaration(name))
const code = ts.transpile(present.map((name) => declaration(name)).join('\n'), {
target: ts.ScriptTarget.ESNext
})
const result = new Function(
...Object.keys(bindings),
code + '; return {' + present.join(',') + '}'
)(...Object.values(bindings))
return { ...bindings, ...result, writes, events, invalidations }
}
async function blocked(h: ReturnType<typeof harness>) {
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) await h.handleSubmit(mode)
assert.deepEqual(h.writes, [], 'failed initialization must never write or arrange')
}
for (const status of [404, 500])
test(`mobile edit GET ${status} stays blocked`, async () => {
const h = harness({
get: async () => {
throw new Error(`HTTP ${status}`)
}
})
await h.handleOpened().catch(() => {})
await blocked(h)
assert.equal(h.pageReady.value, false)
assert.match(h.pageLoadError.value, /404|500/)
assert.equal(h.detailLoading.value, false)
})
test('mobile copy GET failure remains visible and blocked', async () => {
const h = harness({
copy: true,
get: async () => {
throw new Error('copy failed')
}
})
await h.handleOpened().catch(() => {})
await blocked(h)
assert.ok(h.pageLoadError.value)
assert.equal(
h.events.some((event) => event[0] === 'update:visible'),
false
)
})
for (const data of [{}, { id: 'different' }, null])
test(`mobile rejects invalid plan ${JSON.stringify(data)}`, async () => {
const h = harness({ get: async () => ({ data }) })
await h.handleOpened().catch(() => {})
await blocked(h)
assert.ok(h.pageLoadError.value)
})
test('new mobile base dependency failure cannot create', async () => {
const h = harness({
newPlan: true,
master: async () => {
throw new Error('base failed')
}
})
await h.handleOpened().catch(() => {})
await blocked(h)
})
test('contract failure preserves historical products, and formal save waits for a contract line', async () => {
let failed = true
const h = harness({
get: async () => ({
id: 'plan-1',
belongsCompany: 'shanxia',
remark: '原计划',
products: [{ productId: 'p1' }],
load: { contracts: [{ contractId: 'c1' }], products: [{ productId: 'p1', planQty: 12 }] }
}),
contract: async () => {
if (failed) throw new Error('contract failed')
return { id: 'c1', products: [{ productId: 'p1' }] }
}
})
await h.handleOpened().catch(() => {})
await blocked(h)
assert.deepEqual(h.contractProductIdsCache.value, {})
assert.equal(h.loadForm.value.products[0].planQty, 12)
failed = false
await h.handleOpened()
assert.equal(h.pageReady.value, true)
await h.handleSubmit('save')
assert.deepEqual(h.writes, [])
h.loadForm.value.products[0].contractId = 'c1'
h.loadForm.value.products[0].contractProductId = 'line-1'
await h.handleSubmit('save')
assert.deepEqual(h.writes, [
['update', 'plan-1', { remark: '原计划', products: [{ productId: 'p1' }], saveMode: 'formal' }]
])
})
for (const lateError of [false, true])
test(`retry ignores old ${lateError ? 'error' : 'success'} and finally`, async () => {
const old = deferred()
const fresh = deferred()
let count = 0
const h = harness({ get: () => (++count === 1 ? old.promise : fresh.promise) })
const first = h.handleOpened().catch(() => {})
while (count < 1) await Promise.resolve()
const second = h.handleOpened()
while (count < 2) await Promise.resolve()
if (lateError) old.reject(new Error('old error'))
else old.resolve({ id: 'plan-1', remark: 'stale' })
await first
assert.equal(h.detailLoading.value, true)
assert.equal(h.formData.remark, '')
assert.equal(h.pageLoadError.value, '')
fresh.resolve({ id: 'plan-1', remark: 'fresh', products: [] })
await second
assert.equal(h.formData.remark, 'fresh')
assert.equal(h.pageReady.value, true)
})
test('unmount invalidates pending detail without late ref writes or loading events', async () => {
const request = deferred()
const h = harness({ get: () => request.promise })
const work = h.handleOpened()
await Promise.resolve()
await Promise.resolve()
h.invalidatePageLoad()
const before = JSON.stringify(h.formData)
request.resolve({ id: 'plan-1', remark: 'stale' })
await work
assert.equal(JSON.stringify(h.formData), before)
assert.equal(h.pageReady.value, false)
})
test('desktop retains copy failure close, failed contract empty cache and draft save behavior', async () => {
const h = harness({
page: false,
copy: true,
get: async () => {
throw new Error('404')
},
contract: async () => {
throw new Error('offline')
}
})
await h.handleOpened()
assert.ok(h.events.some((event) => event[0] === 'update:visible' && event[1] === false))
assert.deepEqual(await h.fetchContractProductIds('c1'), [])
assert.deepEqual(h.contractProductIdsCache.value, { c1: [] })
await h.handleSubmit('draft')
assert.equal(h.writes[0][0], 'create')
})
test('page template gates form and write actions on readiness and exposes retry/return', () => {
assert.match(descriptor.template!.content, /v-if="isPageVariant && pageLoadError"/)
assert.match(descriptor.template!.content, /v-show="!isPageVariant \|\| pageReady"/)
assert.match(descriptor.template!.content, /v-if="isPageVariant && pageReady"/)
assert.match(descriptor.template!.content, /重新加载/)
assert.match(source, /onBeforeUnmount\(invalidatePageLoad\)/)
})
for (const dependency of ['vehicleApi', 'carrierApi', 'personnelApi', 'fetchCustomerOptions']) {
test(`real mobile base loader fails closed for ${dependency}`, async () => {
const fail = async () => {
throw new Error(`${dependency} failed`)
}
const overrides =
dependency === 'fetchCustomerOptions'
? { fetchCustomerOptions: fail }
: { [dependency]: { list: fail } }
const h = harness({ newPlan: true, realDependencies: true, overrides })
await h.handleOpened()
await blocked(h)
assert.match(h.pageLoadError.value, /failed/)
})
}
test('late base dependency cannot replace new option lists', async () => {
const old = deferred()
let count = 0
const h = harness({
newPlan: true,
realDependencies: true,
overrides: {
vehicleApi: { list: () => (++count === 1 ? old.promise : Promise.resolve([{ id: 'fresh' }])) }
}
})
const first = h.handleOpened()
while (count < 1) await Promise.resolve()
await h.handleOpened()
old.resolve([{ id: 'old' }])
await first
assert.deepEqual(h.vehicleList.value, [{ id: 'fresh' }])
assert.equal(h.pageReady.value, true)
})
test('late product enrichment cannot overwrite retry products or release new suppression flags', async () => {
const old = deferred()
const fresh = deferred()
let count = 0
const h = harness({ enrich: () => (++count === 1 ? old.promise : fresh.promise) })
const first = h.handleOpened()
while (count < 1) await Promise.resolve()
const second = h.handleOpened()
while (count < 2) await Promise.resolve()
old.resolve([{ productId: 'old' }])
await first
assert.equal(h.suppressStopProductSync.value, true)
assert.deepEqual(h.formData.products, [])
fresh.resolve([{ productId: 'fresh' }])
await second
assert.deepEqual(h.formData.products, [{ productId: 'fresh' }])
})
test('late vehicle bundle cannot overwrite retry refs or clear the new vehicle loading flag', async () => {
const old = deferred()
const fresh = deferred()
let count = 0
const h = harness({
realDependencies: true,
get: async () => ({ id: 'plan-1', vehicleId: 'v1' }),
overrides: { loadVehicleViewBundle: () => (++count === 1 ? old.promise : fresh.promise) }
})
const first = h.handleOpened()
while (count < 1) await Promise.resolve()
const second = h.handleOpened()
while (count < 2) await Promise.resolve()
old.resolve({ detail: { carrierId: 'old' }, personnelList: [] })
await first
assert.equal(h.vehicleLoading.value, true)
assert.equal(h.vehicleBundle.value, null)
fresh.resolve({ detail: { carrierId: 'fresh' }, personnelList: [] })
await second
assert.equal(h.formData.carrierId, 'fresh')
})
test('late contract success cannot populate the current generation cache', async () => {
const old = deferred()
const h = harness({ contract: () => old.promise })
const fetch = h.fetchContractProductIds('c1').catch(() => {})
await h.handleOpened()
old.resolve({ products: [{ productId: 'old' }] })
await fetch
assert.deepEqual(h.contractProductIdsCache.value, {})
assert.equal(h.pageLoadError.value, '')
})
test('successful mobile copy clears source ids and transport people before creating', async () => {
const original = {
id: 'plan-1',
belongsCompany: 'shanxia',
vehicleId: 'v1',
driverId: 'd1',
escortId: 'e1',
load: {
id: 'load-1',
contracts: [],
products: [{ id: 'line-1', planProductId: 'product-row-1' }]
},
deliveries: [
{ id: 'stop-1', contracts: [], products: [{ id: 'line-2', planProductId: 'product-row-2' }] }
]
}
const h = harness({ copy: true, get: async () => structuredClone(original) })
await h.handleOpened()
assert.equal(h.pageReady.value, true)
assert.equal(h.loadForm.value.id, undefined)
assert.equal(h.loadForm.value.products[0].planProductId, undefined)
assert.equal(h.deliveryForms.value[0].id, undefined)
assert.equal(h.deliveryForms.value[0].products[0].id, undefined)
assert.equal(h.formData.vehicleId, '')
assert.equal(h.selectedDriverId.value, '')
assert.equal(h.selectedEscortId.value, '')
await h.handleSubmit('draft')
assert.equal(h.writes[0][0], 'create')
assert.equal(
h.writes.some((row) => row[0] === 'update'),
false
)
})
test('draft keeps minimal fields and formal save still requires original validation', async () => {
const h = harness({ newPlan: true })
await h.handleOpened()
h.basicFormRef.value.validate = async () => false
await h.handleSubmit('save')
assert.deepEqual(h.writes, [])
await h.handleSubmit('draft')
assert.equal(h.writes[0][0], 'create')
})
test('desktop edit GET error still propagates and clears the loading state', async () => {
const h = harness({
page: false,
get: async () => {
throw new Error('desktop failure')
}
})
await assert.rejects(h.handleOpened(), /desktop failure/)
assert.equal(h.detailLoading.value, false)
assert.equal(h.pageLoadError.value, '')
})
test('empty dependency error still provides a visible retry state', async () => {
const h = harness({
master: async () => {
throw new Error('')
}
})
await h.handleOpened()
assert.ok(h.pageLoadError.value)
})
test('new initialization resets vehicle loading left by an invalidated generation', async () => {
const h = harness({ newPlan: true })
h.vehicleLoading.value = true
await h.handleOpened()
assert.equal(h.vehicleLoading.value, false)
})
for (const kind of ['driver', 'transport'])
test(`${kind} refill failure prevents saving mobile details`, async () => {
const fail = async () => {
throw new Error(`${kind} failed`)
}
const h = harness({
realDependencies: true,
get: async () => ({
id: 'plan-1',
driverId: kind === 'driver' ? 'd1' : '',
transportId: kind === 'transport' ? 't1' : ''
}),
overrides:
kind === 'driver'
? { personnelApi: { list: async () => [], get: fail } }
: { transportApi: { get: fail } }
})
await h.handleOpened()
await blocked(h)
assert.match(h.pageLoadError.value, /failed/)
})
function deliveryWatcher(h: ReturnType<typeof harness>) {
const watcher = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
node.getText(ast).startsWith('watch(') &&
node.getText(ast).includes('contractKey: collectStopContractIds')
)
assert.ok(watcher)
let callback!: (...args: any[]) => Promise<void>
const bindings = {
mobileStopIdentity, ...h,
pageLoadGeneration: 1,
deliveryContractFetchSeqByIndex: {},
watch: (_source: any, cb: any) => {
callback = cb
}
}
new Function(
...Object.keys(bindings),
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
)(...Object.values(bindings))
return callback
}
test('mobile removing a delivery contract still clears its products', async () => {
const h = harness()
await h.handleOpened()
h.deliveryForms.value = [{ products: [{ productId: 'p1', contractId: 'c1' }], contracts: [] }]
await deliveryWatcher(h)([{ contractKey: '' }], [{ contractKey: 'c1' }])
assert.deepEqual(h.deliveryForms.value[0].products, [])
})
test('delivery contract metadata failure preserves historical products', async () => {
const h = harness({
contract: async () => {
throw new Error('offline')
}
})
await h.handleOpened()
h.deliveryForms.value = [{ products: [{ productId: 'p1' }], contracts: [{ contractId: 'c1' }] }]
await deliveryWatcher(h)([{ contractKey: 'c1' }], [{ contractKey: 'old' }])
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'p1' }])
assert.equal(h.pageReady.value, false)
})
function enrichmentHelper(file: string, name: string, fetchProductDetail: any) {
const text = readFileSync(new URL(`../src/composables/${file}.ts`, import.meta.url), 'utf8')
const source = ts.createSourceFile('helper.ts', text, ts.ScriptTarget.Latest, true)
const declaration = source.statements.find(
(node) => ts.isFunctionDeclaration(node) && node.name?.text === name
)
assert.ok(declaration)
const code = ts.transpile(declaration.getText(source).replace(/^export\s+/, ''), {
target: ts.ScriptTarget.ESNext
})
return new Function('fetchProductDetail', ...Object.keys(brand), code + `;return ${name}`)(
fetchProductDetail,
...Object.values(brand)
)
}
for (const [file, name] of [
['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
]) {
test(`${name} strict mode rejects failed product metadata while default preserves existing behavior`, async () => {
const helper = enrichmentHelper(file, name, async () => {
throw new Error('product offline')
})
const rows = [
{
productId: 'p1',
productName: '历史产品',
isToxic: 1,
brandMode: 'required',
brandId: 'b1',
brandName: '品牌'
}
]
const before = structuredClone(rows)
await assert.rejects(helper(rows, true), /product offline/)
assert.deepEqual(rows, before)
const fallback = await helper(structuredClone(rows))
assert.equal(fallback[0].brandId, 'b1')
assert.equal(fallback[0].isToxic, 1)
})
}
for (const copy of [false, true])
test(`mobile ${copy ? 'copy' : 'edit'} opts into strict product initialization`, async () => {
const strictArgs: unknown[] = []
const h = harness({
copy,
enrich: async (rows: any, strict: boolean) => {
strictArgs.push(strict)
return rows
},
overrides: {
enrichPlanProductLinesAlias: async (rows: any, strict: boolean) => {
strictArgs.push(strict)
return rows
}
}
})
await h.handleOpened()
assert.deepEqual(strictArgs, [true, true])
})
test('delivery slow contract completion cannot clear products selected after fast contract completion', async () => {
const slow = deferred()
const h = harness({
contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
})
await h.handleOpened()
const watchDelivery = deliveryWatcher(h)
h.deliveryForms.value = [
{ contracts: [{ contractId: 'slow' }], products: [{ productId: 'original' }] }
]
const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
slow.resolve({ id: 'slow', products: [] })
await first
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
})
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange']) {
for (const newPlan of [false, true]) {
test(`late ${newPlan ? 'create' : 'update'} ${mode} cannot emit, arrange or clear a new session busy state`, async () => {
const write = deferred()
let started = false
const h = harness({ newPlan })
await h.handleOpened()
h.formData.vehicleId = 'v1'
const apiMethod = newPlan ? 'createFull' : 'update'
h.planApi[apiMethod] = async () => {
started = true
return write.promise
}
const saving = h.handleSubmit(mode)
while (!started) await Promise.resolve()
await h.handleOpened()
h.submitLoading.value = true
h.events.splice(0)
write.resolve({ id: 'new-plan' })
await saving
assert.equal(h.submitLoading.value, true)
assert.deepEqual(h.events, [])
assert.deepEqual(h.writes, [])
})
}
}
for (const stage of ['detail', 'history', 'confirm', 'arrange']) {
test(`arrange ${stage} completion after unmount cannot continue or emit`, async () => {
const pending = deferred()
let started = false
const pause = async () => {
started = true
return pending.promise
}
const h = harness({
realArrange: true,
overrides:
stage === 'confirm'
? { confirmBeforePlanArrange: pause }
: stage === 'history'
? { loadArrangeHistoricalFlags: pause }
: stage === 'arrange'
? { submitPlanArrange: pause }
: {}
})
await h.handleOpened()
h.formData.vehicleId = 'v1'
if (stage === 'detail') h.planApi.get = pause
else
h.planApi.get = async () => ({
id: 'plan-1',
transportStatus: stage === 'history' ? 'arranged' : 'pending',
products: []
})
const work = h.performArrangeAfterSave('plan-1')
while (!started) await Promise.resolve()
h.invalidatePageLoad()
h.events.splice(0)
pending.resolve(stage === 'detail' ? { id: 'plan-1', products: [] } : {})
await work
assert.deepEqual(h.events, [])
assert.deepEqual(h.writes, [])
})
}
for (const [file, name] of [
['usePlContractToPlan', 'enrichPlanProductLinesAlias'],
['usePlToxicProductCertCheck', 'enrichPlanProductsHazardFromCrm']
]) {
for (const response of [{}, { data: null }, { id: 'other' }]) {
test(`${name} strict rejects malformed 200 ${JSON.stringify(response)} and preserves snapshot`, async () => {
const helper = enrichmentHelper(file, name, async () => response)
const rows = [
{
productId: 'p1',
productName: '历史产品',
isToxic: 1,
brandMode: 'required',
brandId: 'brand-1'
}
]
const before = structuredClone(rows)
await assert.rejects(helper(rows, true), /产品/)
assert.deepEqual(rows, before)
await assert.doesNotReject(helper(structuredClone(rows)))
})
}
}
for (const response of [
{},
null,
{ id: 'other', products: [] },
{ id: 'c1' },
{ id: 'c1', products: {} }
]) {
test(`mobile contract malformed 200 ${JSON.stringify(response)} cannot cache an empty list`, async () => {
const h = harness({ contract: async () => response })
await assert.rejects(h.fetchContractProductIds('c1'), /合同/)
assert.deepEqual(h.contractProductIdsCache.value, {})
assert.deepEqual(h.invalidations, ['c1'])
assert.equal(h.pageReady.value, false)
})
}
for (const copy of [false, true])
test(`mobile ${copy ? 'copy' : 'edit'} HTTP 404 error is actionable Chinese`, async () => {
const error = Object.assign(new Error('Request failed with status code 404'), {
response: { status: 404 }
})
const h = harness({
copy,
get: async () => {
throw error
}
})
await h.handleOpened()
assert.equal(h.pageLoadError.value, copy ? '复制来源不存在或已删除' : '运输计划不存在或已删除')
})
test('mobile network error is Chinese while backend business message is preserved', async () => {
for (const [error, expected] of [
[new Error('Network Error'), '网络连接失败,请检查网络后重新加载'],
[
Object.assign(new Error('Request failed with status code 500'), {
response: { data: { message: '当前租户无权查看此计划' } }
}),
'当前租户无权查看此计划'
]
]) {
const h = harness({
get: async () => {
throw error
}
})
await h.handleOpened()
assert.equal(h.pageLoadError.value, expected)
}
})
test('mobile error actions meet the 44px minimum tap target', () => {
const errorStyle = descriptor.styles[0].content.split('.mp-plan-form-page')[0]
assert.match(errorStyle, /\.mp-plan-load-error[\s\S]*?\.el-button[\s\S]*?min-height:\s*44px/)
})
test('obsolete delivery contract error does not close the ready fast selection', async () => {
const slow = deferred()
const h = harness({
contract: (id: string) => (id === 'slow' ? slow.promise : Promise.resolve({ id, products: [] }))
})
await h.handleOpened()
const watchDelivery = deliveryWatcher(h)
h.deliveryForms.value = [{ contracts: [{ contractId: 'slow' }], products: [] }]
const first = watchDelivery([{ contractKey: 'slow' }], [{ contractKey: 'original' }])
h.deliveryForms.value[0].contracts = [{ contractId: 'fast' }]
await watchDelivery([{ contractKey: 'fast' }], [{ contractKey: 'slow' }])
h.deliveryForms.value[0].products = [{ productId: 'new-selection' }]
slow.reject(new Error('old contract error'))
await first
assert.equal(h.pageReady.value, true)
assert.equal(h.pageLoadError.value, '')
assert.deepEqual(h.deliveryForms.value[0].products, [{ productId: 'new-selection' }])
})
test('arrange hazard completion after unmount does not continue to confirmation', async () => {
const pending = deferred()
let calls = 0
let started = false
let confirmed = false
const h = harness({
realArrange: true,
enrich: async (rows: any) => {
calls += 1
if (calls === 1) return rows
started = true
return pending.promise
},
overrides: {
confirmBeforePlanArrange: async () => {
confirmed = true
}
}
})
await h.handleOpened()
h.formData.vehicleId = 'v1'
const work = h.performArrangeAfterSave('plan-1')
while (!started) await Promise.resolve()
h.invalidatePageLoad()
pending.resolve([])
await work
assert.equal(confirmed, false)
assert.deepEqual(h.events, [])
assert.deepEqual(h.writes, [])
})
function loadWatcher(h: ReturnType<typeof harness>) {
const watcher = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
node.getText(ast).startsWith('watch(') &&
node.getText(ast).includes('() => loadForm.value.contracts')
)
assert.ok(watcher)
let callback!: (...args: any[]) => Promise<void>
const bindings = {
mobileStopIdentity, ...h,
pageLoadGeneration: 1,
loadContractFetchSeq: 0,
filterProductsByAllowedProductIds,
syncFromLoadProducts: () => {},
watch: (_source: any, cb: any) => {
callback = cb
}
}
new Function(
...Object.keys(bindings),
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
)(...Object.values(bindings))
return callback
}
for (const rejects of [false, true])
test(`load contract old ${rejects ? 'error' : 'success'} cannot affect current selection`, async () => {
const slow = deferred()
const h = harness({
contract: (id: string) =>
id === 'slow'
? slow.promise
: Promise.resolve({ id, products: [{ productId: 'fast-product' }] })
})
await h.handleOpened()
const watchLoad = loadWatcher(h)
h.loadForm.value = { contracts: [{ contractId: 'slow' }], products: [] }
const first = watchLoad([{ contractId: 'slow' }], [{ contractId: 'original' }])
h.loadForm.value.contracts = [{ contractId: 'fast' }]
await watchLoad([{ contractId: 'fast' }], [{ contractId: 'slow' }])
h.loadForm.value.products = [{ productId: 'fast-product' }]
if (rejects) slow.reject(new Error('obsolete'))
else slow.resolve({ id: 'slow', products: [{ productId: 'slow-product' }] })
await first
assert.deepEqual(h.loadForm.value.products, [{ productId: 'fast-product' }])
assert.deepEqual(h.loadContractProductIds.value, ['fast-product'])
assert.equal(h.pageReady.value, true)
assert.equal(h.pageLoadError.value, '')
})
for (const mode of ['draft', 'save', 'saveAndArrange', 'rearrange'])
test(`desktop ${mode} keeps its save and arrange completion`, async () => {
const h = harness({ page: false, realArrange: true })
await h.handleOpened()
h.formData.vehicleId = 'v1'
h.invalidatePageLoad()
await h.handleSubmit(mode)
assert.equal(h.writes[0][0], 'update')
assert.equal(
h.writes.some((row) => row[0] === 'arrange'),
mode === 'saveAndArrange' || mode === 'rearrange'
)
assert.equal(h.events.filter((event) => event[0] === 'success').length, 1)
assert.equal(h.submitLoading.value, false)
})
// Execute the production watcher through Vue's scheduler, together with the real
// fetchContractProductIds closure from harness(). Only the contract API is stubbed.
function mountDeliveryWatcher(h: ReturnType<typeof harness>) {
const watcher = ast.statements.find(
(node) =>
ts.isExpressionStatement(node) &&
node.getText(ast).startsWith('watch(') &&
node.getText(ast).includes('contractKey: collectStopContractIds')
)
assert.ok(watcher)
const bindings = { ...h, mobileStopIdentity, watch, pageLoadGeneration: 1, deliveryContractFetchSeqByIndex: {} }
const scope = effectScope()
scope.run(() =>
new Function(
...Object.keys(bindings),
ts.transpile(watcher.getText(ast), { target: ts.ScriptTarget.ESNext })
)(...Object.values(bindings))
)
return () => scope.stop()
}
async function flushDeliveryWatchers() {
for (let tick = 0; tick < 30; tick += 1) await nextTick()
}
for (const page of [true, false])
test(`real Vue delivery watcher ${page ? 'mobile' : 'desktop'} clears both changed segments despite crossed requests`, async () => {
const slow = deferred()
const h = harness({
page,
contract: async (id: string) => {
if (id === 'new-0') await slow.promise
return { id, products: [] }
}
})
await h.handleOpened()
h.deliveryForms.value = [
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
]
const stop = mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
await nextTick()
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
await nextTick()
slow.resolve({})
await flushDeliveryWatchers()
assert.deepEqual(
h.deliveryForms.value.map((row: any) => row.products),
[[], []]
)
assert.equal(h.pageLoadError.value, '')
} finally {
stop()
}
})
test('real Vue watcher does not let an earlier batched callback supersede a later segment selection', async () => {
const slow = deferred()
const fast = deferred()
const requests: string[] = []
const h = harness({
contract: async (id: string) => {
requests.push(id)
if (id === 'new-0') await slow.promise
if (id === 'latest-1') await fast.promise
return { id, products: [] }
}
})
await h.handleOpened()
h.deliveryForms.value = [
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
]
const stop = mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
h.deliveryForms.value[1].contracts = [{ contractId: 'intermediate-1' }]
await nextTick()
h.deliveryForms.value[1].contracts = [{ contractId: 'latest-1' }]
await nextTick()
slow.resolve({})
await flushDeliveryWatchers()
fast.resolve({})
await flushDeliveryWatchers()
assert.deepEqual(
h.deliveryForms.value.map((row: any) => row.products),
[[], []]
)
assert.equal(requests.includes('intermediate-1'), false)
h.deliveryForms.value[1].products = [{ productId: 'latest-product-1' }]
await flushDeliveryWatchers()
assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'latest-product-1' }])
} finally {
stop()
}
})
test('real Vue watcher preserves newly selected products while another segment finishes later', async () => {
const slow = deferred()
const h = harness({
contract: async (id: string) => {
if (id === 'new-0') await slow.promise
return { id, products: [] }
}
})
await h.handleOpened()
h.deliveryForms.value = [
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
]
const stop = mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts = [{ contractId: 'new-0' }]
await nextTick()
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
await flushDeliveryWatchers()
h.deliveryForms.value[1].products = [{ productId: 'new-selection' }]
slow.resolve({})
await flushDeliveryWatchers()
assert.deepEqual(h.deliveryForms.value[0].products, [])
assert.deepEqual(h.deliveryForms.value[1].products, [{ productId: 'new-selection' }])
} finally {
stop()
}
})
for (const rejects of [false, true])
test(`real Vue batched delivery changes still process later segments after obsolete first ${rejects ? 'error' : 'success'}`, async () => {
const slow = deferred()
const requests: string[] = []
const h = harness({
contract: async (id: string) => {
requests.push(id)
if (id === 'slow-0') await slow.promise
return { id, products: [] }
}
})
await h.handleOpened()
h.deliveryForms.value = [
{ contracts: [{ contractId: 'old-0' }], products: [{ productId: 'old-product-0', contractId: 'old-0' }] },
{ contracts: [{ contractId: 'old-1' }], products: [{ productId: 'old-product-1', contractId: 'old-1' }] }
]
const stop = mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts = [{ contractId: 'slow-0' }]
h.deliveryForms.value[1].contracts = [{ contractId: 'new-1' }]
await nextTick()
h.deliveryForms.value[0].contracts = [{ contractId: 'latest-0' }]
await flushDeliveryWatchers()
if (rejects) slow.reject(new Error('obsolete first segment'))
else slow.resolve({})
await flushDeliveryWatchers()
assert.ok(
requests.includes('new-1'),
'the batched later segment must still request its contract'
)
assert.deepEqual(
h.deliveryForms.value.map((row: any) => row.products),
[[], []]
)
assert.equal(h.pageReady.value, true)
assert.equal(h.pageLoadError.value, '')
} finally {
stop()
}
})
test('mobile delivery watcher preserves segment products and notes during reorder and clears only changed contract target', async () => {
const slow=deferred()
const h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
await h.handleOpened()
h.deliveryForms.value=[
{customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a',contractId:'old-a'}],notes:'A'},
{customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b',contractId:'old-b'}],notes:'B'}
]
const stop=mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts=[{contractId:'new-a'}]
await nextTick()
h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1},{...h.deliveryForms.value[0],seq:2}]
await flushDeliveryWatchers()
assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[{productId:'a',contractId:'old-a'}]])
slow.resolve({}); await flushDeliveryWatchers()
assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.products),[[{productId:'b',contractId:'old-b'}],[]])
assert.deepEqual(h.deliveryForms.value.map((r:any)=>r.notes),['B','A'])
} finally {stop()}
})
test('mobile removed delivery response cannot clear surviving same-customer segment', async () => {
const slow=deferred(), h=harness({contract:async(id:string)=>{if(id==='new-a')await slow.promise;return {id,products:[]}}})
await h.handleOpened()
h.deliveryForms.value=[{customerId:'same',contracts:[{contractId:'old-a'}],products:[{productId:'a'}]},{customerId:'same',contracts:[{contractId:'old-b'}],products:[{productId:'b'}]}]
const stop=mountDeliveryWatcher(h)
try {
h.deliveryForms.value[0].contracts=[{contractId:'new-a'}];await nextTick()
h.deliveryForms.value=[{...h.deliveryForms.value[1],seq:1}];await flushDeliveryWatchers()
slow.resolve({});await flushDeliveryWatchers()
assert.deepEqual(h.deliveryForms.value[0].products,[{productId:'b'}])
} finally {stop()}
})
for(const removed of [false,true]) test(`mobile load contract ${removed?'removal':'replacement with same product'} clears only its products`,async()=>{
const h=harness({contract:async(id:string)=>({id,products:[{productId:'p'}]})})
await h.handleOpened()
h.loadForm.value={contracts:removed?[]:[{contractId:'new'}],products:[{productId:'p',contractProductId:'old-line',contractId:'old'}]}
const run=loadWatcher(h)
await run(h.loadForm.value.contracts,[{contractId:'old'}])
assert.deepEqual(h.loadForm.value.products,[])
})
for (const newPlan of [false, true]) test(`old server without capability blocks ${newPlan ? 'new' : 'edit'} page readiness and all writes`, async () => {
const h = harness({ newPlan, overrides: { assertMobilePersistenceCapability, planApi: { mobileProductCapability: async () => ({}) } } })
await h.handleOpened()
assert.equal(h.pageReady.value, false)
assert.match(h.pageLoadError.value, /服务.*更新/)
await h.handleSubmit('draft')
assert.deepEqual(h.writes, [])
})
test('successful capability handshake still rejects an old detail response without the explicit marker', async () => {
const h = harness({ overrides: { assertMobilePersistenceCapability } })
await h.handleOpened()
assert.equal(h.pageReady.value, false)
assert.match(h.pageLoadError.value, /服务.*更新/)
})
test('actual copy mapper preserves same-packaging second-load and shared delivery association while clearing source ids', async () => {
const { mobileLoadProductKey } = await import('../src/views/m/pl/plan/mobilePlanPersistence')
const product = (id: string, planProductId: string) => ({ id, planProductId, productId: 'p', productNo: 'P', productName: '标准名', packageSpec: '20kg/袋', model: 'M', unit: '吨' })
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: [] }
const h = harness({ copy: true, get: async () => sourcePlan })
await h.handleOpened()
assert.equal(h.pageReady.value, true)
const key = mobileLoadProductKey(h.loadForm.value.products[1])
assert.deepEqual(h.deliveryForms.value.map((row: any) => row.products[0].mobileLoadProductKey), [key, key])
assert.ok(h.loadForm.value.products.every((row: any) => !row.id && !row.planProductId))
assert.ok(h.deliveryForms.value.every((row: any) => !row.id && !row.products[0].id && !row.products[0].planProductId))
})
test('capability endpoint 404 explains service upgrade rather than a missing plan', async () => {
const h = harness({newPlan: true, overrides: {planApi: {mobileProductCapability: async () => {throw Object.assign(new Error('Request failed with status code 404'), {response:{status:404}})}}}})
await h.handleOpened()
assert.equal(h.pageReady.value,false)
assert.match(h.pageLoadError.value,/服务.*更新/)
assert.doesNotMatch(h.pageLoadError.value,/计划不存在/)
})