From bab911028d7828718aaf322c47e5812cf2f087ba Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 15:41:50 +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 | 122 +++++++++++++++++++++++--- tests/picking-evidence-stage.test.mjs | 52 ++++++++--- 2 files changed, 150 insertions(+), 24 deletions(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 25e34d2..ea3fb51 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -33,8 +33,9 @@ - + + 请先处理并提交全部待拣明细;任务增量更新后,已拍证据会按当前柜次保留。 @@ -123,7 +124,7 @@ - + 装柜证据空仓、半仓、满仓、锁仓及带锁码照 {{ remoteSyncState.label }} @@ -148,9 +149,8 @@ - 当前可先拍照留存;拣货完成并同步装柜状态后,照片会自动上传绑定。 {{ remoteError }} - + 状态尚未同步,请刷新重试 @@ -421,8 +421,11 @@ return this.boxes.length - this.completedCount }, pickingCompleted() { + if (this.remotePick) { + const hasServerPending = this.currentBox.items.some((item) => ['PENDING', 'PICKING'].includes(item.itemStatus)) + return isPickingCompleteTask(this.remotePick) && !hasServerPending + } return Boolean(this.completedBoxes[this.currentBoxIndex]) - || isPickingCompleteTask(this.remotePick) }, pickingOperationReady() { return this.remotePick?.workflowStatus === 'IN_PROGRESS' @@ -543,6 +546,10 @@ }, methods: { selectStage(stage) { + if (stage === 'evidence' && !this.loadingStageReady) { + this.showToast('请先完成新增待拣明细', 'warning') + return + } this.activeStage = stage }, debugEvidence(event, payload = {}) { @@ -563,6 +570,32 @@ }) } catch (_) { /* console may be unavailable on a device */ } }, + isTaskConflict(error) { + return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409 + }, + async refreshAfterTaskConflict(message) { + const requestedTaskId = this.remoteTaskId || this.currentBox.taskId + this.submitId = '' + this.submitting = false + this.remoteError = message + this.showToast(message, 'warning') + await this.syncRemoteTask() + if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && this.remotePick?.taskStatus === 'LOADING') { + try { await this.refreshInventorySnapshot() } catch (error) { this.debugSubmit('refresh.inventory-error', { message: error?.message || '', code: error?.code || '' }) } + } + if (String(this.remoteTaskId || '') === String(requestedTaskId || '') && !this.remoteError) this.remoteError = message + }, + async refreshInventorySnapshot() { + const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse + const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId) + if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试') + if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) { + const error = new Error('库存已变化,请刷新后重试') + error.code = 'A0409' + throw error + } + return snapshot + }, captureEvidenceIdentity() { return { taskId: this.remoteTaskId || this.currentBox.taskId || null, @@ -699,11 +732,26 @@ const restored = restorePickingSession(this.draftIdentity()) if (!restored) return false const itemIds = new Set(this.currentBox.items.map((item) => String(item.id))) - this.itemStates = Object.fromEntries(Object.entries(restored.itemStates || {}).filter(([id]) => itemIds.has(String(id)))) - this.partialQuantities = Object.fromEntries(Object.entries(restored.partialQuantities || {}).filter(([id]) => itemIds.has(String(id)))) - this.scannedBoxCodes = { ...(restored.scannedBoxCodes || {}) } + const serverStates = { ...this.itemStates } + const serverPartialQuantities = { ...this.partialQuantities } + Object.entries(restored.itemStates || {}).forEach(([id, status]) => { + if (!itemIds.has(String(id)) || serverStates[id] !== ITEM_STATUS.PENDING || status === ITEM_STATUS.PENDING) return + const item = this.currentBox.items.find((entry) => String(entry.id) === String(id)) + const partial = Number(restored.partialQuantities?.[id] || 0) + if (status === ITEM_STATUS.PARTIAL && (!partial || partial >= Number(item?.qty || 0))) return + serverStates[id] = status + if (partial > 0) serverPartialQuantities[id] = partial + }) + this.itemStates = serverStates + this.partialQuantities = serverPartialQuantities + this.scannedBoxCodes = Object.fromEntries(Object.entries(restored.scannedBoxCodes || {}).filter(([, id]) => itemIds.has(String(id)))) this.boxDrafts = restored.boxDrafts || {} this.completedBoxes = restored.completedBoxes || {} + if (this.remotePick && !isPickingCompleteTask(this.remotePick)) { + const completedBoxes = { ...this.completedBoxes } + delete completedBoxes[this.currentBoxIndex] + this.completedBoxes = completedBoxes + } this.scanHistory = this.normalizeScanHistory(restored.scanHistory) return true }, @@ -911,6 +959,13 @@ if (!requestedTaskId) { this.remoteError = '当前没有可进入的拣货任务,请返回任务列表刷新'; return } this.rememberEvidenceDraft() const previousIdentity = this.draftIdentity() + const previousTaskVersion = this.pickingVersion + const previousItemIds = new Set(this.currentBox.items.map((item) => String(item.id))) + const previousEvidenceState = { + identity: this.evidenceIdentity ? { ...this.evidenceIdentity } : null, + files: Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).map((entry) => ({ ...entry }))])), + sealNo: this.sealNo + } const requestId = ++this.taskRequestId const selector = { taskId: requestedTaskId } this.currentBox.inboundId = null @@ -1004,9 +1059,26 @@ this.loadingVersion = this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null this.evidenceIdentity = this.captureEvidenceIdentity() this.restoreEvidenceDraft() + if (this.isSameEvidenceIdentity(previousEvidenceState.identity, this.evidenceIdentity)) { + this.evidenceFiles = { ...this.evidenceFiles, ...previousEvidenceState.files } + if (previousEvidenceState.sealNo) this.sealNo = previousEvidenceState.sealNo + } 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.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion if (!this.pickingCompleted) this.restoreRemoteDraft() + if (!this.pickingCompleted) { + const completedBoxes = { ...this.completedBoxes } + delete completedBoxes[this.currentBoxIndex] + this.completedBoxes = completedBoxes + this.activeStage = 'picking' + this.completionSummary = { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 } + } + const addedItemIds = this.currentBox.items.map((item) => String(item.id)).filter((id) => !previousItemIds.has(id)) + const taskVersionChanged = previousTaskVersion !== null && String(previousTaskVersion) !== String(this.pickingVersion) + if (taskVersionChanged || addedItemIds.length) { + this.debugSubmit('refresh.incremental', { previousTaskVersion, taskVersion: this.pickingVersion, addedItemIds, pickingCompleted: this.pickingCompleted }) + if (addedItemIds.length) this.showToast(`已同步 ${addedItemIds.length} 条新增待拣明细`, 'warning') + } if (this.loadingStageReady) await this.loadLoadingEvidence() } catch (error) { if (requestId !== this.taskRequestId) return @@ -1119,8 +1191,12 @@ }, async flushPendingEvidence() { if (!this.loadingStageReady) return - const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => entry.localPath && !entry.fileId).map((entry) => ({ type: slot.type, path: entry.localPath }))) - for (const item of pending) await this.queueEvidenceUpload(item.type, item.path) + const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath, fileId: entry.fileId }))) + for (const item of pending) { + if (!this.loadingStageReady) return + if (item.fileId) await this.saveEvidence(item.type, null, item.path) + else await this.queueEvidenceUpload(item.type, item.path) + } }, async retryEvidence(type) { if (!this.requireEvidenceStage()) return @@ -1161,6 +1237,10 @@ this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return + if (this.isTaskConflict(error)) { + await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试') + return + } this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败') this.showToast('证据保存失败', 'error') } @@ -1186,6 +1266,10 @@ this.showToast('照片已作废,可重新拍摄', 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return + if (this.isTaskConflict(error)) { + await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试') + return + } this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') this.showToast('照片作废失败', 'error') } @@ -1220,6 +1304,10 @@ this.showToast('已确认装柜完成', 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return + if (this.isTaskConflict(error)) { + await this.refreshAfterTaskConflict(error.message || '装柜前置条件已变化,请刷新后重试') + return + } this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败') this.showToast('装柜完成失败', 'error') } @@ -1377,10 +1465,11 @@ const submitId = this.submitId || stableOperationId('submit', operationIdentity, { itemStates: this.itemStates, partialQuantities: this.partialQuantities, incompleteReason }) this.submitId = submitId const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse - const inventorySnapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId) + const inventorySnapshot = await this.refreshInventorySnapshot() this.debugSubmit('submit.inventory-snapshot', { snapshotTokenLength: inventorySnapshot?.snapshotToken ? String(inventorySnapshot.snapshotToken).length : 0, taskVersion: inventorySnapshot?.version ?? null }) if (!inventorySnapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试') const actions = this.currentBox.items.map((item) => { + if (item.itemStatus && !['PENDING', 'PICKING'].includes(item.itemStatus)) return null const status = this.itemState(item) const itemId = item.taskItemId ?? item.remoteItemId ?? item.id if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [], version: Number(item.version || 0) } @@ -1394,17 +1483,22 @@ this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null }) if (submittedTask) { this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask }) + this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? this.pickingVersion this.loadingVersion = submittedTask.inboundVersion ?? submittedTask.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? this.loadingVersion this.evidenceIdentity = this.captureEvidenceIdentity() } // Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions. } catch (error) { this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) }) - const versionConflict = error.code === 'A0409' || error.statusCode === 409 + const versionConflict = this.isTaskConflict(error) const inventoryConflict = versionConflict && /库存/.test(error.message || '') const conflictMessage = inventoryConflict ? '库存已变化,请刷新后重试' : '任务版本已变化,请刷新后重试' - this.remoteError = versionConflict ? conflictMessage : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')) - this.showToast(versionConflict ? conflictMessage : '拣货完成未提交,请查看错误提示', 'error') + if (versionConflict) { + await this.refreshAfterTaskConflict(conflictMessage) + return + } + this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败') + this.showToast('拣货完成未提交,请查看错误提示', 'error') return } finally { this.submitting = false } } diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index bf45cca..fa408d6 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -5,10 +5,10 @@ import { readFile } from 'node:fs/promises' const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') test('picking and photo workspaces are independently selectable', () => { - assert.match(page, /v-if="activeStage === 'evidence'" class="loading-evidence-section"/) + assert.match(page, /v-if="activeStage === 'evidence' && 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.match(page, /照片可先暂存/) + assert.match(page, /pickingCompleted\(\)[\s\S]*?if \(this\.remotePick\)[\s\S]*?hasServerPending[\s\S]*?isPickingCompleteTask\(this\.remotePick\) && !hasServerPending/) + assert.match(page, /请先处理并提交全部待拣明细/) }) test('loading evidence is not requested before picking completion', () => { @@ -36,17 +36,49 @@ test('AWAITING_REVIEW alone cannot request or mutate server evidence without a s assert.doesNotMatch(page, /if \(this\.pickingCompleted\) await this\.loadLoadingEvidence\(\)/) }) -test('picking and evidence are separate operator-selected stages', () => { +test('dynamic pending items withdraw evidence until the server task is picking-complete again', () => { assert.match(page, /class="stage-switch" role="tablist"/) assert.match(page, /@tap="selectStage\('picking'\)"/) assert.match(page, /@tap="selectStage\('evidence'\)"/) - assert.match(page, /selectStage\(stage\) \{[\s\S]*?this\.activeStage = stage/) + assert.match(page, /selectStage\(stage\) \{[\s\S]*?stage === 'evidence' && !this\.loadingStageReady[\s\S]*?请先完成新增待拣明细[\s\S]*?return[\s\S]*?this\.activeStage = stage/) + assert.match(page, /class="stage-tab"[\s\S]*?:disabled="!loadingStageReady"[\s\S]*?装柜取证/) assert.match(page, /activeStage === 'picking'/) - assert.doesNotMatch(page, /activeStage === 'picking' \|\| !loadingStageReady/) - assert.match(page, /requireEvidenceCapture\(\)[\s\S]*?evidenceIdentity\?\.inboundId/) - assert.match(page, /if \(this\.loadingStageReady\) this\.queueEvidenceUpload/) - assert.match(page, /照片已暂存,装柜状态同步后自动上传/) - assert.match(page, /本地暂存,待自动上传/) + assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/) +}) + +test('incremental refresh merges new pending items without overwriting submitted server results', () => { + const restore = page.match(/restoreRemoteDraft\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(restore, /const serverStates = \{ \.\.\.this\.itemStates \}/) + assert.match(restore, /serverStates\[id\] !== ITEM_STATUS\.PENDING/) + assert.match(restore, /status === ITEM_STATUS\.PARTIAL[\s\S]*?partial >= Number\(item\?\.qty/) + assert.match(restore, /this\.remotePick && !isPickingCompleteTask\(this\.remotePick\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\]/) + assert.match(page, /const previousTaskVersion = this\.pickingVersion/) + assert.match(page, /const previousItemIds = new Set/) + assert.match(page, /previousEvidenceState[\s\S]*?isSameEvidenceIdentity\(previousEvidenceState\.identity, this\.evidenceIdentity\)[\s\S]*?this\.evidenceFiles = \{ \.\.\.this\.evidenceFiles, \.\.\.previousEvidenceState\.files \}/) + assert.match(page, /if \(!this\.pickingCompleted\)[\s\S]*?delete completedBoxes\[this\.currentBoxIndex\][\s\S]*?this\.activeStage = 'picking'/) + assert.match(page, /refresh\.incremental[\s\S]*?addedItemIds/) +}) + +test('incremental batch submits only current server-pending items and refreshes stale inventory conflicts', () => { + const submit = page.match(/async performSubmit\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(submit, /item\.itemStatus && !\['PENDING', 'PICKING'\]\.includes\(item\.itemStatus\)\) return null/) + assert.match(submit, /const versionConflict = this\.isTaskConflict\(error\)/) + assert.match(submit, /await this\.refreshAfterTaskConflict\(conflictMessage\)/) + assert.match(submit, /const inventorySnapshot = await this\.refreshInventorySnapshot\(\)/) + assert.match(page, /refreshAfterTaskConflict\(message\)[\s\S]*?this\.submitId = ''[\s\S]*?await this\.syncRemoteTask\(\)[\s\S]*?await this\.refreshInventorySnapshot\(\)/) + assert.match(page, /refreshInventorySnapshot\(\)[\s\S]*?getPickingInventorySnapshot\(this\.remoteTaskId, warehouseId\)[\s\S]*?String\(snapshot\.version\) !== String\(this\.pickingVersion\)[\s\S]*?error\.code = 'A0409'/) + assert.match(page, /this\.pickingVersion = this\.remotePick\.pickingVersion \?\? this\.remotePick\.version/) +}) + +test('final completion preserves backend gate errors and refreshes after an atomic A0409', () => { + const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(error\.message \|\| '装柜前置条件已变化,请刷新后重试'\)/) + const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/) + const flush = page.match(/async flushPendingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(flush, /!entry\.id && entry\.localPath/) + assert.match(flush, /if \(item\.fileId\) await this\.saveEvidence\(item\.type, null, item\.path\)/) + assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/) }) test('evidence identity is bound to task, inbound, loading plan and container booking', () => {