feat(pda): persist and batch cabinet picking
This commit is contained in:
+142
-33
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user