feat(pda): persist and batch cabinet picking

This commit is contained in:
2026-08-10 10:53:11 +08:00
parent 8a79cd02ca
commit 73c1cc3386
5 changed files with 352 additions and 37 deletions
+109 -1
View File
@@ -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 }
+138 -29
View File
@@ -119,18 +119,22 @@
<view class="loading-evidence-section" v-if="currentBox.inboundId">
<view class="section-heading-row">
<view><text class="section-title">装柜证据</text><text class="section-subtitle">四步照片与封签可重复修改,不阻断作业</text></view>
<view><text class="section-title">装柜证据</text><text class="section-subtitle">空仓、半仓、满仓、锁仓及带锁码照</text></view>
<text class="sync-state" :class="remoteSyncState.tone">{{ remoteSyncState.label }}</text>
</view>
<view class="evidence-grid">
<view v-for="slot in evidenceSlots" :key="slot.type" class="evidence-card" :class="{ filled: evidenceEntry(slot.type) }">
<text class="evidence-label">{{ slot.label }}</text>
<text v-if="evidenceEntry(slot.type)" class="evidence-file">{{ evidenceEntry(slot.type).fileName || '已记录' }}</text>
<button class="evidence-btn" @tap="chooseEvidence(slot)">{{ evidenceEntry(slot.type) ? '重新选择' : '上传照片' }}</button>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
{{ evidenceUploadState(slot.type) }}
</text>
<button class="evidence-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
<button v-if="evidenceQueueEntry(slot.type)?.status === 'FAILED'" class="evidence-retry-btn" @tap="retryEvidence(slot.type)">重试上传</button>
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(可选)" maxlength="64" />
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" @tap="saveEvidence('SEAL')">保存封签</button>
</view>
<text v-if="remoteError" class="remote-error" role="alert">{{ remoteError }}</text>
@@ -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
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,
+59 -1
View File
@@ -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() {
+21 -2
View File
@@ -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, /任务版本已变化,请刷新后重试/)
})
+21
View File
@@ -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/)
})