From b03d5e1561d523cab186e0b5fdd5fe48a05a735b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 12:35:03 +0800 Subject: [PATCH] fix(pda): enforce sealed pallet contract and deduplicate activation refresh --- AGENTS.md | 2 +- pages/picking/picking.vue | 12 ++++++++-- tests/picking-refresh-route.test.mjs | 36 ++++++++++++++++++++++++++++ tests/picking-state.test.mjs | 10 ++++++-- utils/picking-state.js | 2 +- 5 files changed, 56 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a5b6e2f..67feef8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ - 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。 - 拣货扫描身份是后端下发的稳定箱唛箱码 `boxCode`;一次有效且未重复的箱码扫描表示拣出一箱。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键或拣货计数单位。 -- 一件代发封托明细由 `Task.items` 的 `unitType/palletId/palletNo/indivisible` 标识,`PALLET` 只扫描 DP 码并一次将同托全部明细写入本地草稿,不接受托内逐箱扫码、部分拣货或跳拣;可整托撤销未提交草稿。点击提交时通过同一 `/tasks/{taskId}/batch` 按每个明细提交 `PICK_PALLET + palletNo + version`,由服务端验证封托和完整集合后原子处理,禁止扫码立即写后端。DP 托盘标识不得与库存资源托盘标识混用。 +- 一件代发封托明细由 `Task.items` 的 `unitType/palletId/palletNo/indivisible` 标识,`SEALED_PALLET` 只扫描 DP 码并一次将同托全部明细写入本地草稿,不接受托内逐箱扫码、部分拣货或跳拣;可整托撤销未提交草稿。点击提交时通过同一 `/tasks/{taskId}/batch` 按每个明细提交 `PICK_PALLET + palletNo + version`,由服务端验证封托和完整集合后原子处理,禁止扫码立即写后端。DP 托盘标识不得与库存资源托盘标识混用。 - 进入任务时由服务端标记“拣货中”;扫描结果先写入绑定作业员、任务和柜次的设备持久化草稿,仅在点击提交时批量写后端,禁止逐码提交。 - 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。 - 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。 diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index d0a50b7..f0488f3 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -532,6 +532,8 @@ }, async onShow() { if (!requireSession()) return + if (this._pageVisible) return this._showRefreshPromise + this._pageVisible = true this.currentUser = session.currentUser this.startHardwareScanner() if (this.cameraPicking) { @@ -543,10 +545,16 @@ this.debugEvidence('preview.resume', { evidenceIdentity: this.evidenceIdentity }) return } - await this.refreshAvailableBoxes() - await this.syncRemoteTask() + if (this._showRefreshPromise) return this._showRefreshPromise + this._showRefreshPromise = (async () => { + await this.refreshAvailableBoxes() + await this.syncRemoteTask() + })() + try { await this._showRefreshPromise } + finally { this._showRefreshPromise = null } }, onHide() { + this._pageVisible = false if (this.evidencePreviewOpening) { this.evidencePreviewOpening = false this.evidencePreviewReturning = true diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs index dea4a9e..c294492 100644 --- a/tests/picking-refresh-route.test.mjs +++ b/tests/picking-refresh-route.test.mjs @@ -4,6 +4,42 @@ import { readFile } from 'node:fs/promises' const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') +test('duplicate onShow shares one load chain while background return refreshes again', async () => { + const page = await read('../pages/picking/picking.vue') + const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0] + const hide = page.match(/onHide\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0] + assert.ok(show && hide) + const hooks = new Function('requireSession', 'session', `return ({${show}${hide}})`)(() => true, { currentUser: { id: '7' } }) + const calls = [] + let release + const pending = new Promise((resolve) => { release = resolve }) + const context = { startHardwareScanner() {}, stopHardwareScanner() {}, async refreshAvailableBoxes() { calls.push('pending'); await pending }, async syncRemoteTask() { calls.push('detail+evidence') } } + const first = hooks.onShow.call(context) + const duplicate = hooks.onShow.call(context) + assert.deepEqual(calls, ['pending']) + release() + await Promise.all([first, duplicate]) + assert.deepEqual(calls, ['pending', 'detail+evidence']) + await hooks.onShow.call(context) + assert.equal(calls.length, 2) + hooks.onHide.call(context) + await hooks.onShow.call(context) + assert.deepEqual(calls, ['pending', 'detail+evidence', 'pending', 'detail+evidence']) + const onLoad = page.match(/onLoad\(options = \{\}\) \{[\s\S]*?\r?\n\t\t\},/)?.[0] + assert.doesNotMatch(onLoad, /refreshAvailableBoxes|syncRemoteTask/) +}) + +test('camera and evidence preview resume still avoid unnecessary refreshes', async () => { + const page = await read('../pages/picking/picking.vue') + const show = page.match(/async onShow\(\) \{[\s\S]*?\r?\n\t\t\},/)?.[0] + const hooks = new Function('requireSession', 'session', `return ({${show}})`)(() => true, { currentUser: {} }) + for (const flag of ['cameraPicking', 'evidencePreviewReturning']) { + const context = { [flag]: true, startHardwareScanner() {}, debugEvidence() {}, refreshAvailableBoxes() { throw new Error('unexpected refresh') } } + await hooks.onShow.call(context) + assert.equal(context._pageVisible, true) + } +}) + test('direct H5 refresh restores the page user before rendering picking content', async () => { const page = await read('../pages/picking/picking.vue') diff --git a/tests/picking-state.test.mjs b/tests/picking-state.test.mjs index e563ecc..9672773 100644 --- a/tests/picking-state.test.mjs +++ b/tests/picking-state.test.mjs @@ -8,14 +8,20 @@ const state = await import(`data:text/javascript;base64,${Buffer.from(source).to const items = [{ id: 1 }, { id: 2 }, { id: 3 }] test('sealed pallet matches its DP code and never accepts an individual carton', () => { - const pallet = { id: '1', unitType: 'PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 } + const pallet = { id: '1', unitType: 'SEALED_PALLET', indivisible: true, palletNo: 'DP-260909-0143', boxCodes: ['IN#2-1', 'IN#2-2'], qty: 2 } assert.equal(state.classifyScan([pallet], {}, 'IN#2-1').type, 'unknown') assert.equal(state.classifyScan([pallet], {}, pallet.palletNo).type, 'pending') assert.equal(state.classifyScan([pallet], { 1: state.ITEM_STATUS.PICKED }, pallet.palletNo).type, 'processed') }) +test('only the authoritative sealed-pallet unit type selects pallet behavior', () => { + assert.equal(state.isPalletItem({ unitType: 'SEALED_PALLET', indivisible: true }), true) + assert.equal(state.isPalletItem({ unitType: 'PALLET', indivisible: true }), false) + assert.equal(state.isPalletItem({ unitType: 'PACKAGE', indivisible: true }), false) +}) + test('mixed task keeps package scanning separate from sealed pallet scanning', () => { - const mixed = [{ id: '1', unitType: 'PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }] + const mixed = [{ id: '1', unitType: 'SEALED_PALLET', palletNo: 'DP-260909-0001', boxCodes: ['BOX-1'] }, { id: '2', unitType: 'PACKAGE', boxCodes: ['BOX-2'] }] assert.equal(state.classifyScan(mixed, {}, 'BOX-2').item.id, '2') assert.equal(state.classifyScan(mixed, {}, 'DP-260909-0001').item.id, '1') assert.equal(state.classifyScan(mixed, {}, 'BOX-1').type, 'unknown') diff --git a/utils/picking-state.js b/utils/picking-state.js index 3f60c49..7b8fbb6 100644 --- a/utils/picking-state.js +++ b/utils/picking-state.js @@ -72,5 +72,5 @@ export function classifyScan(items, states, scanCode, scannedBoxCodes = {}) { } export function isPalletItem(item) { - return item?.unitType === 'PALLET' || item?.indivisible === true + return item?.unitType === 'SEALED_PALLET' }