From f7b84ea88769d395438fef777ee97f3e32bfe9fe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 10:12:55 +0800 Subject: [PATCH] feat(fleet): show vehicle delivery and reassignment records --- src/modules/overseas-master-data/AGENTS.md | 2 + .../overseas-master-data/PROTOTYPE_MATRIX.md | 2 +- .../api/vehicleFulfillment.test.ts | 17 ++++ .../api/vehicleFulfillment.ts | 33 ++++++++ .../VehicleFulfillmentTable.test.ts | 59 ++++++++++++++ .../components/VehicleFulfillmentTable.vue | 80 +++++++++++++++++++ .../pages/OverseasMasterDataMount.test.ts | 12 ++- .../pages/OverseasMasterDataViews.test.ts | 5 +- .../pages/VehicleDetailView.vue | 5 +- 9 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/modules/overseas-master-data/api/vehicleFulfillment.test.ts create mode 100644 src/modules/overseas-master-data/api/vehicleFulfillment.ts create mode 100644 src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts create mode 100644 src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index fecd66ad..6123eeb4 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -1,5 +1,7 @@ # 海外通用资料与车辆管理 +- 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。 + - 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。 - 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。 - 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index ba7ad62e..84fb542b 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -13,7 +13,7 @@ | 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` | | 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 | | 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 | -| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;派送区说明从现有排车/派送管理查看,更换区明确尚未接入 | 不拼装订单/派送数据;用户提示不展示内部接口说明 | +| 车辆 | 详情 | `10-general.html`的`page-vehicle-detail`、`vehicle-detail-orders-title`(455)、`vehicle-detail-history-title`(459) | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录;只读表不添加操作列 | 已接车辆详情和两个分页只读投影;派送保留单号、BOL/来源、客户、目的地、预约日期、状态和关联关系;车次统一改派按事件展示原/新车辆及司机和关联排车,不虚构逐派送改派事件。操作人姓名未记录时为“—” | 按稳定车辆身份及不可变历史关联,不按车牌猜测;使用派送/车次各自只读权限。真实1920×1080页面及请求量待新接口发布后验收 | | 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529`;本次车辆容量设计见车辆管理规划第4.1节 | 居中弹框 `modal-vehicle-edit` | 原760px两列;新增托位/载重/容积后按高密度规范扩为1080px三列 | 已接创建/编辑、独立状态与导出;三项容量同填或全空,不从车型文本推算 | create/edit/status 权限独立;占用中关键变更由服务端拒绝 | | 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE | diff --git a/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts b/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts new file mode 100644 index 00000000..67ce90f3 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleFulfillment.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listVehicleDeliveries, listVehicleAssignmentChanges } from './vehicleFulfillment' +vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn() }, unwrapApiResponse: (value: {data: unknown}) => value.data })) +beforeEach(() => { vi.clearAllMocks() }) +it('encodes the stable vehicle key and adapts serialized pagination without a plate or customer override', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ deliveryNo: 'DL-1' }], page: '1', size: '10', total: '1', pages: '1' } }) + const result=await listVehicleDeliveries('900719925474099301') + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/vehicles/900719925474099301/deliveries', { params: { page: 1, size: 10 } }) + expect(result.total).toBe(1) +}) +it('history preserves large serialized versions and rejects invalid pagination', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { records: [{ tripNo:'TR-1', recordVersion:'900719925474099301' }], page:'1',size:'10',total:'1',pages:'1' } }) + expect((await listVehicleAssignmentChanges('8')).records[0]?.recordVersion).toBe('900719925474099301') + vi.mocked(http.get).mockResolvedValue({ data: { records: [], page:'1',size:'10',total:'not-a-number',pages:'1' } }) + await expect(listVehicleAssignmentChanges('8')).rejects.toThrow('分页数值') +}) diff --git a/src/modules/overseas-master-data/api/vehicleFulfillment.ts b/src/modules/overseas-master-data/api/vehicleFulfillment.ts new file mode 100644 index 00000000..2ab7d149 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleFulfillment.ts @@ -0,0 +1,33 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export interface VehicleDelivery { + deliveryNo: string; dispatchNo: string; tripNo: string; bolNo: string | null; customerName: string | null + destinationType: string; destinationWarehouseCode: string | null; destinationWarehouseName: string | null + deliveryAddress: string | null; plannedDeliveryAt: string | null; status: string; relationship: 'PLANNED' | 'DEPARTED' +} +export interface VehicleAssignmentChange { + tripNo: string; recordVersion: string; occurredAt: string; reason: string | null + previousVehiclePlate: string | null; vehiclePlate: string | null; previousDriverName: string | null; driverName: string | null + relationship: 'REPLACED_OUT' | 'REPLACED_IN' | 'DRIVER_CHANGED'; dispatchNos: string[] +} +type Numeric = string | number +type WirePage = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +type WireChange = Omit & { recordVersion: Numeric } +function integer(value: Numeric) { + const number = Number(value) + if (value == null || value === '' || !Number.isSafeInteger(number) || number < 0) throw new Error('车辆履约接口返回无效分页数值') + return number +} +function page(value: WirePage, normalize: (row: W) => T): PageResult { + return { records: value.records.map(normalize), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +const base = (vehicleId: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}` +export async function listVehicleDeliveries(vehicleId: string, currentPage = 1, size = 10) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(vehicleId)}/deliveries`, { params: { page: currentPage, size } })) + return page(result, row => row) +} +export async function listVehicleAssignmentChanges(vehicleId: string, currentPage = 1, size = 10) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(vehicleId)}/assignment-history`, { params: { page: currentPage, size } })) + return page(result, row => ({ ...row, recordVersion: String(row.recordVersion) })) +} diff --git a/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts new file mode 100644 index 00000000..27ab2e95 --- /dev/null +++ b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.test.ts @@ -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: '
' }, + ElButton: { template: '' }, LoadErrorState: { name: 'LoadErrorState', props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(wrapper); return wrapper +} +beforeEach(() => { + vi.clearAllMocks(); permissions.allowed = true + vi.mocked(api.listVehicleDeliveries).mockResolvedValue(result('DL-1') as Awaited>) + 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>) => 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>); 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) +}) diff --git a/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue new file mode 100644 index 00000000..3f3ad49c --- /dev/null +++ b/src/modules/overseas-master-data/components/VehicleFulfillmentTable.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts index b510f109..8f0d61a5 100644 --- a/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts +++ b/src/modules/overseas-master-data/pages/OverseasMasterDataMount.test.ts @@ -37,5 +37,15 @@ describe('overseas master data real page mounts', () => { expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true) }) - it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(14); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('派送记录请在海外仓的排车管理或派送管理中查看') }) + it('mounts vehicle detail and scopes both real fulfillment projections to the loaded vehicle', async () => { + const wrapper = mountView(VehicleDetailView); await flushPromises() + expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST') + expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(14) + expect(wrapper.get('.density-fields').text()).toContain('在役') + expect(wrapper.text()).not.toContain('IN_SERVICE') + const tables = wrapper.findAllComponents({ name: 'VehicleFulfillmentTable' }) + expect(tables.map(table => table.props())).toEqual([ + { vehicleId: 'VH-TEST-001', kind: 'deliveries' }, { vehicleId: 'VH-TEST-001', kind: 'changes' }, + ]) + }) }) diff --git a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts index 8ea74d87..bb5dc121 100644 --- a/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts +++ b/src/modules/overseas-master-data/pages/OverseasMasterDataViews.test.ts @@ -76,8 +76,9 @@ describe('overseas master data real contract pages', () => { expect(detail).toContain('派送订单明细') expect(detail).toContain('派送更换记录') expect(detail).toContain('getVehicle(') - expect(detail).toContain('派送记录请在海外仓的排车管理或派送管理中查看') - expect(detail).toContain('车辆更换记录尚未接入') + expect(detail).toContain(':vehicle-id="detail.id" kind="deliveries"') + expect(detail).toContain(':vehicle-id="detail.id" kind="changes"') + expect(detail).not.toContain('车辆更换记录尚未接入') expect(detail).not.toContain('AppDrawer') expect(detail).not.toContain('const vehicle =') }) diff --git a/src/modules/overseas-master-data/pages/VehicleDetailView.vue b/src/modules/overseas-master-data/pages/VehicleDetailView.vue index 1ed8f8ce..f84e0b54 100644 --- a/src/modules/overseas-master-data/pages/VehicleDetailView.vue +++ b/src/modules/overseas-master-data/pages/VehicleDetailView.vue @@ -2,6 +2,7 @@ import '@/styles/adminDensity.css' import { onBeforeUnmount, ref, watch } from 'vue' import VehicleCareTable from '../components/VehicleCareTable.vue' +import VehicleFulfillmentTable from '../components/VehicleFulfillmentTable.vue' import type { VehicleCareType } from '../vehicleCareTypes' import StatusBadge from '@/shared/components/StatusBadge.vue' import { vehicleStatusLabels } from '../presentation' @@ -17,5 +18,5 @@ function back(){void router.push({name:'overseas-master-data-vehicles'})} watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true }) onBeforeUnmount(() => { request++ }) - - + +