优化
This commit is contained in:
@@ -21,7 +21,7 @@ test('invalid, missing and unsafe native quantities fail at every item, position
|
||||
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 })
|
||||
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 })
|
||||
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 }
|
||||
@@ -31,13 +31,13 @@ function page(dependencies = {}) {
|
||||
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 } })
|
||||
const proof = (command, version = 1) => ({ dispatchNo: command.dispatchNo, action: command.action, idempotencyKey: command.body.idempotencyKey, recordVersion: version, stage: command.action === 'LOADING_COMPLETE' ? '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 }), /原排车/)
|
||||
assert.throws(() => beginDispatchCommand('PC/2', 'LOADING_COMPLETE', { 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 () => {
|
||||
@@ -46,7 +46,7 @@ test('real picking requires the native gate, physical source and current stage;
|
||||
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)
|
||||
state.current.pickingReconciled = true; assert.equal(state.canComplete, true)
|
||||
})
|
||||
test('return carton pick keeps its exact item, quantity and explicit version zero without inventing a position', async () => {
|
||||
storage(); let sent
|
||||
@@ -64,16 +64,16 @@ test('unknown POST queries the original key and retries the unchanged payload; a
|
||||
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 () => {
|
||||
test('proven loading completion already delivered to the driver survives a failed GET without another 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(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)
|
||||
})
|
||||
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' } } })
|
||||
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' } } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.rejected, false)
|
||||
state.discardRejected(); assert.ok(fulfillment.dispatch.pendingCommand)
|
||||
patchDispatchCommand({ result: proof(fulfillment.dispatch.pendingCommand, '9007199254740993') })
|
||||
@@ -86,10 +86,10 @@ test('actual loading confirmation leaves reference measurements blank and blocks
|
||||
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 () => {
|
||||
test('missing versions surface a local error before picking or loading completion 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)
|
||||
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)
|
||||
})
|
||||
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 })
|
||||
@@ -184,7 +184,85 @@ test('two picked logical sources use the single authoritative physical loading r
|
||||
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 }) })
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'DELIVERING', 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 }])
|
||||
await state.completeLoading(); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(state.departed, true); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.deepEqual(sent[1].measurements, [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 100, volumeCbm: 2, deliveryIndex: 1 }])
|
||||
})
|
||||
|
||||
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('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 and measurements through a network replay and immediately enters delivery', async () => {
|
||||
storage(); let fail = true; const sent = []
|
||||
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest', measurements: [{ warehousePalletId: '21', weightKg: 100, volumeCbm: 2, palletSpaces: 1, deliveryIndex: 1 }] })
|
||||
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: 'DELIVERING' }) })
|
||||
await state.recoverCommand(); restoreFulfillmentDraft('operator:warehouse'); state.current.recordVersion = 9; await state.recoverCommand()
|
||||
assert.deepEqual(sent[0], sent[1]); assert.equal(sent[1].recordVersion, 0); assert.equal(state.current.stage, 'DELIVERING'); assert.equal(state.canComplete, false); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
await state.completeLoading(); await state.recoverCommand(); assert.equal(sent.length, 2)
|
||||
})
|
||||
test('loading completion requires a delivering result and does not accept an intermediate stage as success', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
|
||||
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
|
||||
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
|
||||
|
||||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, resetFulfillmentSession, restoreFulfillmentDraft, updateFulfillmentDraft } from '../src/stores/fulfillment.js'
|
||||
import {
|
||||
completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, departDispatch, getDispatchTask, getOutboundTask,
|
||||
reviewDispatchLoading, completeDispatchLoading, getDispatchLoading, getDispatchCommand, requireDispatchVersion, completeOutboundTask, getDispatchTask, getOutboundTask,
|
||||
listDispatchTasks, listOutboundTasks, normalizeDispatchTask, normalizeOutboundTask,
|
||||
pickDispatchItem, pickOutboundPallet, pickOutboundTask, resolveOutboundTask, outboundPickPayload
|
||||
} from '../src/api/fulfillment.js'
|
||||
@@ -95,12 +95,12 @@ 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', '/loading-complete', '/depart']) assert.match(api, new RegExp(path.replace('/', '\\/')))
|
||||
for (const path of ['dispatches', 'outbound-tasks', '/pick', '/loading-complete', '/loading-review']) assert.match(api, new RegExp(path.replace('/', '\\/')))
|
||||
for (const forbidden of ['customerId', 'warehouseId', 'locationId']) assert.doesNotMatch(api, new RegExp(forbidden))
|
||||
assert.match(api, /encodeURIComponent/)
|
||||
})
|
||||
|
||||
test('fulfillment API calls the frozen list detail pick complete and depart routes with exact payloads', async () => {
|
||||
test('fulfillment API calls the frozen list detail pick complete and review routes with exact payloads', async () => {
|
||||
const calls = []
|
||||
session.apiBase = '/api'; session.token = 'overseas-token'
|
||||
globalThis.uni = {
|
||||
@@ -119,7 +119,7 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
|
||||
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 reviewDispatchLoading('DSP/1', { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
|
||||
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
|
||||
await resolveOutboundTask('OUT/1')
|
||||
await pickOutboundTask('OUT/TASK/1', { outboundPalletItemId: '33', inventoryPositionId: '44', packages: 1, idempotencyKey: 'out-pick-key' })
|
||||
@@ -127,10 +127,9 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
|
||||
await completeOutboundTask('OUT/TASK/1', { idempotencyKey: 'out-complete-key' })
|
||||
|
||||
assert.equal(calls.length, 13)
|
||||
assert.deepEqual(calls.find(call => call.url.endsWith('/depart')).data, { recordVersion: '4', idempotencyKey: 'depart-key' })
|
||||
assert.deepEqual(calls.find(call => call.url.endsWith('/dispatches/DSP%2F1/loading-review')).data, { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
|
||||
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')))
|
||||
assert.ok(calls.some((call) => call.url.includes('/outbound-tasks/OUT%2FTASK%2F1/pallets/DP%2F1/pick')))
|
||||
assert.ok(calls.some((call) => call.url.endsWith('/outbound-tasks/resolve/OUT%2F1')))
|
||||
@@ -145,12 +144,12 @@ test('fulfillment API calls the frozen list detail pick complete and depart rout
|
||||
})
|
||||
|
||||
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
|
||||
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 }] }] })
|
||||
const dispatch = normalizeDispatchTask({ taskNo: 'DPT-1', dispatchNo: 'DSP-1', actions: { LOADING_COMPLETE: { 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.actions.DEPART.allowed, false)
|
||||
assert.equal(dispatch.actions.DEPART.blockReason, '请先在平台安排派送')
|
||||
assert.equal(dispatch.actions.LOADING_COMPLETE.allowed, false)
|
||||
assert.equal(dispatch.actions.LOADING_COMPLETE.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')
|
||||
@@ -175,13 +174,13 @@ test('outbound draft restoration retains its retry key and stays scoped to opera
|
||||
assert.throws(() => fulfillmentIdempotencyKey('dispatch', 'pick', {}), /完整原命令/)
|
||||
})
|
||||
|
||||
test('pages retain loading, offline, diagnostics and separate native dispatch completion/departure', async () => {
|
||||
test('pages retain loading, offline, diagnostics and single native dispatch completion into delivery', 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.doesNotMatch(dispatch, /departDispatch|canDepart|AFTER_LOADING|确认整车发车|等待后台安排派送/); 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/)
|
||||
@@ -242,8 +241,8 @@ async function dispatchScanHarness(items) {
|
||||
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
|
||||
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},fulfillment:{dispatch:draft},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=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
|
||||
state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
|
||||
const state={current:{dispatchNo:'DS-TEST',stage:'LOADING',actions:{PICK:{allowed:true}},items:items.map(item=>({sourceKey:`source-${item.dispatchItemId}`,sourceBusinessType:'RELABEL_ORDER',sourceBusinessNo:`RL-${item.dispatchItemId}`,positions:[],...item,returnCartons:item.returnCartons||[]}))},error:{message:'previous error'},successMessage:'',setError(error){this.error={message:error.message}}}
|
||||
state.gate=definition.methods.gate.bind(state);state.selectPallet=definition.methods.selectPallet.bind(state);state.chooseSource=definition.methods.chooseSource.bind(state)
|
||||
return {state,draft,scan:definition.methods.handleScan.bind(state),sounds:()=>sounds}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user