diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue index dc44de1..a9b0f42 100644 --- a/src/pages/operation/operation.vue +++ b/src/pages/operation/operation.vue @@ -181,7 +181,23 @@ drainScanQueue() { if (!this._pageActive || this.loading || this.busy || this.activeTarget === 'quantity' || !this.scanQueue.length || this.phase !== 'receive') return; const next = this.scanQueue.shift(); this.dispatchHardwareScan(next) }, suspendReceiving() { this._pageActive = false; this._scanEpoch = (this._scanEpoch || 0) + 1; this._resumeReceiving = true; clearTimeout(this._drainTimer); clearTimeout(this._toastTimer); this.scanQueue = []; this.stopScanner() }, scheduleScanDrain() { clearTimeout(this._drainTimer); if (!this._pageActive) return; const epoch = this._scanEpoch || 0; this._drainTimer = setTimeout(() => { if (this._pageActive && epoch === (this._scanEpoch || 0)) this.drainScanQueue() }, 0) }, - async run(action, work) { if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试'); this.busy = true; this.error = null; try { const result = await work(); this.showToast(`${action}成功`, 'success'); return result } catch (error) { const mappedError = toReceivingError(error, `${action}失败`); this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError } finally { this.busy = false; if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() } }, + async run(action, work, isCurrent = () => true) { + if (this.busy || !this._pageActive) throw new Error('当前页面未就绪,请返回作业页重试') + this.busy = true; this.error = null + try { + const result = await work() + // 只读校验失效后不再回写结果,也不提示旧请求成功或失败。 + if (!isCurrent()) return null + this.showToast(`${action}成功`, 'success'); return result + } catch (error) { + if (!isCurrent()) return null + const mappedError = toReceivingError(error, `${action}失败`) + this.error = mappedError; playErrorSound(); this.showToast(mappedError.message, 'error'); throw mappedError + } finally { + this.busy = false + if (this._pageActive && this._resumeReceiving) { this._resumeReceiving = false; this.bootstrap() } else this.scheduleScanDrain() + } + }, setScanTargetForCurrent(value) { receiving.activeScanTarget = value?.status === 'WAIT_SIGN' ? 'container' : 'carton' }, async restorePhotoPreviews() { await Promise.all(receiving.photoSlots.filter((photo) => photo?.downloadPath && !photo.localPath).map(async (photo) => { try { await restoreReceivingPhoto(photo, downloadCurrentUnloadPhoto) } catch (error) { this.error = error } })) }, async restoreDomesticPhotoPreviews() { if (this.phase !== 'sign' && !this.domesticPhotosExpanded) return; await Promise.all(this.domesticLoadingPhotos.filter((photo) => photo.downloadPath && !photo.localPath).map((photo) => this.loadDomesticPhoto(photo))) }, @@ -205,13 +221,21 @@ async resolvePallet(raw) { const palletCode = String(raw?.detail?.value ?? raw ?? this.palletInput).trim() if (!palletCode || !this.activeInbound || this.busy) return + const inbound = this.activeInbound, current = this.current, inboundCode = inbound.inboundCode + const scanEpoch = this._scanEpoch || 0, operatorId = session.currentUser?.id, warehouseId = session.currentUser?.warehouseId, tenantId = session.tenantId + // 响应只属于发起时的输入、入仓单、柜会话和作业身份;离开再返回也必须重验。 + const isCurrent = () => this._pageActive && scanEpoch === (this._scanEpoch || 0) + && this.current === current && this.activeInbound === inbound && this.activeInbound.inboundCode === inboundCode + && session.currentUser?.id === operatorId && session.currentUser?.warehouseId === warehouseId && session.tenantId === tenantId + && this.palletInput.trim() === palletCode receiving.resolvedPallet = null; this.palletInput = palletCode; this.packageCount = ''; receiving.activeScanTarget = 'pallet' try { const value = await this.run('校验托盘', async () => { - const result = await resolveCurrentPallet(palletCode, this.activeInbound.inboundCode) + const result = await resolveCurrentPallet(palletCode, inboundCode) if (!result.compatible) throw Object.assign(new Error('托盘不匹配,请重扫'), { code: 'PALLET_INCOMPATIBLE' }) return result - }) + }, isCurrent) + if (!value || !isCurrent()) return receiving.resolvedPallet = value; this.palletInput = value.palletCode; this.packageCount = Number(value.currentReceivedPackages || 0) || '' receiving.activeScanTarget = this.activeInbound.destinationType === 'FBA' ? 'quantity' : 'carton'; playSuccessSound() } catch (error) { if (!this.error) this.error = error } diff --git a/tests/receiving-lifecycle.test.mjs b/tests/receiving-lifecycle.test.mjs index 5f3b27f..c17812c 100644 --- a/tests/receiving-lifecycle.test.mjs +++ b/tests/receiving-lifecycle.test.mjs @@ -91,6 +91,79 @@ test('an incompatible pallet never reports success and a corrected scan can be v assert.equal(successes, 1) }) +test('late pallet validation cannot replace edited input and only the corrected pallet can be received', async t => { + store.resetReceivingSession(); store.setCurrentReceiving(sessionValue()) + store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' } + const pending = defer(), receipts = [], calls = [], toasts = []; let successes = 0 + const { vm } = page({ toReceivingError, playSuccessSound: () => successes++, + resolveCurrentPallet: (palletCode, inboundCode) => { + calls.push({ palletCode, inboundCode }) + return palletCode === 'PALLET-A' ? pending.promise : Promise.resolve({ palletCode, compatible: true, version: 2, currentReceivedPackages: 3 }) + }, + receiveCurrentBulk: async payload => { receipts.push(payload); return {} }, + getCurrentReceiving: async () => sessionValue(), + }) + t.after(() => vm.suspendReceiving()) + vm.showToast = (message, type) => toasts.push({ message, type }); vm.applyCurrentReceiving = async () => {} + const validation = vm.resolvePallet('PALLET-A') + vm.palletInput = 'PALLET-B' + pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 }) + await validation + assert.equal(vm.palletInput, 'PALLET-B') + assert.equal(vm.resolvedPallet, null) + assert.equal(vm.packageCount, '') + assert.equal(vm.canSubmitReceipt, false) + assert.equal(vm.busy, false) + assert.equal(successes, 0) + assert.deepEqual(toasts, []) + await vm.submitReceipt(); assert.equal(receipts.length, 0) + await vm.resolvePallet('PALLET-B') + assert.equal(vm.canSubmitReceipt, true) + assert.equal(successes, 1) + await vm.submitReceipt() + assert.deepEqual(calls, [{ palletCode: 'PALLET-A', inboundCode: 'IN-A' }, { palletCode: 'PALLET-B', inboundCode: 'IN-A' }]) + assert.deepEqual(receipts.map(({ palletCode }) => palletCode), ['PALLET-B']) +}) + +test('obsolete pallet failures cannot replace current input or feedback', async t => { + store.resetReceivingSession(); store.setCurrentReceiving(sessionValue()) + store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' } + const pending = defer(), toasts = []; let failures = 0 + const { vm } = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playErrorSound: () => failures++ }) + t.after(() => vm.suspendReceiving()); vm.showToast = (message, type) => toasts.push({ message, type }) + const validation = vm.resolvePallet('PALLET-A') + vm.palletInput = 'PALLET-B' + pending.reject(Object.assign(new Error('Pallet code is not part of the current FBA destination group'), { code: 'A0404' })) + await validation + assert.equal(vm.palletInput, 'PALLET-B'); assert.equal(vm.error, null) + assert.equal(vm.resolvedPallet, null); assert.equal(vm.canSubmitReceipt, false) + assert.equal(vm.busy, false); assert.equal(failures, 0); assert.deepEqual(toasts, []) +}) + +test('late pallet validation cannot cross a receiving context or page lifetime', async t => { + for (const [name, change] of [ + ['carton', () => { store.receiving.resolvedCarton = { inboundCode: 'IN-B', cartonMark: 'IN-B#1', destinationType: 'FBA' } }], + ['container', () => store.setCurrentReceiving(sessionValue({ containerNo: 'CAB-B' }))], + ['operator', ({ deps }) => { deps.session.currentUser = { id: 'another-operator', warehouseId: '900' } }], + ['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()) + store.receiving.resolvedCarton = { inboundCode: 'IN-A', cartonMark: 'IN-A#22-1', destinationType: 'FBA' } + const pending = defer(), toasts = []; let successes = 0 + const context = page({ toReceivingError, resolveCurrentPallet: () => pending.promise, playSuccessSound: () => successes++ }) + const { vm } = context + t.after(() => vm.suspendReceiving()); vm.bootstrap = async () => {}; vm.showToast = (message, type) => toasts.push({ message, type }) + const validation = vm.resolvePallet('PALLET-A') + change(context) + pending.resolve({ palletCode: 'PALLET-A', compatible: true, version: 1, currentReceivedPackages: 3 }) + await validation + assert.equal(vm.resolvedPallet, null); assert.equal(vm.packageCount, '') + assert.equal(vm.canSubmitReceipt, false); assert.equal(vm.busy, false) + assert.equal(successes, 0); assert.deepEqual(toasts, []) + }) +}) + test('signed photo changes, account/customer/cabinet changes and image failure cannot inherit stale previews', async () => { store.resetReceivingSession(); let calls = 0 const { vm, deps } = page({ downloadCurrentUnloadPhoto: async () => `local:${++calls}`, downloadCurrentDomesticLoadingPhoto: async () => '' })