fix(dispatch): 拣货拍照分区并展示有效预约审核依据
This commit is contained in:
@@ -55,6 +55,18 @@ test('a failed upload does not replace a saved photo or advance the stage', asyn
|
||||
assert.ok(state.pending.key); assert.equal(state.pending.photo, undefined)
|
||||
})
|
||||
|
||||
test('definitive file rejection allows selecting a new image while retaining the saved one', async () => {
|
||||
let uploads = 0
|
||||
const { state } = component({ getLoadingDocuments: async () => data({ photos: [photo] }), uploadLoadingPhoto: async () => {
|
||||
if (++uploads === 1) throw { code: 'A0400', message: '图片内容无效' }
|
||||
return photo
|
||||
} })
|
||||
await state.load(); await state.choose('LOADING_SCENE')
|
||||
assert.equal(state.pending, null); assert.equal(state.snapshot.photos[0].fileId, photo.fileId)
|
||||
assert.equal(state.error.message, '图片内容无效')
|
||||
await state.choose('LOADING_SCENE'); assert.equal(uploads, 2); assert.equal(state.pending, null)
|
||||
})
|
||||
|
||||
test('loading documents can be saved during LOADING without claiming departure', async () => {
|
||||
assert.equal(normalizeLoadingDocuments(data({ stage: 'LOADING' }), 'PC/1').stage, 'LOADING')
|
||||
assert.throws(() => normalizeLoadingDocuments(data({ stage: 'BEFORE_LOADING' }), 'PC/1'), /不一致/)
|
||||
|
||||
@@ -66,7 +66,7 @@ test('unknown POST queries the original key and retries the unchanged payload; a
|
||||
})
|
||||
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: [] })
|
||||
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
|
||||
const state = page({ completeDispatchLoading: async () => { posts++; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'AFTER_LOADING', recordVersion: 3 }) } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'AFTER_LOADING')
|
||||
await state.recoverCommand(); assert.equal(posts, 1); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(fulfillment.dispatch.pendingCommand, null)
|
||||
@@ -83,7 +83,7 @@ test('actual loading confirmation leaves reference measurements blank and blocks
|
||||
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)
|
||||
assert.equal(state.loadingRows[0].deliveryIndex, 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 loading completion and never produce a write', async () => {
|
||||
@@ -186,10 +186,20 @@ test('two picked logical sources use the single authoritative physical loading r
|
||||
let sent
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (...args) => { sent = args; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', items: rows, pickingReconciled: true }) })
|
||||
state.current = task({ items: rows, pickingReconciled: true }); await state.prepareLoading(); assert.equal(state.loadingRows.length, 1)
|
||||
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); 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 }])
|
||||
await state.completeLoading(); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(state.departed, false); assert.equal(state.canComplete, false); assert.equal(state.loadingPreview, null); assert.equal('measurements' in sent[1], false)
|
||||
})
|
||||
|
||||
const reviewTask = (overrides = {}) => task({ stage: 'BEFORE_LOADING', actions: { LOADING_REVIEW: { allowed: true }, PICK: { allowed: false } }, resources: { vehicle: { plate: 'ABC123', carrierName: '车队' }, driver: { name: '司机', phone: '123' } }, reviews: { dispatchReview: { status: 'APPROVED', reason: '已核对预约' }, loadingReview: { status: 'PENDING' } }, ...overrides })
|
||||
test('missing appointment blocks approval while preserving rejection to customer service', async () => {
|
||||
storage(); const sent = []
|
||||
const state = page({ reviewDispatchLoading: async (...args) => { sent.push(args); return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1 }) })
|
||||
state.current = reviewTask({ actions: { LOADING_REVIEW: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })
|
||||
assert.equal(state.reviewTime('2026-10-21T17:00:00Z'), '2026-10-22 01:00')
|
||||
assert.equal(state.reviewTime(null), '未填写')
|
||||
state.reviewReason = '请客服补齐预约时间'
|
||||
await state.submitReview('APPROVED'); assert.equal(sent.length, 0); assert.match(state.error.message, /预约收货时间尚未确认/)
|
||||
await state.submitReview('REJECTED'); assert.equal(sent.length, 1); assert.equal(sent[0][1].decision, 'REJECTED')
|
||||
})
|
||||
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]), '待理货审核')
|
||||
@@ -253,22 +263,22 @@ test('review UI exposes authoritative resources and reason without reviewer data
|
||||
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 () => {
|
||||
test('loading completion keeps original manifest 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 }] })
|
||||
beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'original-manifest' })
|
||||
const state = page({ completeDispatchLoading: async (no, body) => { sent.push(body); if (fail) { fail = false; throw { network: true } }; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchCommand: async () => { throw { code: '404' } }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING' }) })
|
||||
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, 'AFTER_LOADING'); 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 an after-loading result and does not accept an intermediate stage as success', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest', measurements: [] })
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'manifest' })
|
||||
const state = page({ completeDispatchLoading: async () => ({ ...proof(fulfillment.dispatch.pendingCommand), stage: 'LOADING' }) })
|
||||
await state.recoverCommand(); assert.ok(fulfillment.dispatch.pendingCommand); assert.match(state.error.message, /返回结果未确认/)
|
||||
})
|
||||
|
||||
test('documents already complete allow loading confirmation to return DELIVERING and recover without reposting', async () => {
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash', measurements: [] })
|
||||
storage(); beginDispatchCommand('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, manifestHash: 'hash' })
|
||||
let posts = 0, reads = 0
|
||||
const state = page({ completeDispatchLoading: async () => { posts++; return { ...proof(fulfillment.dispatch.pendingCommand, 3), stage: 'DELIVERING' } }, getDispatchTask: async () => { if (++reads === 1) throw Error('read failed'); return task({ stage: 'DELIVERING', recordVersion: 3 }) } })
|
||||
await state.recoverCommand(); assert.equal(fulfillment.dispatch.pendingCommand.result.stage, 'DELIVERING')
|
||||
@@ -285,3 +295,33 @@ test('scanning received pallets defaults to real nine and eleven boxes without m
|
||||
state.handleScan('PALLET-1'); assert.equal(state.packages, 11); assert.equal(state.canPick, true); await state.submitPick()
|
||||
assert.deepEqual(sent, [9, 11]); assert.equal(state.pickedPackages, 20); assert.equal(state.remainingPackages, 0)
|
||||
})
|
||||
|
||||
test('photo tab preserves the selected pallet and quantity and ignores picking broadcasts', () => {
|
||||
storage(); const state = page(); state.handleScan('PALLET-1'); state.updatePackages({ detail: { value: '1' } })
|
||||
const before = JSON.parse(JSON.stringify(fulfillment.dispatch))
|
||||
state.selectTab('photos'); state.handleScan('UNKNOWN-PALLET')
|
||||
assert.equal(state.activeTab, 'photos'); assert.equal(state.error, null)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(fulfillment.dispatch)), before)
|
||||
state.selectTab('picking'); assert.equal(state.selectedItem.palletNo, 'PALLET-1'); assert.equal(state.packages, 1)
|
||||
})
|
||||
|
||||
test('same-task photo refresh stays on photos while a new task starts on picking', async () => {
|
||||
storage(); const state = page({ getDispatchTask: async no => task({ dispatchNo: no, recordVersion: 2 }) })
|
||||
state.selectTab('photos'); await state.openTask('PC/1'); assert.equal(state.activeTab, 'photos')
|
||||
await state.openTask('PC/2'); assert.equal(state.activeTab, 'picking')
|
||||
})
|
||||
|
||||
test('tab changes cannot interrupt an upload or pending command, or open photos before loading', () => {
|
||||
storage(); const state = page(); state.busy = true; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.busy = false; state.current.stage = 'BEFORE_LOADING'; state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
state.current.stage = 'LOADING'; beginDispatchCommand('PC/1', 'PICK', { recordVersion: 0 })
|
||||
state.selectTab('photos'); assert.equal(state.activeTab, 'picking')
|
||||
})
|
||||
|
||||
test('thirty scanned pallets confirm once without weight volume or pallet entry', async () => {
|
||||
storage(); let sent
|
||||
const preview = { dispatchNo: 'PC/1', recordVersion: 0, manifestHash: 'thirty', pickingReconciled: true, loadingComplete: { allowed: true }, pallets: Array.from({ length: 30 }, (_, i) => ({ warehousePalletId: String(i), palletNo: `PALLET-${i}`, deliveryIndex: 1, pickedPackages: 10, weightKg: null, volumeCbm: null, evidence: [] })) }
|
||||
const state = page({ getDispatchLoading: async () => preview, completeDispatchLoading: async (_, body) => { sent = body; return proof(fulfillment.dispatch.pendingCommand) }, getDispatchTask: async () => task({ recordVersion: 1, stage: 'AFTER_LOADING', pickingReconciled: true }) })
|
||||
state.current = task({ pickingReconciled: true, items: [item({ pickedPackages: 2 })] }); await state.prepareLoading(); await state.completeLoading()
|
||||
assert.equal(state.error, null); assert.equal(state.current.stage, 'AFTER_LOADING'); assert.equal(sent.manifestHash, 'thirty'); assert.equal('measurements' in sent, false)
|
||||
})
|
||||
|
||||
@@ -118,7 +118,7 @@ test('fulfillment API calls the frozen list detail pick complete and review 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 getDispatchLoading('DSP/1'); await getDispatchCommand('DSP/1', 'pick/key'); await completeDispatchLoading('DSP/1', { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' })
|
||||
await reviewDispatchLoading('DSP/1', { recordVersion: 0, idempotencyKey: 'review-key', decision: 'REJECTED', reason: '车牌不符' })
|
||||
await listOutboundTasks('PENDING'); await getOutboundTask('OUT/TASK/1')
|
||||
await resolveOutboundTask('OUT/1')
|
||||
@@ -140,7 +140,7 @@ test('fulfillment API calls the frozen list detail pick complete and review rout
|
||||
assert.equal(Object.hasOwn(call.data || {}, 'warehouseId'), false)
|
||||
}
|
||||
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')))
|
||||
assert.deepEqual(dispatchComplete.data, { recordVersion: 1, idempotencyKey: 'complete-key', manifestHash: 'hash' }); assert.ok(calls.some(call => call.url.endsWith('/commands/pick%2Fkey')))
|
||||
})
|
||||
|
||||
test('dispatch and outbound adapters retain stable business identifiers and count progress', () => {
|
||||
@@ -241,7 +241,7 @@ async function dispatchScanHarness(items) {
|
||||
const draft={palletCode:'OLD',dispatchItemId:'old',cartonCode:''};let sounds=0
|
||||
const deps={PortalHeader:{},ScanTargetInput:{},DispatchHandoverStatus:{},DispatchLoadingDocuments:{},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',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}}}
|
||||
const state={activeTab:'picking',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