feat(pda): display warehouse-scoped fleet handover status

This commit is contained in:
2026-10-03 12:44:19 +08:00
parent 3990074352
commit a1a8bb4b8b
5 changed files with 142 additions and 3 deletions
+2
View File
@@ -90,6 +90,8 @@
## 质量门禁
- 已发车排车详情按需挂载`src/components/DispatchHandoverStatus.vue`,通过`src/api/trip-handover.js`读取当前派送资源和最近交接;每次进入/刷新一次GET,列表不逐行请求。仅只读、不提交客户或仓ID;后台确认交接,已完成派送保留完成时资源。回归`tests/trip-handover.test.mjs`。
- 安装:`npm install`;检查:`npm test`。
- 仓储转运测量、打托、照片或标签入口变更运行 `node --test tests/warehouse-transfer-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;静态合同不替代真实扫码、绑定及打印验收。
- 退货签收、合箱或工作台入口变更先运行 `node --test tests/return-receipt-api.test.mjs tests/return-packing-api.test.mjs tests/project-contract.test.mjs tests/workbench-availability.test.mjs`;离线检查不替代真实 API 联调与真机扫码验收。
+26
View File
@@ -0,0 +1,26 @@
import { overseasRequest } from './overseas.js'
const text = value => typeof value === 'string' ? value : ''
const transport = value => ({ vehiclePlate: text(value?.vehiclePlate), driverName: text(value?.driverName), driverPhone: text(value?.driverPhone) })
export function normalizeHandoverStatus(value) {
if (!value?.dispatchNo || !value?.tripNo || !Array.isArray(value.deliveries)) throw new Error('交接状态缺少排车或派送依据')
return {
dispatchNo: text(value.dispatchNo), tripNo: text(value.tripNo), tripStatus: text(value.tripStatus),
tripRecordVersion: value.tripRecordVersion == null ? '' : String(value.tripRecordVersion),
originalDeparture: transport(value.originalDeparture),
deliveries: value.deliveries.map(row => {
const packages = Number(row.packages)
if (!row.deliveryNo || row.packages == null || String(row.packages).trim() === '' || !Number.isSafeInteger(packages) || packages <= 0) throw new Error('交接状态中的派送箱数无效')
return { deliveryNo: text(row.deliveryNo), status: text(row.status), packages, terminalAt: text(row.terminalAt), transport: transport(row.transport) }
}),
latestHandover: value.latestHandover ? { handoverNo: text(value.latestHandover.handoverNo), handedOverAt: text(value.latestHandover.handedOverAt),
location: text(value.latestHandover.location), previous: transport(value.latestHandover.previous), replacement: transport(value.latestHandover.replacement) } : null
}
}
export async function getDispatchHandoverStatus(dispatchNo) {
const number = String(dispatchNo || '').trim()
if (!number) throw new Error('请先打开排车任务')
const result = normalizeHandoverStatus(await overseasRequest('GET', `/pda/v1/last-leg/dispatches/${encodeURIComponent(number)}/handover-status`))
if (result.dispatchNo !== number) throw new Error('返回的交接记录不属于当前排车任务')
return result
}
+61
View File
@@ -0,0 +1,61 @@
<template>
<view class="handover-card">
<view class="handover-heading"><text class="handover-title">发车与交接记录</text><button :disabled="loading" @tap="load">刷新</button></view>
<text v-if="loading">正在读取当前运输资源…</text>
<view v-else-if="error" class="handover-error" role="alert">
<text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text>
<button @tap="load">重新读取</button>
</view>
<template v-else-if="status">
<text class="handover-line">车次 {{ status.tripNo }}</text>
<text class="handover-line">原始发车:{{ status.originalDeparture.vehiclePlate || '—' }} · {{ status.originalDeparture.driverName || '—' }}</text>
<view v-if="status.latestHandover" class="handover-record">
<text class="handover-line">最近交接 {{ status.latestHandover.handoverNo || '—' }}</text>
<text class="handover-line">{{ time(status.latestHandover.handedOverAt) }} · {{ status.latestHandover.location || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.vehiclePlate || '—' }} → {{ status.latestHandover.replacement.vehiclePlate || '—' }}</text>
<text class="handover-line">{{ status.latestHandover.previous.driverName || '—' }} → {{ status.latestHandover.replacement.driverName || '—' }}</text>
</view>
<text v-else class="handover-line">本排车暂无在途交接记录。</text>
<view v-for="delivery in status.deliveries" :key="delivery.deliveryNo" class="handover-delivery">
<text class="handover-line">{{ delivery.deliveryNo }} · {{ delivery.packages }} 箱 · {{ label(delivery) }}</text>
<text class="handover-line">{{ delivery.terminalAt ? '完成时承运' : '当前承运' }}:{{ delivery.transport.vehiclePlate || '—' }} · {{ delivery.transport.driverName || '—' }}</text>
<text v-if="delivery.transport.driverPhone" class="handover-line">联系电话 {{ delivery.transport.driverPhone }}</text>
</view>
<text class="handover-note">交接由后台确认。本页只读,不重复发车。</text>
</template>
</view>
</template>
<script>
import { getDispatchHandoverStatus } from '@/api/trip-handover.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
export default {
props: { dispatchNo: { type: String, required: true }, recordVersion: { type: [String, Number], default: null } },
data() { return { status: null, error: null, loading: false, generation: 0 } },
computed: { scopeKey() { return `${this.dispatchNo}:${this.recordVersion ?? ''}` } },
watch: { scopeKey: { immediate: true, handler() { this.status = null; this.load() } } },
beforeUnmount() { this.generation++ },
methods: {
async load() {
const generation = ++this.generation
this.error = null; this.status = null
if (!this.dispatchNo) { this.loading = false; return }
this.loading = true
try { const result = await getDispatchHandoverStatus(this.dispatchNo); if (generation === this.generation) this.status = result }
catch (cause) { if (generation === this.generation) this.error = toDispatchError(cause, '交接记录读取失败') }
finally { if (generation === this.generation) this.loading = false }
},
time(value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' },
label(delivery) {
if (delivery.terminalAt) return delivery.status === 'RETURNED' ? '运输已完成' : '已签收'
return { WAIT_DELIVERY: '派送中', RETURN_PENDING: '待退回' }[delivery.status] || '待核对'
}
}
}
</script>
<style scoped>
.handover-card{padding:28rpx;background:#fff;border-radius:20rpx;margin-top:20rpx;color:#334155;font-size:26rpx}
.handover-heading{display:flex;align-items:center;justify-content:space-between;gap:16rpx}.handover-title{font-size:30rpx;font-weight:600}
.handover-heading button{margin:0;font-size:26rpx}.handover-line{display:block;margin-top:12rpx;overflow-wrap:anywhere}
.handover-record,.handover-delivery{padding-top:16rpx;margin-top:16rpx;border-top:1px solid #e2e8f0}.handover-note{display:block;margin-top:20rpx;color:#64748b}
.handover-error{color:#b91c1c}.handover-error text{display:block;margin-top:12rpx}
</style>
+5 -3
View File
@@ -30,7 +30,7 @@
<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-meta">任务号 {{ task.taskNo || '—' }} · {{ task.status === 'DEPARTED' ? '原发车车牌' : '车牌' }} {{ task.vehiclePlate || '—' }}</text>
<text class="task-enter">查看任务详情 ›</text>
</button>
</view>
@@ -38,9 +38,10 @@
</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>
@@ -88,6 +89,7 @@
<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'
@@ -98,7 +100,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 || '' },
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { getDispatchHandoverStatus, normalizeHandoverStatus } from '../src/api/trip-handover.js'
import { session } from '../src/stores/session.js'
const wire = () => ({ dispatchNo: 'PC-ONE', tripNo: 'TR-ONE', tripStatus: 'IN_TRANSIT', tripRecordVersion: '90071992547409931',
originalDeparture: { vehiclePlate: 'ORIGINAL', driverName: '原司机' },
deliveries: [{ deliveryNo: 'DL-DONE', status: 'DELIVERED', packages: '10', terminalAt: '2026-10-03T01:00:00Z', transport: { vehiclePlate: 'ORIGINAL', driverName: '原司机' } },
{ deliveryNo: 'DL-PENDING', status: 'WAIT_DELIVERY', packages: 20, terminalAt: null, transport: { vehiclePlate: 'NEW', driverName: '接替司机' } }], latestHandover: null })
test('PDA query stays a GET in the dedicated namespace and never supplies customer or warehouse', async () => {
const calls = []; session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { calls.push(options); options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
const result = await getDispatchHandoverStatus('PC-ONE')
assert.equal(calls.length, 1); assert.equal(calls[0].method, 'GET'); assert.match(calls[0].url, /\/pda\/v1\/last-leg\/dispatches\/PC-ONE\/handover-status$/)
assert.ok(!calls[0].data?.customerId); assert.ok(!calls[0].data?.warehouseId)
assert.equal(result.tripRecordVersion, '90071992547409931'); assert.equal(result.deliveries[0].transport.vehiclePlate, 'ORIGINAL'); assert.equal(result.deliveries[1].transport.vehiclePlate, 'NEW')
})
test('normalization exposes only business references and does not replace missing fields with internal IDs', () => {
const data = wire(); data.customerId = 'secret-customer'; data.originalDeparture = { vehicleId: 'secret-vehicle' }; data.deliveries[0].id = 'secret-id'
const value = normalizeHandoverStatus(data)
assert.doesNotMatch(JSON.stringify(value), /secret-/); assert.equal(value.originalDeparture.vehiclePlate, '')
assert.equal(value.deliveries[0].packages, 10)
for (const packages of [null, '', -1, 0, '1.5']) assert.throws(() => normalizeHandoverStatus({ ...data, deliveries: [{ ...data.deliveries[0], packages }] }), /箱数无效/)
})
test('a response for another dispatch is rejected', async () => {
session.apiBase = '/gateway-api'; session.token = 'test-token'
globalThis.uni = { request(options) { options.success({ statusCode: 200, data: { code: '200', data: wire() }, header: {} }) } }
await assert.rejects(getDispatchHandoverStatus('PC-OTHER'), /不属于当前排车/)
})
test('the component ignores stale responses after switching task and has no write commands', async () => {
const file = await readFile(new URL('../src/components/DispatchHandoverStatus.vue', import.meta.url), 'utf8')
const pending = []
const context = { getDispatchHandoverStatus: () => new Promise(resolve => pending.push(resolve)), toDispatchError: error => ({ message: error.message }) }
const source = file.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^import .*$/gm, '').replace('export default', 'component =')
vm.runInNewContext(source, context)
const component = context.component, state = { ...component.data(), dispatchNo: 'PC-OLD' }
const first = component.methods.load.call(state)
state.dispatchNo = 'PC-NEW'; const second = component.methods.load.call(state)
pending[1]({ dispatchNo: 'PC-NEW' }); await second
pending[0]({ dispatchNo: 'PC-OLD' }); await first
assert.equal(state.status.dispatchNo, 'PC-NEW')
assert.doesNotMatch(file, /confirmTripHandover|departDispatch|pickDispatchItem|POST/)
const page = await readFile(new URL('../src/pages/dispatch/dispatch.vue', import.meta.url), 'utf8')
assert.match(page, /DispatchHandoverStatus v-if="departed" :dispatch-no="current.dispatchNo"/)
})