From dd393424c1f03f394d35a0182871c94e4b65ffeb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 09:57:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/picking/picking.vue | 75 +++++++++++++++------------ tests/picking-evidence-stage.test.mjs | 39 +++++++++----- 2 files changed, 70 insertions(+), 44 deletions(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index ca6a61d..4bb7876 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -418,6 +418,8 @@ return this.pickingCompleted && this.evidenceIdentity && this.evidenceIdentity.inboundId + && this.loadingVersion !== null + && this.loadingVersion !== undefined && Number.isFinite(Number(this.loadingVersion)) }, partialTargetItem() { @@ -515,6 +517,7 @@ console.info('[PDA] picking.submit', { event, taskId: this.remoteTaskId, + inboundId: this.currentBox.inboundId || this.evidenceIdentity?.inboundId || null, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse, @@ -682,6 +685,8 @@ this.taskRequestId += 1 this.submitting = false this.currentBoxIndex = index + this.currentBox.inboundId = null + this.currentBox.inboundVersion = null this.remoteTaskId = null this.remotePick = null this.pickingVersion = null @@ -825,12 +830,12 @@ } this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) }) this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null - this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null - this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion }) + this.loadingVersion = task.inboundVersion ?? task.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null + this.evidenceIdentity = this.captureEvidenceIdentity() + this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion, evidenceIdentity: this.evidenceIdentity }) this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion if (!this.pickingCompleted) this.restoreRemoteDraft() - this.resetEvidenceState() - if (this.pickingCompleted) await this.loadLoadingEvidence() + if (this.loadingStageReady) await this.loadLoadingEvidence() } catch (error) { if (requestId !== this.taskRequestId) return this.debugSubmit('refresh.error', { requestId, message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' }) @@ -838,16 +843,17 @@ } finally { if (requestId === this.taskRequestId) this.remoteLoading = false } }, async loadLoadingEvidence() { - if (!this.pickingCompleted || !this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) return + if (!this.loadingStageReady || !this.evidenceIdentity || !isRemoteReady(this.evidenceIdentity.inboundId)) return const contextId = this.evidenceContextId + const identity = { ...this.evidenceIdentity } try { - const evidence = await getEvidence(this.currentBox.inboundId, this.currentBox.tenantId) - if (contextId !== this.evidenceContextId) return + const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId) + if (!this.isEvidenceContextActive(contextId, identity)) return this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) const seal = this.evidenceEntry('SEAL') this.sealNo = seal?.sealNo || '' } catch (error) { - if (contextId !== this.evidenceContextId) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') } }, @@ -867,27 +873,28 @@ if (!isOnline()) { this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } const contextId = this.evidenceContextId - const inboundId = this.currentBox.inboundId + const identity = { ...this.evidenceIdentity } uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return const path = result.tempFilePaths && result.tempFilePaths[0] if (!path) return const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] } this.queueEvidenceUpload(slot.type, path) }, fail: () => { - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.showToast('未选择照片,可稍后重试', 'warning') } }) }, async queueEvidenceUpload(type, path) { if (!this.requireEvidenceStage()) return - if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } + if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } const contextId = this.evidenceContextId + const identity = { ...this.evidenceIdentity } const queue = this.evidenceQueue - const entry = queue.add({ inboundId: this.currentBox.inboundId, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) + const entry = queue.add({ ...identity, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path }) await queue.flush() - if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return + if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return const latest = queue.snapshot().find((item) => item.id === entry.id) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} @@ -898,11 +905,12 @@ if (!this.requireEvidenceStage()) return const item = this.evidenceQueueEntry(type) const contextId = this.evidenceContextId + const identity = { ...this.evidenceIdentity } const queue = this.evidenceQueue if (!item || !queue.retry(item.id)) return this.remoteError = '' await queue.flush() - if (contextId !== this.evidenceContextId || queue !== this.evidenceQueue) return + if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return const latest = queue.latest(type) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } const current = this.evidenceEntry(type) || {} @@ -911,21 +919,22 @@ }, async saveEvidence(type, operationId = null) { if (!this.requireEvidenceStage()) return - if (!this.currentBox.inboundId) { this.showToast('演示任务已记录本地证据', 'success'); return } - if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } + if (!this.evidenceIdentity?.inboundId) { this.showToast('状态尚未同步,请刷新重试', 'warning'); return } + if (!isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } const contextId = this.evidenceContextId - const inboundId = this.currentBox.inboundId + const identity = { ...this.evidenceIdentity } + const inboundId = identity.inboundId const tenantId = this.currentBox.tenantId const current = this.evidenceEntry(type) || {} if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } - const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` } + const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` } try { const result = await upsertEvidence(inboundId, payload, tenantId) - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败') this.showToast('证据保存失败', 'error') } @@ -933,47 +942,49 @@ async saveSealNo() { if (!this.requireEvidenceStage()) return if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return } - await this.saveEvidence('SEAL', `seal-no-${this.remoteTaskId || this.currentBox.inboundId}-${Date.now()}`) + await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`) }, async voidEvidencePhoto(photo) { if (!this.requireEvidenceStage()) return - if (!photo?.id || !this.currentBox.inboundId) return + if (!photo?.id || !this.evidenceIdentity?.inboundId) return const contextId = this.evidenceContextId - const inboundId = this.currentBox.inboundId + const identity = { ...this.evidenceIdentity } + const inboundId = identity.inboundId const tenantId = this.currentBox.tenantId const reason = 'PDA现场标记错图' try { await voidEvidence(inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${inboundId}-${photo.id}-${photo.version || 0}` }, tenantId) - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) } this.showToast('照片已作废,可重新拍摄', 'success') } catch (error) { - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') this.showToast('照片作废失败', 'error') } }, async completeLoadingTask() { if (!this.requireEvidenceStage()) return - if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return } + if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return } if (this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return } if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } const contextId = this.evidenceContextId - const inboundId = this.currentBox.inboundId + const identity = { ...this.evidenceIdentity } + const inboundId = identity.inboundId const tenantId = this.currentBox.tenantId const loadingVersion = this.loadingVersion this.submitting = true try { await completeLoading(inboundId, { expectedVersion: loadingVersion, remark: 'PDA确认装柜完成' }, tenantId) - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.showToast('已确认装柜完成', 'success') } catch (error) { - if (!this.isEvidenceContextActive(contextId, inboundId)) return + if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败') this.showToast('装柜完成失败', 'error') } finally { - if (this.isEvidenceContextActive(contextId, inboundId)) this.submitting = false + if (this.isEvidenceContextActive(contextId, identity)) this.submitting = false } }, pickScannedBox(item, boxCode) { @@ -1156,7 +1167,7 @@ item.finalPartialQuantity = this.partialQuantities[item.id] || 0 }) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true } - this.loadLoadingEvidence() + if (this.loadingStageReady) this.loadLoadingEvidence() this.saveCurrentBoxDraft() if (remoteRequired) clearPickingDraft(this.draftIdentity()) this.submitId = '' diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index aed7e88..453ca83 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -5,16 +5,16 @@ import { readFile } from 'node:fs/promises' const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') test('loading evidence is mounted only after the current picking task is complete', () => { - assert.match(page, /v-if="currentBox\.inboundId && pickingCompleted" class="loading-evidence-section"/) + assert.match(page, /v-if="loadingStageReady" class="loading-evidence-section"/) assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/) assert.match(page, /pickingCompleted\(\)[\s\S]*?completedBoxes\[this\.currentBoxIndex\][\s\S]*?isPickingCompleteTask\(this\.remotePick\)/) assert.doesNotMatch(page, /class="loading-evidence-section" v-if="currentBox\.inboundId"/) }) test('loading evidence is not requested before picking completion', () => { - assert.match(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) + assert.match(page, /if \(this\.loadingStageReady\) await this\.loadLoadingEvidence\(\)/) const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(loader, /if \(!this\.pickingCompleted/) + assert.match(loader, /if \(!this\.loadingStageReady/) assert.match(loader, /await getEvidence\(/) assert.match(page, /this\.completedBoxes = \{[\s\S]*?this\.loadLoadingEvidence\(\)/) }) @@ -26,8 +26,23 @@ test('operators receive actionable refresh guidance without internal version wor assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/) }) +test('AWAITING_REVIEW alone cannot mount or request evidence without a server loading version', () => { + assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/) + assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/) + assert.match(page, /if \(!this\.loadingStageReady \|\| !this\.evidenceIdentity/) + assert.doesNotMatch(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) +}) + +test('evidence identity is bound to task, inbound, loading plan and container booking', () => { + assert.match(page, /captureEvidenceIdentity\(\)[\s\S]*?taskId:[\s\S]*?inboundId:[\s\S]*?loadingPlanId:[\s\S]*?containerBookingId:/) + assert.match(page, /isSameEvidenceIdentity\(left, right\)[\s\S]*?taskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/) + assert.match(page, /this\.currentBox\.inboundId = firstItem\.inboundId/) + assert.doesNotMatch(page, /firstItem\.inboundId \|\| this\.currentBox\.inboundId/) + assert.match(page, /evidenceIdentity: this\.captureEvidenceIdentity\(\)/) +}) + test('evidence mutations defensively reject calls before picking completion', () => { - assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(this\.pickingCompleted\) return true[\s\S]*?请先提交拣货完成/) + assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/) for (const method of ['chooseEvidence', 'queueEvidenceUpload', 'retryEvidence', 'saveEvidence', 'saveSealNo', 'voidEvidencePhoto', 'completeLoadingTask']) { const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' assert.match(body, /if \(!this\.requireEvidenceStage\(\)\) return/, `${method} should guard the evidence stage`) @@ -49,9 +64,9 @@ test('switching cabinets invalidates old sync and evidence upload state', () => test('camera callbacks cannot append evidence after switching cabinets', () => { const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(chooser, /const contextId = this\.evidenceContextId/) - assert.match(chooser, /const inboundId = this\.currentBox\.inboundId/) - assert.equal((chooser.match(/isEvidenceContextActive\(contextId, inboundId\)/g) || []).length, 2) - assert.match(chooser, /isEvidenceContextActive\(contextId, inboundId\)[\s\S]*?this\.evidenceFiles =/) + assert.match(chooser, /const identity = \{ \.\.\.this\.evidenceIdentity \}/) + assert.equal((chooser.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length, 2) + assert.match(chooser, /isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.evidenceFiles =/) }) test('evidence save, void and loading completion ignore stale cabinet responses', () => { @@ -62,16 +77,16 @@ test('evidence save, void and loading completion ignore stale cabinet responses' ]) { const body = page.match(new RegExp(`async ${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' assert.match(body, /const contextId = this\.evidenceContextId/, `${method} should capture the evidence context`) - assert.match(body, /const inboundId = this\.currentBox\.inboundId/, `${method} should capture stable inbound identity`) - assert.match(body, new RegExp(`await ${awaitedCall}\\([\\s\\S]*?if \\(!this\\.isEvidenceContextActive\\(contextId, inboundId\\)\\) return`), `${method} should guard the success writeback`) - assert.ok((body.match(/isEvidenceContextActive\(contextId, inboundId\)/g) || []).length >= 2, `${method} should guard success and error writebacks`) + assert.match(body, /const identity = \{ \.\.\.this\.evidenceIdentity \}/, `${method} should capture stable evidence identity`) + assert.match(body, new RegExp(`await ${awaitedCall}\\([\\s\\S]*?if \\(!this\\.isEvidenceContextActive\\(contextId, identity\\)\\) return`), `${method} should guard the success writeback`) + assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`) } const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, inboundId\)[\s\S]*?this\.submitting = false/) + assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.submitting = false/) }) test('completed server tasks restore evidence stage without being claimed again', () => { assert.match(page, /const remoteTask = normalizePickingTask\(task\)/) assert.match(page, /if \(!isPickingCompleteTask\(remoteTask\)\) \{[\s\S]*?claimContainerPickingTask/) - assert.match(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) + assert.match(page, /if \(this\.loadingStageReady\) await this\.loadLoadingEvidence\(\)/) })