fix(dispatch): 拣货拍照分区并展示有效预约审核依据

This commit is contained in:
2026-10-21 19:44:32 +08:00
parent a181239cbf
commit 92ea409ce5
6 changed files with 131 additions and 29 deletions
+47 -7
View File
@@ -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)
})