fix(fleet): distinguish signed returned and remaining trip cargo

This commit is contained in:
2026-10-03 15:00:10 +08:00
parent 5641ea0f7e
commit 6a135c3982
6 changed files with 36 additions and 9 deletions
@@ -81,6 +81,6 @@
| 外接询价、详情与承运占位 | 用户确认运营后台登记外接业务;首版不扩展客户在线撮合 | 车次详情外接页签、1080px居中三列表单,详情只读、确认/取消居中确认;操作列随计划/执行阶段使用320/170px,保留原行操作 | 已接登记/编辑/确认/取消、独立权限及已确认预订余位;已接真实来货登记、实收复核和排车关联。2026-10-03截图`fleet-external-booking-1920.png`及DOM复现旧440px操作列使1640px详情容器内表格宽1720px;本次仅收窄闲置列宽,部署后的1920×1080复验仍待完成 |
| 计划容量与剩余运力 | 本轮外接剩余运力方案,原型无完整占位工作台 | 车次详情独立页签,复用托盘核对表,显示预计值和每段剩余托位/kg/m³ | 源码已接计划容量;已扣除确认承运的外接占位;不把预订当实物装载 |
| 实际装载确认 | 新业务规则,无原型对应操作 | 详情页内实物托盘表及确认区 | 已按用户确认接入首版;真实页面验收待执行 |
| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立 |
| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立。原实发、实签、本趟实退及未结箱数分别展示,混合结果显示“余货已签收”;一次结案查询提供数量,不逐票补请求,1920×1080真实视觉待验 |
| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 |
@@ -20,7 +20,7 @@ const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' }
const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }],
segments: [{ fromIndex: '0', toIndex: '1', occupied: load, remaining: { palletSpaces: '29', weightKg: '17899.875', volumeCbm: '88.995' } }], operatorId: longId, confirmedAt: trip.updatedAt }
const loading = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: confirmed, blockReason: null }
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', terminalAt: trip.updatedAt }
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', signedPackages: '8', returnedPackages: '4', remainingPackages: '0', terminalAt: trip.updatedAt }
const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
describe('fleet trip API contract', () => {
@@ -93,12 +93,13 @@ describe('fleet trip API contract', () => {
it('loads completion and immutable history only when explicitly requested', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '5', canComplete: false, blockReason: '另一票未签收', outcomes: [outcome] }))
expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12 }] })
expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0 }] })
vi.mocked(http.get).mockResolvedValueOnce(envelope({ page: '2', size: '10', total: '12', pages: '2', records: [{ recordVersion: '5', action: 'COMPLETE', reason: null, operatorId: longId, occurredAt: trip.updatedAt,
snapshot: { trip, externalBookings: [], capacityPlan: null, loadingReview: reviewState, dispatches: detail.dispatches, confirmedLoad: confirmed, completion: { idempotencyKey: 'key', requestHash: 'hash', completedAt: trip.updatedAt, recordedAt: trip.updatedAt, operatorId: longId, outcomes: [outcome] } } }] }))
const history = await listTripHistory(trip.tripNo, 2, 10)
expect(http.get).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/history`, { params: { page: 2, size: 10 } })
expect(history.records[0]?.snapshot.completion?.outcomes[0]?.packages).toBe(12)
expect(history.records[0]?.snapshot.completion?.outcomes[0]).toMatchObject({ signedPackages: 8, returnedPackages: 4, remainingPackages: 0 })
expect(history.records[0]?.snapshot.trip.recordVersion).toBe(0)
})
@@ -26,7 +26,7 @@ type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> &
}
type WireLoading = Omit<TripLoading, 'recordVersion' | 'cargo' | 'confirmedLoad'> & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null }
type WirePlanning = Omit<TripCapacityPlanning, 'recordVersion' | 'cargo' | 'capacityPlan' | 'availableSegments'> & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] }
type WireOutcome = Omit<TripOutcome, 'packages'> & { packages: Numeric }
type WireOutcome = Omit<TripOutcome, 'packages' | 'signedPackages' | 'returnedPackages' | 'remainingPackages'> & { packages: Numeric; signedPackages: Numeric; returnedPackages: Numeric; remainingPackages: Numeric }
type WireInspection = Omit<TripCompletionInspection, 'recordVersion' | 'outcomes'> & { recordVersion: Numeric; outcomes: WireOutcome[] }
type WireHistory = Omit<TripHistory, 'recordVersion' | 'snapshot'> & {
recordVersion: Numeric
@@ -52,7 +52,8 @@ const review = (value: WireReview): TripReviewState => ({ ...value, recordVersio
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) })
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) })
function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null {
return value == null ? null : { ...value, cargo: value.cargo.map(cargo),
@@ -42,7 +42,11 @@ const bindingsColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', w
{ key: 'boundAt', label: '关联时间', slotName: 'boundAt', minWidth: 180 }]
const outcomeColumns: DataTableColumn[] = [{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 220 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 180 }, { key: 'status', label: '状态', slotName: 'status', width: 115 },
{ key: 'packages', prop: 'packages', label: '箱数', width: 110 }, { key: 'terminalAt', label: '完成时间', slotName: 'terminalAt', minWidth: 180 }]
{ key: 'packages', prop: 'packages', label: '原实发箱数', width: 110 },
{ key: 'signedPackages', prop: 'signedPackages', label: '实签箱数', width: 100 },
{ key: 'returnedPackages', prop: 'returnedPackages', label: '本趟实退箱数', width: 120 },
{ key: 'remainingPackages', prop: 'remainingPackages', label: '未结箱数', width: 100 },
{ key: 'terminalAt', label: '完成时间', slotName: 'terminalAt', minWidth: 180 }]
const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED: '已退回', RETURN_PENDING: '待退回', DELIVERING: '派送中', WAIT_DELIVERY: '待派送', CANCELLED: '已取消' }
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
@@ -199,7 +203,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
<template v-if="activeTab === 'completion'">
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ row.status === 'DELIVERED' && row.returnedPackages > 0 ? '余货已签收' : outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
</FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
<el-form v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" label-position="top" class="density-form release-form" :disabled="!!busy || !!releaseRequest" @submit.prevent="release">
@@ -25,7 +25,7 @@ const detail: TripDetail = { trip, driverQualification: null, dispatches: [], lo
const layout = { template: '<div><slot name="header"/><slot/></div>' }
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',$event.target.value)" />' }
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }
const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="status" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }
const wrappers: VueWrapper[] = []
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
@@ -46,6 +46,27 @@ beforeEach(() => {
})
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('shows mixed signature and return quantities from one lazy completion read', async () => {
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } })
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: 'A', destination: 'ONT8', status: 'DELIVERED',
packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0, terminalAt: trip.updatedAt }
vi.mocked(api.getTripCompletion).mockResolvedValue({ tripNo: trip.tripNo, recordVersion: 3, canComplete: true, blockReason: null, outcomes: [outcome] })
const wrapper = mount(TripDetailView); await flushPromises()
expect(api.getTripCompletion).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'completion'); await flushPromises()
expect(api.getTripCompletion).toHaveBeenCalledExactlyOnceWith('TR-TEST')
const result = wrapper.findComponent({ name: 'DataTable' })
expect(result.props('data')).toEqual([outcome])
expect(result.props('columns')).toEqual(expect.arrayContaining([
expect.objectContaining({ prop: 'packages', label: '原实发箱数' }),
expect.objectContaining({ prop: 'signedPackages', label: '实签箱数' }),
expect.objectContaining({ prop: 'returnedPackages', label: '本趟实退箱数' }),
expect.objectContaining({ prop: 'remainingPackages', label: '未结箱数' }),
]))
expect(wrapper.text()).toContain('余货已签收')
expect(api.completeTrip).not.toHaveBeenCalled()
})
it('mounts direct costs only when the costs tab is selected', async () => {
const wrapper = mount(TripDetailView); await flushPromises()
expect(wrapper.findComponent({ name: 'TripCostPanel' }).exists()).toBe(false)
@@ -52,7 +52,7 @@ export interface TripPlannedLoad { manifestHash: string; cargo: TripCargo[]; pal
export interface TripCapacityPlanning { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; capacityPlan: TripPlannedLoad | null; planValid: boolean; blockReason: string | null; availableSegments: TripSegment[] }
export interface TripLoading { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; confirmedLoad: TripConfirmedLoad | null; blockReason: string | null }
export interface TripLoadConfirmation { recordVersion: number; manifestHash: string; pallets: TripMeasurement[]; reason: string }
export interface TripOutcome { dispatchNo: string; deliveryNo: string; customerId: string; destination: string | null; status: string; packages: number; terminalAt: string | null }
export interface TripOutcome { dispatchNo: string; deliveryNo: string; customerId: string; destination: string | null; status: string; packages: number; signedPackages: number; returnedPackages: number; remainingPackages: number; terminalAt: string | null }
export interface TripCompletionInspection { tripNo: string; recordVersion: number; canComplete: boolean; blockReason: string | null; outcomes: TripOutcome[] }
export interface TripComplete { recordVersion: number; idempotencyKey: string; reason: string }
export interface TripResourceRelease {