This commit is contained in:
2026-10-21 17:27:13 +08:00
parent a95cfe0684
commit a181239cbf
8 changed files with 109 additions and 22 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ function slotPath(no, slot) {
}
export function normalizeLoadingDocuments(value, no) {
if (value?.dispatchNo !== no || !/^\d+$/.test(String(value.recordVersion ?? '')) || !Array.isArray(value.photos)
|| !['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
|| !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('装车资料与当前排车事实不一致')
const slots = new Set()
for (const photo of value.photos) {
if (!loadingPhotoSlots.includes(photo.slot) || !photo.fileId || slots.has(photo.slot)) throw new Error('装车照片引用不完整或重复')
@@ -1,6 +1,7 @@
<template>
<view class="loading-documents">
<view class="heading"><text>装车资料</text><button :disabled="busy" @tap="load">刷新</button></view>
<text v-if="snapshot?.stage === 'LOADING'">可边装车边上传 5 张装车照片;确认装车完成且照片齐全后自动进入派送中。</text>
<text v-if="snapshot?.stage === 'AFTER_LOADING'">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view v-if="pending"><text>有装车照片尚未确认保存。</text><button :disabled="busy || !editable" @tap="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</button></view>
+6 -4
View File
@@ -9,7 +9,7 @@
<view v-if="loading" class="fulfillment-state"><text>正在加载排车事实…</text></view>
<view v-if="error" class="fulfillment-error" role="alert"><text class="state-title">{{ error.message }}</text><text v-if="error.code">错误码:{{ error.code }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text><text v-if="error.network">网络中断,原请求和作业草稿已保留。</text><button :disabled="busy || loading" @tap="retry">重新核对</button></view>
<template v-if="!current && !loading"><view class="list-toolbar"><text class="list-title">排车任务</text><button :disabled="busy" @tap="loadTasks">刷新</button></view><view v-if="!tasks.length && !error" class="fulfillment-state">暂无排车任务,可扫描排车单号读取。</view><button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)"><text class="business-code">{{ task.dispatchNo }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text><text class="status-badge">{{ stageLabel(task) }}</text><text class="task-meta">任务号 {{ task.taskNo || '—' }}</text></button></template>
<template v-if="current"><DispatchLoadingDocuments v-if="['AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(current.stage)" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="busy = $event" @saved="openTask(current.dispatchNo)" /><DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
<template v-if="current"><DispatchLoadingDocuments v-if="['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(current.stage)" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="busy = $event" @saved="openTask(current.dispatchNo)" /><DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.recordVersion" />
<view class="fulfillment-card summary-card"><view class="task-head"><text class="business-code">{{ current.dispatchNo }}</text><text class="status-badge">{{ stageLabel(current) }}</text></view><text class="task-meta">任务号 {{ current.taskNo || '—' }}</text><text v-if="!gate('PICK') && current.stage === 'LOADING'" class="gate-warning">{{ current.actions.PICK?.blockReason || '拣货资格尚未允许,请核对两级审核及当前作业状态' }}</text><view class="metrics"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value">{{ remainingPackages }}</text><text>未拣箱数</text></view></view></view>
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
@@ -28,9 +28,10 @@
</view>
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)"><view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text>{{ item.pickedPackages }}/{{ item.scheduledPackages }}箱</text></view><text class="task-meta">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }} {{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · {{ routeLabel(item) }}</text><text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text><view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view></view>
<view v-if="!current.items.length" class="fulfillment-state">本车暂无货物,空单不能核平、装车或发车。</view>
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading || Boolean(pendingCommand)" @tap="retry">刷新托盘实收信息</button></view>
<view v-if="sourceChoices.length > 1" class="fulfillment-card"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy || Boolean(pendingCommand)" @tap="chooseSource(item)">{{ item.sourceBusinessNo || '—' }} · {{ item.customerName || '—' }} · 可拣{{ pickablePackages(item) }}箱</button></view>
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card"><text class="card-title">本次拣货:{{ selectedItem.palletNo || '来源箱唛' }}</text><text v-if="selectedItem.returnCartons.length">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text><view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy || Boolean(pendingCommand)" @input="updatePackages" /><text>最多{{ selectedRemaining }}箱</text></view><button class="primary-button" :disabled="!canPick || busy || Boolean(pendingCommand)" @tap="submitPick">确认真实拣货</button></view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card"><text class="card-title">确认装车完成</text><text>{{ current.pickingReconciled ? 'PDA拣货已核平,确认装车完成后进入装车后,补齐5张装车照片再进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || Boolean(pendingCommand)" @tap="prepareLoading">核对实载并确认装车完成</button></view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card"><text class="card-title">确认装车完成</text><text>{{ current.pickingReconciled ? 'PDA拣货已核平,可边装车边上传5张装车照片;确认装车完成且照片齐全后自动进入派送中。' : '先完成全部真实拣货,不能补录数量代替核平。' }}</text><text v-if="!gate('LOADING_COMPLETE')" class="gate-warning">{{ current.actions.LOADING_COMPLETE?.blockReason || '尚未获得装车完成资格' }}</text><button class="primary-button" :disabled="!canComplete || busy || Boolean(pendingCommand)" @tap="prepareLoading">核对实载并确认装车完成</button></view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
<view v-if="loadingPreview" class="loading-overlay"><view class="loading-dialog" role="dialog" aria-label="核对真实装载"><text class="card-title">核对真实装载 · {{ loadingPreview.dispatchNo }}</text><scroll-view scroll-y class="loading-dialog-body"><view v-for="row in loadingRows" :key="row.warehousePalletId" class="fulfillment-card"><text class="business-code">{{ row.palletNo || '—' }}</text><text>实际托位</text><input type="number" :value="row.palletSpaces ?? ''" placeholder="请确认实际托位" :disabled="busy" @input="setMeasurement(row, 'palletSpaces', $event)" /><text>实测重量(kg)</text><input type="digit" :value="row.weightKg ?? ''" placeholder="请输入实测重量,参考值不自动确认" :disabled="busy" @input="setMeasurement(row, 'weightKg', $event)" /><text>实测体积(m³)</text><input type="digit" :value="row.volumeCbm ?? ''" placeholder="请输入实测体积" :disabled="busy" @input="setMeasurement(row, 'volumeCbm', $event)" /><text>已匹配卸货顺序:{{ row.deliveryIndex ?? '尚未匹配,请后台核对' }}</text></view><view v-for="row in loadingPreview.pallets" :key="row.warehousePalletId"><text v-for="evidence in row.evidence" :key="evidence.field + evidence.sourceType">{{ evidence.actual ? '真实测量依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }}:{{ evidence.value ?? '—' }}</text></view></scroll-view><view class="count-row"><button :disabled="busy" @tap="loadingPreview = null">返回</button><button :disabled="busy || Boolean(pendingCommand)" @tap="completeLoading">确认实际装车完成</button></view></view></view>
@@ -104,7 +105,8 @@ export default {
selectPallet(item) {
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: 1 })
const actualPackages = item.returnCartons.length ? 1 : Math.min(item.scheduledPackages - item.pickedPackages, item.positions.find(row => row.availablePackages > 0)?.availablePackages || 0)
updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, sourceKey: item.sourceKey, cartonCode: '', packages: actualPackages })
this.sourceChoices = []; this.choiceCartonCode = ''; this.error = null
},
async submitPick() { if (!this.canPick || this.pendingCommand) return; try { const body = { recordVersion: requireDispatchVersion(this.current.recordVersion), positionId: this.selectedReturnCarton ? null : this.selectedPosition.positionId, packages: this.selectedReturnCarton ? 1 : Number(this.packages), productQuantity: this.selectedReturnCarton?.productQuantity || 0, cartonMark: this.selectedReturnCarton?.cartonMark || null, remark: null }; beginDispatchCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }); await this.recoverCommand() } catch (cause) { this.setError(cause, '拣货未提交') } },
@@ -124,7 +126,7 @@ export default {
await this.recoverCommand()
} catch (cause) { this.setError(cause, '理货审核未提交') }
},
async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING' || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
async recoverCommand() { if (!this.pendingCommand || this.busy || this.loading) return; const command = JSON.parse(JSON.stringify(this.pendingCommand)), scope = fulfillment.scopeKey, previousAttempt = command.attempted; await this.run(async () => { const ensureScope = () => { if (scope !== fulfillment.scopeKey) throw new Error('作业身份已变化,请重新进入任务') }; let result = command.result; if (!result && previousAttempt) { try { result = await getDispatchCommand(command.dispatchNo, command.body.idempotencyKey) } catch (cause) { if (!['404', 'A0404', 'HTTP_404'].includes(String(cause.code))) throw cause } ensureScope() } if (!result) { patchDispatchCommand({ attempted: true }); try { const { dispatchItemId, ...body } = command.body; result = command.action === 'PICK' ? await pickDispatchItem(command.dispatchNo, dispatchItemId, body) : command.action === 'LOADING_COMPLETE' ? await completeDispatchLoading(command.dispatchNo, body) : command.action === 'LOADING_REVIEW' ? await reviewDispatchLoading(command.dispatchNo, body) : null } catch (cause) { ensureScope(); if (!previousAttempt && !cause.network && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(cause.code))) patchDispatchCommand({ rejected: true }); throw cause } } ensureScope(); if (!result || result.dispatchNo !== command.dispatchNo || result.action !== command.action || result.idempotencyKey !== command.body.idempotencyKey || !result.result || command.action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || command.action === 'LOADING_REVIEW' && result.stage !== (command.body.decision === 'APPROVED' ? 'LOADING' : 'BEFORE_LOADING')) throw new Error('返回结果未确认原排车动作,请保留原请求核对'); requireDispatchVersion(result.recordVersion); patchDispatchCommand({ result }); const latest = await getDispatchTask(command.dispatchNo); ensureScope(); if (latest.dispatchNo !== command.dispatchNo) throw new Error('恢复详情与原排车单不一致'); if (!dispatchVersionAtLeast(latest.recordVersion, result.recordVersion)) throw new Error('任务版本早于已确认动作,请重读原请求'); this.current = latest; updateFulfillmentDraft('dispatch', { currentNo: latest.dispatchNo, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 }); finishDispatchCommand(command.body.idempotencyKey); this.loadingPreview = null; if (command.action === 'LOADING_REVIEW') this.reviewReason = ''; this.successMessage = command.action === 'PICK' ? '本次真实拣货已记录' : command.action === 'LOADING_COMPLETE' ? latest.stage === 'DELIVERING' ? '装车完成、资料齐全,已进入派送中' : '装车完成已确认,请补齐装车资料' : command.action === 'LOADING_REVIEW' ? command.body.decision === 'APPROVED' ? '理货审核已通过,可以开始真实拣货' : '已退回客服处理(车辆信息),等待修改并重新提交' : '排车操作已确认'; playSuccessSound() }) },
discardRejected() { if (this.busy || !this.pendingCommand?.rejected) return; discardRejectedDispatchCommand(); this.retry() },
async retry() { if (this.pendingCommand) await this.recoverCommand(); else if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else await this.loadTasks() },
async run(work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else this.busy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.busy = false } },