Release/test #1
+109
-1
@@ -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 }
|
||||
|
||||
+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,
|
||||
|
||||
+59
-1
@@ -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() {
|
||||
|
||||
@@ -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, /任务版本已变化,请刷新后重试/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
Reference in New Issue
Block a user