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/) })