fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:06 +08:00
parent 161128ff9e
commit 54e49e2426
28 changed files with 530 additions and 413 deletions
+111 -39
View File
@@ -3,28 +3,38 @@
<PortalHeader title="排车作业" :user="user" />
<scroll-view class="fulfillment-scroll" scroll-y><view class="portal-content fulfillment-content">
<view class="fulfillment-heading"><text class="fulfillment-title">理货审核、真实拣货与装车</text></view>
<view v-if="pendingCommand" class="fulfillment-card gate-card"><text>排车 {{ pendingCommand.dispatchNo }} 的原操作结果待核对,请保留原请求。</text><button :disabled="busy || loading" @tap="recoverCommand">核对并重试原请求</button><button v-if="pendingCommand.rejected" :disabled="busy" @tap="discardRejected">按明确拒绝结果重新核对</button></view>
<view v-if="current" class="detail-toolbar"><text class="detail-title">{{ current.dispatchNo }}</text><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
<view v-if="current" class="stage-switch" role="tablist" aria-label="排车作业">
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading || Boolean(pendingCommand)" @tap="selectTab('picking')">拣货作业</button>
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading || Boolean(pendingCommand)" @tap="selectTab('photos')">装车拍照</button>
<button id="dispatch-picking-tab" class="stage-tab" :class="{ active: activeTab === 'picking' }" role="tab" :aria-selected="activeTab === 'picking'" aria-controls="dispatch-picking-panel" :disabled="busy || loading" @tap="selectTab('picking')">拣货作业</button>
<button id="dispatch-photos-tab" class="stage-tab" :class="{ active: activeTab === 'photos' }" role="tab" :aria-selected="activeTab === 'photos'" aria-controls="dispatch-photos-panel" :disabled="!canShowPhotos || busy || loading" @tap="selectTab('photos')">装车拍照</button>
</view>
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card"><ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" /><button :disabled="busy || loading || !scanCode || Boolean(pendingCommand)" @tap="handleScan(scanCode)">匹配扫描</button></view>
<view v-if="!current || activeTab === 'picking'" class="fulfillment-card dispatch-scan-card">
<text class="card-title">{{ current ? '扫码拣货' : '扫码查询排车' }}</text>
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号/实物托盘码/来源箱唛" placeholder="扫描业务单号或实物码" :active="activeTab === 'picking'" @update:modelValue="scanCode = $event" @submit="handleScan" />
<button class="secondary-button" :disabled="busy || loading || !scanCode" @tap="handleScan(scanCode)">匹配扫描</button>
</view>
<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>
<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">
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="busy = $event" @saved="openTask(current.dispatchNo)" /></view>
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" role="tabpanel" aria-labelledby="dispatch-picking-tab">
<view v-show="activeTab === 'photos'" id="dispatch-photos-panel" role="tabpanel" aria-labelledby="dispatch-photos-tab"><DispatchLoadingDocuments v-if="canShowPhotos" :key="current.dispatchNo" :dispatch-no="current.dispatchNo" @busy-change="photoBusy = $event" @saved="openTask(current.dispatchNo)" /></view>
<view v-show="activeTab === 'picking'" id="dispatch-picking-panel" class="picking-workspace" role="tabpanel" aria-labelledby="dispatch-picking-tab">
<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 class="fulfillment-card summary-card">
<text class="card-title">任务摘要</text>
<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>
<text class="picking-section-label">拣货进度</text>
<view class="metrics" aria-label="拣货进度"><view><text class="metric-value">{{ plannedPackages }}</text><text>计划箱数</text></view><view><text class="metric-value success">{{ pickedPackages }}</text><text>真实已拣</text></view><view><text class="metric-value warning">{{ remainingPackages }}</text><text>未拣箱数</text></view></view>
</view>
<view v-if="current.stage === 'BEFORE_LOADING'" class="fulfillment-card review-card">
<text class="card-title">理货员审核</text>
<text class="task-meta">车牌号:{{ current.resources?.vehicle?.plate || '—' }} · 托车公司:{{ current.resources?.vehicle?.carrierName || '—' }}</text>
<text class="task-meta">司机:{{ current.resources?.driver?.name || '—' }} · 电话:{{ current.resources?.driver?.phone || '—' }}</text>
<text class="task-meta">计划发车(北京时间):{{ reviewTime(current.plannedDepartureAt) }}</text>
<text class="task-meta">预计送达(北京时间):{{ reviewTime(current.estimatedArrivalAt) }}</text>
<text v-for="(appointment, index) in current.fbaAppointments || []" :key="appointment.batchKey + '-' + index" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || appointment.batchNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : '未确认' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
<text v-for="appointment in current.fbaAppointments || []" :key="appointment.destinationKey" class="task-meta">{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status === 'SUCCESS' ? '已确认' : appointment.status === 'REGISTERED' ? '已登记' : '未填写' }}:{{ reviewTime(appointment.confirmedAt) }}(北京时间)</text>
<text class="task-meta">驾驶证号:{{ current.resources?.driver?.evidence?.licenseNo || '—' }} · 有效期:{{ current.resources?.driver?.evidence?.licenseExpiresOn || '—' }}</text>
<text class="task-meta">调度审核:{{ reviewLabel(current.reviews?.dispatchReview) }} · {{ current.reviews?.dispatchReview?.reviewedAt || '—' }}</text>
<text v-if="current.reviews?.dispatchReview?.reason" class="task-meta">调度意见:{{ current.reviews.dispatchReview.reason }}</text>
@@ -32,17 +42,31 @@
<template v-if="canReview">
<text v-if="current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" class="gate-warning">{{ current.actions.LOADING_REVIEW.blockReason }}</text>
<text class="task-meta">请核对车辆、司机及下方货物;通过后才能开始真实拣货。驳回将回到客服处理(车辆信息)。</text>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading || Boolean(pendingCommand)" @input="updateReviewReason" />
<view class="count-row"><button :disabled="busy || loading || Boolean(pendingCommand) || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || Boolean(pendingCommand) || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
<textarea :value="reviewReason" maxlength="1000" placeholder="审核意见(驳回时必填)" :disabled="busy || loading" @input="updateReviewReason" />
<view class="count-row"><button :disabled="busy || loading || !reviewReason.trim()" @tap="submitReview('REJECTED')">驳回至客服处理</button><button class="primary-button" :disabled="busy || loading || current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED'" @tap="submitReview('APPROVED')">审核通过</button></view>
</template>
<text v-else-if="!rejectionReason" class="gate-warning">{{ current.actions.LOADING_REVIEW?.blockReason || '当前不具备理货审核资格,请先完成客服提交和调度审核' }}</text>
</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>
<text v-if="current.items.length" class="picking-section-title">托盘与货物来源</text>
<view v-for="item in current.items" :key="item.dispatchItemId" class="fulfillment-card item-card picking-item" :class="{ selected: selectedItem?.dispatchItemId === item.dispatchItemId }" @tap="selectPallet(item)">
<view class="task-head"><text class="business-code">{{ item.palletNo || '未关联托盘码' }}</text><text v-if="selectedItem?.dispatchItemId === item.dispatchItemId" class="picking-selected">当前选择</text></view>
<view class="picking-fact"><text class="picking-fact-label">{{ item.sourceBusinessType === 'RELABEL_ORDER' ? '换标单号' : '来源业务号' }}</text><text>{{ item.sourceBusinessNo || '—' }}</text></view>
<view class="picking-fact"><text class="picking-fact-label">客户</text><text>{{ item.customerName || '—' }}</text></view>
<view class="picking-fact"><text class="picking-fact-label">目的地</text><text>{{ routeLabel(item) }}</text></view>
<text v-for="carton in item.returnCartons" :key="carton.returnCartonId" class="task-meta picking-carton">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
<text class="picking-item-progress">已拣 {{ item.pickedPackages }} / {{ item.scheduledPackages }} 箱</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' && gate('PICK') && selectedItem && selectedItem.scheduledPackages > selectedItem.pickedPackages && selectedRemaining === 0" class="fulfillment-card gate-card"><text class="gate-warning">该托盘暂无可拣来源,请核对是否已完成实收,或货物是否冻结、被占用及来源发生变化。</text><button :disabled="busy || loading" @tap="retry">刷新托盘实收信息</button></view>
<view v-if="sourceChoices.length > 1" class="fulfillment-card picking-source-choices"><text class="card-title">请选择本次真实拣货来源</text><button v-for="item in sourceChoices" :key="item.dispatchItemId" :disabled="busy" @tap="chooseSource(item)"><text class="business-code">{{ item.sourceBusinessNo || '—' }}</text><text class="task-meta">客户:{{ item.customerName || '—' }}</text><text class="picking-item-progress">可拣 {{ pickablePackages(item) }} 箱</text></button></view>
<view v-if="current.stage === 'LOADING' && gate('PICK') && selectedItem && selectedRemaining > 0" class="fulfillment-card picking-current">
<text class="card-title">当前拣货</text><text class="business-code">{{ selectedItem.palletNo || '来源箱唛' }}</text>
<text v-if="selectedItem.returnCartons.length" class="picking-instruction">{{ selectedReturnCarton ? `已扫 ${selectedReturnCarton.cartonMark}` : '请扫描来源箱唛,每次一箱' }}</text>
<view v-else class="picking-quantity"><text class="picking-instruction">拣货箱数</text><input type="number" aria-label="本次拣货箱数" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" :disabled="busy" @input="updatePackages" /><text class="task-meta">本次最多可拣 {{ selectedRemaining }} 箱</text></view>
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">确认真实拣货</button>
</view>
<view v-if="current.stage === 'LOADING'" class="fulfillment-card gate-card picking-completion"><text class="card-title">确认装车完成</text><text class="picking-instruction">{{ 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" @tap="prepareLoading">确认装车完成</button></view>
</view>
</template><view v-if="successMessage" class="fulfillment-success" role="status">{{ successMessage }}</view>
</view></scroll-view>
@@ -51,7 +75,7 @@
<view class="loading-dialog-heading"><text id="loading-confirm-title" class="loading-dialog-title">确认装车完成</text><text class="loading-dialog-number">{{ loadingPreview.dispatchNo }}</text></view>
<view class="loading-counts"><view class="loading-count"><text class="loading-count-value">{{ loadingRows.length }}<text class="loading-count-unit"> 托</text></text><text class="loading-count-label">已拣托盘</text></view><view class="loading-count"><text class="loading-count-value">{{ loadingRows.reduce((sum, row) => sum + Number(row.pickedPackages || 0), 0) }}<text class="loading-count-unit"> 箱</text></text><text class="loading-count-label">已拣箱数</text></view></view>
<text class="loading-confirm-question">本车货物已全部装车?</text><text class="loading-confirm-note">照片可在“装车拍照”中继续上传</text>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy || Boolean(pendingCommand)" :loading="busy" @tap="completeLoading">确认装车</button></view>
<view class="loading-dialog-actions"><button class="loading-cancel" :disabled="busy" @tap="loadingPreview = null">取消</button><button class="loading-confirm" :disabled="busy" :loading="busy" @tap="completeLoading">确认装车</button></view>
</view>
</view>
</view>
@@ -61,17 +85,18 @@ import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
import DispatchLoadingDocuments from '@/components/DispatchLoadingDocuments.vue'
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, getDispatchCommand, listDispatchTasks, pickDispatchItem, requireDispatchVersion, dispatchVersionAtLeast } from '@/api/fulfillment.js'
import { fulfillment, beginDispatchCommand, patchDispatchCommand, finishDispatchCommand, discardRejectedDispatchCommand, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { reviewDispatchLoading, completeDispatchLoading, getDispatchTask, getDispatchLoading, listDispatchTasks, pickDispatchItem, requireDispatchVersion } from '@/api/fulfillment.js'
import { fulfillment, restoreFulfillmentDraft, updateFulfillmentDraft } from '@/stores/fulfillment.js'
import { requireSession, session } from '@/stores/session.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
export default {
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus, DispatchLoadingDocuments },
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, busy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
data() { return { user: session.currentUser, tasks: [], current: null, activeTab: 'picking', scanCode: '', loading: false, requestBusy: false, photoBusy: false, error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false, generation: 0, loadingPreview: null, loadingRows: [], sourceChoices: [], choiceCartonCode: '', reviewReason: '' } },
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, packages() { return fulfillment.dispatch.packages },
busy() { return this.requestBusy || this.photoBusy },
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, packages() { return fulfillment.dispatch.packages },
selectedItem() { return this.current?.items.find(row => row.dispatchItemId === fulfillment.dispatch.dispatchItemId && row.sourceKey === fulfillment.dispatch.sourceKey) || null },
selectedReturnCarton() { const matches = this.selectedItem?.returnCartons.filter(row => row.cartonMark === fulfillment.dispatch.cartonCode && row.status === 'OUTBOUND_PENDING') || []; return matches.length === 1 ? matches[0] : null },
selectedPosition() { return this.selectedItem && this.selectedItem.scheduledPackages > this.selectedItem.pickedPackages ? this.selectedItem.positions.find(row => row.availablePackages > 0) || null : null },
@@ -88,17 +113,16 @@ export default {
onShow() { if (!requireSession()) return; this.user = session.currentUser; restoreFulfillmentDraft(`${this.user?.id || ''}:${this.user?.warehouseId || ''}`); this.startScanner(); if (!this.bootstrapped) { this.bootstrapped = true; if (this.resumeOnEntry && fulfillment.dispatch.currentNo) this.openTask(fulfillment.dispatch.currentNo); else this.loadTasks() } else if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo); else this.loadTasks() },
onHide() { this.stopScanner(); this.generation++ }, beforeUnmount() { this.stopScanner(); this.generation++ },
methods: {
selectTab(tab) { if (this.busy || this.loading || this.pendingCommand || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
selectTab(tab) { if (this.busy || this.loading || !['picking', 'photos'].includes(tab) || (tab === 'photos' && !this.canShowPhotos)) return; this.activeTab = tab },
gate(action) { return this.current?.actions?.[action]?.allowed === true },
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => this.handleScan(value), onError: error => this.setError(error, '扫码广播注册失败') }) }, stopScanner() { this._stopScanner?.(); this._stopScanner = null },
updatePackages(event) { if (this.pendingCommand) return; const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
updatePackages(event) { const value = Number(event.detail.value); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? value : 0 }) },
async loadTasks() { const generation = ++this.generation; await this.run(async () => { const result = await listDispatchTasks(); if (generation === this.generation) { this.tasks = result; this.current = null } }, true) },
async openTask(no) { if (this.pendingCommand && this.pendingCommand.dispatchNo !== no) return this.setError(new Error('请先核对原排车请求,不能切到另一张单执行'), '操作未完成'); const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
async openTask(no) { const generation = ++this.generation; await this.run(async () => { const value = await getDispatchTask(no); if (generation !== this.generation) return; if (value.dispatchNo !== no) throw new Error('返回排车与扫描业务号不一致'); const same = fulfillment.dispatch.currentNo === no; if (this.current?.dispatchNo !== no || this.current?.recordVersion !== value.recordVersion) this.reviewReason = ''; if (this.current?.dispatchNo !== no || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) this.activeTab = 'picking'; this.current = value; this.sourceChoices = []; this.choiceCartonCode = ''; updateFulfillmentDraft('dispatch', { currentNo: no, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', sourceKey: same ? fulfillment.dispatch.sourceKey : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '', packages: same ? fulfillment.dispatch.packages : 1 }); this.scanCode = '' }, true) },
closeTask() { if (this.busy) return; this.current = null; this.activeTab = 'picking'; this.reviewReason = ''; this.loadingPreview = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 }); this.loadTasks() },
handleScan(raw) {
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim()
if (!code || this.busy || this.loading || this.activeTab !== 'picking') return
if (this.pendingCommand) return this.setError(new Error('请先核对原排车操作结果'), '原请求未完成')
this.error = null; this.successMessage = ''; this.sourceChoices = []; this.choiceCartonCode = ''
updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', sourceKey: '', cartonCode: '', packages: 1 })
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
@@ -112,7 +136,7 @@ export default {
this.chooseSource(matches[0])
},
chooseSource(item) {
if (this.pendingCommand || this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
if (this.busy || !this.sourceChoices.some(row => row.dispatchItemId === item.dispatchItemId)) return
const cartonCode = this.choiceCartonCode
this.selectPallet(item)
if (fulfillment.dispatch.dispatchItemId !== item.dispatchItemId) return
@@ -124,33 +148,55 @@ export default {
return Math.min(remaining, item.returnCartons.length ? item.returnCartons.filter(row => row.status === 'OUTBOUND_PENDING').length : item.positions.reduce((sum, row) => sum + row.availablePackages, 0))
},
selectPallet(item) {
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.pendingCommand || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (this.current?.stage !== 'LOADING' || !this.gate('PICK') || this.busy || !item?.dispatchItemId || item.scheduledPackages <= item.pickedPackages) return
if (!item.sourceKey || item.sourceBusinessType === 'RELABEL_ORDER' && !item.sourceBusinessNo) return this.setError(new Error('来源业务身份未完整返回,请重新读取任务'), '请选择明细')
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, '拣货未提交') } },
async prepareLoading() { if (!this.canComplete || this.pendingCommand) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
async completeLoading() { if (!this.loadingPreview || this.pendingCommand || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); beginDispatchCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }); await this.recoverCommand() } catch (cause) { this.setError(cause, '装车完成未确认') } },
updateReviewReason(event) { if (this.busy || this.loading || this.pendingCommand) return; this.reviewReason = String(event.detail.value || '') },
async submitPick() { if (!this.canPick) 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 }; await this.submitCommand(this.current.dispatchNo, 'PICK', { ...body, dispatchItemId: this.selectedItem.dispatchItemId }) } catch (cause) { this.setError(cause, '拣货未提交') } },
async prepareLoading() { if (!this.canComplete) return; await this.run(async () => { const data = await getDispatchLoading(this.current.dispatchNo); if (data.dispatchNo !== this.current.dispatchNo || data.pickingReconciled !== true || data.loadingComplete?.allowed !== true) throw new Error(data.loadingComplete?.blockReason || '装车前置未满足'); requireDispatchVersion(data.recordVersion); this.loadingPreview = data; this.loadingRows = data.pallets }) },
async completeLoading() { if (!this.loadingPreview || this.busy) return; try { if (!this.loadingRows.length || this.loadingRows.some(row => row.deliveryIndex == null || !Number.isInteger(row.deliveryIndex) || row.deliveryIndex < 1)) throw new Error('托盘卸货站尚未匹配,请后台核对'); await this.submitCommand(this.loadingPreview.dispatchNo, 'LOADING_COMPLETE', { recordVersion: requireDispatchVersion(this.loadingPreview.recordVersion), manifestHash: this.loadingPreview.manifestHash, remark: null }) } catch (cause) { this.setError(cause, '装车完成未确认') } },
updateReviewReason(event) { if (this.busy || this.loading) return; this.reviewReason = String(event.detail.value || '') },
async submitReview(decision) {
if (!this.canReview || this.busy || this.loading || this.pendingCommand || !['APPROVED', 'REJECTED'].includes(decision)) return
if (!this.canReview || this.busy || this.loading || !['APPROVED', 'REJECTED'].includes(decision)) return
if (decision === 'APPROVED' && this.current.actions.LOADING_REVIEW?.blockCode === 'APPROVAL_BLOCKED') return this.setError(new Error(this.current.actions.LOADING_REVIEW.blockReason), '预约及时间尚未核对')
const reason = this.reviewReason.trim()
if (decision === 'REJECTED' && !reason) return this.setError(new Error('请填写驳回原因,退回客服处理'), '审核未提交')
try {
const no = this.current.dispatchNo, version = requireDispatchVersion(this.current.recordVersion)
const confirmation = await uni.showModal({ title: decision === 'APPROVED' ? '确认理货审核通过' : '确认退回客服处理', content: decision === 'APPROVED' ? '请确认车辆、司机和货物已核对,通过后进入装车中。' : `驳回原因:${reason}。本单将回到客服处理(车辆信息),重新提交后再逐级审核。` })
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview || this.pendingCommand) return
beginDispatchCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
await this.recoverCommand()
if (!confirmation.confirm || this.current?.dispatchNo !== no || this.current?.recordVersion !== version || !this.canReview) return
await this.submitCommand(no, 'LOADING_REVIEW', { recordVersion: version, decision, reason: reason || null })
} 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' && !['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 } },
async submitCommand(no, action, body) {
if (this.busy || this.loading) return
const scope = fulfillment.scopeKey
await this.run(async () => {
const ensureScope = () => { if (scope !== fulfillment.scopeKey || this.current?.dispatchNo !== no) throw new Error('作业身份已变化,请重新进入任务') }
ensureScope()
const { dispatchItemId, ...payload } = body
payload.idempotencyKey = `pda-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}`
const result = action === 'PICK' ? await pickDispatchItem(no, dispatchItemId, payload)
: action === 'LOADING_COMPLETE' ? await completeDispatchLoading(no, payload) : await reviewDispatchLoading(no, payload)
ensureScope()
if (!result || result.dispatchNo !== no || result.action !== action || result.idempotencyKey !== payload.idempotencyKey || !result.result) throw new Error('操作结果未确认,请刷新任务查看')
requireDispatchVersion(result.recordVersion)
if (action === 'LOADING_COMPLETE' && !['AFTER_LOADING', 'DELIVERING'].includes(result.stage) || action === 'LOADING_REVIEW' && result.stage !== (body.decision === 'REJECTED' ? 'BEFORE_LOADING' : 'LOADING')) throw new Error('返回结果未确认本次操作,请刷新任务查看')
this.loadingPreview = null
if (action === 'LOADING_REVIEW') this.reviewReason = ''
const current = await getDispatchTask(no); ensureScope()
if (current.dispatchNo !== no) throw new Error('任务与当前排车单不一致')
if (!dispatchVersionAtLeast(current.recordVersion, result.recordVersion)) throw new Error('详情版本早于已确认操作,请刷新任务查看')
this.current = current
updateFulfillmentDraft('dispatch', { currentNo: no, dispatchItemId: '', palletCode: '', cartonCode: '', packages: 1 })
this.successMessage = action === 'PICK' ? '本次真实拣货已记录' : action === 'LOADING_COMPLETE' ? '装车完成已确认' : body.decision === 'REJECTED' ? '已退回客服处理' : '理货审核已提交'
playSuccessSound()
})
},
async retry() { 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.requestBusy = true; try { await work() } catch (cause) { this.setError(cause, '排车操作失败') } finally { this.loading = false; this.requestBusy = false } },
setError(cause, fallback) { this.error = toDispatchError(cause, fallback); playErrorSound() },
stageLabel(task) { if (task.status === 'CANCELLED') return '已取消'; if (task.stage === 'BEFORE_LOADING') { if (task.actions?.LOADING_REVIEW?.allowed === true) return '待理货审核'; if ([task.reviews?.dispatchReview, task.reviews?.loadingReview].some(review => review?.status === 'REJECTED')) return '已退回客服处理' }; return ({ BEFORE_LOADING: '装车前', LOADING: '装车中', AFTER_LOADING: '装车后', DELIVERING: '派送中', DELIVERED: '派送完成' })[task.stage] || '状态待核实' },
reviewTime(value) { if (!value) return '未填写'; const date = new Date(new Date(value).getTime() + 8 * 60 * 60 * 1000); return Number.isNaN(date.getTime()) ? '时间无效' : date.toISOString().slice(0, 16).replace('T', ' ') },
@@ -161,6 +207,32 @@ export default {
</script>
<style scoped>
@import './fulfillment.scss';
.dispatch-scan-card .card-title { margin-bottom: 12px; font-size: 16px; }
.picking-workspace .card-title { font-size: 16px; }
.picking-workspace .task-head { flex-wrap: wrap; align-items: flex-start; margin-top: 10px; }
.picking-workspace .business-code { min-width: 0; font-size: 16px; line-height: 1.5; }
.picking-workspace .task-meta { font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.picking-workspace .status-badge { font-size: 12px; }
.picking-workspace .gate-warning { font-size: 13px; line-height: 1.6; }
.picking-section-label, .picking-section-title { display: block; margin: 16px 0 10px; color: #536982; font-size: 14px; font-weight: 700; }
.picking-workspace .metrics { margin-top: 0; padding: 14px 0; }
.picking-workspace .metrics view { font-size: 12px; }
.picking-workspace .metric-value { font-size: 26px; }
.picking-workspace .picking-item { margin-bottom: 12px; }
.picking-item .task-head { margin-top: 0; }
.picking-selected { padding: 4px 8px; border-radius: 6px; color: #135ee8; background: #e6f0ff; font-size: 12px; }
.picking-fact { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; color: #243c58; font-size: 14px; line-height: 1.6; }
.picking-fact-label { flex: 0 0 74px; color: #718095; }
.picking-fact > text:last-child { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.picking-item-progress { display: block; margin-top: 12px; color: #135ee8; font-size: 14px; font-weight: 700; }
.picking-source-choices button { min-height: 48px; margin-top: 12px; padding: 12px; text-align: left; line-height: 1.5; background: #f3f7ff; }
.picking-source-choices .business-code { display: block; }
.picking-current { border-color: #9dc0f4; }
.picking-current > .business-code { display: block; margin-top: 10px; }
.picking-instruction { display: block; margin-top: 10px; color: #536982; font-size: 14px; line-height: 1.6; overflow-wrap: anywhere; }
.picking-quantity input { box-sizing: border-box; width: 100%; height: 48px; margin-top: 10px; padding: 0 12px; border: 2px solid #135ee8; border-radius: 8px; color: #17314f; font-size: 18px; background: #fff; }
.picking-workspace .primary-button, .dispatch-scan-card .secondary-button { min-height: 48px; font-size: 16px; line-height: 48px; }
.picking-completion { margin-top: 16px; }
.stage-switch { position: sticky; top: 0; z-index: 2; display: flex; gap: 8rpx; margin-bottom: 20rpx; padding: 8rpx; border: 1px solid #dbe5f0; border-radius: 20rpx; background: #eef2f7; }
.stage-tab { flex: 1; margin: 0; min-height: 84rpx; padding: 0 16rpx; border-radius: 14rpx; color: #64748b; font-size: 28rpx; font-weight: 600; line-height: 84rpx; background: transparent; }
.stage-tab::after { border: 0; }