From 63898d9a3203cbbac12de8258b22c0b411796cb0 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Mon, 31 Aug 2026 18:08:43 +0800
Subject: [PATCH] fix(pda): bind loading evidence to cabinet tasks
---
api/pda-loading.js | 114 ++++++---------
pages/picking/picking.vue | 198 ++++++++++----------------
tests/pda-loading-api.test.mjs | 55 ++++---
tests/picking-evidence-stage.test.mjs | 59 ++++----
tests/seal-save-idempotency.test.mjs | 7 +-
5 files changed, 174 insertions(+), 259 deletions(-)
diff --git a/api/pda-loading.js b/api/pda-loading.js
index 95cd73b..5f62af9 100644
--- a/api/pda-loading.js
+++ b/api/pda-loading.js
@@ -19,9 +19,8 @@ const EVIDENCE_SLOTS = Object.freeze([
{ type: 'SEAL', label: '带锁码照' }
])
-// C02 supplies inboundId/inboundVersion and loading.pickingVersion. C04 supplies
-// inbound-scoped task and evidence operations. No local task is promoted to a
-// server success when any required identifier or version is absent.
+// Picking and cabinet evidence are both scoped by the canonical PDA task. The
+// task warehouse is always sent as an authorization boundary and never guessed.
function debugLog(event, payload = {}) {
try { console.info(`[PDA] ${event}`, payload) } catch (_) { /* console may be unavailable on a device */ }
@@ -29,10 +28,6 @@ function debugLog(event, payload = {}) {
const request = apiRequest
-function idempotencyKey(operation, inboundId) {
- return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
-}
-
function stableIdempotencyKey(operationId, suffix = '') {
const raw = `pda-${operationId}${suffix ? `-${suffix}` : ''}`
let hash = 2166136261
@@ -44,12 +39,6 @@ function stableIdempotencyKey(operationId, suffix = '') {
return `${prefix}-${(hash >>> 0).toString(36)}-${raw.length}`.slice(0, 128)
}
-function tenantQuery(tenantId) {
- const value = tenantId ?? session.tenantId
- if (!value) throw new Error('缺少客户隔离标识 tenantId,无法请求现场任务')
- return String(value)
-}
-
export function isRemoteReady(inboundId) {
return Boolean(session.token && (session.tenantId || inboundId))
}
@@ -190,41 +179,18 @@ export function normalizeInventorySnapshot(snapshot = {}) {
return normalizePickingTask({ ...snapshot, version: snapshot.taskVersion ?? snapshot.version })
}
-export function getPickingTask(inboundId, tenantId) {
+export function getEvidence(taskId, warehouseId) {
+ if (!taskId) throw new Error('缺少装柜任务标识,无法加载装柜证据')
+ if (!warehouseId) throw new Error('缺少任务仓库标识,无法加载装柜证据')
return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`,
method: 'GET'
})
}
-export function startPicking(inboundId, payload, tenantId) {
- return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/start?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
- method: 'POST',
- header: { 'Idempotency-Key': idempotencyKey('start-picking', inboundId) },
- data: payload
- })
-}
-
-export function completePicking(inboundId, payload, tenantId) {
- return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/picking/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
- method: 'POST',
- header: { 'Idempotency-Key': idempotencyKey('complete-picking', inboundId) },
- data: payload
- })
-}
-
-export function getEvidence(inboundId, tenantId) {
- return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
- method: 'GET'
- })
-}
-
-export function getEvidencePreviewUrl(inboundId, evidenceId, tenantId) {
- if (!inboundId || !evidenceId) return ''
- return `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/content/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`
+export function getEvidencePreviewUrl(taskId, evidenceId, warehouseId) {
+ if (!taskId || !evidenceId || !warehouseId) return ''
+ return `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`
}
export function downloadEvidencePreview(previewUrl) {
@@ -260,56 +226,56 @@ export function downloadEvidencePreview(previewUrl) {
})
}
-export function upsertEvidence(inboundId, payload, tenantId) {
- if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型')
- return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
- method: 'POST',
- header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${inboundId}-${payload.evidenceType}-${payload.fileId || 'seal'}`) },
- data: payload
- })
-}
-
-export function voidEvidence(inboundId, evidenceId, payload = {}, tenantId) {
- if (!inboundId || !evidenceId) throw new Error('缺少装柜证据标识,无法作废')
+export function voidEvidence(taskId, evidenceId, warehouseId, payload = {}) {
+ if (!taskId || !evidenceId) throw new Error('缺少装柜任务或证据标识,无法作废')
+ if (!warehouseId) throw new Error('缺少任务仓库标识,无法作废装柜证据')
if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空')
return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/void/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
+ url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/void`,
method: 'POST',
- header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `void-evidence-${inboundId}-${evidenceId}-${payload.expectedVersion ?? 0}`) },
- data: payload
+ data: { warehouseId, expectedVersion: payload.expectedVersion ?? 0, reason: String(payload.reason).trim() }
})
}
/** Upload a local photo to the file service and return its durable fileId. */
-export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) {
+export function uploadEvidenceFile(taskId, warehouseId, payload, localPath, onProgress) {
requireOnline('当前网络不可用,无法上传装柜照片')
+ if (!taskId) throw new Error('缺少装柜任务标识,无法上传装柜照片')
+ if (!warehouseId) throw new Error('缺少任务仓库标识,无法上传装柜照片')
+ const evidenceType = payload?.evidenceType
if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型')
if (!localPath) throw new Error('缺少待上传的照片')
- const tenant = tenantQuery(tenantId)
+ if (payload.expectedVersion == null) throw new Error('缺少装柜证据版本,无法上传装柜照片')
+ if (evidenceType === 'SEAL' && !String(payload.sealNo || '').trim()) throw new Error('带锁码照必须填写封签号')
const baseUrl = session.apiBase || '/api/platform/v1'
- debugLog('evidence.upload.start', { inboundId, evidenceType, tenantId: tenant, hasLocalPath: Boolean(localPath) })
+ debugLog('evidence.upload.start', { taskId, evidenceType, hasLocalPath: Boolean(localPath) })
+ const query = [
+ `warehouseId=${encodeURIComponent(warehouseId)}`,
+ `expectedVersion=${encodeURIComponent(payload.expectedVersion)}`,
+ `evidenceType=${encodeURIComponent(evidenceType)}`
+ ]
+ if (payload.sealNo) query.push(`sealNo=${encodeURIComponent(String(payload.sealNo).trim())}`)
+ if (payload.remark) query.push(`remark=${encodeURIComponent(payload.remark)}`)
return apiUpload({
- url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`,
+ url: `${baseUrl}/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?${query.join('&')}`,
filePath: localPath,
name: 'file',
+ header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `evidence-${taskId}-${evidenceType}-${payload.expectedVersion}`) }
}, onProgress).then((response) => {
let body = response.data
try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ }
const code = String(body?.code ?? response.statusCode)
- debugLog('evidence.upload.response', { inboundId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', requestId: body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', traceId: body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', fileId: body?.data?.fileId || null })
- if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; throw error }
+ debugLog('evidence.upload.response', { taskId, evidenceType, statusCode: response.statusCode, code, message: body?.message || '', requestId: body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || '', traceId: body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || '', fileId: body?.data?.fileId || null })
+ if (code !== '200') {
+ const error = new Error(body?.message || '文件上传失败')
+ error.code = body?.code || response.statusCode
+ error.statusCode = response.statusCode
+ error.requestId = body?.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] || ''
+ error.traceId = body?.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] || ''
+ throw error
+ }
return body?.data || body
- }).catch((error) => { debugLog('evidence.upload.error', { inboundId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
-}
-
-export function completeLoading(inboundId, payload, tenantId) {
- return request({
- url: `/inbounds/${encodeURIComponent(inboundId)}/loading/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`,
- method: 'POST',
- header: { 'Idempotency-Key': idempotencyKey('complete-loading', inboundId) },
- data: payload
- })
+ }).catch((error) => { debugLog('evidence.upload.error', { taskId, evidenceType, message: error.message || error.errMsg || '' }); throw error })
}
/** Complete the cabinet PDA task after the operator has captured all evidence. */
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index baa2bb0..59d54a4 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -139,9 +139,6 @@
{{ evidenceUploadState(slot.type) }}
-
- {{ slot.type === 'SEAL' ? '照片已上传,请输入封签号并保存' : '照片已上传,等待保存' }}
-
本地暂存,待自动上传
@@ -151,8 +148,8 @@
{{ remoteError }}
-
-
+
+
状态尚未同步,请刷新重试
@@ -341,7 +338,7 @@
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
- import { claimContainerPickingTask, completeContainerPickingTask, completePicking, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, getPickingTask, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js'
+ import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
@@ -388,7 +385,7 @@
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
evidenceSlots: EVIDENCE_SLOTS,
- evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, loadingVersion: null, pickingVersion: null, remotePick: null,
+ evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
remoteError: '', remoteLoading: false, submitting: false,
@@ -433,16 +430,16 @@
},
pickingOperationReady() {
return this.remotePick?.workflowStatus === 'IN_PROGRESS'
- && Boolean(this.remoteTaskId && this.currentBox.inboundId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
+ && Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId)
&& this.pickingVersion !== null
},
loadingStageReady() {
return this.pickingCompleted
&& this.evidenceIdentity
- && this.evidenceIdentity.inboundId
- && this.loadingVersion !== null
- && this.loadingVersion !== undefined
- && Number.isFinite(Number(this.loadingVersion))
+ && this.evidenceIdentity.taskId
+ && this.evidenceIdentity.warehouseId
+ && this.pickingVersion !== null
+ && Number.isFinite(Number(this.pickingVersion))
},
partialTargetItem() {
return this.currentBox.items.find((item) => item.id === this.partialTargetId) || null
@@ -497,7 +494,7 @@
: !this.completedBoxes[entry.index])
},
remoteSyncState() {
- if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return { label: '未连接', tone: 'error' }
+ if (!isRemoteReady(this.remoteTaskId || this.currentBox.taskId)) return { label: '未连接', tone: 'error' }
if (this.remoteLoading) return { label: '同步中', tone: 'loading' }
if (this.remoteError) return { label: '同步失败', tone: 'error' }
return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' }
@@ -506,7 +503,7 @@
return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim())
},
sealReadyToSave() {
- return Boolean(String(this.sealNo || '').trim()) && this.hasUnboundUploadedEvidence('SEAL')
+ return Boolean(String(this.sealNo || '').trim()) && this.hasPendingLocalEvidence('SEAL')
}
},
created() {
@@ -636,10 +633,9 @@
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,
+ warehouseId: this.currentBox.warehouseId || null,
pickingVersion: this.pickingVersion,
pending: this.summary.pending,
...payload
@@ -665,7 +661,7 @@
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 warehouseId = this.currentBox.warehouseId
const snapshot = await getPickingInventorySnapshot(this.remoteTaskId, warehouseId)
if (!snapshot?.snapshotToken) throw new Error('库存快照未同步,请刷新任务后重试')
if (this.pickingVersion !== null && String(snapshot.version) !== String(this.pickingVersion)) {
@@ -676,15 +672,11 @@
return snapshot
},
captureEvidenceIdentity() {
- return {
- taskId: this.remoteTaskId || this.currentBox.taskId || null,
- inboundId: this.currentBox.inboundId || null,
- loadingPlanId: this.currentBox.loadingPlanId || null,
- containerBookingId: this.currentBox.containerBookingId || null
- }
+ return { taskId: this.remoteTaskId || this.currentBox.taskId || null, warehouseId: this.currentBox.warehouseId || null }
},
isSameEvidenceIdentity(left, right) {
- return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].every((key) => String(left?.[key] ?? '') === String(right?.[key] ?? ''))
+ return String(left?.taskId ?? '') === String(right?.taskId ?? '')
+ && String(left?.warehouseId ?? '') === String(right?.warehouseId ?? '')
},
isMergedContainerRecord(task) {
return task?.recordStatus === 'MERGED' || Boolean(task?.mergedIntoContainerBookingId)
@@ -699,12 +691,7 @@
warehouseId: previousIdentity?.warehouseId ?? this.currentBox.warehouseId
}
clearPickingDraft(staleIdentity)
- const evidenceKey = this.evidenceDraftKey({
- taskId: staleIdentity.taskId,
- inboundId: previousIdentity?.inboundId ?? task?.inboundId,
- loadingPlanId: staleIdentity.loadingPlanId,
- containerBookingId: staleIdentity.containerBookingId
- })
+ const evidenceKey = this.evidenceDraftKey({ taskId: staleIdentity.taskId, warehouseId: staleIdentity.warehouseId })
this.evidenceDrafts = Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([key]) => key !== evidenceKey))
this.evidencePreviewCache = Object.fromEntries(Object.entries(this.evidencePreviewCache).filter(([key]) => !key.startsWith(`${evidenceKey}|`)))
this.taskRequestId += 1
@@ -714,7 +701,6 @@
this.remoteTaskId = null
this.remotePick = null
this.pickingVersion = null
- this.loadingVersion = null
this.assignmentVersion = null
this.itemStates = {}
this.partialQuantities = {}
@@ -737,11 +723,11 @@
&& this.isSameEvidenceIdentity(this.evidenceIdentity, identity)
},
evidenceDraftKey(identity) {
- return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].map((key) => String(identity?.[key] ?? '')).join('|')
+ return `${String(identity?.taskId ?? '')}|${String(identity?.warehouseId ?? '')}`
},
rememberEvidenceDraft(identity = this.evidenceIdentity) {
const key = this.evidenceDraftKey(identity)
- if (!key.replace(/\|/g, '')) return
+ if (!identity?.taskId || !identity?.warehouseId) return
const files = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath).map((entry) => ({ ...entry }))]).filter(([, entries]) => entries.length))
if (Object.keys(files).length || String(this.sealNo || '').trim()) this.evidenceDrafts = { ...this.evidenceDrafts, [key]: { files, sealNo: this.sealNo || '' } }
},
@@ -761,7 +747,7 @@
this.evidenceDrafts = Object.keys(files).length || draft.sealNo ? { ...this.evidenceDrafts, [key]: { ...draft, files } } : Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([draftKey]) => draftKey !== key))
},
requireEvidenceCapture() {
- if (!this.evidenceIdentity?.inboundId) {
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) {
this.showToast('任务状态尚未同步,请刷新重试', 'warning')
return false
}
@@ -769,7 +755,7 @@
},
requireEvidenceMutation() {
if (!this.requireEvidenceCapture()) return false
- if (!isRemoteReady(this.evidenceIdentity.inboundId)) {
+ if (!isRemoteReady(this.evidenceIdentity.taskId)) {
this.showToast('当前会话未连接服务端,证据保留本地', 'warning')
return false
}
@@ -800,7 +786,7 @@
this.sealNo = ''
this.sealSaving = false
this.evidenceQueue = createEvidenceUploadQueue({
- upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, item.tenantId, onProgress),
+ upload: (item, onProgress) => uploadEvidenceFile(item.taskId, item.warehouseId, item, item.localPath, onProgress),
onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
})
},
@@ -814,7 +800,7 @@
},
draftIdentity() {
const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId
- return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, inboundId: this.currentBox.inboundId || this.remotePick?.inboundId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse }
+ return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId, inboundId: this.currentBox.inboundId || this.remotePick?.inboundId, loadingPlanId: this.currentBox.loadingPlanId || this.remotePick?.loadingPlanId, containerBookingId: this.currentBox.containerBookingId || this.remotePick?.containerBookingId, warehouseId: this.currentBox.warehouseId || null }
},
restoreRemoteDraft() {
const restored = restorePickingSession(this.draftIdentity())
@@ -945,7 +931,6 @@
this.remoteTaskId = this.currentBox.taskId || null
this.remotePick = null
this.pickingVersion = null
- this.loadingVersion = null
this.assignmentVersion = null
this.resetEvidenceState()
this.boxDropdownOpen = false
@@ -1024,7 +1009,11 @@
evidenceItems(result) {
const items = Array.isArray(result?.evidences) ? result.evidences : (Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [])
const seen = new Set()
- return items.filter((item) => {
+ return items.map((item) => ({
+ ...item,
+ id: String(item.evidenceId ?? item.id ?? ''),
+ fileId: String(item.fileId ?? '')
+ })).filter((item) => {
const key = `${item.evidenceType || ''}:${item.fileId || `id-${item.id || ''}`}`
if (seen.has(key)) return false
seen.add(key)
@@ -1034,7 +1023,6 @@
validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') },
evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null },
hasPendingLocalEvidence(type) { return this.evidenceEntries(type).some((item) => item.localPath && !item.fileId) },
- hasUnboundUploadedEvidence(type) { return this.evidenceEntries(type).some((item) => item.fileId && !item.id) },
evidenceQueueEntry(type) {
return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null
},
@@ -1045,7 +1033,6 @@
if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%`
if (item.status === 'FAILED') return item.error || '上传失败'
if (item.status === 'SUCCEEDED') {
- if (type === 'SEAL' && this.hasUnboundUploadedEvidence(type)) return '照片已上传,请输入封签号并保存'
return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传')
}
if (item.status === 'REPLACED') return '已替换'
@@ -1069,13 +1056,12 @@
this.currentBox.inboundId = null
this.currentBox.loadingPlanId = null
this.currentBox.containerBookingId = null
- this.loadingVersion = null
this.resetEvidenceState()
this.debugSubmit('refresh.start', { requestId, selector })
this.remoteLoading = true
this.remoteError = ''
try {
- const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse
+ const warehouseId = this.currentBox.warehouseId
const task = await getContainerPickingTask(selector, warehouseId)
if (requestId !== this.taskRequestId) return
if (!task) throw new Error('服务端未返回当前拣货任务')
@@ -1087,7 +1073,7 @@
if (task.recordStatus !== 'ACTIVE') throw new Error('当前柜记录尚未就绪,请返回任务列表刷新')
const detailTaskId = task.taskId ?? task.id
if (String(detailTaskId || '') !== String(requestedTaskId)) throw new Error('服务端返回的拣货任务与当前选择不一致,请返回刷新')
- if (!task.inboundId || !task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
+ if (!task.loadingPlanId || !task.containerBookingId) throw new Error('任务尚未同步完整作业关联,请返回任务列表刷新')
if (task.version === null || task.version === undefined) throw new Error('任务状态尚未同步,请刷新重试')
if (!task.currentContainerNo) throw new Error('任务尚未同步集装箱号,请返回任务列表刷新')
const remoteItems = task.items || task.pickItems || task.details || task.lines
@@ -1112,12 +1098,8 @@
skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0
}))
const firstItem = this.currentBox.items[0]
- const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId))
- if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新')
this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo
this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode
- this.currentBox.inboundId = task.inboundId
- this.currentBox.tenantId = evidenceItem.tenantId || null
const serverStates = createItemStates(this.currentBox.items)
const serverPartialQuantities = {}
this.currentBox.items.forEach((item) => {
@@ -1155,14 +1137,13 @@
this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) })
if (remoteTask.workflowStatus === 'UNASSIGNED' && this.remotePick.workflowStatus !== 'IN_PROGRESS') throw new Error('任务领取尚未完成,请刷新重试')
this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null
- 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.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, evidenceIdentity: this.evidenceIdentity })
this.assignmentVersion = this.remotePick.assignment?.version ?? this.remotePick.assignmentVersion ?? this.assignmentVersion
if (!this.pickingCompleted) this.restoreRemoteDraft()
if (!this.pickingCompleted) {
@@ -1186,15 +1167,15 @@
} finally { if (requestId === this.taskRequestId) this.remoteLoading = false }
},
async loadLoadingEvidence() {
- if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) return
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) return
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
try {
- const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId)
+ const evidence = await getEvidence(identity.taskId, identity.warehouseId)
if (!this.isEvidenceContextActive(contextId, identity)) return
const remoteItems = this.evidenceItems(evidence).map((item) => ({
...item,
- previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.inboundId, item.id, this.currentBox.tenantId)
+ previewUrl: item.previewUrl || getEvidencePreviewUrl(identity.taskId, item.id, identity.warehouseId)
}))
this.debugEvidence('list.loaded', { evidenceIdentity: identity, count: remoteItems.length, previewCount: remoteItems.filter((item) => item.previewUrl).length })
const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)]))
@@ -1261,6 +1242,10 @@
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.rememberEvidenceDraft(identity)
+ if (slot.type === 'SEAL') {
+ this.showToast('带锁码照已拍摄,请输入封签号并保存', 'success')
+ return
+ }
this.queueEvidenceUpload(slot.type, path)
}, fail: (error) => {
this.cameraPicking = false
@@ -1269,36 +1254,41 @@
this.showToast('未选择照片,可稍后重试', 'warning')
} })
},
- async queueEvidenceUpload(type, path) {
- if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return }
+ applyEvidenceResponse(result, type, localPath, identity) {
+ const remoteItems = this.evidenceItems(result)
+ const saved = [...remoteItems].reverse().find((item) => item.evidenceType === type && item.status !== 'VOID' && item.status !== 'VOIDED')
+ if (saved?.fileId && localPath) this.cacheEvidencePreview(saved.fileId, localPath, identity)
+ const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([entryType, entries]) => [entryType, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath && entry.localPath !== localPath)]).filter(([, entries]) => entries.length))
+ this.evidenceFiles = remoteItems.map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles)
+ const seal = this.evidenceEntry('SEAL')
+ if (seal?.sealNo) this.sealNo = seal.sealNo
+ this.removeDraftPhoto(type, localPath)
+ },
+ async queueEvidenceUpload(type, path, sealNo = null) {
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,证据保留本地', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const queue = this.evidenceQueue
- const entry = queue.add({ ...identity, tenantId: this.currentBox.tenantId, evidenceType: type, localPath: path })
+ const active = [...this.validEvidenceEntries(type)].reverse()[0]
+ const expectedVersion = active?.version ?? 0
+ const operationId = stableOperationId('evidence-upload', `${identity.taskId}|${identity.warehouseId}`, { evidenceType: type, expectedVersion, localPath: path, sealNo: sealNo || '' })
+ const entry = queue.add({ taskId: identity.taskId, warehouseId: identity.warehouseId, evidenceType: type, expectedVersion, sealNo: sealNo || null, remark: null, operationId, localPath: path })
this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
await queue.flush()
if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
const latest = queue.snapshot().find((item) => item.id === entry.id)
this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' })
if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
- const entries = this.evidenceFiles[type] || []
- const current = entries.find((item) => item.localPath === path) || {}
- this.evidenceFiles = { ...this.evidenceFiles, [type]: entries.map((item) => item.localPath === path ? { ...item, fileId: latest.result?.fileId, fileName: latest.result?.fileName || item.fileName, uploadStatus: 'SUCCEEDED' } : item) }
- if (type === 'SEAL') {
- this.rememberEvidenceDraft(identity)
- this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
- return
- }
- await this.saveEvidence(type, null, path)
+ this.applyEvidenceResponse(latest.result, type, path, identity)
+ this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已上传并保存`, 'success')
},
async flushPendingEvidence() {
- if (!this.evidenceIdentity?.inboundId) return
- 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 })))
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
+ const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath })))
for (const item of pending) {
- if (!this.evidenceIdentity?.inboundId) return
- if (item.fileId && item.type !== 'SEAL') await this.saveEvidence(item.type, null, item.path)
- else if (item.fileId) continue
- else await this.queueEvidenceUpload(item.type, item.path)
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
+ if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
+ await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
}
},
async retryEvidence(type) {
@@ -1313,69 +1303,30 @@
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) || {}
- this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] }
- if (type === 'SEAL') {
- this.rememberEvidenceDraft(identity)
- this.showToast('带锁码照已上传,请输入封签号并点击保存封签', 'success')
- return
- }
- await this.saveEvidence(type)
- },
- async saveEvidence(type, operationId = null, localPath = null) {
- if (!this.requireEvidenceMutation()) return
- if (type === 'SEAL' && !String(this.sealNo || '').trim()) { this.showToast('请输入封签号后点击保存封签', 'warning'); return }
- const contextId = this.evidenceContextId
- const identity = { ...this.evidenceIdentity }
- const inboundId = identity.inboundId
- const tenantId = this.currentBox.tenantId
- const current = (localPath ? this.evidenceEntries(type).find((item) => item.localPath === localPath) : null) || 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-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` }
- this.debugSubmit('evidence.bind.start', { evidenceType: type, fileId: current.fileId, expectedVersion: payload.expectedVersion, evidenceIdentity: identity })
- try {
- const result = await upsertEvidence(inboundId, payload, tenantId)
- if (!this.isEvidenceContextActive(contextId, identity)) return
- this.debugSubmit('evidence.bind.success', { evidenceType: type, fileId: current.fileId, evidenceCount: result?.evidences?.length || 0, evidenceIdentity: identity })
- this.cacheEvidencePreview(current.fileId, current.localPath, identity)
- this.evidenceFiles = this.evidenceItems(result).map((item) => this.withCachedEvidencePreview(item, identity)).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)
- if (current.localPath) this.evidenceFiles = { ...this.evidenceFiles, [type]: (this.evidenceFiles[type] || []).filter((entry) => entry.id || entry.localPath !== current.localPath) }
- this.removeDraftPhoto(type, current.localPath)
- this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success')
- } catch (error) {
- if (!this.isEvidenceContextActive(contextId, identity)) return
- if (this.isTaskConflict(error)) {
- await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请完成新增待拣项后重试'))
- return
- }
- this.remoteError = error.network ? '网络失败,证据未提交,请重试' : this.remoteFailureMessage(error, '证据保存失败')
- this.showToast('证据保存失败', 'error')
- }
+ this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
+ this.showToast(`${type === 'SEAL' ? '封签' : '照片'}重试成功`, 'success')
},
async saveSealNo() {
if (this.sealSaving) return
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
this.rememberEvidenceDraft()
- const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.fileId && !item.id)
+ const current = [...this.evidenceEntries('SEAL')].reverse().find((item) => item.localPath && !item.id)
if (!current) { this.showToast('当前封签已保存,无需重复提交', 'success'); return }
- const operationId = stableOperationId('seal', stableTaskOperationIdentity(this.draftIdentity()), { fileId: String(current.fileId), sealNo: String(this.sealNo).trim() })
this.sealSaving = true
try {
- await this.saveEvidence('SEAL', operationId, current.localPath || null)
+ await this.queueEvidenceUpload('SEAL', current.localPath, String(this.sealNo).trim())
} finally {
this.sealSaving = false
}
},
async voidEvidencePhoto(photo) {
if (!this.requireEvidenceMutation()) return
- if (!photo?.id || !this.evidenceIdentity?.inboundId) return
+ if (!photo?.id || !this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
const contextId = this.evidenceContextId
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)
+ await voidEvidence(identity.taskId, photo.id, identity.warehouseId, { expectedVersion: photo.version || 0, reason })
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')
@@ -1391,8 +1342,8 @@
},
async completeLoadingTask() {
if (!this.requireEvidenceStage()) return
- if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('装柜任务尚未同步,请刷新后重试', 'warning'); return }
- if (this.pickingVersion === null || this.loadingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
+ if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
@@ -1403,12 +1354,13 @@
remark: serverAttempt.remark || 'PDA确认装柜完成'
} : null)
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
- const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
+ const completionIdentity = this.draftIdentity()
+ const operationIdentity = stableTaskOperationIdentity(completionIdentity)
const operationId = savedAttempt?.operationId || stableOperationId('complete', operationIdentity, {
taskId: this.remoteTaskId,
expectedTaskVersion,
- loadingPlanId: identity.loadingPlanId,
- containerBookingId: identity.containerBookingId
+ loadingPlanId: completionIdentity.loadingPlanId,
+ containerBookingId: completionIdentity.containerBookingId
})
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
@@ -1625,7 +1577,7 @@
const operationIdentity = stableTaskOperationIdentity(this.draftIdentity())
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 warehouseId = this.currentBox.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('库存快照未同步,请刷新任务后重试')
@@ -1635,7 +1587,6 @@
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()
}
this.showToast('拣货已提交,正在刷新最新状态', 'success')
@@ -1645,7 +1596,6 @@
this.showToast('拣货已提交,状态同步未完成,请刷新任务', 'warning')
return
}
- // 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 = this.isTaskConflict(error)
diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs
index cd53937..f5d19c3 100644
--- a/tests/pda-loading-api.test.mjs
+++ b/tests/pda-loading-api.test.mjs
@@ -6,23 +6,20 @@ const source = await readFile(new URL('../api/pda-loading.js', import.meta.url),
const http = await readFile(new URL('../api/http.js', import.meta.url), 'utf8')
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
-test('PDA loading adapter keeps C04 read and write paths', () => {
+test('PDA loading adapter uses only task-scoped cabinet evidence paths', () => {
for (const path of [
- '/first-leg/pda-loading/tasks/pending',
- '/loading/picking?tenantId=',
- '/loading/picking/start?tenantId=',
- '/loading/picking/complete?tenantId=',
- '/loading/evidence/pda?tenantId=',
- '/loading/complete?tenantId='
+ '/first-leg/pda-loading/tasks/pending'
]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?')))
+ assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence/)
assert.match(source, /'Idempotency-Key'/)
assert.match(source, /PHOTO_STEP_1[\s\S]*PHOTO_STEP_4[\s\S]*SEAL/)
for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label))
assert.match(source, /apiUpload\(/)
assert.match(http, /uni\.uploadFile/)
- assert.match(source, /\/files\/loading-evidence\//)
- assert.match(source, /loading\/evidence\/pda\?tenantId=/)
- assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void\/pda\?tenantId=/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\?\$\{query\.join\('&'\)\}/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/void/)
+ assert.doesNotMatch(source, /inbounds\/\$\{encodeURIComponent\(inboundId\)\}\/loading\/evidence/)
+ assert.doesNotMatch(source, /loading\/evidence\/pda\?tenantId=/)
assert.match(source, /export function completeContainerPickingTask\(taskId, payload = \{\}\)/)
assert.match(source, /first-leg\/pda-loading\/tasks\/\$\{encodeURIComponent\(taskId\)\}\/complete/)
assert.match(source, /expectedTaskVersion/)
@@ -31,7 +28,7 @@ test('PDA loading adapter keeps C04 read and write paths', () => {
test('PDA adapter does not treat non-200 response bodies or network failures as success', () => {
assert.match(http, /if \(code !== '200'\)/)
assert.match(http, /next\.network = true/)
- assert.match(source, /throw new Error\('缺少客户隔离标识 tenantId/)
+ assert.match(source, /throw new Error\('缺少装柜任务标识,无法上传装柜照片/)
})
test('C02 identifiers and separate picking/inbound versions are normalized explicitly', () => {
@@ -39,14 +36,14 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli
assert.match(source, /inboundVersion: row\.inboundVersion/)
assert.match(source, /pickingVersion: row\.pickingVersion \?\? row\.loading\?\.pickingVersion/)
assert.match(source, /pickingVersion: task\.pickingVersion \?\? task\.version/)
- assert.match(page, /expectedVersion: Number\(this\.pickingVersion\)/)
+ assert.match(page, /version: Number\(this\.pickingVersion\)/)
assert.match(page, /ITEM_STATUS, classifyScan, createItemStates, getPickSummary, submitStates/)
assert.match(page, /taskItemId: item\.id \?\? item\.itemId/)
assert.match(page, /submit\.batch/)
assert.match(page, /item\.itemStatus === 'PICKED'/)
assert.match(page, /item\.itemStatus === 'PARTIAL'/)
assert.match(page, /!pickingCompleted && !summary\.canSubmit/)
- assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/)
+ assert.doesNotMatch(page, /loadingVersion/)
assert.match(page, /状态尚未同步,请刷新重试/)
})
@@ -58,14 +55,14 @@ test('PDA normalizes the frozen active and merged container identity fields', ()
})
test('picking page exposes five evidence slots, retry and remote completion states', () => {
- for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成', 'loadingVersion']) {
+ for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
assert.match(`${source}\n${page}`, new RegExp(text))
}
assert.match(page, /createEvidenceUploadQueue/)
assert.match(page, /retryEvidence/)
assert.match(page, /uploadEvidenceFile/)
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
- assert.match(page, /completePicking\(/)
+ assert.doesNotMatch(page, /completePicking\(/)
assert.match(page, /sourceType: \['camera'\]/)
assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/)
assert.match(page, /evidenceEntries\(slot\.type\)/)
@@ -73,15 +70,16 @@ test('picking page exposes five evidence slots, retry and remote completion stat
assert.match(page, /evidenceComplete/)
})
-test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => {
- assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/)
- assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)[\s\S]*?\.reduce/)
- assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/)
+test('PDA evidence binding normalizes string ids from the task-scoped DTO', () => {
+ assert.match(page, /evidenceItems\(result\)[\s\S]*?id: String\(item\.evidenceId \?\? item\.id/)
+ assert.match(page, /fileId: String\(item\.fileId/)
+ assert.match(page, /applyEvidenceResponse\(result, type, localPath, identity\)[\s\S]*?this\.evidenceFiles = remoteItems[\s\S]*?\.reduce/)
+ assert.match(page, /cacheEvidencePreview\(saved\.fileId, localPath/)
})
-test('PDA downloads authenticated evidence previews returned by the backend', () => {
- assert.match(source, /export function getEvidencePreviewUrl\(inboundId, evidenceId, tenantId\)/)
- assert.match(source, /loading\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\/pda\?tenantId=/)
+test('PDA downloads authenticated task-scoped evidence previews', () => {
+ assert.match(source, /export function getEvidencePreviewUrl\(taskId, evidenceId, warehouseId\)/)
+ assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content\?warehouseId=/)
assert.match(source, /export function downloadEvidencePreview\(previewUrl\)/)
assert.match(source, /apiDownload\(\{ url \}\)/)
assert.match(http, /uni\.downloadFile\(/)
@@ -93,6 +91,17 @@ test('PDA downloads authenticated evidence previews returned by the backend', ()
assert.match(source, /const url = resolveEvidencePreviewUrl\(previewUrl, session\.apiBase/)
})
+test('cabinet evidence never selects an inbound owner or builds a container route in the PDA', () => {
+ const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
+ for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
+ assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
+ assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/)
+ assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/)
+ assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
+ assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
+ assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+})
+
test('PDA cabinet picking adapter claims online and submits one atomic batch', () => {
assert.match(source, /\/first-leg\/pda-loading\/tasks\/pending/)
assert.doesNotMatch(source, /query\.push\(`cabinetNo=/)
@@ -125,7 +134,7 @@ test('PDA cabinet picking adapter claims online and submits one atomic batch', (
assert.match(page, /库存快照未同步,请刷新任务后重试/)
assert.match(page, /拣货完成未提交,请查看错误提示/)
assert.match(page, /if \(remoteTask\.workflowStatus === 'UNASSIGNED'\)[\s\S]*claimContainerPickingTask/)
- assert.match(page, /this\.loadingVersion = this\.remotePick\.inboundVersion \?\? this\.remotePick\.loadingVersion/)
+ assert.doesNotMatch(page, /loadingVersion/)
assert.match(page, /taskRequestId/)
assert.match(page, /resetEvidenceState/)
assert.match(page, /availablePickQuantity/)
diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs
index 789e6a8..90a807b 100644
--- a/tests/picking-evidence-stage.test.mjs
+++ b/tests/picking-evidence-stage.test.mjs
@@ -32,10 +32,10 @@ test('picking completion leads to current cabinet evidence instead of an all-com
assert.doesNotMatch(page, /查看全部完成|allCompletePopup|nextBox\(\)/)
})
-test('loading evidence is requested as soon as the claimed task has a stable evidence identity', () => {
+test('loading evidence is requested by task id as soon as the task is claimed', () => {
assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)[\s\S]*?await this\.loadLoadingEvidence\(\)/)
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(loader, /if \(!this\.evidenceIdentity\?\.inboundId/)
+ assert.match(loader, /if \(!this\.evidenceIdentity\?\.taskId/)
assert.doesNotMatch(loader, /if \(!this\.loadingStageReady/)
assert.match(loader, /await getEvidence\(/)
})
@@ -50,8 +50,8 @@ test('operators receive actionable refresh guidance without internal version wor
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
})
-test('AWAITING_REVIEW without a server loading version blocks final completion but not evidence maintenance', () => {
- assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/)
+test('AWAITING_REVIEW uses the canonical task version for final completion', () => {
+ assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.evidenceIdentity\.taskId[\s\S]*?this\.evidenceIdentity\.warehouseId[\s\S]*?this\.pickingVersion !== null[\s\S]*?Number\.isFinite/)
assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?requireEvidenceStage/)
assert.match(page, /requireEvidenceMutation\(\)[\s\S]*?requireEvidenceCapture/)
@@ -119,35 +119,28 @@ test('final completion preserves backend gate errors and refreshes after an atom
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(sanitizeUserMessage\(error\.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'\)\)/)
assert.doesNotMatch(complete, /拖车|trucking/i)
- 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 && item\.type !== 'SEAL'\) await this\.saveEvidence\(item\.type, null, item\.path\)/)
- assert.match(flush, /else if \(item\.fileId\) continue/)
- assert.match(flush, /else await this\.queueEvidenceUpload\(item\.type, item\.path\)/)
})
test('seal photo upload waits for an explicit seal-number save without waiting for picking completion', () => {
- const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(queue, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type, null, path\)/)
- assert.match(retry, /if \(type === 'SEAL'\)[\s\S]*?rememberEvidenceDraft\(identity\)[\s\S]*?return[\s\S]*?saveEvidence\(type\)/)
- assert.match(save, /type === 'SEAL' && !String\(this\.sealNo \|\| ''\)\.trim\(\)[\s\S]*?请输入封签号后点击保存封签/)
- assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.saveEvidence\('SEAL'/)
+ const chooser = page.match(/chooseEvidence\(slot\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(chooser, /slot\.type === 'SEAL'[\s\S]*?带锁码照已拍摄,请输入封签号并保存[\s\S]*?return/)
+ assert.match(page, /async saveSealNo\(\)[\s\S]*?await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
- assert.match(page, /hasUnboundUploadedEvidence\(slot\.type\)[\s\S]*?照片已上传,请输入封签号并保存/)
+ assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
})
-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 = task\.inboundId/)
+test('cabinet evidence identity is task-scoped and never selects an inbound owner', () => {
+ const capture = page.match(/captureEvidenceIdentity\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ const same = page.match(/isSameEvidenceIdentity\(left, right\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
+ assert.match(capture, /taskId: this\.remoteTaskId \|\| this\.currentBox\.taskId/)
+ assert.match(capture, /warehouseId: this\.currentBox\.warehouseId/)
+ assert.doesNotMatch(capture, /inboundId|loadingPlanId|containerBookingId/)
+ assert.match(same, /left\?\.taskId[\s\S]*?right\?\.taskId/)
+ assert.doesNotMatch(same, /inboundId|loadingPlanId|containerBookingId/)
assert.match(page, /String\(detailTaskId \|\| ''\) !== String\(requestedTaskId\)/)
- assert.match(page, /!task\.inboundId \|\| !task\.loadingPlanId \|\| !task\.containerBookingId/)
- assert.match(page, /evidenceItem[\s\S]*?String\(task\.inboundId\)/)
+ assert.match(page, /!task\.loadingPlanId \|\| !task\.containerBookingId/)
+ assert.doesNotMatch(page, /const evidenceItem|String\(task\.inboundId\)/)
assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/)
})
@@ -156,28 +149,26 @@ test('merged or non-active cabinet details are rejected before claim and clear s
assert.match(sync, /if \(this\.isMergedContainerRecord\(task\)\)[\s\S]*?this\.clearMergedTask\(task, previousIdentity\)[\s\S]*?return[\s\S]*?task\.recordStatus !== 'ACTIVE'[\s\S]*?claimContainerPickingTask/)
const clear = page.match(/clearMergedTask\(task, previousIdentity\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(clear, /taskId: previousIdentity\?\.taskId \?\? task\?\.taskId/)
- assert.match(clear, /inboundId: previousIdentity\?\.inboundId \?\? task\?\.inboundId/)
assert.match(clear, /loadingPlanId: previousIdentity\?\.loadingPlanId \?\? task\?\.loadingPlanId/)
assert.match(clear, /containerBookingId: previousIdentity\?\.containerBookingId \?\? task\?\.containerBookingId/)
assert.match(clear, /clearPickingDraft\(staleIdentity\)/)
assert.match(clear, /this\.evidenceDrafts = Object\.fromEntries/)
assert.match(clear, /this\.evidencePreviewCache = Object\.fromEntries/)
- for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'loadingVersion = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&')))
+ for (const reset of ['remoteLoading = false', 'submitting = false', 'remoteTaskId = null', 'scanHistory = []', 'currentBox.containerBookingId = null', 'currentBox.currentContainerNo = null', 'resetEvidenceState()']) assert.match(clear, new RegExp(reset.replace(/[.()[\]]/g, '\\$&')))
})
test('photos upload, bind, reload and void before picking completion', () => {
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(queue, /await queue\.flush\(\)[\s\S]*?await this\.saveEvidence\(type, null, path\)/)
+ assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
- assert.match(retry, /await queue\.flush\(\)[\s\S]*?await this\.saveEvidence\(type\)/)
+ assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
- assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.inboundId[\s\S]*?getEvidence/)
+ assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
- assert.match(page, /async saveEvidence\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
assert.match(page, /async voidEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
@@ -200,7 +191,7 @@ test('evidence photos render thumbnails and support full-screen preview', () =>
assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/)
assert.match(page, /cacheEvidencePreview\(fileId, localPath/)
assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/)
- assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.inboundId, item\.id, this\.currentBox\.tenantId\)/)
+ assert.match(page, /item\.previewUrl \|\| getEvidencePreviewUrl\(identity\.taskId, item\.id, identity\.warehouseId\)/)
assert.match(page, /['"]list\.loaded['"]/)
assert.match(page, /loadEvidencePreviews\(contextId, identity\)[\s\S]*?downloadEvidencePreview\(item\.previewUrl\)/)
assert.match(page, /['"]preview\.downloaded['"]/)
@@ -211,7 +202,6 @@ test('switching cabinets invalidates old sync and evidence upload state', () =>
const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(selector, /this\.taskRequestId \+= 1/)
assert.match(selector, /this\.pickingVersion = null/)
- assert.match(selector, /this\.loadingVersion = null/)
assert.match(selector, /this\.submitting = false/)
assert.match(selector, /this\.resetEvidenceState\(\)/)
assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
@@ -247,7 +237,6 @@ test('returning from an image preview does not refresh the picking task', () =>
test('evidence save, void and loading completion ignore stale cabinet responses', () => {
for (const [method, awaitedCall] of [
- ['saveEvidence', 'upsertEvidence'],
['voidEvidencePhoto', 'voidEvidence'],
['completeLoadingTask', 'completeContainerPickingTask']
]) {
@@ -270,7 +259,7 @@ test('completed server tasks restore evidence stage without being claimed again'
})
test('picking mutations stay closed until the canonical task has been claimed', () => {
- assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?inboundId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
+ assert.match(page, /pickingOperationReady\(\)[\s\S]*?workflowStatus === 'IN_PROGRESS'[\s\S]*?remoteTaskId[\s\S]*?loadingPlanId[\s\S]*?containerBookingId/)
assert.match(page, /requirePickingOperation\(\)[\s\S]*?任务尚未领取完成/)
for (const method of ['processScannedValue', 'pickItem', 'openSkip', 'openPartial', 'submitPicking']) {
const body = page.match(new RegExp(`${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || ''
diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs
index fed88dd..86a3b79 100644
--- a/tests/seal-save-idempotency.test.mjs
+++ b/tests/seal-save-idempotency.test.mjs
@@ -4,12 +4,13 @@ import { readFile } from 'node:fs/promises'
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
-test('saving a seal is single-flight and reuses a stable photo-bound operation', () => {
+test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => {
assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/)
assert.match(page, /sealSaving \? '保存中…' : '保存封签'/)
assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/)
- assert.match(page, /find\(\(item\) => item\.fileId && !item\.id\)/)
- assert.match(page, /stableOperationId\('seal',[\s\S]*?fileId: String\(current\.fileId\)[\s\S]*?sealNo: String\(this\.sealNo\)\.trim\(\)/)
+ assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)
+ assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
+ assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
})