fix(pda): 隔离失效收货扫码并及时返回装车列表

This commit is contained in:
2026-11-07 07:49:32 +08:00
parent aabfcfbeb1
commit e65a4677d7
4 changed files with 159 additions and 6 deletions
+8 -5
View File
@@ -53,13 +53,14 @@ export default {
const image = await readLoadingPhoto(this.number, photo); this.ensureScope(); this.images[photo.fileId] = image
}))
},
async refreshFacts() {
async refreshFacts({ previewCompleted = true } = {}) {
this.needsRefresh = true
const value = await getLoadingDocuments(this.number); this.ensureScope()
this.snapshot = value; this.needsRefresh = false; await this.previews()
this.snapshot = value; this.needsRefresh = false
if (!value.complete || previewCompleted) await this.previews()
},
async load() { await this.run(() => this.refreshFacts()) },
async refresh() { await this.run(async () => { await this.refreshFacts(); return true }, true) },
async refresh() { await this.run(async () => { await this.refreshFacts({ previewCompleted: false }); return true }, true) },
async choose(slot, replaceFileId = null) {
if (this.busy || !this.editable) return
const reviewRound = this.snapshot.reviewRound
@@ -85,12 +86,14 @@ export default {
} catch (cause) {
// 保存可能已成功;只重读最新事实,失败时必须先刷新再继续。
try {
await this.refreshFacts()
await this.refreshFacts({ previewCompleted: false })
reconciled = this.snapshot.recordVersion !== latest.recordVersion || this.snapshot.dispatchRecordVersion !== latest.dispatchRecordVersion || this.snapshot.stage !== latest.stage
} catch { /* 保留原错误与刷新入口。 */ }
throw cause
}
saved++; await this.previews()
saved++
// 资料齐全后返回列表,跳转只依赖保存回执,避免被预览下载阻塞。
if (!this.snapshot.complete) await this.previews()
if (!this.editable) break
}
uni.showToast({ title: this.snapshot.stage === 'DELIVERING' && this.snapshot.complete ? '资料齐全,已进入派送中' : `已保存${saved}张照片`, icon: 'none' })
+40 -1
View File
@@ -265,7 +265,46 @@
async previewPhoto(index) { const photo = this.photoSlots[index]; if (!photo) return; try { const path = await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto); if (path) uni.previewImage({ urls: [path], current: path }) } catch (error) { this.error = error } },
async signContainer() { if (!this.photosReady) return; const photos = this.photoSlots.map((item, index) => ({ role: this.photoSteps[index].role, fileId: String(item.fileId) })); const key = idempotencyKey('sign', { containerNo: this.current.containerNo, photos }); try { const value = await this.run('签收当前柜', async () => this.validateReceivingWarehouse(await signCurrentReceiving({ photos, idempotencyKey: key.value }), '当前接柜会话')); releaseIdempotencyKey(key.signature); await this.applyCurrentReceiving(value); this.clearReceiptContext() } catch (_) {} },
clearReceiptContext() { receiving.resolvedCarton = null; receiving.resolvedPallet = null; this.cartonInput = ''; this.palletInput = ''; this.packageCount = ''; receiving.activeScanTarget = 'carton'; this.scheduleScanDrain() },
async resolveCarton(raw) { const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim(); if (!cartonMark || !this._pageActive) return; const scanEpoch = this._scanEpoch || 0; if (this.busy) return this.handleHardwareScan(cartonMark); try { const outcome = await this.run('处理货物码', async () => { const value = await resolveCurrentCarton(cartonMark); if (value.operationMode !== 'DROPSHIP_PALLET') return { value }; if (!this._pageActive || scanEpoch !== (this._scanEpoch || 0)) throw new Error('页面已离开,请返回后重新扫码确认收货'); const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }; const key = idempotencyKey('whole-pallet-receipt', { containerNo: this.current?.containerNo, ...payload }); const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value }); const current = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话'); releaseIdempotencyKey(key.signature); return { value, result, current } }); if (outcome.result) { receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current); this.clearReceiptContext(); playSuccessSound(); return } receiving.resolvedCarton = outcome.value; receiving.resolvedPallet = null; this.cartonInput = outcome.value.cartonMark; this.palletInput = ''; this.packageCount = ''; playSuccessSound() } catch (error) { console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId }) } },
async resolveCarton(raw) {
const cartonMark = String(raw?.detail?.value ?? raw ?? this.cartonInput).trim()
if (!cartonMark || !this._pageActive) return
if (this.busy) return this.handleHardwareScan(cartonMark)
const current = this.current, scanEpoch = this._scanEpoch || 0
const operatorId = session.currentUser?.id, warehouseId = session.currentUser?.warehouseId, tenantId = session.tenantId
const sameContext = () => this.current === current && session.currentUser?.id === operatorId
&& session.currentUser?.warehouseId === warehouseId && session.tenantId === tenantId
const isCurrent = () => sameContext() && this._pageActive && scanEpoch === (this._scanEpoch || 0)
&& this.cartonInput.trim() === cartonMark
// 新箱码必须重新解析和验托,失败时也不能沿用上一箱的可提交结果。
receiving.resolvedCarton = null; receiving.resolvedPallet = null
this.cartonInput = cartonMark; this.palletInput = ''; this.packageCount = ''
let receiptSent = false
const canApply = () => receiptSent ? sameContext() : isCurrent()
try {
const outcome = await this.run('处理货物码', async () => {
const value = await resolveCurrentCarton(cartonMark)
if (!isCurrent()) return null
if (value.operationMode !== 'DROPSHIP_PALLET') return { value }
const payload = { sourcePalletNo: value.sourcePalletNo || cartonMark }
const key = idempotencyKey('whole-pallet-receipt', { containerNo: current?.containerNo, ...payload })
// 已发送的整托实收须保留同一柜、同一身份的成功回执,页面短暂隐藏后仍可恢复。
receiptSent = true
const result = await receiveCurrentWholePallet({ ...payload, idempotencyKey: key.value })
if (!sameContext()) return null
const updated = this.validateReceivingWarehouse(await getCurrentReceiving(), '当前接柜会话')
releaseIdempotencyKey(key.signature)
return { value, result, current: updated }
}, canApply)
if (!outcome || !canApply()) return
if (outcome.result) {
receiving.lastReceipt = outcome.result; await this.applyCurrentReceiving(outcome.current)
this.clearReceiptContext(); playSuccessSound(); return
}
receiving.resolvedCarton = outcome.value; this.cartonInput = outcome.value.cartonMark; playSuccessSound()
} catch (error) {
console.error('[Overseas PDA cargo] failed', { cartonMark, message: error.message, code: error.code, requestId: error.requestId, traceId: error.traceId })
}
},
async resolvePallet(raw) {
const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim()
if (!palletCode || !this.activeInbound || this.busy) return
+43
View File
@@ -45,6 +45,49 @@ test('saving the final loading photo reports the confirmed complete snapshot aft
assert.equal(uploads, 1)
})
test('a completed photo save notifies navigation without waiting for image downloads', async () => {
const completed = data({ recordVersion: '1', complete: true, stage: 'DELIVERING', editable: true,
photos: loadingPhotoSlots.map((slot, index) => ({ ...photo, slot, fileId: String(1001 + index) })) })
let finishDownload, downloads = 0
const pending = new Promise(resolve => { finishDownload = resolve })
const { state, events } = component({ bindLoadingPhoto: async () => completed,
readLoadingPhoto: async () => { downloads++; await pending; return 'image' } })
await state.load()
const choosing = state.choose(photo.slot)
try {
await new Promise(resolve => setImmediate(resolve))
assert.equal(downloads, 0)
assert.deepEqual(events.at(-1), ['saved', completed])
assert.equal(state.busy, false)
} finally { finishDownload(); await choosing }
})
test('refreshing or reconciling completed photos reports completion without preview downloads', async t => {
for (const action of ['refresh', 'reconcile']) await t.test(action, async () => {
let latest = data(), binds = 0, downloads = 0
const completed = data({ recordVersion: '1', complete: true, stage: 'DELIVERING',
photos: loadingPhotoSlots.map((slot, index) => ({ ...photo, slot, fileId: String(1001 + index) })) })
const { state, events } = component({ getLoadingDocuments: async () => latest,
readLoadingPhoto: async () => { downloads++; return 'image' },
bindLoadingPhoto: async () => { binds++; latest = completed; throw new Error('保存回执超时') } })
await state.load()
if (action === 'refresh') { latest = completed; await state.refresh() }
else await state.choose(photo.slot)
assert.equal(downloads, 0); assert.equal(state.busy, false)
assert.deepEqual(events.at(-1), ['saved', completed])
assert.equal(binds, action === 'reconcile' ? 1 : 0)
})
})
test('opening completed photo history still loads its previews', async () => {
let downloads = 0
const { state, events } = component({ getLoadingDocuments: async () => data({ complete: true, photos: [photo] }),
readLoadingPhoto: async () => { downloads++; return 'history-image' } })
await state.load()
assert.equal(downloads, 1); assert.equal(state.images[photo.fileId], 'history-image')
assert.equal(events.some(event => event[0] === 'saved'), false)
})
test('refresh reads all photos saved by PC and refreshes the task stage without another write', async () => {
let latest = data({ photos: [photo] }), writes = 0, fail = false
const { state, events } = component({ getLoadingDocuments: async () => { if (fail) throw new Error('刷新失败'); return latest },
+68
View File
@@ -50,6 +50,74 @@ test('receiving broadcast targets use larger tap areas and show an inactive sele
assert.match(input, /active \? '当前扫码' : '点按选中'/)
})
test('resolving a new carton invalidates the previous receipt even when the new code fails', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#1', destinationType: 'OVERSEAS' }
store.receiving.resolvedPallet = { palletCode: 'PAL-A', compatible: true, version: 1 }
let writes = 0
const { vm } = page({ resolveCurrentCarton: async () => { throw new Error('箱码不属于当前柜') },
receiveCurrentCarton: async () => { writes++ } })
t.after(() => vm.suspendReceiving())
vm.cartonInput = 'IN-A#1'; vm.palletInput = 'PAL-A'
assert.equal(vm.canSubmitReceipt, true)
await vm.resolveCarton('WRONG-CARTON')
assert.equal(vm.resolvedCarton, null); assert.equal(vm.resolvedPallet, null)
assert.equal(vm.palletInput, ''); assert.equal(vm.canSubmitReceipt, false)
await vm.submitReceipt()
assert.equal(writes, 0)
})
test('late carton resolution cannot overwrite edited input or another receiving context', async t => {
for (const [name, change] of [
['input', ({ vm }) => { vm.cartonInput = 'IN-B#1' }],
['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))],
['operator', ({ deps }) => { deps.session.currentUser.id = 'another-operator' }],
['warehouse', ({ deps }) => { deps.session.currentUser.warehouseId = '901' }],
['hide and return', ({ vm, definition }) => { definition.onHide.call(vm); definition.onShow.call(vm) }],
]) await t.test(name, async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(), toasts = []; let successes = 0
const context = page({ resolveCurrentCarton: () => pending.promise, playSuccessSound: () => successes++ })
const { vm } = context
vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type })
t.after(() => vm.suspendReceiving())
const resolving = vm.resolveCarton('IN-A#1')
change(context)
pending.resolve({ cartonMark: 'IN-A#1', inboundCode: 'IN-A', destinationType: 'FBA' })
await resolving
assert.equal(vm.resolvedCarton, null); assert.equal(vm.busy, false)
assert.equal(successes, 0); assert.deepEqual(toasts, [])
if (name === 'input') assert.equal(vm.cartonInput, 'IN-B#1')
})
})
test('obsolete carton errors cannot replace current input or feedback', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(), toasts = []
const { vm } = page({ resolveCurrentCarton: () => pending.promise })
t.after(() => vm.suspendReceiving())
vm.showToast = (message, type) => toasts.push({ message, type })
const resolving = vm.resolveCarton('IN-A#1')
vm.cartonInput = 'IN-B#1'
pending.reject(new Error('旧箱码解析失败'))
await resolving
assert.equal(vm.cartonInput, 'IN-B#1'); assert.equal(vm.error, null)
assert.equal(vm.busy, false); assert.deepEqual(toasts, [])
})
test('dropship resolution cannot submit after its operator changes', async t => {
store.resetReceivingSession(); store.setCurrentReceiving(sessionValue())
const pending = defer(); let writes = 0
const { vm, deps } = page({ resolveCurrentCarton: () => pending.promise,
receiveCurrentWholePallet: async () => { writes++ } })
t.after(() => vm.suspendReceiving())
const resolving = vm.resolveCarton('DP-A')
deps.session.currentUser.id = 'new-operator'
pending.resolve({ operationMode: 'DROPSHIP_PALLET', sourcePalletNo: 'DP-A' })
await resolving
assert.equal(writes, 0); assert.equal(vm.resolvedCarton, null)
})
test('camera resume retains all unsigned uploads by role and clears them across session or identity changes', async () => {
store.resetReceivingSession()
const unsigned = sessionValue({ status: 'WAIT_SIGN', photos: [], domesticLoadingPhotos: [] })