Files
pda-overseas-uniapp/tests/dispatch-workflow.test.mjs
T
2026-10-20 10:36:04 +08:00

155 lines
19 KiB
JavaScript

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]
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: 2, pickedPackages: 0 }], returnCartons: [], ...overrides })
test('native string quantities normalize without changing Long identity or version', () => {
const id = '9007199254740993'
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: '12', pickedPackages: '0' }], returnCartons: [{ returnCartonId: id, productQuantity: '24' }] })] })))
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)
assert.deepEqual(value.items[0].positions[0], { positionId: id, balanceId: id, availablePackages: 12, allocatedPackages: 12, pickedPackages: 0 })
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 }] })] }), /排车数量/)
}
})
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 }, DEPART: { allowed: false } }, items: [item()], ...overrides })
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 = {}) {
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {}, session: { currentUser: {} }, fulfillment, requireDispatchVersion, dispatchVersionAtLeast, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, updateFulfillmentDraft, playSuccessSound() {}, playErrorSound() {}, toDispatchError: error => ({ message: error.message }), ...dependencies }
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
}
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : command.action === 'DEPART' ? 'DELIVERING' : 'LOADING', result: { confirmed: true } })
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)
assert.throws(() => beginDispatchCommand('PC/2', 'DEPART', { recordVersion: 2 }), /原排车/)
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)
state.current.pickingReconciled = true; assert.equal(state.canComplete, true); assert.equal(state.canDepart, false)
})
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)
})
test('proven loading completion survives a failed GET and later departure by the other endpoint without another loading POST', async () => {
storage(); let posts = 0, reads = 0
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, 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, 'AFTER_LOADING')
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
test('later rejection cannot erase an earlier unknown write and older details cannot release a proven result', async () => {
storage(); beginDispatchCommand('PC/1', 'DEPART', { recordVersion: 0 }); patchDispatchCommand({ attempted: true })
const state = page({ getDispatchCommand: async () => { throw { code: '404' } }, departDispatch: async () => { throw { code: 'A0409', message: 'conflict' } } })
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].weightKg, null); assert.equal(state.loadingRows[0].volumeCbm, null)
Object.assign(state.loadingRows[0], { weightKg: 92, volumeCbm: 1.5 }); await state.completeLoading(); assert.equal(posts, 0); assert.match(state.error.message, /卸货站/)
})
test('missing versions surface a local error before picking or departure and never produce a write', async () => {
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)
state.current = task({ recordVersion: undefined, stage: 'AFTER_LOADING', actions: { DEPART: { allowed: true } } }); await state.depart(); assert.match(state.error.message, /版本/); assert.equal(fulfillment.dispatch.pendingCommand, null)
})
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)
})
test('position picking is bounded by this dispatch allocation and skips already picked allocations', () => {
storage(); const state = page(); state.current.items[0].positions = [{ positionId: 'already-picked', availablePackages: 20, allocatedPackages: 1, pickedPackages: 1 }, { positionId: 'actual-position', availablePackages: 20, allocatedPackages: 1, pickedPackages: 0 }]
state.selectPallet(state.current.items[0]); assert.equal(state.selectedPosition.positionId, 'actual-position'); assert.equal(state.selectedRemaining, 1)
updateFulfillmentDraft('dispatch', { packages: 2 }); assert.equal(state.canPick, false)
})
test('a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity', async () => {
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: 5, pickedPackages: 0 }] })]
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)
})
for (const sourceType of ['INBOUND_PLAN', 'RETURN_RECEIPT']) test(`ordinary ${sourceType} null business fields survive real wire normalization and exact picking`, async () => {
storage(); let sent
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: '4', pickedPackages: '0', positions: [{ positionId: '9007199254740993', balanceId: '9007199254740994', availablePackages: '4', allocatedPackages: '4', pickedPackages: '0' }] })] }))
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 }) })
state.current = normalized; state.handleScan('PALLET-1'); assert.equal(state.canPick, true)
state.updatePackages({ detail: { value: '4' } }); await state.submitPick()
assert.equal(sent[1], 'item-1'); assert.equal(sent[2].positionId, '9007199254740993'); assert.equal(sent[2].packages, 4); assert.equal(sent[2].recordVersion, '0')
assert.equal('customerId' in sent[2], false)
})
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
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 }) })
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
await state.completeLoading(); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
})