From fc1944fb7e05372a8ce2a1f69a7af9720691009e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 11:53:43 +0800 Subject: [PATCH] fix(pda): enforce cabinet evidence contract --- api/pda-loading.js | 19 ++++++-- pages/picking/picking.vue | 88 ++++++++++++++++++++++++++-------- tests/pda-loading-api.test.mjs | 12 ++++- 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/api/pda-loading.js b/api/pda-loading.js index 9b696c4..76aa551 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -121,7 +121,7 @@ export async function submitContainerPickingBatch(task, actions, { warehouseId, const results = [] let version = task.version ?? task.pickingVersion for (const action of actions || []) { - const common = { warehouseId, version: action.version ?? version, operationId: `${submitOperationId}-${action.itemId}` } + const common = { warehouseId, locationIds: action.locationIds || [], version: action.version ?? version, operationId: `${submitOperationId}-${action.itemId}` } let result if (action.type === 'PICK') result = await pickContainerItem(task.taskId, action.itemId, { ...common, pickedPackageCount: action.pickedPackageCount }) else if (action.type === 'PARTIAL') result = await partialContainerItem(task.taskId, action.itemId, { ...common, finalPickedPackageCount: action.finalPickedPackageCount }) @@ -190,7 +190,7 @@ export function completePicking(inboundId, payload, tenantId) { export function getEvidence(inboundId, tenantId) { return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, + url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, method: 'GET' }) } @@ -198,9 +198,20 @@ export function getEvidence(inboundId, tenantId) { export function upsertEvidence(inboundId, payload, tenantId) { if (!EVIDENCE_TYPES.includes(payload.evidenceType)) throw new Error('不支持的装柜证据类型') return request({ - url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, + url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, method: 'POST', - header: { 'Idempotency-Key': idempotencyKey(`evidence-${payload.evidenceType}`, inboundId) }, + 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('缺少装柜证据标识,无法作废') + if (!String(payload.reason || '').trim()) throw new Error('作废原因不能为空') + return request({ + url: `/inbounds/${encodeURIComponent(inboundId)}/loading/evidence/${encodeURIComponent(evidenceId)}/void/pda?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, + method: 'POST', + header: { 'Idempotency-Key': stableIdempotencyKey(payload.operationId || `void-evidence-${inboundId}-${evidenceId}-${payload.expectedVersion ?? 0}`) }, data: payload }) } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 0e841b7..1b1dce3 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -123,9 +123,12 @@ {{ remoteSyncState.label }} - - {{ slot.label }} - {{ evidenceEntry(slot.type).fileName || '已记录' }} + + {{ slot.label }} + + {{ photo.fileName || '已记录' }} + + {{ evidenceUploadState(slot.type) }} @@ -135,10 +138,10 @@ - + {{ remoteError }} - + 等待统一订单详情提供装柜版本号后可提交完成 @@ -321,7 +324,7 @@ import { navigateBackOr } from '@/utils/navigation.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' - import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence } from '@/api/pda-loading.js' + import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence, voidEvidence } from '@/api/pda-loading.js' import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -398,13 +401,16 @@ partialInputValid() { if (!this.partialTargetItem) return false const value = parseInt(this.partialInput, 10) - return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty + const available = this.availablePickQuantity(this.partialTargetItem) + return Number.isInteger(value) && value > 0 && value < this.partialTargetItem.qty && (available === null || value <= available) }, partialValidationMessage() { if (!this.partialInput || !this.partialTargetItem) return '' const value = parseInt(this.partialInput, 10) if (!Number.isInteger(value) || value <= 0) return '请输入大于 0 的整数' if (value >= this.partialTargetItem.qty) return `部分拣货必须少于 ${this.partialTargetItem.qty},整单请返回后点击“确认拣出”` + const available = this.availablePickQuantity(this.partialTargetItem) + if (available !== null && value > available) return `超过库存/库位可用量,可拣 ${available}` return '' }, submitButtonText() { @@ -432,6 +438,9 @@ if (this.remoteLoading) return { label: '同步中', tone: 'loading' } if (this.remoteError) return { label: '同步失败', tone: 'error' } return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' } + }, + evidenceComplete() { + return this.evidenceSlots.every((slot) => this.validEvidenceEntries(slot.type).length > 0) && Boolean(String(this.sealNo || '').trim()) } }, created() { @@ -616,7 +625,9 @@ playSuccessSound() this.pickItem(result.item.id, 'scan') }, - evidenceEntry(type) { return this.evidenceFiles[type] || null }, + evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) }, + 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 }, evidenceQueueEntry(type) { return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null }, @@ -649,6 +660,10 @@ qty: Number(item.qty ?? item.packageCount ?? item.requiredPackageCount ?? 0), unit: item.unit ?? '件', location: item.location ?? item.locationCode ?? '', + availablePackageCount: item.availablePackageCount ?? item.locationAvailablePackageCount ?? item.availableQty ?? item.stockAvailableQty ?? null, + locationIds: item.locationIds ?? item.reservationIds ?? [], + locationAvailable: item.locationAvailable ?? item.locationUsable ?? null, + locationStatus: item.locationStatus ?? null, skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0 })) this.itemStates = createItemStates(this.currentBox.items) @@ -666,8 +681,8 @@ this.restoreRemoteDraft() if (this.currentBox.inboundId) { const evidence = await getEvidence(this.currentBox.inboundId) - this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { all[item.evidenceType] = item; return all }, {}) - const seal = this.evidenceFiles.SEAL + this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) + const seal = this.evidenceEntry('SEAL') if (seal) this.sealNo = seal.sealNo || '' } } catch (error) { @@ -681,10 +696,11 @@ }, chooseEvidence(slot) { if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } - uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'], success: (result) => { + uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera'], success: (result) => { const path = result.tempFilePaths && result.tempFilePaths[0] if (!path) return - this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: { ...(this.evidenceFiles[slot.type] || {}), evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } } + const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } + this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] } this.queueEvidenceUpload(slot.type, path) }, fail: () => this.showToast('未选择照片,可稍后重试', 'warning') }) }, @@ -694,7 +710,8 @@ await this.evidenceQueue.flush() const latest = this.evidenceQueue.snapshot().find((item) => item.id === entry.id) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } - this.evidenceFiles = { ...this.evidenceFiles, [type]: { ...this.evidenceFiles[type], fileId: latest.result?.fileId, fileName: latest.result?.fileName || this.evidenceFiles[type].fileName, uploadStatus: 'SUCCEEDED' } } + 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' }] } await this.saveEvidence(type) }, async retryEvidence(type) { @@ -704,23 +721,42 @@ await this.evidenceQueue.flush() const latest = this.evidenceQueue.latest(type) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } - this.evidenceFiles = { ...this.evidenceFiles, [type]: { ...this.evidenceFiles[type], fileId: latest.result?.fileId, fileName: latest.result?.fileName || this.evidenceFiles[type]?.fileName, uploadStatus: 'SUCCEEDED' } } + 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' }] } await this.saveEvidence(type) }, - async saveEvidence(type) { + async saveEvidence(type, operationId = null) { if (!this.currentBox.inboundId) { this.showToast('演示任务已记录本地证据', 'success'); return } if (!isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } - const current = this.evidenceFiles[type] || {} - if (type !== 'SEAL' && !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 } + const current = this.evidenceEntry(type) || {} + if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } + const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${this.remoteTaskId || this.currentBox.inboundId}-${type}-${current.fileId || 'seal'}` } try { const result = await upsertEvidence(this.currentBox.inboundId, payload) - this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { all[item.evidenceType] = item; return all }, this.evidenceFiles) + this.evidenceFiles = (result && result.evidences ? result.evidences : []).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败'); this.showToast('证据保存失败', 'error') } }, + async saveSealNo() { + if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return } + await this.saveEvidence('SEAL', `seal-no-${this.remoteTaskId || this.currentBox.inboundId}-${Date.now()}`) + }, + async voidEvidencePhoto(photo) { + if (!photo?.id || !this.currentBox.inboundId) return + const reason = 'PDA现场标记错图' + try { + await voidEvidence(this.currentBox.inboundId, photo.id, { expectedVersion: photo.version || 0, reason, operationId: `void-${this.currentBox.inboundId}-${photo.id}-${photo.version || 0}` }) + this.evidenceFiles = { ...this.evidenceFiles, [photo.evidenceType]: (this.evidenceFiles[photo.evidenceType] || []).map((item) => item.id === photo.id ? { ...item, status: 'VOID', voidReason: reason } : item) } + this.showToast('照片已作废,可重新拍摄', 'success') + } catch (error) { + this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') + this.showToast('照片作废失败', 'error') + } + }, async completeLoadingTask() { if (!isRemoteReady(this.currentBox.inboundId) || this.loadingVersion === null) return + if (!this.completedBoxes[this.currentBoxIndex]) { this.showToast('请先提交拣货完成', 'warning'); return } + if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return } this.submitting = true try { await completeLoading(this.currentBox.inboundId, { expectedVersion: this.loadingVersion, remark: 'PDA确认装柜完成' }); this.showToast('已确认装柜完成', 'success') } catch (error) { this.remoteError = error.network ? '网络失败,装柜完成未提交' : (error.message || '装柜完成失败'); this.showToast('装柜完成失败', 'error') } @@ -728,6 +764,8 @@ }, pickItem(id, source = 'manual') { const item = this.currentBox.items.find((entry) => entry.id === id) + const available = this.availablePickQuantity(item) + if (available !== null && available < Number(item.qty || 0)) { this.showToast(`库存/库位可用量不足:可拣 ${available}`, 'error'); return } this.itemStates = { ...this.itemStates, [id]: ITEM_STATUS.PICKED } if (source === 'scan' && item) { this.recordScanResult('success', '扫描成功', item.sku, `${item.location} · 已拣 ${item.qty} ${item.unit}`) @@ -787,12 +825,20 @@ if (!item) return if (!this.partialInputValid) return const value = parseInt(this.partialInput, 10) + const available = this.availablePickQuantity(item) + if (available !== null && value > available) { this.showToast(`超过库存/库位可用量:可拣 ${available}`, 'error'); return } this.itemStates = { ...this.itemStates, [item.id]: ITEM_STATUS.PARTIAL } this.partialQuantities = { ...this.partialQuantities, [item.id]: value } this.closeOverlay(false) this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success') this.persistSession() }, + availablePickQuantity(item) { + if (!item) return null + if (item.locationAvailable === false || item.locationStatus === 'DISABLED') return 0 + const raw = item.locationAvailablePackageCount ?? item.availablePackageCount ?? item.availableQty ?? item.stockAvailableQty + return raw === null || raw === undefined || raw === '' ? null : Math.max(0, Number(raw)) + }, openBoxList(type) { this.boxListType = type this.boxDropdownOpen = false @@ -846,8 +892,8 @@ const actions = this.currentBox.items.map((item) => { const status = this.itemState(item) const itemId = item.taskItemId ?? item.remoteItemId ?? item.id - if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0) } - if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0) } + if (status === ITEM_STATUS.PICKED) return { type: 'PICK', itemId, pickedPackageCount: Number(item.qty || 0), locationIds: item.locationIds || [] } + if (status === ITEM_STATUS.PARTIAL) return { type: 'PARTIAL', itemId, finalPickedPackageCount: Number(this.partialQuantities[item.id] || 0), locationIds: item.locationIds || [] } if (status === ITEM_STATUS.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣' } if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)` } return null diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 018a32e..874ef78 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -11,7 +11,7 @@ test('PDA loading adapter keeps C04 read and write paths', () => { '/loading/picking?tenantId=', '/loading/picking/start?tenantId=', '/loading/picking/complete?tenantId=', - '/loading/evidence?tenantId=', + '/loading/evidence/pda?tenantId=', '/loading/complete?tenantId=' ]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?'))) assert.match(source, /'Idempotency-Key'/) @@ -19,6 +19,8 @@ test('PDA loading adapter keeps C04 read and write paths', () => { for (const label of ['空仓照片', '半仓照片', '满仓照片', '锁仓照片', '带锁码照']) assert.match(source, new RegExp(label)) assert.match(source, /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=/) }) test('PDA adapter does not treat non-200 response bodies or network failures as success', () => { @@ -46,6 +48,11 @@ test('picking page exposes five evidence slots, retry and remote completion stat assert.match(page, /uploadEvidenceFile/) assert.match(page, /submitting \|\| completedBoxes\[currentBoxIndex\]/) assert.match(page, /completePicking\(/) + assert.match(page, /sourceType: \['camera'\]/) + assert.doesNotMatch(page, /sourceType: \['camera', 'album'\]/) + assert.match(page, /evidenceEntries\(slot\.type\)/) + assert.match(page, /voidEvidencePhoto\(/) + assert.match(page, /evidenceComplete/) }) test('PDA cabinet picking adapter exposes online claim and item action contracts', () => { @@ -59,4 +66,7 @@ test('PDA cabinet picking adapter exposes online claim and item action contracts assert.match(page, /claimContainerPickingTask\(/) assert.match(page, /submitContainerPickingBatch\(/) assert.match(page, /任务版本已变化,请刷新后重试/) + assert.match(source, /locationIds: action\.locationIds \|\| \[\]/) + assert.match(page, /availablePickQuantity/) + assert.match(page, /库存\/库位可用量不足/) })