fix(pda): restore whole-trip departure and handover after merge

This commit is contained in:
2026-10-08 18:08:42 +08:00
parent a8992266ed
commit 962978ad6a
2 changed files with 52 additions and 13 deletions
+23 -13
View File
@@ -26,21 +26,23 @@
</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 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.vehiclePlate || '—' }}</text>
<text class="task-enter">查看并开始拣货 ›</text>
<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 || '—' }} · 车牌 {{ current.vehiclePlate || '—' }}</text>
<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>
@@ -69,12 +71,14 @@
</view>
<view v-if="pickingCompleted" class="fulfillment-card gate-card">
<text class="card-title">发车门禁</text>
<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>
<text v-else class="gate-note">本车所有排车单已就绪,确认后将一并发车。</text>
<button v-if="!departed" class="depart-button" :disabled="!canDepart || busy" @tap="depart">{{ busyAction === 'depart' ? '发车中…' : '确认整车发车' }}</button>
</view>
</template>
@@ -87,7 +91,8 @@
<script>
import PortalHeader from '@/components/PortalHeader.vue'
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { completeDispatchPicking, departDispatch, getDispatchTask, listDispatchTasks, pickDispatchItem } from '@/api/fulfillment.js'
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'
@@ -97,7 +102,7 @@
const finishedStatuses = ['COMPLETED', 'PICKING_COMPLETED', 'READY_TO_DEPART', 'DEPARTED']
export default {
components: { PortalHeader, ScanTargetInput },
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 || '' },
@@ -112,9 +117,9 @@
allPicked() { return this.plannedPackages > 0 && this.remainingPackages === 0 },
pickingCompleted() { return finishedStatuses.includes(this.current?.status) },
departed() { return this.current?.status === 'DEPARTED' },
canPick() { return (this.selectedItem?.returnCartons?.length ? Boolean(this.selectedReturnCarton) : Boolean(this.selectedPosition) && Number(this.packages) > 0 && Number(this.packages) <= this.selectedRemaining) && this.current?.canPick === true && !this.pickingCompleted },
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.plannedPackages > 0 && this.remainingPackages === 0 },
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' },
@@ -177,8 +182,13 @@
},
async depart() {
if (!this.canDepart) return
const key = fulfillmentIdempotencyKey('dispatch', 'depart', { dispatchNo: this.current.dispatchNo })
await this.run('执行发车', async () => { const result = await departDispatch(this.current.dispatchNo, { idempotencyKey: key.value, remark: '海外 PDA 拣货核平后发车' }); this.current = { ...this.current, status: result?.status || 'DEPARTED' }; releaseFulfillmentKey('dispatch', key.signature); this.successMessage = '排车已发车'; playSuccessSound() })
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 = '' } },
+29
View File
@@ -13,6 +13,35 @@ import { toDispatchError } from '../src/utils/dispatch-error.js'
const read = (path) => readFile(new URL(path, import.meta.url), 'utf8')
test('dispatch merge retains return carton picking and review gates together', async () => {
const source = (await read('../src/pages/dispatch/dispatch.vue')).match(/<script>([\s\S]*?)<\/script>/)[1]
const definition = new Function('PortalHeader', 'ScanTargetInput', 'DispatchHandoverStatus',
source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))({}, {}, {})
const state = { current: { canPick: true }, selectedItem: { returnCartons: [{}] },
selectedReturnCarton: { cartonMark: 'RETURN-BOX' }, selectedPosition: null,
packages: 1, selectedRemaining: 1, pickingCompleted: false, plannedPackages: 1, remainingPackages: 0 }
assert.equal(definition.computed.canPick.call(state), true)
state.selectedReturnCarton = null
assert.equal(definition.computed.canPick.call(state), false)
state.selectedItem.returnCartons = []
state.selectedPosition = { id: 'position' }
assert.equal(definition.computed.canPick.call(state), true)
for (const canPick of [false, undefined, 'true']) {
state.current.canPick = canPick
assert.equal(definition.computed.canPick.call(state), false)
assert.equal(definition.computed.canComplete.call(state), false)
}
const calls = []
const deps = { pickDispatchItem: async (...args) => calls.push(args), getDispatchTask: async () => state.current,
fulfillmentIdempotencyKey: () => ({ value: 'retry-key', signature: 'signature' }), releaseFulfillmentKey() {},
updateFulfillmentDraft() {}, playSuccessSound() {}, PortalHeader: {}, ScanTargetInput: {}, DispatchHandoverStatus: {} }
const page = new Function(...Object.keys(deps), source.replace(/^\s*import .*$/gm, '').replace('export default', 'return'))(...Object.values(deps))
await page.methods.submitPick.call({ canPick: true, current: { dispatchNo: 'DS-RETURN' },
selectedItem: { dispatchItemId: 'item' }, selectedReturnCarton: { cartonMark: 'RETURN-BOX', productQuantity: 5 },
run: async (_, work) => work() })
assert.deepEqual(calls, [['DS-RETURN', 'item', { positionId: null, cartonMark: 'RETURN-BOX', packages: 1, productQuantity: 5, idempotencyKey: 'retry-key' }]])
})
test('trip departure preserves version zero and rejects incomplete trip identity', () => {
const task = normalizeDispatchTask({ tripNo: 'TR-1', tripRecordVersion: 0, tripDispatchCount: 2, items: [] })
assert.equal(task.tripRecordVersion, '0')