优化
This commit is contained in:
@@ -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)
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user