206 lines
18 KiB
Vue
206 lines
18 KiB
Vue
<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>
|
||
<text class="fulfillment-subtitle">客户与当前海外仓由登录令牌和服务端确定;扫描排车单号或任务中的托盘码开始作业</text>
|
||
</view>
|
||
<view v-if="current" class="detail-toolbar"><view><text class="detail-kicker">当前任务</text><text class="detail-title">排车拣货详情</text></view><button :disabled="busy" @tap="closeTask">‹ 返回列表</button></view>
|
||
|
||
<view class="fulfillment-card">
|
||
<ScanTargetInput name="dispatch" :model-value="scanCode" label="排车单号 / 托盘码 / 来源箱唛" placeholder="扫描排车单号、托盘码或退货箱唛" :active="true" @update:modelValue="updateScanCode" @submit="handleScan" />
|
||
<button class="secondary-button" :disabled="!scanCode || loading || busy" @tap="handleScan(scanCode)">{{ current ? '匹配托盘' : '打开排车任务' }}</button>
|
||
<text class="scan-help">网络失败不会清空草稿,再次提交会沿用同一幂等键。</text>
|
||
</view>
|
||
|
||
<view v-if="loading" class="fulfillment-state"><text>正在加载排车任务…</text></view>
|
||
<view v-else-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" class="offline-note">网络连接失败,未完成作业与幂等键已保留。</text>
|
||
<button class="secondary-button" :disabled="loading || busy" @tap="retry">重试</button>
|
||
</view>
|
||
|
||
<template v-if="!loading && !current">
|
||
<view class="list-toolbar"><view><text class="list-title">排车任务</text><text class="list-count">{{ tasks.length }} 个任务</text></view><button :disabled="loading" @tap="loadTasks">刷新</button></view>
|
||
<view v-if="tasks.length" class="task-list">
|
||
<button v-for="task in tasks" :key="task.dispatchNo" class="task-card" @tap="openTask(task.dispatchNo)">
|
||
<view class="task-head"><view class="task-code-row"><text class="business-code">{{ task.dispatchNo || '—' }}</text><text v-if="task.dispatchNo === recentTaskNo" class="recent-badge">上次作业</text></view><text class="status-badge">{{ statusLabel(task.status) }}</text></view>
|
||
<text class="task-meta">任务号 {{ task.taskNo || '—' }} · {{ task.status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task.vehiclePlate || '—' }}</text>
|
||
<text class="task-enter">查看任务详情 ›</text>
|
||
</button>
|
||
</view>
|
||
<view v-else-if="!error" class="fulfillment-state"><text class="state-title">暂无排车拣货任务</text><text>可刷新列表,或直接扫描排车单号恢复作业。</text></view>
|
||
</template>
|
||
|
||
<template v-if="current">
|
||
<DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo" :record-version="current.tripRecordVersion" />
|
||
<view class="fulfillment-card summary-card">
|
||
<view class="task-head"><text class="business-code">{{ current.dispatchNo || '—' }}</text><text class="status-badge">{{ statusLabel(current.status) }}</text></view>
|
||
<text class="task-meta">任务号 {{ current.taskNo || '—' }} · {{ departed ? '原发车车牌' : '车牌' }} {{ current.vehiclePlate || '—' }}</text>
|
||
<text v-if="!current.canPick && !pickingCompleted" class="gate-warning">{{ current.pickBlockReason || '请先通过调度审核再拣货' }}</text>
|
||
<view class="metrics"><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.items.length" class="item-list">
|
||
<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">{{ routeLabel(item) }}</text>
|
||
<text v-for="carton in item.returnCartons" :key="carton.cartonMark" class="task-meta">{{ carton.cartonMark }} · {{ carton.status === 'OUTBOUNDED' ? '已拣货' : '待扫描' }}</text>
|
||
<view class="progress"><view :style="{ width: `${itemProgress(item)}%` }"></view></view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="fulfillment-state"><text class="state-title">任务没有可拣货明细</text><text>请联系平台确认排车托盘是否已下发。</text></view>
|
||
|
||
<view v-if="selectedItem && selectedRemaining > 0" class="fulfillment-card">
|
||
<text class="card-title">本次拣货:{{ selectedItem.palletNo }}</text>
|
||
<text v-if="selectedItem.returnCartons.length" class="task-meta">{{ selectedReturnCarton ? `已扫描 ${selectedReturnCarton.cartonMark}` : '请扫描此托盘上的来源箱唛,每次拣货一箱' }}</text>
|
||
<view v-else class="count-row"><text>拣货箱数</text><input type="number" :value="packages" :placeholder="`请输入 1-${selectedRemaining} 箱`" @input="updatePackages" /><text>最多 {{ selectedRemaining }} 箱</text></view>
|
||
<button class="primary-button" :disabled="!canPick || busy" @tap="submitPick">{{ busyAction === 'pick' ? '提交中…' : '确认拣货' }}</button>
|
||
</view>
|
||
|
||
<view v-if="canCompleteStage" class="fulfillment-card gate-card">
|
||
<text class="card-title">完成拣货</text>
|
||
<text v-if="remainingPackages > 0" class="gate-warning">尚有 {{ remainingPackages }} 箱未拣,不能完成排车拣货。</text>
|
||
<text v-else class="gate-note">全部计划箱数已拣齐,可以完成任务。</text>
|
||
<button class="primary-button" :disabled="!canComplete || busy" @tap="completePicking">{{ busyAction === 'complete' ? '完成中…' : '完成排车拣货' }}</button>
|
||
</view>
|
||
|
||
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
|
||
<text class="card-title">整车发车</text>
|
||
<text v-if="current.tripNo" class="task-meta">车次 {{ current.tripNo }} · 共 {{ current.tripDispatchCount }} 张排车单</text>
|
||
<text v-if="current.driverWarning && !departed" class="gate-warning">{{ current.driverWarning }}</text>
|
||
<text v-if="!allPicked" class="gate-warning">服务端仍显示 {{ remainingPackages }} 箱未拣,不允许发车。</text>
|
||
<text v-else-if="departed" class="gate-success">该排车任务已执行发车,不可重复提交。</text>
|
||
<text v-else-if="!current.canDepart" class="gate-warning">{{ current.departBlockReason || '当前排车暂不满足发车条件,请刷新后重试。' }}</text>
|
||
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
|
||
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
|
||
</view>
|
||
</template>
|
||
|
||
<view v-if="successMessage" class="fulfillment-success" role="status"><text>{{ successMessage }}</text></view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import PortalHeader from '@/components/PortalHeader.vue'
|
||
import ScanTargetInput from '@/components/ScanTargetInput.vue'
|
||
import DispatchHandoverStatus from '@/components/DispatchHandoverStatus.vue'
|
||
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem, tripDepartureRequest } from '@/api/fulfillment.js'
|
||
import { fulfillment, fulfillmentIdempotencyKey, releaseFulfillmentKey, 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'
|
||
|
||
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
|
||
|
||
export default {
|
||
components: { PortalHeader, ScanTargetInput, DispatchHandoverStatus },
|
||
data() { return { user: session.currentUser, tasks: [], current: null, scanCode: '', loading: false, busy: false, busyAction: '', error: null, successMessage: '', bootstrapped: false, resumeOnEntry: false } },
|
||
computed: {
|
||
recentTaskNo() { return fulfillment.dispatch.currentNo || '' },
|
||
packages() { return fulfillment.dispatch.packages },
|
||
selectedItem() { return this.current?.items?.find((item) => fulfillment.dispatch.dispatchItemId ? item.dispatchItemId === fulfillment.dispatch.dispatchItemId : item.palletNo === fulfillment.dispatch.palletCode) || null },
|
||
selectedReturnCarton() { return this.selectedItem?.returnCartons?.find((carton) => carton.cartonMark === fulfillment.dispatch.cartonCode && carton.status === 'OUTBOUND_PENDING') || null },
|
||
selectedPosition() { return this.selectedItem?.positions?.find((item) => item.availablePackages > 0) || this.selectedItem?.positions?.[0] || null },
|
||
selectedRemaining() { const remaining = this.selectedItem?.scheduledPackages - this.selectedItem?.pickedPackages || 0; return Math.max(0, this.selectedItem?.returnCartons?.length ? remaining : Math.min(remaining, this.selectedPosition?.availablePackages || 0)) },
|
||
plannedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.scheduledPackages, 0) },
|
||
pickedPackages() { return (this.current?.items || []).reduce((total, item) => total + item.pickedPackages, 0) },
|
||
remainingPackages() { return Math.max(0, this.plannedPackages - this.pickedPackages) },
|
||
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
|
||
departed() { return this.current?.status === 'DEPARTED' },
|
||
canPick() { return this.current?.canPick === true && (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && !this.pickingCompleted },
|
||
canCompleteStage() { return Boolean(this.current) && !this.pickingCompleted },
|
||
canComplete() { return this.current?.canPick === true && this.plannedPackages > 0 && this.remainingPackages === 0 },
|
||
canDepart() { return this.pickingCompleted && this.allPicked && this.current?.canDepart === true && !this.departed }
|
||
},
|
||
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()
|
||
return
|
||
}
|
||
if (this.current?.dispatchNo) this.openTask(this.current.dispatchNo)
|
||
else this.loadTasks()
|
||
},
|
||
onHide() { this.stopScanner() },
|
||
beforeUnmount() { this.stopScanner() },
|
||
methods: {
|
||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => this.handleScan(value), onError: (error) => this.setError(error, '扫码广播注册失败') }) },
|
||
stopScanner() { if (this._stopScanner) this._stopScanner(); this._stopScanner = null },
|
||
updateScanCode(value) { this.scanCode = value },
|
||
updatePackages(event) { const value = Math.trunc(Number(event.detail.value || 0)); updateFulfillmentDraft('dispatch', { packages: Number.isFinite(value) ? Math.max(0, value) : 0 }) },
|
||
async loadTasks() { await this.run('加载排车任务', async () => { this.tasks = await listDispatchTasks(); this.current = null }, true) },
|
||
async openTask(dispatchNo) {
|
||
const code = String(dispatchNo || '').trim(); if (!code) return
|
||
await this.run('恢复排车任务', async () => { const previousNo = fulfillment.dispatch.currentNo; this.current = await getDispatchTask(code); const same = previousNo === this.current.dispatchNo; updateFulfillmentDraft('dispatch', { currentNo: this.current.dispatchNo, palletCode: same ? fulfillment.dispatch.palletCode : '', dispatchItemId: same ? fulfillment.dispatch.dispatchItemId : '', cartonCode: same ? fulfillment.dispatch.cartonCode : '' }); this.scanCode = '' }, true)
|
||
},
|
||
closeTask() { this.current = null; this.scanCode = ''; this.error = null; this.successMessage = ''; updateFulfillmentDraft('dispatch', { currentNo: '', palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.loadTasks() },
|
||
handleScan(raw) {
|
||
const code = String(raw?.detail?.value ?? raw ?? this.scanCode).trim(); if (!code || this.busy || this.loading) return
|
||
console.info('[Overseas PDA dispatch] scan', { code, task: this.current?.dispatchNo || '' })
|
||
this.scanCode = code
|
||
if (!this.current || code === this.current.dispatchNo || code === this.current.taskNo) return this.openTask(code === this.current?.taskNo ? this.current.dispatchNo : code)
|
||
const cartonItem = this.current.items.find((item) => item.returnCartons?.some((carton) => carton.cartonMark === code))
|
||
if (cartonItem) {
|
||
if (cartonItem.returnCartons.find((carton) => carton.cartonMark === code)?.status !== 'OUTBOUND_PENDING') return this.setError(new Error('该箱已拣货或不可出库'), '扫码未匹配')
|
||
this.selectPallet(cartonItem); updateFulfillmentDraft('dispatch', { cartonCode: code, packages: 1 }); this.scanCode = ''; playSuccessSound(); return
|
||
}
|
||
const item = this.current.items.find((value) => [value.palletNo, value.fbaWarehouseCode].includes(code))
|
||
if (!item) return this.setError(new Error('扫码未匹配当前排车任务的托盘或稳定业务码'), '扫码未匹配')
|
||
this.selectPallet(item); this.scanCode = ''; playSuccessSound()
|
||
},
|
||
selectPallet(item) { if (!item?.palletNo || item.scheduledPackages <= item.pickedPackages) return; updateFulfillmentDraft('dispatch', { palletCode: item.palletNo, dispatchItemId: item.dispatchItemId, cartonCode: '', packages: Math.max(1, Math.min(item.scheduledPackages - item.pickedPackages, item.positions?.[0]?.availablePackages || 1)) }) },
|
||
async submitPick() {
|
||
if (!this.canPick) return
|
||
const pick = this.selectedReturnCarton ? { positionId: null, cartonMark: this.selectedReturnCarton.cartonMark, packages: 1, productQuantity: this.selectedReturnCarton.productQuantity } : { positionId: this.selectedPosition.id, packages: Number(this.packages), productQuantity: 0 }
|
||
const payload = { dispatchNo: this.current.dispatchNo, dispatchItemId: this.selectedItem.dispatchItemId, ...pick }
|
||
const key = fulfillmentIdempotencyKey('dispatch', 'pick', payload)
|
||
await this.run('提交排车拣货', async () => {
|
||
await pickDispatchItem(this.current.dispatchNo, this.selectedItem.dispatchItemId, { ...pick, idempotencyKey: key.value })
|
||
this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); updateFulfillmentDraft('dispatch', { palletCode: '', dispatchItemId: '', cartonCode: '', packages: 1 }); this.successMessage = '本次排车拣货已记录'; playSuccessSound()
|
||
})
|
||
},
|
||
async completePicking() {
|
||
if (!this.canComplete) return
|
||
const key = fulfillmentIdempotencyKey('dispatch', 'complete', { dispatchNo: this.current.dispatchNo })
|
||
await this.run('完成排车拣货', async () => { await completeDispatchPicking(this.current.dispatchNo, { idempotencyKey: key.value }); this.current = await getDispatchTask(this.current.dispatchNo); releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车拣货任务已完成'; playSuccessSound() })
|
||
},
|
||
async depart() {
|
||
if (!this.canDepart) return
|
||
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { tripNo: this.current.tripNo, recordVersion: this.current.tripRecordVersion })
|
||
await this.run('执行整车发车', async () => {
|
||
const result = await departDispatch(this.current.dispatchNo, tripDepartureRequest(this.current, key.value))
|
||
if (result?.status !== 'IN_TRANSIT' || result.tripNo !== this.current.tripNo || !result.dispatches?.some(item => item.dispatchNo === this.current.dispatchNo)) throw new Error('发车结果未包含当前车次和排车单,请刷新核对')
|
||
this.current = await getDispatchTask(this.current.dispatchNo)
|
||
releaseFulfillmentKey('dispatch', key.signature); this.successMessage = `车次 ${result.tripNo} 已整车发车`; playSuccessSound()
|
||
})
|
||
},
|
||
async retry() { if (this.current?.dispatchNo) await this.openTask(this.current.dispatchNo); else if (fulfillment.dispatch.currentNo) await this.openTask(fulfillment.dispatch.currentNo); else await this.loadTasks() },
|
||
async run(action, work, loading = false) { if (this.busy || this.loading) return; this.error = null; this.successMessage = ''; if (loading) this.loading = true; else { this.busy = true; this.busyAction = action.includes('发车') ? 'depart' : action.includes('完成') ? 'complete' : 'pick' } try { await work() } catch (error) { this.setError(error, `${action}失败`) } finally { this.loading = false; this.busy = false; this.busyAction = '' } },
|
||
setError(error, fallback) { this.error = toDispatchError(error, fallback); console.error('[Overseas PDA dispatch] failed', this.error); playErrorSound() },
|
||
statusLabel(status) { return ({ PDA_PENDING: '待拣货', PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', COMPLETED: '拣货完成', PICKING_COMPLETED: '拣货完成', READY_TO_DEPART: '待发车', DEPARTED: '已发车', CANCELLED: '已取消' })[status] || status || '状态未知' },
|
||
routeLabel(item) { return item.warehouseRouteLabel || [item.destinationWarehouseCode, item.destinationWarehouseName, item.fbaWarehouseCode].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';
|
||
</style>
|