feat(fleet): show vehicle delivery and reassignment records

This commit is contained in:
2026-10-03 10:12:55 +08:00
parent 94b8b8b31c
commit f7b84ea887
9 changed files with 209 additions and 6 deletions
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import VehicleFulfillmentTable from './VehicleFulfillmentTable.vue'
import * as api from '../api/vehicleFulfillment'
const permissions = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => permissions.allowed }) }))
vi.mock('../api/vehicleFulfillment', () => ({ listVehicleDeliveries: vi.fn(), listVehicleAssignmentChanges: vi.fn() }))
const wrappers: VueWrapper[] = []
const result = (number: string) => ({ records: [{ deliveryNo: number }], page: 1, size: 10, total: 1, pages: 1 })
function mount(kind: 'deliveries' | 'changes' = 'deliveries') {
const wrapper = shallowMount(VehicleFulfillmentTable, { props: { vehicleId: '900719925474099301', kind }, global: { stubs: {
DataTable: { name: 'DataTable', props: ['data','columns','page','pageSize','total','loading'], emits: ['update:page','update:pageSize'], template: '<div><slot name="toolbar-actions"/></div>' },
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: { name: 'LoadErrorState', props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => {
vi.clearAllMocks(); permissions.allowed = true
vi.mocked(api.listVehicleDeliveries).mockResolvedValue(result('DL-1') as Awaited<ReturnType<typeof api.listVehicleDeliveries>>)
vi.mocked(api.listVehicleAssignmentChanges).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
})
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()) })
it('loads only its displayed projection once and does not fetch per-row details', async () => {
const wrapper = mount(); await flushPromises()
expect(api.listVehicleDeliveries).toHaveBeenCalledExactlyOnceWith('900719925474099301',1,10)
expect(api.listVehicleAssignmentChanges).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(result('DL-1').records)
})
it('does not call either endpoint without the required viewing permissions', async () => {
permissions.allowed = false; const wrapper = mount(); await flushPromises()
expect(api.listVehicleDeliveries).not.toHaveBeenCalled(); expect(api.listVehicleAssignmentChanges).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('暂无派送记录查看权限')
})
it('changing vehicle from a later page resets pagination and sends one scoped request', async () => {
const wrapper = mount(); await flushPromises()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises()
vi.mocked(api.listVehicleDeliveries).mockClear()
await wrapper.setProps({ vehicleId: '900719925474099302' }); await flushPromises()
expect(api.listVehicleDeliveries).toHaveBeenCalledExactlyOnceWith('900719925474099302',1,10)
})
it('a late response for the previous vehicle cannot replace the new vehicle records', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof api.listVehicleDeliveries>>) => void
vi.mocked(api.listVehicleDeliveries).mockImplementationOnce(() => new Promise(resolve => { resolveOld=resolve }))
const wrapper = mount()
await wrapper.setProps({ vehicleId: '900719925474099302' }); await flushPromises()
resolveOld(result('DL-OLD') as Awaited<ReturnType<typeof api.listVehicleDeliveries>>); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(result('DL-1').records)
})
it('changes use the history endpoint and do not reveal current customer delivery data', async () => {
mount('changes'); await flushPromises()
expect(api.listVehicleAssignmentChanges).toHaveBeenCalledExactlyOnceWith('900719925474099301',1,10)
expect(api.listVehicleDeliveries).not.toHaveBeenCalled()
})
it('failed reads show an error and clear stale rows instead of showing unrelated prior data', async () => {
const wrapper = mount(); await flushPromises()
vi.mocked(api.listVehicleDeliveries).mockRejectedValueOnce(new Error('读取失败'))
await wrapper.find('button').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual([])
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listVehicleDeliveries, listVehicleAssignmentChanges, type VehicleDelivery, type VehicleAssignmentChange } from '../api/vehicleFulfillment'
const props = defineProps<{ vehicleId: string; kind: 'deliveries' | 'changes' }>()
const access = useAccessPreviewStore()
const allowed = computed(() => access.can(permission.vehicleList) && access.can(props.kind === 'deliveries' ? 'platform:overseas-warehouse:delivery:list' : permission.tripList))
const rows = ref<(VehicleDelivery | VehicleAssignmentChange)[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const title = computed(() => props.kind === 'deliveries' ? '派送订单明细' : '派送更换记录')
let generation = 0
const columns = computed<DataTableColumn[]>(() => props.kind === 'deliveries' ? [
{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 210 },
{ key: 'source', label: 'BOL / 来源排车', minWidth: 215, slotName: 'source' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'destination', label: '目的仓 / 地址', minWidth: 180, slotName: 'destination' },
{ key: 'plannedDeliveryAt', label: '预约日期', minWidth: 165, slotName: 'date' },
{ key: 'status', label: '派送状态', width: 115, slotName: 'status' },
{ key: 'relationship', label: '关联关系', minWidth: 220, slotName: 'relationship' },
] : [
{ key: 'occurredAt', label: '时间', minWidth: 165, slotName: 'date' },
{ key: 'tripNo', prop: 'tripNo', label: '车次', minWidth: 210 },
{ key: 'dispatchNos', label: '关联排车', minWidth: 210, slotName: 'dispatches' },
{ key: 'relationship', label: '动作', width: 100, slotName: 'relationship' },
{ key: 'vehicle', label: '原车辆 → 新车辆', minWidth: 230, slotName: 'vehicles' },
{ key: 'driver', label: '原司机 → 新司机', minWidth: 180, slotName: 'drivers' },
{ key: 'reason', prop: 'reason', label: '原因', minWidth: 200 },
{ key: 'operator', label: '操作人', width: 100, slotName: 'operator' },
])
const statuses: Record<string, string> = { WAIT_DELIVERY: '待派送', DISPATCHED: '已派送', IN_TRANSIT: '运输中', DELIVERED: '已签收', RETURNED: '已退回', CANCELLED: '已取消', DELIVERY_FAILED: '派送异常' }
const relations: Record<string, string> = { PLANNED: '当前计划车辆', DEPARTED: '实际发车车辆', REPLACED_OUT: '换出本车', REPLACED_IN: '换入本车', DRIVER_CHANGED: '更换司机' }
function date(value: string | null | undefined) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' }
function rowKey(row: VehicleDelivery | VehicleAssignmentChange) { return 'deliveryNo' in row ? row.deliveryNo : `${row.tripNo}:${row.recordVersion}` }
async function load() {
const request = ++generation
rows.value = []; total.value = 0; error.value = null
if (!allowed.value || !props.vehicleId) { loading.value = false; return }
loading.value = true
try {
const result = props.kind === 'deliveries'
? await listVehicleDeliveries(props.vehicleId, page.value, size.value)
: await listVehicleAssignmentChanges(props.vehicleId, page.value, size.value)
if (request === generation) { rows.value = result.records; total.value = result.total }
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, `${title.value}加载失败`) }
finally { if (request === generation) loading.value = false }
}
watch([() => props.vehicleId, () => props.kind, allowed, page, size], (current, prior) => {
if (prior?.length && (current[0] !== prior[0] || current[1] !== prior[1] || current[4] !== prior[4]) && page.value !== 1) { page.value = 1; return }
void load()
}, { immediate: true })
onBeforeUnmount(() => { generation++ })
</script>
<template>
<section :aria-label="title">
<p v-if="!allowed" class="fulfillment-note">暂无{{ kind === 'deliveries' ? '派送记录' : '车次历史' }}查看权限。</p>
<template v-else>
<p class="fulfillment-note">{{ kind === 'deliveries' ? '计划中的派送按当前分配车辆展示,实际发车后按发车记录归属车辆。' : '按车次保存更换前后的真实记录;同车次可能关联多张排车单。' }}</p>
<LoadErrorState v-if="error" :title="`${title}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :row-key="rowKey" :empty-text="`暂无${title}`">
<template #toolbar-actions><el-button :disabled="loading" @click="load">刷新</el-button></template>
<template #source="{ row }"><div>{{ row.bolNo || '—' }}</div><div>{{ row.dispatchNo || '未关联排车' }}</div></template>
<template #destination="{ row }">{{ row.destinationType === 'PRIVATE' ? row.deliveryAddress || '—' : row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template>
<template #date="{ row }">{{ date(kind === 'deliveries' ? row.plannedDeliveryAt : row.occurredAt) }}</template>
<template #status="{ row }">{{ statuses[row.status] || row.status || '—' }}</template>
<template #relationship="{ row }"><div>{{ relations[row.relationship] || '—' }}</div><div v-if="kind === 'deliveries'">{{ row.tripNo }}</div></template>
<template #dispatches="{ row }"><div v-for="number in row.dispatchNos" :key="number">{{ number }}</div><span v-if="!row.dispatchNos.length">未关联排车</span></template>
<template #vehicles="{ row }">{{ row.previousVehiclePlate || '—' }} → {{ row.vehiclePlate || '—' }}</template>
<template #drivers="{ row }">{{ row.previousDriverName || '—' }} → {{ row.driverName || '—' }}</template>
<template #operator>—</template>
</DataTable>
</template>
</section>
</template>
<style scoped>.fulfillment-note{margin:0 0 var(--admin-space-4);color:var(--cc-muted)}</style>