Files
pda-overseas-uniapp/tests/dispatch-workflow.test.mjs
T

328 lines
37 KiB
JavaScript
Raw Normal View History

2026-10-19 17:56:30 +08:00
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft, resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion, dispatchVersionAtLeast, normalizeDispatchTask } from '../src/api/fulfillment.js'
const source = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).match(/<script>([\s\S]*?)<\/script>/)[1]
2026-10-20 14:24:59 +08:00
const item = (overrides = {}) => ({ dispatchItemId: 'item-1', sourceKey: 'source-1', sourceBusinessType: 'RELABEL_ORDER', sourceBusinessNo: 'RL-1', customerId: 'customer-1', customerName: '客户甲', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 0, positions: [{ positionId: 'position-1', availablePackages: 2, allocatedPackages: 0, pickedPackages: 0 }], returnCartons: [], ...overrides })
2026-10-19 17:56:30 +08:00
test('native string quantities normalize without changing Long identity or version', () => {
const id = '9007199254740993'
2026-10-20 14:24:59 +08:00
const value = normalizeDispatchTask(JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', recordVersion: id, items: [item({ dispatchItemId: id, scheduledPackages: '12', pickedPackages: '0', positions: [{ positionId: id, balanceId: id, availablePackages: '12', allocatedPackages: '0', pickedPackages: '0' }], returnCartons: [{ returnCartonId: id, productQuantity: '24' }] })] })))
2026-10-19 17:56:30 +08:00
assert.equal(value.recordVersion, id); assert.equal(value.items[0].dispatchItemId, id)
assert.equal(value.items[0].scheduledPackages, 12); assert.equal(value.items[0].pickedPackages, 0)
2026-10-20 14:24:59 +08:00
assert.deepEqual(value.items[0].positions[0], { positionId: id, balanceId: id, availablePackages: 12, allocatedPackages: 0, pickedPackages: 0 })
2026-10-19 17:56:30 +08:00
assert.equal(value.items[0].returnCartons[0].returnCartonId, id); assert.equal(value.items[0].returnCartons[0].productQuantity, 24)
assert.equal(normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ scheduledPackages: String(Number.MAX_SAFE_INTEGER) })] }).items[0].scheduledPackages, Number.MAX_SAFE_INTEGER)
})
test('invalid, missing and unsafe native quantities fail at every item, position and carton boundary', () => {
for (const invalid of [undefined, null, true, '', ' ', '01', '+1', '-1', '1.0', '1e2', -1, 0.5, NaN, Infinity, '9007199254740992', 9007199254740992]) {
for (const field of ['scheduledPackages', 'pickedPackages']) assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ [field]: invalid })] }), /排车数量/)
for (const field of ['availablePackages', 'allocatedPackages', 'pickedPackages']) assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ positions: [{ ...item().positions[0], [field]: invalid }] })] }), /排车数量/)
assert.throws(() => normalizeDispatchTask({ dispatchNo: 'PC/1', items: [item({ returnCartons: [{ productQuantity: invalid }] })] }), /排车数量/)
}
})
2026-10-21 14:30:45 +08:00
const task = (overrides = {}) => normalizeDispatchTask({ taskNo: 'TASK-1', dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', pickingReconciled: false, actions: { PICK: { allowed: true }, LOADING_COMPLETE: { allowed: true } }, items: [item()], ...overrides })
2026-10-19 17:56:30 +08:00
function storage() { const values = new Map(); globalThis.uni = { setStorageSync: (key, value) => values.set(key, structuredClone(value)), getStorageSync: key => structuredClone(values.get(key)), removeStorageSync: key => values.delete(key), showModal: async () => ({ confirm: true }) }; resetFulfillmentSession(); restoreFulfillmentDraft('operator:warehouse'); return values }
function page(dependencies = {}) {
2026-10-21 17:27:13 +08:00
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, DispatchLoadingDocuments: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
2026-10-19 17:56:30 +08:00
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const state = { ...definition.data(), current: task() }
for (const [name, method] of Object.entries(definition.methods)) state[name] = method.bind(state)
for (const [name, getter] of Object.entries(definition.computed)) Object.defineProperty(state, name, { get: () => getter.call(state) })
return state
}
2026-10-21 17:27:13 +08:00
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING', result: { confirmed: true } })
2026-10-19 17:56:30 +08:00
test('full pending command survives restoration, rejects another operation, and isolates the next operator', () => {
storage(); const pending = beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
const original = structuredClone(JSON.parse(JSON.stringify(pending)))
fulfillment.dispatch.pendingCommand = null; restoreFulfillmentDraft('operator:warehouse')
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch.pendingCommand)), original)
2026-10-21 14:30:45 +08:00
assert.throws(() => beginDispatchCommand('PC/2', 'LOADING_COMPLETE', { recordVersion: 2 }), /原排车/)
2026-10-19 17:56:30 +08:00
restoreFulfillmentDraft('another:warehouse'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading', async () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
for (const allowed of [false, 'true', undefined]) { state.current.actions.PICK.allowed = allowed; assert.equal(state.canPick, false) }
state.current.actions.PICK.allowed = true; state.current.stage = 'BEFORE_LOADING'; assert.equal(state.canPick, false)
state.current = task({ items: [] , pickingReconciled: true }); assert.equal(state.canComplete, false)
state.current = task({ items: [item({ pickedPackages: 2 })] }); assert.equal(state.canComplete, false)
2026-10-21 14:30:45 +08:00
state.current.pickingReconciled = true; assert.equal(state.canComplete, true)
2026-10-19 17:56:30 +08:00
})
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
storage(); let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = task({ items: [item({ returnCartons: [{ returnCartonId: 'rc-1', cartonMark: 'RC-1', status: 'OUTBOUND_PENDING', productQuantity: 5 }] })] })
state.handleScan('RC-1'); await state.submitPick()
assert.equal(sent[0], 'PC/1'); assert.equal(sent[1], 'item-1')
assert.deepEqual({ ...sent[2], idempotencyKey: 'key' }, { recordVersion: 0, positionId: null, packages: 1, productQuantity: 5, cartonMark: 'RC-1', remark: null, idempotencyKey: 'key' })
assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('unknown POST queries the original key and retries the unchanged payload; a subsequent equal physical pick gets a new key', async () => {
storage(); const sent = []; let fail = true
const state = page({ pickDispatchItem: async (no, id, body) => { sent.push({ no, id, body }); if (fail) { fail = false; throw { network: true, message: 'offline' } } return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.ok(fulfillment.dispatch.pendingCommand)
state.current.recordVersion = 80; await state.recoverCommand(); assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].body.recordVersion, 0)
state.selectPallet(state.current.items[0]); await state.submitPick(); assert.notEqual(sent[2].body.idempotencyKey, sent[1].body.idempotencyKey)
})
2026-10-21 14:30:45 +08:00
test('proven loading completion already delivered to the driver survives a failed GET without another POST', async () => {
2026-10-19 17:56:30 +08:00
storage(); let posts = 0, reads = 0
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
2026-10-21 17:27:13 +08:00
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'AFTER_LOADING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
2026-10-19 17:56:30 +08:00
})
test('later rejection cannot erase an earlier unknown write and older details cannot release a proven result', async () => {
2026-10-21 14:30:45 +08:00
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, completeDispatchLoading: async () => { throw { code: 'A0409', message: 'conflict' } } })
2026-10-19 17:56:30 +08:00
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
state.discardRejected(); assert.ok(fulfillment.dispatch.pendingCommand)
patchDispatchCommand({ result: proof(fulfillment.dispatch.pendingCommand, '9007199254740993') })
const stale = page({ getDispatchTask: async () => task({ recordVersion: '9007199254740992' }) }); await stale.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
})
test('actual loading confirmation leaves reference measurements blank and blocks missing unloading mapping', async () => {
storage(); const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'manifest', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: 'pallet-1', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: null, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: false, sourceType: 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
let posts = 0; const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async () => { posts++ } })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading()
assert.equal(state.loadingRows[0].deliveryIndex, null)
2026-10-19 17:56:30 +08:00
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
2026-10-21 14:30:45 +08:00
test('missing versions surface a local error before picking or loading completion and never produce a write', async () => {
2026-10-19 17:56:30 +08:00
storage(); const state = page(); state.selectPallet(state.current.items[0]); state.current.recordVersion = undefined
await state.submitPick(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
2026-10-21 14:30:45 +08:00
state.loadingPreview = { dispatchNo: 'PC/1', recordVersion: undefined, manifestHash: 'manifest' }; state.loadingRows = [{ warehousePalletId: '21', weightKg: 1, volumeCbm: 1, palletSpaces: 1, deliveryIndex: 1 }]; await state.completeLoading(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
2026-10-19 17:56:30 +08:00
})
test('a first server error remains unknown and cannot be discarded as a definite business rejection', async () => {
storage(); beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0, dispatchItemId: 'item-1', packages: 1 })
const state = page({ pickDispatchItem: async () => { throw { code: 'A0500', message: 'server failure' } } })
await state.recoverCommand(); state.discardRejected(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
})
2026-10-20 14:24:59 +08:00
test('position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets', () => {
storage(); const state = page(); state.current = task({ items: [item({ scheduledPackages: '3', pickedPackages: '1', positions: [{ positionId: 'empty-position', availablePackages: '0', allocatedPackages: '20', pickedPackages: '0' }, { positionId: 'actual-position', availablePackages: '20', allocatedPackages: '0', pickedPackages: '7' }] })] })
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 2)
assert.equal(state.pickablePackages(state.current.items[0]), 2); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 2 }); assert.equal(state.canPick, true)
updateFulfillmentDraft('dispatch', { packages: 3 }); assert.equal(state.canPick, false)
})
for (const scenario of [
{ name: 'zero available stock', scheduled: '2', picked: '0', available: '0', allocated: '20', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'source already reconciled', scheduled: '1', picked: '1', available: '8', allocated: '0', stockPicked: '0', packages: '1', maximum: 0, position: null },
{ name: 'quantity above this dispatch source remaining', scheduled: '3', picked: '2', available: '9', allocated: '0', stockPicked: '8', packages: '2', maximum: 1, position: 'position-1' },
{ name: 'quantity above this position available stock', scheduled: '5', picked: '0', available: '2', allocated: '0', stockPicked: '0', packages: '3', maximum: 2, position: 'position-1' },
]) test(`real stock buckets prevent a pick for ${scenario.name}`, async () => {
storage(); let posts = 0
const state = page({ pickDispatchItem: async () => { posts++ } })
state.current = task({ pickingReconciled: scenario.scheduled === scenario.picked, items: [item({ scheduledPackages: scenario.scheduled, pickedPackages: scenario.picked, positions: [{ positionId: 'position-1', availablePackages: scenario.available, allocatedPackages: scenario.allocated, pickedPackages: scenario.stockPicked }] })] })
state.selectPallet(state.current.items[0]); state.updatePackages({ detail: { value: scenario.packages } })
assert.equal(state.selectedPosition?.positionId ?? null, scenario.position); assert.equal(state.selectedRemaining, scenario.maximum)
assert.equal(state.pickablePackages(state.current.items[0]), scenario.maximum); assert.equal(state.canPick, false)
await state.submitPick(); assert.equal(posts, 0); assert.equal(fulfillment.dispatch.pendingCommand, null)
2026-10-19 17:56:30 +08:00
})
2026-10-20 10:36:04 +08:00
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
2026-10-20 14:24:59 +08:00
storage(); const rows = [item({ scheduledPackages: 3 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', customerName: '客户乙', scheduledPackages: 5, positions: [{ positionId: 'position-2', availablePackages: 5, allocatedPackages: 0, pickedPackages: 0 }] })]
2026-10-20 10:36:04 +08:00
let sent
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows.map((row, index) => index ? { ...row, pickedPackages: 5 } : row) }) })
state.current = task({ items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.deepEqual(state.sourceChoices.map(row => [row.sourceBusinessNo, row.customerId, state.pickablePackages(row)]), [['RL-1', 'customer-1', 2], ['RL-2', 'customer-2', 5]])
state.chooseSource(state.sourceChoices[1]); state.updatePackages({ detail: { value: '5' } }); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].positionId, 'position-2'); assert.equal(sent[2].packages, 5); assert.equal(state.canComplete, false)
})
test('an ambiguous source carton requires explicit source selection and never picks the first matching item', async () => {
storage(); let sent
const rows = [item({ returnCartons: [{ returnCartonId: 'carton-1', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 2 }] }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', customerId: 'customer-2', returnCartons: [{ returnCartonId: 'carton-2', cartonMark: 'SHARED-MARK', status: 'OUTBOUND_PENDING', productQuantity: 7 }] })]
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, items: rows }) })
state.current = task({ items: rows }); state.handleScan('SHARED-MARK')
assert.equal(state.sourceChoices.length, 2); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
state.chooseSource(state.sourceChoices[1]); await state.submitPick()
assert.equal(sent[1], 'item-2'); assert.equal(sent[2].cartonMark, 'SHARED-MARK'); assert.equal(sent[2].productQuantity, 7); assert.equal(sent[2].positionId, null)
})
test('selection restoration cannot reuse the same item ID for a different logical source', () => {
storage(); const state = page(); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
state.current.items[0].sourceKey = 'different-source'; assert.equal(state.selectedItem, null); assert.equal(state.canPick, false)
})
2026-10-20 14:24:59 +08:00
for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${sourceType} real 1/0/0 stock and null business fields allow one exact pick`, async () => {
2026-10-20 10:36:04 +08:00
storage(); let sent
2026-10-20 14:24:59 +08:00
const raw = JSON.parse(JSON.stringify({ dispatchNo: 'PC/1', taskNo: 'TASK-1', recordVersion: '0', status: 'ACTIVE', stage: 'LOADING', actions: { PICK: { allowed: true } }, pickingReconciled: false, items: [item({ sourceKey: `${sourceType}:source-1`, sourceBusinessType: null, sourceBusinessNo: null, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] })] }))
2026-10-20 10:36:04 +08:00
const normalized = normalizeDispatchTask(raw)
assert.equal(normalized.items[0].sourceBusinessType, null); assert.equal(normalized.items[0].sourceBusinessNo, null)
const state = page({ pickDispatchItem: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => normalizeDispatchTask({ ...raw, recordVersion: 1 }) })
2026-10-20 14:24:59 +08:00
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true); assert.equal(state.selectedRemaining, 1)
assert.equal(state.pickablePackages(state.current.items[0]), 1)
state.updatePackages({ detail: { value: '1' } }); await state.submitPick()
assert.equal(sent[1], 'item-1'); assert.equal(sent[2].positionId, '9007199254740993'); assert.equal(sent[2].packages, 1); assert.equal(sent[2].recordVersion, '0')
2026-10-20 10:36:04 +08:00
assert.equal('customerId' in sent[2], false)
})
2026-10-20 14:24:59 +08:00
test('shared relabel pallet with real string 1/0/0 positions requires a source choice and picks each exact source once', async () => {
storage(); const sent = []; let version = 5
let rows = ['1', '2'].map(number => item({ dispatchItemId: `item-${number}`, sourceKey: `source-${number}`, sourceBusinessNo: `RL-${number}`, customerId: `customer-${number}`, scheduledPackages: '1', pickedPackages: '0', positions: [{ positionId: `position-${number}`, availablePackages: '1', allocatedPackages: '0', pickedPackages: '0' }] }))
const state = page({ pickDispatchItem: async (no, itemId, body) => {
sent.push({ no, itemId, body }); rows = rows.map(row => row.dispatchItemId === itemId ? { ...row, pickedPackages: '1', positions: row.positions.map(position => ({ ...position, availablePackages: '0' })) } : row)
return proof(fulfillment.dispatch.pendingCommand, ++version)
}, getDispatchTask: async () => task({ recordVersion: version, items: rows, pickingReconciled: rows.every(row => row.pickedPackages === '1') }) })
state.current = task({ recordVersion: version, items: rows }); state.handleScan('PALLET-1')
assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
assert.deepEqual(state.sourceChoices.map(row => [row.sourceKey, state.pickablePackages(row)]), [['source-1', 1], ['source-2', 1]])
state.chooseSource(state.sourceChoices[1]); assert.equal(state.selectedRemaining, 1); assert.equal(state.canPick, true); await state.submitPick()
assert.equal(state.current.stage, 'LOADING'); assert.equal(state.canComplete, false)
assert.equal(state.current.items[0].pickedPackages, 0); assert.equal(state.current.items[1].pickedPackages, 1)
state.handleScan('PALLET-1'); assert.equal(state.selectedItem.sourceKey, 'source-1'); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent.map(entry => [entry.no, entry.itemId, entry.body.positionId, entry.body.packages, entry.body.recordVersion]), [['PC/1', 'item-2', 'position-2', 1, 5], ['PC/1', 'item-1', 'position-1', 1, 6]])
assert.notEqual(sent[0].body.idempotencyKey, sent[1].body.idempotencyKey)
assert.ok(sent.every(entry => !('customerId' in entry.body) && !('warehouseId' in entry.body)))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canPick, false); assert.equal(state.canComplete, true)
})
2026-10-20 10:36:04 +08:00
test('RELABEL_ORDER still requires its true business number and all sources require sourceKey', () => {
storage(); const state = page()
state.selectPallet(item({ sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.match(state.error.message, /来源业务身份/)
state.selectPallet(item({ sourceKey: '', sourceBusinessType: null, sourceBusinessNo: null })); assert.equal(fulfillment.dispatch.dispatchItemId, '')
})
test('duplicate matching carton facts inside one source fail closed rather than taking its first carton', () => {
storage(); const state = page()
state.current = task({ items: [item({ returnCartons: ['one', 'two'].map(returnCartonId => ({ returnCartonId, cartonMark: 'DUPLICATE', status: 'OUTBOUND_PENDING', productQuantity: 1 })) })] })
state.handleScan('DUPLICATE'); assert.match(state.error.message, /重复待拣箱唛/); assert.equal(fulfillment.dispatch.dispatchItemId, ''); assert.equal(state.canPick, false)
})
test('two picked logical sources use the single authoritative physical loading row without duplicating measurements', async () => {
storage(); const rows = [item({ pickedPackages: 2 }), item({ dispatchItemId: 'item-2', sourceKey: 'source-2', sourceBusinessNo: 'RL-2', pickedPackages: 2 })]
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'one-physical-pallet', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', palletSpaces: 1, deliveryIndex: 1, weightKg: 100, volumeCbm: 2, evidence: [{ field: 'weightKg', actual: true }, { field: 'volumeCbm', actual: true }] }] }
let sent
2026-10-21 17:27:13 +08:00
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
2026-10-20 10:36:04 +08:00
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
2026-10-21 14:30:45 +08:00
})
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
test('missing appointment blocks approval while preserving rejection to customer service', async () => {
storage(); const sent = []
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
assert.equal(state.reviewTime(null), '未填写')
state.reviewReason = '请客服补齐预约时间'
await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
})
2026-10-21 14:30:45 +08:00
test('review task is reachable in the existing list and blocks actual source selection until approval', async () => {
storage(); const state = page({ listDispatchTasks: async () => [reviewTask()], getDispatchTask: async () => reviewTask() })
await state.loadTasks(); assert.equal(state.tasks.length, 1); assert.equal(state.stageLabel(state.tasks[0]), '待理货审核')
await state.openTask('PC/1'); assert.equal(state.canReview, true); assert.equal(state.canPick, false)
state.selectPallet(state.current.items[0]); assert.equal(state.selectedItem, null)
state.handleScan('PALLET-1'); assert.match(state.error.message, /审核通过/); assert.equal(state.selectedItem, null)
for (const allowed of [false, undefined, 'true']) { state.current.actions.LOADING_REVIEW.allowed = allowed; assert.equal(state.canReview, false) }
})
test('reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility', async () => {
storage(); let sent
const state = page({ reviewDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = ' '; await state.submitReview('REJECTED')
assert.match(state.error.message, /原因/); assert.equal(fulfillment.dispatch.pendingCommand, null)
state.reviewReason = ' 已核对车辆和货物 '; await state.submitReview('APPROVED')
assert.deepEqual({ ...sent[1], idempotencyKey: 'key' }, { recordVersion: 0, decision: 'APPROVED', reason: '已核对车辆和货物', idempotencyKey: 'key' })
assert.equal(state.current.stage, 'LOADING'); state.selectPallet(state.current.items[0]); assert.equal(state.canPick, true)
assert.equal(state.reviewReason, ''); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('rejected loading review returns to customer service with its reason and never enables picking', async () => {
storage(); let sent
const rejected = reviewTask({ recordVersion: 1, actions: { LOADING_REVIEW: { allowed: false } }, reviews: { dispatchReview: { status: 'NOT_SUBMITTED', invalidatedReason: '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview: { status: 'REJECTED', reason: '车牌不符' } } })
const state = page({ reviewDispatchLoading: async (no, body) => { sent = body; return { ...proof(fulfillment.dispatch.pendingCommand), stage: 'BEFORE_LOADING' } }, getDispatchTask: async () => rejected })
state.current = reviewTask(); state.reviewReason = '车牌不符'; await state.submitReview('REJECTED')
assert.equal(sent.decision, 'REJECTED'); assert.equal(state.rejectionReason, '车牌不符'); assert.equal(state.canReview, false)
assert.equal(state.stageLabel(state.current), '已退回客服处理'); assert.equal(state.canPick, false); assert.match(state.successMessage, /客服处理/)
})
test('review network uncertainty preserves exact decision reason version and key across restore and replay', async () => {
storage(); const sent = []; let fail = true
const state = page({ reviewDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: 'HTTP_404' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); state.reviewReason = '现场已核对'; await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, false); restoreFulfillmentDraft('operator:warehouse')
state.current.recordVersion = 99; state.reviewReason = '不得覆盖原文'; await state.recoverCommand()
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(sent[1].reason, '现场已核对'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('Admin wins a review race: definite conflict preserves command for explicit refresh and no automatic new approval', async () => {
storage(); let writes = 0
const state = page({ reviewDispatchLoading: async () => { writes++; throw { code: 'A0409', message: '版本已变化' } }, getDispatchTask: async () => task({ recordVersion: 1 }) })
state.current = reviewTask(); await state.submitReview('APPROVED')
assert.equal(fulfillment.dispatch.pendingCommand.rejected, true); assert.equal(writes, 1)
state.discardRejected(); await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(fulfillment.dispatch.pendingCommand, null); assert.equal(state.canReview, false); assert.equal(writes, 1)
})
test('review confirmation cannot submit a different version or a revoked gate', async () => {
for (const change of [state => { state.current.recordVersion = 2 }, state => { state.current.actions.LOADING_REVIEW.allowed = false }]) {
storage(); const state = page(); state.current = reviewTask(); uni.showModal = async () => { change(state); return { confirm: true } }
await state.submitReview('APPROVED'); assert.equal(fulfillment.dispatch.pendingCommand, null)
}
})
test('wrong review result stage or stale refresh retains original command until authoritative confirmation', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_REVIEW', { recordVersion: 0, decision: 'REJECTED', reason: '车牌不符' })
const state = page({ reviewDispatchLoading: async () => proof(fulfillment.dispatch.pendingCommand) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
patchDispatchCommand({ result: { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'BEFORE_LOADING' } })
const stale = page({ getDispatchTask: async () => reviewTask({ recordVersion: 2 }) }); await stale.recoverCommand()
assert.ok(fulfillment.dispatch.pendingCommand); assert.match(stale.error.message, /版本早于/)
})
test('review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action', async () => {
const template = (await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')).split('<script>')[0]
for (const field of ['resources?.vehicle?.plate', 'resources?.driver?.name', 'resources?.driver?.phone', 'reviews?.dispatchReview?.reason', 'rejectionReason']) assert.ok(template.includes(field))
assert.ok(template.includes("submitReview('APPROVED')")); assert.ok(template.includes("submitReview('REJECTED')"))
assert.doesNotMatch(template, /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/)
})
test('loading completion keeps original manifest through a network replay and immediately enters delivery', async () => {
2026-10-21 14:30:45 +08:00
storage(); let fail = true; const sent = []
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest' })
2026-10-21 17:27:13 +08:00
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
2026-10-21 14:30:45 +08:00
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
2026-10-21 17:27:13 +08:00
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
2026-10-21 14:30:45 +08:00
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
})
2026-10-21 17:27:13 +08:00
test('loading completion requires an after-loading result and does not accept an intermediate stage as success', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
2026-10-21 14:30:45 +08:00
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
2026-10-20 10:36:04 +08:00
})
2026-10-21 17:27:13 +08:00
test('documents already complete allow loading confirmation to return DELIVERING and recover without reposting', async () => {
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash' })
2026-10-21 17:27:13 +08:00
let posts = 0, reads = 0
const state = page({ completeDispatchLoading: async () => { posts++; return { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'DELIVERING' } }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
assert.match(state.successMessage, /已进入派送中/)
})
test('scanning received pallets defaults to real nine and eleven boxes without manually reentering estimates', async () => {
storage(); const sent = []
let rows = [9, 11].map((quantity, index) => item({ dispatchItemId: `received-${index}`, sourceKey: `PLAN:${index}`, sourceBusinessType: null, sourceBusinessNo: null, palletNo: `PALLET-${index}`, scheduledPackages: quantity, positions: [{ positionId: `position-${index}`, availablePackages: quantity, allocatedPackages: 0, pickedPackages: 0 }] }))
const state = page({ pickDispatchItem: async (number, id, body) => { sent.push(body.packages); rows = rows.map(row => row.dispatchItemId === id ? { ...row, pickedPackages: body.packages, positions: [] } : row); return proof(fulfillment.dispatch.pendingCommand, sent.length) }, getDispatchTask: async () => task({ items: rows, recordVersion: sent.length }) })
state.current = task({ items: rows })
state.handleScan('PALLET-0'); assert.equal(state.packages, 9); assert.equal(state.canPick, true); await state.submitPick()
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
assert.deepEqual(sent, [9, 11]); assert.equal(state.pickedPackages, 20); assert.equal(state.remainingPackages, 0)
})
test('photo tab preserves the selected pallet and quantity and ignores picking broadcasts', () => {
storage(); const state = page(); state.handleScan('PALLET-1'); state.updatePackages({ detail: { value: '1' } })
const before = JSON.parse(JSON.stringify(fulfillment.dispatch))
state.selectTab('photos'); state.handleScan('UNKNOWN-PALLET')
assert.equal(state.activeTab, 'photos'); assert.equal(state.error, null)
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch)), before)
state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
})
test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
await state.openTask('PC/2'); assert.equal(state.activeTab, 'picking')
})
test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
storage(); const state = page(); state.busy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.busy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
state.current.stage = 'LOADING'; beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0 })
state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
})
test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
storage(); let sent
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
})