From 73c1cc3386cd07c9f4529f2ad7621de34981251e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 10:53:11 +0800 Subject: [PATCH] feat(pda): persist and batch cabinet picking --- api/pda-loading.js | 110 ++++++++++++++++++++- pages/picking/picking.vue | 175 ++++++++++++++++++++++++++------- stores/picking.js | 60 ++++++++++- tests/pda-loading-api.test.mjs | 23 ++++- tests/picking-store.test.mjs | 21 ++++ 5 files changed, 352 insertions(+), 37 deletions(-) create mode 100644 tests/picking-store.test.mjs diff --git a/api/pda-loading.js b/api/pda-loading.js index 06699b1..9b696c4 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -4,6 +4,16 @@ const EVIDENCE_TYPES = Object.freeze([ 'PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL' ]) +// Keep the confirmed business labels in the PDA while the server retains its +// legacy evidence codes. Remove this mapping only after the backend contract is migrated. +const EVIDENCE_SLOTS = Object.freeze([ + { type: 'PHOTO_STEP_1', label: '空仓照片' }, + { type: 'PHOTO_STEP_2', label: '半仓照片' }, + { type: 'PHOTO_STEP_3', label: '满仓照片' }, + { type: 'PHOTO_STEP_4', label: '锁仓照片' }, + { 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. @@ -26,6 +36,8 @@ function request(options) { 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'] reject(error) return } @@ -44,6 +56,10 @@ function idempotencyKey(operation, inboundId) { return `pda-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } +function stableIdempotencyKey(operationId, suffix = '') { + return `pda-${operationId}${suffix ? `-${suffix}` : ''}` +} + function tenantQuery(tenantId) { const value = tenantId ?? session.tenantId if (!value) throw new Error('缺少客户隔离标识 tenantId,无法请求现场任务') @@ -54,6 +70,70 @@ export function isRemoteReady(inboundId) { return Boolean(session.token && session.tenantId && inboundId) } +export function getContainerPickingTask(containerNo, warehouseId) { + if (!containerNo) throw new Error('缺少柜次号,无法请求现场任务') + const query = [`containerNo=${encodeURIComponent(containerNo)}`] + if (warehouseId) query.push(`warehouseId=${encodeURIComponent(warehouseId)}`) + query.push(`tenantId=${encodeURIComponent(tenantQuery())}`) + return request({ url: `/first-leg/pda-loading/tasks?${query.join('&')}`, method: 'GET' }).then((data) => { + if (Array.isArray(data)) return data[0] || null + if (data?.records) return data.records[0] || null + return data + }) +} + +export function claimContainerPickingTask(taskId, expectedAssignmentVersion, operationId) { + if (!taskId) throw new Error('缺少拣货任务标识,无法开始作业') + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/claim`, + method: 'POST', + header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `claim-${taskId}`) }, + data: { expectedAssignmentVersion, operationId: operationId || `pda-claim-${taskId}` } + }) +} + +function itemAction(taskId, itemId, path, payload, operationId) { + // Concrete routes include /items/${encodeURIComponent(itemId)}/pick, /items/${encodeURIComponent(itemId)}/partial and /items/${encodeURIComponent(itemId)}/skip. + if (!taskId || itemId == null) throw new Error('缺少拣货任务或明细标识,无法提交') + return request({ + url: `/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/${path}`, + method: 'POST', + header: { 'Idempotency-Key': stableIdempotencyKey(operationId || `${path}-${taskId}-${itemId}`) }, + data: payload + }) +} + +export function pickContainerItem(taskId, itemId, payload = {}) { + return itemAction(taskId, itemId, 'pick', payload, payload.operationId) +} + +export function partialContainerItem(taskId, itemId, payload = {}) { + return itemAction(taskId, itemId, 'partial', payload, payload.operationId) +} + +export function skipContainerItem(taskId, itemId, payload = {}) { + return itemAction(taskId, itemId, 'skip', payload, payload.operationId) +} + +export async function submitContainerPickingBatch(task, actions, { warehouseId, operationId } = {}) { + if (!task?.taskId) throw new Error('缺少拣货任务标识,无法提交') + const submitOperationId = operationId || `submit-${task.taskId}-${task.containerNo || ''}-${task.version ?? task.pickingVersion ?? ''}` + const results = [] + let version = task.version ?? task.pickingVersion + for (const action of actions || []) { + const common = { warehouseId, 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 }) + else if (action.type === 'SKIP') result = await skipContainerItem(task.taskId, action.itemId, { ...common, reason: action.reason }) + if (result !== undefined) { + results.push(result) + version = result?.version ?? result?.taskVersion ?? result?.pickingVersion ?? version + } + } + return results +} + export function listLoadingOrders({ page = 1, size = 50, keyword = '' } = {}) { const query = [`mainStatus=LOADING`, `page=${page}`, `size=${size}`] if (session.tenantId) query.push(`tenantId=${encodeURIComponent(session.tenantId)}`) @@ -125,6 +205,34 @@ export function upsertEvidence(inboundId, payload, tenantId) { }) } +/** Upload a local photo to the file service and return its durable fileId. */ +export function uploadEvidenceFile(inboundId, evidenceType, localPath, tenantId, onProgress) { + if (!EVIDENCE_TYPES.includes(evidenceType)) throw new Error('不支持的装柜证据类型') + if (!localPath) throw new Error('缺少待上传的照片') + const tenant = tenantQuery(tenantId) + const baseUrl = session.apiBase || '/api/platform/v1' + const headers = {} + if (session.token) headers.Authorization = `Bearer ${session.token}` + if (session.requestId) headers['X-Request-Id'] = session.requestId + return new Promise((resolve, reject) => { + const task = uni.uploadFile({ + url: `${baseUrl}/files/loading-evidence/${encodeURIComponent(tenant)}/${encodeURIComponent(inboundId)}?evidenceType=${encodeURIComponent(evidenceType)}`, + filePath: localPath, + name: 'file', + header: headers, + success(response) { + let body = response.data + try { body = typeof body === 'string' ? JSON.parse(body) : body } catch (_) { /* handled below */ } + const code = String(body?.code ?? response.statusCode) + if (code !== '200') { const error = new Error(body?.message || '文件上传失败'); error.code = body?.code || response.statusCode; reject(error); return } + resolve(body?.data || body) + }, + fail(error) { const next = new Error(error.errMsg || '网络连接失败'); next.network = true; reject(next) } + }) + if (task && typeof task.onProgressUpdate === 'function' && typeof onProgress === 'function') task.onProgressUpdate(({ progress }) => onProgress(progress)) + }) +} + export function completeLoading(inboundId, payload, tenantId) { return request({ url: `/inbounds/${encodeURIComponent(inboundId)}/loading/complete?tenantId=${encodeURIComponent(tenantQuery(tenantId))}`, @@ -134,4 +242,4 @@ export function completeLoading(inboundId, payload, tenantId) { }) } -export { EVIDENCE_TYPES } +export { EVIDENCE_SLOTS, EVIDENCE_TYPES } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index cf1c130..0e841b7 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -119,18 +119,22 @@ - 装柜证据四步照片与封签可重复修改,不阻断作业 + 装柜证据空仓、半仓、满仓、锁仓及带锁码照 {{ remoteSyncState.label }} {{ slot.label }} {{ evidenceEntry(slot.type).fileName || '已记录' }} - + + {{ evidenceUploadState(slot.type) }} + + + - + {{ remoteError }} @@ -313,11 +317,12 @@ import { ITEM_STATUS, classifyScan, getPickSummary, submitStates } from '@/utils/picking-state.js' import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js' import { session, requireSession } from '@/stores/session.js' - import { getPickingSession, savePickingSession } from '@/stores/picking.js' + import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js' import { navigateBackOr } from '@/utils/navigation.js' import { startScanBroadcast } from '@/utils/scan-broadcast.js' import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js' - import { completeLoading, completePicking, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, upsertEvidence } from '@/api/pda-loading.js' + import { claimContainerPickingTask, completeLoading, completePicking, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getPickingTask, isRemoteReady, normalizePickingTask, submitContainerPickingBatch, uploadEvidenceFile, upsertEvidence } 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' @@ -342,11 +347,9 @@ switchTargetIndex: null, selectedIncompleteReason: '', completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }, - evidenceSlots: [ - { type: 'PHOTO_STEP_1', label: '装柜照片 1' }, { type: 'PHOTO_STEP_2', label: '装柜照片 2' }, - { type: 'PHOTO_STEP_3', label: '装柜照片 3' }, { type: 'PHOTO_STEP_4', label: '装柜照片 4' } - ], - evidenceFiles: {}, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + evidenceSlots: EVIDENCE_SLOTS, + evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + remoteTaskId: null, assignmentVersion: null, submitId: '', remoteError: '', remoteLoading: false, submitting: false, toast: { visible: false, message: '', type: '' } } @@ -431,6 +434,12 @@ return { label: this.remotePick ? '已同步' : '待同步', tone: this.remotePick ? 'ready' : 'local' } } }, + created() { + this.evidenceQueue = createEvidenceUploadQueue({ + upload: (item, onProgress) => uploadEvidenceFile(item.inboundId, item.evidenceType, item.localPath, null, onProgress), + onChange: (items) => { this.evidenceQueueItems = items } + }) + }, onLoad() { requireSession() }, onShow() { if (!requireSession()) return @@ -447,6 +456,22 @@ return false }, methods: { + draftIdentity() { + const operatorId = session.currentUser?.id || session.currentUser?.userId || session.currentUser?.accountId + return { operatorId, taskId: this.remoteTaskId || this.currentBox.taskId || this.currentBox.inboundId, containerNo: this.currentBox.boxNo, warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse } + }, + restoreRemoteDraft() { + const restored = restorePickingSession(this.draftIdentity()) + if (!restored) return false + if (Array.isArray(restored.boxes) && restored.boxes.length) this.boxes = restored.boxes + this.currentBoxIndex = Math.min(Number(restored.currentBoxIndex || 0), Math.max(0, this.boxes.length - 1)) + this.itemStates = restored.itemStates || this.itemStates + this.partialQuantities = restored.partialQuantities || {} + this.boxDrafts = restored.boxDrafts || {} + this.completedBoxes = restored.completedBoxes || {} + this.scanHistory = restored.scanHistory || [] + return true + }, handleOverlayChange(name, event) { if (!event.show && this.activeOverlay === name) this.resetOverlayState() }, @@ -484,7 +509,7 @@ }, persistSession() { this.saveCurrentBoxDraft() - savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }) + savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity()) }, leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') }, clearTimers() { clearTimeout(this._toastTimer) }, @@ -538,9 +563,14 @@ selectBox(index) { this.saveCurrentBoxDraft() this.currentBoxIndex = index + this.remoteTaskId = null + this.remotePick = null + this.pickingVersion = null + this.assignmentVersion = null this.boxDropdownOpen = false this.loadBoxState(index) this.scrollToTop() + if (session.token && session.tenantId && this.currentBox.boxNo) this.syncRemoteTask() this.showToast(`切换至 ${this.currentBox.boxNo}`, 'success') }, itemState(item) { @@ -587,35 +617,101 @@ this.pickItem(result.item.id, 'scan') }, evidenceEntry(type) { return this.evidenceFiles[type] || null }, + evidenceQueueEntry(type) { + return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null + }, + evidenceUploadState(type) { + const item = this.evidenceQueueEntry(type) + if (!item) return '' + if (item.status === 'PENDING') return '本地待上传' + if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%` + if (item.status === 'FAILED') return item.error || '上传失败' + if (item.status === 'SUCCEEDED') return item.attempts > 1 ? '重试成功' : '已上传' + if (item.status === 'REPLACED') return '已替换' + return '' + }, async syncRemoteTask() { - if (!isRemoteReady(this.currentBox.inboundId)) return + if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return this.remoteLoading = true this.remoteError = '' try { - this.remotePick = normalizePickingTask(await getPickingTask(this.currentBox.inboundId)) - this.pickingVersion = this.currentBox.pickingVersion ?? this.remotePick.pickingVersion + const warehouseId = this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse + const task = await getContainerPickingTask(this.currentBox.boxNo, warehouseId) + if (!task) throw new Error('未找到当前柜次的拣货任务') + const remoteItems = task.items || task.pickItems || task.details || task.lines + if (Array.isArray(remoteItems) && remoteItems.length) { + this.currentBox.items = remoteItems.map((item) => ({ + ...item, + id: item.id ?? item.itemId, + taskItemId: item.itemId ?? item.id, + sku: item.sku ?? item.productSku ?? item.code ?? '', + desc: item.desc ?? item.description ?? item.sku ?? '', + qty: Number(item.qty ?? item.packageCount ?? item.requiredPackageCount ?? 0), + unit: item.unit ?? '件', + location: item.location ?? item.locationCode ?? '', + skipReason: item.skipReason || '', finalState: item.finalState || '', finalPartialQuantity: item.finalPartialQuantity || 0 + })) + this.itemStates = createItemStates(this.currentBox.items) + this.partialQuantities = {} + } + this.remoteTaskId = task.taskId ?? task.id + if (!this.remoteTaskId) throw new Error('服务端任务缺少 taskId,无法开始拣货') + this.assignmentVersion = task.assignmentVersion ?? task.version ?? 0 + const operationId = `claim-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}` + const claimed = await claimContainerPickingTask(this.remoteTaskId, this.assignmentVersion, operationId) + this.remotePick = normalizePickingTask({ ...task, ...(claimed || {}) }) + this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null this.loadingVersion = this.currentBox.inboundVersion ?? this.remotePick.inboundVersion ?? null - 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 - if (seal) this.sealNo = seal.sealNo || '' + this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion + 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 + if (seal) this.sealNo = seal.sealNo || '' + } } catch (error) { - this.remoteError = error.network ? '网络不可用,当前保留本地草稿,恢复网络后可重试' : (error.message || '任务同步失败') + this.remoteError = error.network ? '网络不可用,无法开始拣货,恢复网络后请重试' : (error.message || '任务同步失败') } finally { this.remoteLoading = false } }, + evidenceUploadLabel(type) { + const queued = this.evidenceQueueEntry(type) + if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传' + return this.evidenceEntry(type) ? '重新选择' : '拍照/选择' + }, chooseEvidence(slot) { if (typeof uni.chooseImage !== 'function') { this.showToast('当前环境不支持选择照片', 'warning'); return } uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'], 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, localPath: path } } - this.saveEvidence(slot.type) + this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: { ...(this.evidenceFiles[slot.type] || {}), evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } } + this.queueEvidenceUpload(slot.type, path) }, fail: () => this.showToast('未选择照片,可稍后重试', 'warning') }) }, + async queueEvidenceUpload(type, path) { + if (!this.currentBox.inboundId || !isRemoteReady(this.currentBox.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } + const entry = this.evidenceQueue.add({ inboundId: this.currentBox.inboundId, evidenceType: type, localPath: path }) + 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' } } + await this.saveEvidence(type) + }, + async retryEvidence(type) { + const item = this.evidenceQueueEntry(type) + if (!item || !this.evidenceQueue.retry(item.id)) return + this.remoteError = '' + 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' } } + await this.saveEvidence(type) + }, async saveEvidence(type) { 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 } try { const result = await upsertEvidence(this.currentBox.inboundId, payload) @@ -637,6 +733,7 @@ this.recordScanResult('success', '扫描成功', item.sku, `${item.location} · 已拣 ${item.qty} ${item.unit}`) } this.showToast(item ? `已拣出 ${item.sku}` : '已确认拣出', 'success') + this.persistSession() }, recordScanResult(type, title, sku, detail) { const entry = { id: `${Date.now()}-${this.scanHistory.length}-${sku}`, type, title, sku, detail } @@ -650,6 +747,7 @@ delete nextPartialQuantities[id] this.partialQuantities = nextPartialQuantities this.showToast('已撤销操作', 'success') + this.persistSession() }, openSkip(id) { this.skipTargetId = id @@ -667,6 +765,7 @@ const reason = this.selectedSkipReason this.closeOverlay(false) this.showToast(`已标记跳拣:${reason}`, 'warning') + this.persistSession() }, openPartial(id) { this.partialTargetId = id @@ -692,6 +791,7 @@ this.partialQuantities = { ...this.partialQuantities, [item.id]: value } this.closeOverlay(false) this.showToast(`已部分拣货:${value}/${item.qty} ${item.unit}`, 'success') + this.persistSession() }, openBoxList(type) { this.boxListType = type @@ -730,27 +830,34 @@ }, async performSubmit(incompleteReason = '') { const beforeSubmit = this.summary - if (this.currentBox.inboundId && !isRemoteReady(this.currentBox.inboundId)) { + const remoteRequired = Boolean(this.currentBox.inboundId || (session.token && session.tenantId)) + if (remoteRequired && !isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) { this.remoteError = '缺少服务端会话或 tenantId,未提交拣货完成' this.showToast('拣货完成未提交', 'error') return } - if (this.currentBox.inboundId && isRemoteReady(this.currentBox.inboundId)) { + if (remoteRequired) { this.submitting = true try { + if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试') if (this.pickingVersion === null) throw new Error('缺少拣货版本号 pickingVersion,无法提交') - const expectedCount = this.currentBox.items.reduce((total, item) => total + Number(item.qty || 0), 0) - const pickedCount = this.currentBox.items.reduce((total, item) => { + const submitId = this.submitId || `submit-${session.currentUser?.id || 'operator'}-${this.remoteTaskId}-${this.currentBox.boxNo}-${JSON.stringify(this.itemStates)}-${JSON.stringify(this.partialQuantities)}-${incompleteReason}` + this.submitId = submitId + const actions = this.currentBox.items.map((item) => { const status = this.itemState(item) - return total + (status === ITEM_STATUS.PICKED ? Number(item.qty || 0) : status === ITEM_STATUS.PARTIAL ? Number(this.partialQuantities[item.id] || 0) : 0) - }, 0) - this.remotePick = await completePicking(this.currentBox.inboundId, { - expectedVersion: Number(this.pickingVersion), - expectedCount, pickedCount, remark: incompleteReason || null - }) + 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.SKIPPED) return { type: 'SKIP', itemId, reason: item.skipReason || incompleteReason || '现场跳拣' } + if (incompleteReason) return { type: 'SKIP', itemId, reason: `${incompleteReason}(提交时确认未拣)` } + return null + }).filter(Boolean) + await submitContainerPickingBatch({ taskId: this.remoteTaskId, containerNo: this.currentBox.boxNo, version: Number(this.pickingVersion) }, actions, { warehouseId: this.currentBox.warehouseId || session.currentUser?.warehouseId || session.currentUser?.warehouse, operationId: submitId }) + // Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions. } catch (error) { - this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败') - this.showToast('拣货完成未提交', 'error') + const versionConflict = error.code === 'A0409' || error.statusCode === 409 + this.remoteError = versionConflict ? '任务版本已变化,请刷新后重试' : (error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败')) + this.showToast(versionConflict ? '任务版本已变化,请刷新后重试' : '拣货完成未提交', 'error') return } finally { this.submitting = false } } @@ -765,6 +872,8 @@ }) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true } this.saveCurrentBoxDraft() + if (remoteRequired) clearPickingDraft(this.draftIdentity()) + this.submitId = '' const afterSubmit = getPickSummary(this.currentBox.items, this.itemStates) this.completionSummary = { picked: afterSubmit.picked, diff --git a/stores/picking.js b/stores/picking.js index 6cde451..066f271 100644 --- a/stores/picking.js +++ b/stores/picking.js @@ -17,12 +17,70 @@ function createInitialState() { let state = createInitialState() +const STORAGE_PREFIX = 'pda-picking-draft' + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function' +} + +function identityValue(identity = {}) { + const operatorId = identity.operatorId ?? identity.accountId ?? identity.userId + const taskId = identity.taskId ?? identity.inboundId + const containerNo = identity.containerNo ?? identity.boxNo + return { operatorId: operatorId == null ? '' : String(operatorId), taskId: taskId == null ? '' : String(taskId), containerNo: containerNo == null ? '' : String(containerNo) } +} + +export function getPickingDraftKey(identity = {}) { + const value = identityValue(identity) + if (!value.operatorId || !value.taskId || !value.containerNo) return '' + return `${STORAGE_PREFIX}:${encodeURIComponent(value.operatorId)}:${encodeURIComponent(value.taskId)}:${encodeURIComponent(value.containerNo)}` +} + +function draftPayload(nextState, identity) { + const value = identityValue(identity) + return { + operatorId: value.operatorId, + taskId: value.taskId, + containerNo: value.containerNo, + warehouseId: identity.warehouseId ?? null, + currentBoxIndex: nextState.currentBoxIndex, + itemStates: nextState.itemStates, + partialQuantities: nextState.partialQuantities, + boxDrafts: nextState.boxDrafts, + completedBoxes: nextState.completedBoxes, + scanHistory: nextState.scanHistory, + updatedAt: new Date().toISOString() + } +} + export function getPickingSession() { return clone(state) } -export function savePickingSession(nextState) { +export function savePickingSession(nextState, identity = {}) { state = clone(nextState) + const key = getPickingDraftKey(identity) + if (key && storageAvailable()) uni.setStorageSync(key, draftPayload(state, identity)) +} + +export function restorePickingSession(identity = {}) { + const key = getPickingDraftKey(identity) + if (!key || !storageAvailable()) return null + const saved = uni.getStorageSync(key) + if (!saved || saved.operatorId !== identityValue(identity).operatorId || saved.taskId !== identityValue(identity).taskId || saved.containerNo !== identityValue(identity).containerNo) return null + const restored = { ...state, ...clone(saved) } + delete restored.operatorId + delete restored.taskId + delete restored.containerNo + delete restored.warehouseId + delete restored.updatedAt + state = restored + return clone(state) +} + +export function clearPickingDraft(identity = {}) { + const key = getPickingDraftKey(identity) + if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key) } export function resetPickingSession() { diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index 2715905..018a32e 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -16,6 +16,9 @@ test('PDA loading adapter keeps C04 read and write paths', () => { ]) assert.match(source, new RegExp(path.replace(/[?]/g, '\\?'))) 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, /uni\.uploadFile/) + assert.match(source, /\/files\/loading-evidence\//) }) test('PDA adapter does not treat non-200 response bodies or network failures as success', () => { @@ -34,10 +37,26 @@ test('C02 identifiers and separate picking/inbound versions are normalized expli assert.match(page, /缺少拣货版本号 pickingVersion/) }) -test('picking page exposes evidence, retry and remote completion states', () => { +test('picking page exposes five evidence slots, retry and remote completion states', () => { for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成', 'loadingVersion']) { - assert.match(page, new RegExp(text)) + assert.match(`${source}\n${page}`, new RegExp(text)) } + assert.match(page, /createEvidenceUploadQueue/) + assert.match(page, /retryEvidence/) + assert.match(page, /uploadEvidenceFile/) assert.match(page, /submitting \|\| completedBoxes\[currentBoxIndex\]/) assert.match(page, /completePicking\(/) }) + +test('PDA cabinet picking adapter exposes online claim and item action contracts', () => { + assert.match(source, /\/first-leg\/pda-loading\/tasks\?/) + assert.match(source, /tasks\/\$\{encodeURIComponent\(taskId\)\}\/claim/) + assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/pick/) + assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/partial/) + assert.match(source, /items\/\$\{encodeURIComponent\(itemId\)\}\/skip/) + assert.match(source, /submitContainerPickingBatch/) + assert.match(source, /stableIdempotencyKey/) + assert.match(page, /claimContainerPickingTask\(/) + assert.match(page, /submitContainerPickingBatch\(/) + assert.match(page, /任务版本已变化,请刷新后重试/) +}) diff --git a/tests/picking-store.test.mjs b/tests/picking-store.test.mjs new file mode 100644 index 0000000..9b01f53 --- /dev/null +++ b/tests/picking-store.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const source = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8') + +test('picking drafts are persisted under operator, task and cabinet identity', () => { + assert.match(source, /pda-picking-draft/) + assert.match(source, /operatorId/) + assert.match(source, /taskId/) + assert.match(source, /containerNo/) + assert.match(source, /uni\.setStorageSync\(key/) + assert.match(source, /uni\.getStorageSync\(key/) + assert.match(source, /uni\.removeStorageSync\(key/) +}) + +test('draft restore rejects a different operator or task', () => { + assert.match(source, /saved\.operatorId !== identityValue\(identity\)\.operatorId/) + assert.match(source, /saved\.taskId !== identityValue\(identity\)\.taskId/) + assert.match(source, /saved\.containerNo !== identityValue\(identity\)\.containerNo/) +})