From 8d2d80ec11bf3d34a56d117fc1aad469a2b1c9e3 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Fri, 2 Oct 2026 11:12:53 +0800
Subject: [PATCH] fix(pda): guard duplicate submission and invalidate hidden
task requests
---
pages/picking/picking.vue | 38 +++++---
tests/page-contract.test.mjs | 5 +-
tests/picking-refresh-route.test.mjs | 11 ++-
tests/picking-request-lifecycle.test.mjs | 118 +++++++++++++++++++++++
4 files changed, 155 insertions(+), 17 deletions(-)
create mode 100644 tests/picking-request-lifecycle.test.mjs
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index b893c0f..74cb8b0 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -282,7 +282,7 @@
- 确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。
+ 确认提交后,以下待拣商品会标记为“未拣出”,并记录所选原因。
{{ item.location ? `${item.sku} · ${item.location}` : item.sku }}
另有 {{ pendingItems.length - 3 }} 件
@@ -293,7 +293,7 @@
-
+
@@ -571,12 +571,15 @@
return
}
if (this._showRefreshPromise) return this._showRefreshPromise
- this._showRefreshPromise = (async () => {
+ const showGeneration = this.taskRequestId
+ const refreshPromise = (async () => {
await this.refreshAvailableBoxes()
+ if (!this._pageVisible || showGeneration !== this.taskRequestId) return
await this.syncRemoteTask()
})()
- try { await this._showRefreshPromise }
- finally { this._showRefreshPromise = null }
+ this._showRefreshPromise = refreshPromise
+ try { await refreshPromise }
+ finally { if (this._showRefreshPromise === refreshPromise) this._showRefreshPromise = null }
},
onHide() {
this._pageVisible = false
@@ -584,10 +587,11 @@
this.evidencePreviewOpening = false
this.evidencePreviewReturning = true
}
+ if (!this.cameraPicking && !this.evidencePreviewReturning) this.invalidateTaskSync()
this.stopHardwareScanner()
},
- onUnload() { this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
- beforeUnmount() { this.stopHardwareScanner(); this.clearTimers() },
+ onUnload() { this._pageVisible = false; this.invalidateTaskSync(); this.evidenceContextId++; this.stopHardwareScanner(); this.persistSession(); this.clearTimers() },
+ beforeUnmount() { this._pageVisible = false; this.invalidateTaskSync(); this.stopHardwareScanner(); this.clearTimers() },
onBackPress() {
if (this.activeOverlay) { this.closeOverlay(); return true }
if (this.boxDropdownOpen) { this.boxDropdownOpen = false; return true }
@@ -595,6 +599,11 @@
return false
},
methods: {
+ invalidateTaskSync() { this.taskRequestId++; this.remoteLoading = false; this._showRefreshPromise = null },
+ isTaskSyncActive(requestId, taskId) {
+ return this._pageVisible === true && requestId === this.taskRequestId
+ && String(this.remoteTaskId || this.currentBox.taskId) === String(taskId)
+ },
containerReference,
isSelectableTask(task) {
return Boolean(task?.recordStatus === 'ACTIVE'
@@ -1090,7 +1099,7 @@
return ''
},
async syncRemoteTask() {
- if (!session.token) return
+ if (!session.token || !this._pageVisible) return
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return }
this.rememberEvidenceDraft()
@@ -1114,7 +1123,7 @@
try {
const warehouseId = this.currentBox.warehouseId
const task = await getContainerPickingTask(selector, warehouseId)
- if (requestId !== this.taskRequestId) return
+ if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
if (!task) throw new Error('服务端未返回当前拣货任务')
if (this.isMergedContainerRecord(task)) {
this.clearMergedTask(task, previousIdentity)
@@ -1181,9 +1190,10 @@
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null
if (remoteTask.workflowStatus === 'UNASSIGNED') {
+ if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${task.loadingPlanId}-${task.containerBookingId}`
claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId)
- if (requestId !== this.taskRequestId) return
+ if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
} else if (remoteTask.workflowStatus === 'IN_PROGRESS') {
const assigneeId = remoteTask.assignment?.assigneeId
if (!assigneeId || String(assigneeId) !== String(session.currentUser?.id || '')) throw new Error('当前任务已由其他作业员领取,请返回任务列表刷新')
@@ -1215,9 +1225,10 @@
if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning')
}
await this.loadLoadingEvidence()
+ if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
if (this.loadingStageReady) this.activeStage = 'evidence'
} catch (error) {
- if (requestId !== this.taskRequestId) return
+ if (!this.isTaskSyncActive(requestId, requestedTaskId)) return
this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' })
this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : this.remoteFailureMessage(error, '任务同步失败')
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
@@ -1675,10 +1686,11 @@
this.performSubmit()
},
confirmIncompleteSubmit() {
- if (!this.selectedIncompleteReason) return
- this.performSubmit(this.selectedIncompleteReason)
+ if (this.submitting || !this.selectedIncompleteReason) return
+ return this.performSubmit(this.selectedIncompleteReason)
},
async performSubmit(incompleteReason = '') {
+ if (this.submitting) return
const beforeSubmit = this.summary
const remoteRequired = Boolean(session.token)
this.remoteError = ''
diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs
index f525ef9..59f0008 100644
--- a/tests/page-contract.test.mjs
+++ b/tests/page-contract.test.mjs
@@ -292,7 +292,10 @@ test('picking session persists per task identity and clears on logout', async ()
assert.match(store, /savePickingSession/)
assert.match(store, /resetPickingSession/)
assert.match(session, /signOut\(\)[\s\S]*?resetPickingSession\(\)/)
- assert.match(page, /onUnload\(\) \{ this\.stopHardwareScanner\(\); this\.persistSession\(\)/)
+ const unload = page.match(/onUnload\(\) \{([^}]+)\}/)?.[1] || ''
+ assert.match(unload, /this\.stopHardwareScanner\(\)/)
+ assert.match(unload, /this\.persistSession\(\)/)
+ assert.match(unload, /this\.invalidateTaskSync\(\)/)
})
test('shared visual contracts preserve accessible PDA targets', async () => {
diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs
index 128999d..6a7d078 100644
--- a/tests/picking-refresh-route.test.mjs
+++ b/tests/picking-refresh-route.test.mjs
@@ -36,12 +36,14 @@ test('duplicate onShow shares one load chain while background return refreshes a
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 invalidate = page.match(/invalidateTaskSync\(\) \{[^\r\n]+\},/)?.[0]
+ assert.ok(show && hide && invalidate)
+ const hooks = new Function('requireSession', 'session', `return ({${show}${hide}${invalidate}})`)(() => 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 context = { taskRequestId: 0, invalidateTaskSync: hooks.invalidateTaskSync,
+ 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'])
@@ -51,6 +53,9 @@ test('duplicate onShow shares one load chain while background return refreshes a
await hooks.onShow.call(context)
assert.equal(calls.length, 2)
hooks.onHide.call(context)
+ assert.equal(context.taskRequestId, 1)
+ assert.equal(context.remoteLoading, false)
+ assert.equal(context._showRefreshPromise, null)
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]
diff --git a/tests/picking-request-lifecycle.test.mjs b/tests/picking-request-lifecycle.test.mjs
new file mode 100644
index 0000000..858eea4
--- /dev/null
+++ b/tests/picking-request-lifecycle.test.mjs
@@ -0,0 +1,118 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import vm from 'node:vm'
+import { readFile } from 'node:fs/promises'
+
+const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+const script = source.match(/