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
+142 -33
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
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,