This commit is contained in:
2026-10-19 17:56:30 +08:00
parent e556d75081
commit 0cfefb327a
7 changed files with 399 additions and 427 deletions
+103
View File
@@ -0,0 +1,103 @@
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', 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)
})
+51 -125
View File
@@ -4,9 +4,9 @@ import { readFile } from 'node:fs/promises'
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
import {
completeDispatchPicking, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload, tripDepartureRequest
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
} from '../src/api/fulfillment.js'
import { session } from '../src/stores/session.js'
import { toDispatchError } from '../src/utils/dispatch-error.js'
@@ -23,50 +23,10 @@ test('dispatch network failures show recovery guidance and retain diagnostics',
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('dispatch merge retains return carton picking and review gates together', async () => {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
packages: 1, selectedRemaining: 1, pickingCompleted: false, plannedPackages: 1, remainingPackages: 0 }
assert.equal(definition.computed.canPick.call(state), true)
state.selectedReturnCarton = null
assert.equal(definition.computed.canPick.call(state), false)
state.selectedItem.returnCartons = []
state.selectedPosition = { id: 'position' }
assert.equal(definition.computed.canPick.call(state), true)
for (const canPick of [false, undefined, 'true']) {
state.current.canPick = canPick
assert.equal(definition.computed.canPick.call(state), false)
assert.equal(definition.computed.canComplete.call(state), false)
}
const calls = []
const deps = { pickDispatchItem: async (...args) => calls.push(args), getDispatchTask: async () => state.current,
fulfillmentIdempotencyKey: () => ({ value: 'retry-key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, playSuccessSound() {}, PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {} }
const page = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
await page.methods.submitPick.call({ canPick: true, current: { dispatchNo: 'DS-RETURN' },
selectedItem: { dispatchItemId: 'item' }, selectedReturnCarton: { cartonMark: 'RETURN-BOX', productQuantity: 5 },
run: async (_, work) => work() })
assert.deepEqual(calls, [['DS-RETURN', 'item', { positionId: null, cartonMark: 'RETURN-BOX', packages: 1, productQuantity: 5, idempotencyKey: 'retry-key' }]])
})
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')
assert.equal(task.tripDispatchCount, 2)
assert.deepEqual(tripDepartureRequest(task, 'departure-key'), { recordVersion: '0', idempotencyKey: 'departure-key', remark: '海外 PDA 整车核平后发车' })
for (const invalid of [{}, { tripNo: 'TR-1' }, { tripNo: 'TR-1', tripRecordVersion: '-1' }]) assert.throws(() => tripDepartureRequest(invalid, 'key'), /车次信息不完整/)
})
test('whole trip departure UI identifies all members and refreshes server state before releasing retry key', async () => {
const page = await read('../src/pages/dispatch/dispatch.vue')
assert.match(page, /current\.tripNo[\s\S]*current\.tripDispatchCount/)
assert.match(page, /确认整车发车/)
assert.match(page, /tripDepartureRequest\(this.current, key.value\)/)
assert.doesNotMatch(page, /status: result\?\.status \|\| 'DEPARTED'/)
assert.match(page, /result\.dispatches\?\.some[\s\S]*await getDispatchTask[\s\S]*releaseFulfillmentKey/)
test('native dispatch versions preserve explicit zero and reject missing or invalid versions', () => {
assert.equal(requireDispatchVersion(0), 0)
assert.equal(requireDispatchVersion('9007199254740993'), '9007199254740993')
for (const value of [undefined, null, -1, '-1', '', '1.1', 1.1, Number.MAX_SAFE_INTEGER + 1]) assert.throws(() => requireDispatchVersion(value), /版本/)
})
test('outbound partial pick submits the selected carton and distinguishes shared-pallet items', async () => {
@@ -135,7 +95,7 @@ test('phase-five workbench registers only dispatch and outbound PDA delivery bou
test('fulfillment API uses dedicated PDA routes and never sends customer or warehouse scope', async () => {
const api = await read('../src/api/fulfillment.js')
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/loading-complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/')))
for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden))
assert.match(api, /encodeURIComponent/)
})
@@ -156,9 +116,9 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
}
}
await listDispatchTasks('PICKING'); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await completeDispatchPicking('DSP/1', { idempotencyKey: 'complete-key' })
await listDispatchTasks(); await getDispatchTask('DSP/1')
await pickDispatchItem('DSP/1', '11', { recordVersion: 0, positionId: '22', packages: 1, productQuantity: 0, idempotencyKey: 'pick-key' })
await getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 300, volumeCbm: 1, deliveryIndex: 1 }] })
await departDispatch('DSP/1', { recordVersion: '4', idempotencyKey: 'depart-key' })
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
await resolveOutboundTask('OUT/1')
@@ -166,9 +126,9 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
await pickOutboundPallet('OUT/TASK/1', 'DP/1', { idempotencyKey: 'out-pallet-key' })
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
assert.equal(calls.length, 11)
assert.equal(calls.length, 13)
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
assert.ok(calls.some((call) => call.url.endsWith('/dispatches?status=PICKING')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches')))
assert.ok(calls.some((call) => call.url.includes('/dispatches/DSP%2F1/items/11/pick')))
assert.ok(calls.some((call) => call.url.endsWith('/dispatches/DSP%2F1/depart')))
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pick')))
@@ -180,17 +140,17 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
assert.equal(Object.hasOwn(call.data || {}, 'customerId'), false)
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
}
const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/complete'))
assert.deepEqual(dispatchComplete.data, { idempotencyKey: 'complete-key' })
const dispatchComplete = calls.find((call) => call.url.endsWith('/dispatches/DSP%2F1/loading-complete'))
assert.deepEqual(dispatchComplete.data, { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 300, volumeCbm: 1, deliveryIndex: 1 }] }); assert.ok(calls.some(call => call.url.endsWith('/commands/pick%2Fkey')))
})
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', canDepart: false, departBlockReason: '请先在平台完成“转派送”', items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ id: 9, availablePackages: 5, pickedPackages: 2 }] }] })
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', actions: { DEPART: { allowed: false, blockReason: '请先在平台安排派送' } }, items: [{ dispatchItemId: 8, palletNo: 'PLT-1', scheduledPackages: 5, pickedPackages: 2, positions: [{ positionId: 9, availablePackages: 5, allocatedPackages: 5, pickedPackages: 2 }] }] })
assert.equal(dispatch.dispatchNo, 'DSP-1')
assert.equal(dispatch.items[0].palletNo, 'PLT-1')
assert.equal(dispatch.items[0].positions[0].availablePackages, 5)
assert.equal(dispatch.canDepart, false)
assert.equal(dispatch.departBlockReason, '请先在平台完成“转派送”')
assert.equal(dispatch.actions.DEPART.allowed, false)
assert.equal(dispatch.actions.DEPART.blockReason, '请先在平台安排派送')
const outbound = normalizeOutboundTask({ task: { taskNo: 'OUT-TASK-1', outboundNo: 'OUT-1', status: 'PENDING' }, items: [{ outboundPalletItemId: 3, palletNo: 'PLT-2', skuCode: 'SKU-1', inboundCode: 'INB-1', packageCount: 4, pickedPackageCount: 1, status: 'PARTIAL_PICKED', positions: [{ inventoryPositionId: 7, palletNo: 'PLT-2', cartonMark: 'BOX-1', skuCode: 'SKU-1', inboundCode: 'INB-1', availablePackages: 3, pickedPackages: 1 }] }] })
assert.equal(outbound.taskNo, 'OUT-TASK-1')
@@ -202,60 +162,31 @@ test('dispatch and outbound adapters retain stable business identifiers and coun
assert.equal(outbound.items[0].positions[0].availablePackages, 3)
})
test('draft restoration scopes work to the signed-in operator warehouse and retains retry idempotency', () => {
storageMock(); resetFulfillmentSession()
assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), false)
updateFulfillmentDraft('dispatch', { currentNo: 'DSP-001', palletCode: 'PLT-001', packages: 3 })
const first = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
const retry = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
assert.equal(retry.value, first.value)
fulfillment.dispatch.currentNo = ''
assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
assert.equal(fulfillment.dispatch.currentNo, 'DSP-001')
assert.equal(fulfillment.dispatch.pendingKeys[first.signature], first.value)
releaseFulfillmentKey('dispatch', first.signature)
const next = fulfillmentIdempotencyKey('dispatch', 'pick', { dispatchNo: 'DSP-001', palletNo: 'PLT-001', packages: 3 })
assert.notEqual(next.value, first.value)
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false)
assert.equal(fulfillment.dispatch.currentNo, '')
test('outbound draft restoration retains its retry key and stays scoped to operator and warehouse', () => {
storageMock(); resetFulfillmentSession(); restoreFulfillmentDraft('operator-a:warehouse-a')
updateFulfillmentDraft('outbound', { currentTaskNo: 'OUT-001' })
const first = fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 })
assert.equal(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
fulfillment.outbound.currentTaskNo = ''; assert.equal(restoreFulfillmentDraft('operator-a:warehouse-a'), true)
assert.equal(fulfillment.outbound.currentTaskNo, 'OUT-001')
releaseFulfillmentKey('outbound', first.signature)
assert.notEqual(fulfillmentIdempotencyKey('outbound', 'pick', { taskNo: 'OUT-001', packages: 3 }).value, first.value)
assert.equal(restoreFulfillmentDraft('operator-b:warehouse-a'), false); assert.equal(fulfillment.outbound.currentTaskNo, '')
assert.throws(() => fulfillmentIdempotencyKey('dispatch', 'pick', {}), /完整原命令/)
})
test('pages expose loading empty error offline and permission diagnostics with guarded writes', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
for (const page of [dispatch, outbound]) {
assert.match(page, /startScanBroadcast/)
assert.match(page, /正在加载/)
assert.match(page, /暂无/)
assert.match(page, /requestId[\s\S]*traceId/)
assert.match(page, /error\.network[\s\S]*保留/)
assert.match(page, /fulfillmentIdempotencyKey/)
assert.match(page, /releaseFulfillmentKey/)
assert.match(page, /if \(!this\.can/)
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
}
assert.match(dispatch, /completeDispatchPicking/)
assert.match(dispatch, /toDispatchError/)
assert.match(dispatch, /this\.remainingPackages === 0/)
assert.doesNotMatch(dispatch, /partialReason|reason \}/)
assert.match(dispatch, /this\.pickingCompleted && this\.allPicked && this\.current\?\.canDepart === true && !this\.departed/)
assert.match(dispatch, /current\.departBlockReason/)
assert.match(dispatch, /this\.selectedPosition\?\.availablePackages \|\| 0/)
assert.doesNotMatch(dispatch, /availablePackages - (?:this\.)?selectedPosition.*pickedPackages/)
assert.match(dispatch, /departDispatch/)
assert.doesNotMatch(dispatch, /departedNo/)
assert.match(outbound, /completeOutboundTask/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
assert.match(outbound, /整托扫描托盘码,散箱扫描箱唛/)
assert.match(outbound, /退货整托出库扫托盘码,散箱出库扫箱唛/)
assert.match(outbound, /if \(!this\.current\) return this\.openScannedTask\(code\)/)
assert.doesNotMatch(outbound, /if \(!this\.current\)[^\n]*openTask\(code\)/)
assert.match(outbound, /this\.remainingPackages === 0/)
assert.match(outbound, /position\.availablePackages <= 0/)
assert.doesNotMatch(outbound, /position\.availablePackages - position\.pickedPackages/)
for (const page of [dispatch, outbound]) assert.match(page, /await get(?:Dispatch|Outbound)Task[\s\S]*releaseFulfillmentKey/)
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
test('pages retain loading, offline, diagnostics and separate native dispatch completion/departure', async () => {
const [dispatch, outbound] = await Promise.all([read('../src/pages/dispatch/dispatch.vue'), read('../src/pages/outbound/outbound.vue')])
for (const page of [dispatch,outbound]) {
for (const text of ['startScanBroadcast', '正在加载', '暂无', 'requestId', 'traceId', '返回列表']) assert.ok(page.includes(text))
assert.doesNotMatch(page, /模拟成功|静默成功|customerId:|warehouseId:|locationId:/)
}
assert.match(dispatch, /completeDispatchLoading/); assert.match(dispatch, /pickingReconciled === true/)
assert.match(dispatch, /beginDispatchCommand/); assert.match(dispatch, /await getDispatchTask[\s\S]*finishDispatchCommand/)
assert.doesNotMatch(dispatch, /tripDepartureRequest|completeDispatchPicking|fulfillmentIdempotencyKey/)
assert.match(outbound, /resolveOutboundTask[\s\S]*workflow === 'DISPATCH'[\s\S]*pages\/dispatch\/dispatch/)
assert.match(outbound, /outboundPickPayload/); assert.match(outbound, /await getOutboundTask[\s\S]*releaseFulfillmentKey/)
assert.doesNotMatch(dispatch + outbound, /DeliveryOrder|deliveryStatus|customerVisit|\/deliveries/)
})
test('fulfillment workbench entries open task lists before details and keep explicit cross-workflow resume', async () => {
@@ -299,26 +230,21 @@ test('receiving conflict recovery keeps request and trace evidence visible on na
assert.match(operation, /\.error-card text\{overflow-wrap:anywhere;word-break:break-word\}/)
})
test('dispatch review gate preserves the server decision without treating a string or missing value as approval', async () => {
assert.equal(normalizeDispatchTask({ canPick: true }).canPick, true)
for (const canPick of [false, undefined, 'true']) assert.equal(normalizeDispatchTask({ canPick }).canPick, false)
assert.equal(normalizeDispatchTask({ canPick: false, pickBlockReason: '调度审核已打回:预约待核对' }).pickBlockReason, '调度审核已打回:预约待核对')
const source = await read('../src/pages/dispatch/dispatch.vue')
assert.match(source, /canPick\(\) \{ return this.current\?\.canPick === true/)
assert.match(source, /canComplete\(\) \{ return this.current\?\.canPick === true/)
test('native action gates reject strings and retain server block reasons', () => {
for (const allowed of [true, false, undefined, 'true']) {
const task=normalizeDispatchTask({dispatchNo:'DS-1',items:[],actions:{PICK:{allowed,blockReason:'审核未通过'}}})
assert.equal(task.actions.PICK.allowed,allowed===true); assert.equal(task.actions.PICK.blockReason,'审核未通过')
}
})
async function dispatchScanHarness(items) {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const draft = { palletCode: 'OLD', dispatchItemId: 'old', cartonCode: '' }; let sounds = 0
const deps = { PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {},
updateFulfillmentDraft: (_, value) => Object.assign(draft, value), playSuccessSound: () => { sounds++ },
console: { info() {} } }
const definition = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
const state = { current: { dispatchNo: 'DS-TEST', items }, error: { message: 'previous error' }, successMessage: '',
setError(error) { this.error = { message: error.message } } }
state.selectPallet = definition.methods.selectPallet.bind(state)
return { state, draft, scan: definition.methods.handleScan.bind(state), sounds: () => sounds }
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},updateFulfillmentDraft:(_,value)=>Object.assign(draft,value),playSuccessSound:()=>{sounds++}}
const definition=new Function(...Object.keys(deps),source.replace(/^\s*import .*$/gm,'').replace('export default','return'))(...Object.values(deps))
const state={current:{dispatchNo:'DS-TEST',items:items.map(item=>({...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
state.selectPallet=definition.methods.selectPallet.bind(state)
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
}
test('dispatch wrong scan clears prior selection and a valid pallet clears the old error', async () => {