Files
pda-overseas-uniapp/src/pages/dispatch/dispatch.vue
T

191 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view v-if="user" class="page-shell portal-page fulfillment-page">
<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>
</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="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">
<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">
<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>
<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 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>
<text v-if="rejectionReason" class="gate-warning">已退回客服处理(车辆信息):{{ rejectionReason }}。修改后须重新提交审核。</text>
<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>
</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>
<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>
</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-modal="true" aria-labelledby="loading-confirm-title">
<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>
</view>
</view>
</template>
<script>
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 { 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: '' } },
computed: {
recentTaskNo() { return fulfillment.dispatch.currentNo || '' }, pendingCommand() { return fulfillment.dispatch.pendingCommand }, 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 },
selectedRemaining() { const remaining = Math.max(0, (this.selectedItem?.scheduledPackages || 0) - (this.selectedItem?.pickedPackages || 0)); return this.selectedItem?.returnCartons.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0) },
plannedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.scheduledPackages, 0) }, pickedPackages() { return (this.current?.items || []).reduce((sum, row) => sum + row.pickedPackages, 0) }, remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
canShowPhotos() { return ['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(this.current?.stage) },
departed() { return ['DELIVERING', 'DELIVERED'].includes(this.current?.stage) },
canPick() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.gate('PICK') && this.selectedRemaining > 0 && (this.selectedItem?.returnCartons.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number.isInteger(Number(this.packages)) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) },
canComplete() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'LOADING' && this.current.pickingReconciled === true && this.plannedPackages > 0 && this.remainingPackages === 0 && this.gate('LOADING_COMPLETE') },
canReview() { return this.current?.status === 'ACTIVE' && this.current?.stage === 'BEFORE_LOADING' && this.gate('LOADING_REVIEW') },
rejectionReason() { return [this.current?.reviews?.loadingReview, this.current?.reviews?.dispatchReview].find(review => review?.status === 'REJECTED')?.reason || '' },
},
onLoad(options) { this.resumeOnEntry = options?.resume === '1' },
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 },
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 }) },
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) },
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)
if (this.current.stage !== 'LOADING' || !this.gate('PICK')) return this.setError(new Error(this.current.actions?.PICK?.blockReason || '理货审核通过后才能开始真实拣货'), '尚不能拣货')
const cartonItems = this.current.items.filter(item => item.returnCartons.some(carton => carton.cartonMark === code))
if (cartonItems.some(item => item.returnCartons.filter(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING').length > 1)) return this.setError(new Error('同一来源返回重复待拣箱唛,请重新读取并核对箱记录'), '扫码未匹配')
const matches = cartonItems.length ? cartonItems.filter(item => item.scheduledPackages > item.pickedPackages && item.returnCartons.some(carton => carton.cartonMark === code && carton.status === 'OUTBOUND_PENDING')) : this.current.items.filter(item => item.palletNo === code && item.scheduledPackages > item.pickedPackages)
if (!matches.length) return this.setError(new Error(cartonItems.length ? '该箱已拣或不可出库' : this.current.items.some(item => item.palletNo === code) ? '该托盘已完成拣货,请勿重复操作' : '扫描未匹配本车实物托盘或来源箱唛'), '扫码未匹配')
this.sourceChoices = matches; this.choiceCartonCode = cartonItems.length ? code : ''
if (matches.length > 1) return this.setError(new Error('此扫码命中多条待拣明细,请按来源业务号、客户和可拣箱数选择'), '请选择明细')
this.chooseSource(matches[0])
},
chooseSource(item) {
if (this.pendingCommand || 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
if (cartonCode) updateFulfillmentDraft('dispatch', { cartonCode, packages: 1 })
this.scanCode = ''; playSuccessSound()
},
pickablePackages(item) {
const remaining = Math.max(0, item.scheduledPackages - item.pickedPackages)
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 (!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 submitReview(decision) {
if (!this.canReview || this.busy || this.loading || this.pendingCommand || !['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()
} 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 } },
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', ' ') },
reviewLabel(review) { return ({ NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' })[review?.status] || '待核对' },
routeLabel(item) { return [item.destination?.warehouseCode, item.destination?.warehouseName, item.destination?.address].filter(Boolean).join(' · ') || '目的仓未关联' }, itemProgress(item) { return item.scheduledPackages ? Math.min(100, Math.round(item.pickedPackages / item.scheduledPackages * 100)) : 0 }
}
}
</script>
<style scoped>
@import './fulfillment.scss';
.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; }
.stage-tab.active { color: #1262f4; background: #fff; box-shadow: 0 1px 4px rgba(31, 41, 55, .12); }
.stage-tab[disabled] { opacity: .55; }
.review-card textarea { box-sizing: border-box; width: 100%; min-height: 160rpx; padding: 16rpx; border: 1px solid #cbd5e1; border-radius: 8rpx; }
.loading-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .46); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 40rpx; box-sizing: border-box; }
.loading-dialog { width: 100%; max-width: 620rpx; max-height: 88vh; overflow-y: auto; box-sizing: border-box; background: #fff; border-radius: 28rpx; padding: 36rpx 32rpx 32rpx; box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, .18); }
.loading-dialog-heading { display: flex; flex-direction: column; gap: 10rpx; }
.loading-dialog-title { color: #142c49; font-size: 36rpx; font-weight: 700; line-height: 1.4; }
.loading-dialog-number { color: #7a8aa0; font-size: 26rpx; line-height: 1.5; overflow-wrap: anywhere; }
.loading-counts { display: flex; margin: 28rpx 0; padding: 24rpx 0; border-radius: 20rpx; background: #f3f7ff; }
.loading-count { flex: 1; display: flex; align-items: center; flex-direction: column; gap: 8rpx; }
.loading-count + .loading-count { border-left: 1px solid #dce6f6; }
.loading-count-value { color: #1764ec; font-size: 48rpx; font-weight: 700; line-height: 1.2; }
.loading-count-unit { font-size: 26rpx; font-weight: 500; }
.loading-count-label { color: #72839b; font-size: 24rpx; }
.loading-confirm-question { display: block; color: #253a54; font-size: 30rpx; font-weight: 600; line-height: 1.5; }
.loading-confirm-note { display: block; color: #7a8aa0; font-size: 25rpx; line-height: 1.5; margin-top: 10rpx; }
.loading-dialog-actions { display: flex; gap: 20rpx; margin-top: 32rpx; }
.loading-dialog-actions button { flex: 1; min-width: 0; margin: 0; height: 88rpx; line-height: 88rpx; padding: 0 12rpx; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; }
.loading-dialog-actions button::after { border: 0; }
.loading-cancel { color: #52657d; background: #f0f3f8; }
.loading-confirm { color: #fff; background: #1764ec; }
.loading-dialog-actions button[disabled] { opacity: .5; }
</style>