From dc35288a1bb0169ae8d06783bdfcc770c150b8fb Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 04:35:55 +0800 Subject: [PATCH] feat(fleet): integrate trip management and fee coverage workflows --- AGENTS.md | 7 +- src/app/components/AdminSidebarMenu.test.ts | 4 +- src/core/navigation/adminNavigation.test.ts | 102 ++------ src/core/navigation/adminNavigation.ts | 72 +++--- src/core/navigation/prototypeMenuIcons.ts | 12 + .../api/overseasContainerFees.test.ts | 4 + .../api/overseasContainerFees.ts | 2 + .../ContainerLastLegFeesPanel.test.ts | 9 + .../components/ContainerLastLegFeesPanel.vue | 7 +- .../pages/ContainersView.vue | 8 +- .../pages/DomesticContainersView.test.ts | 2 +- .../pages/DomesticContainersView.vue | 4 +- src/modules/iam/utils/permissionTree.ts | 3 +- src/modules/overseas-master-data/AGENTS.md | 23 ++ .../overseas-master-data/CONTRACT_GAPS.md | 4 +- .../overseas-master-data/PROTOTYPE_MATRIX.md | 63 ++++- .../overseas-master-data/api/drivers.test.ts | 75 ++++++ .../overseas-master-data/api/drivers.ts | 100 ++++++++ .../api/overseasMasterData.test.ts | 4 +- .../api/overseasMasterData.ts | 4 +- .../overseas-master-data/api/tripBookings.ts | 47 ++++ .../overseas-master-data/api/trips.test.ts | 114 +++++++++ src/modules/overseas-master-data/api/trips.ts | 112 +++++++++ .../api/vehicleCare.test.ts | 45 ++++ .../overseas-master-data/api/vehicleCare.ts | 70 ++++++ .../overseas-master-data/bookingTypes.ts | 24 ++ .../components/DriverEditorDialog.vue | 124 ++++++++++ .../components/DriverLicensePreview.vue | 47 ++++ .../components/TripAssignmentPanel.vue | 74 ++++++ .../TripBookingFulfillmentDialog.test.ts | 45 ++++ .../TripBookingFulfillmentDialog.vue | 95 ++++++++ .../components/TripBookingPanel.test.ts | 57 +++++ .../components/TripBookingPanel.vue | 144 ++++++++++++ .../components/TripLoadingPanel.vue | 82 +++++++ .../components/TripPlanDialog.vue | 84 +++++++ .../components/VehicleCareBehavior.test.ts | 154 +++++++++++++ .../components/VehicleCareEditor.vue | 139 +++++++++++ .../components/VehicleCareTable.vue | 110 +++++++++ .../overseas-master-data/driverTypes.ts | 74 ++++++ .../pages/DriverDetailView.vue | 168 ++++++++++++++ .../pages/DriversBehavior.test.ts | 217 ++++++++++++++++++ .../pages/DriversView.vue | 145 ++++++++++++ .../pages/OverseasMasterDataMount.test.ts | 4 +- .../pages/OverseasMasterDataViews.test.ts | 12 +- .../OverseasMasterDataWriteBehavior.test.ts | 2 +- .../pages/TripDetailView.vue | 213 +++++++++++++++++ .../pages/TripsBehavior.test.ts | 140 +++++++++++ .../overseas-master-data/pages/TripsView.vue | 59 +++++ .../pages/VehicleDetailView.vue | 16 +- .../pages/VehiclesView.vue | 16 +- .../overseas-master-data/permissions.ts | 22 ++ .../overseas-master-data/routes.test.ts | 4 + src/modules/overseas-master-data/routes.ts | 26 ++- src/modules/overseas-master-data/tripTypes.ts | 66 ++++++ src/modules/overseas-master-data/types.ts | 3 + .../overseas-master-data/vehicleCareTypes.ts | 41 ++++ src/modules/overseas-warehouse/AGENTS.md | 2 + .../overseas-warehouse/PROTOTYPE_MATRIX.md | 23 ++ .../overseas-warehouse/api/deliveryOrders.ts | 4 +- .../overseas-warehouse/api/dispatches.test.ts | 13 +- .../overseas-warehouse/api/dispatches.ts | 24 ++ .../components/DeliveryFeeCoveragePanel.vue | 35 +++ .../components/SmartDispatchDialog.vue | 34 +-- .../dispatchPhysicalWeight.test.ts | 16 ++ .../dispatchPhysicalWeight.ts | 22 ++ .../feeCoverageTypes.test.ts | 20 ++ .../overseas-warehouse/feeCoverageTypes.ts | 29 +++ .../pages/DeliveryOrderDetailView.test.ts | 20 +- .../pages/DeliveryOrderDetailView.vue | 18 +- .../pages/DispatchDetailView.test.ts | 11 + .../pages/DispatchDetailView.vue | 10 +- .../pages/DispatchesView.test.ts | 58 ++++- .../pages/DispatchesView.vue | 46 +++- .../pages/LastMileOrdersView.behavior.test.ts | 74 +++++- .../pages/LastMileOrdersView.vue | 52 ++++- .../pages/OutboundsView.vue | 2 +- .../pages/ReturnInventoryManagementView.vue | 2 +- .../pages/prototypeContract.test.ts | 6 +- .../suppliers/pages/SuppliersView.test.ts | 2 +- src/modules/suppliers/pages/SuppliersView.vue | 4 +- .../pages/UnifiedOrdersView.vue | 11 +- src/shared/components/AdminSelect.test.ts | 75 ++++++ src/shared/components/AdminSelect.ts | 9 + src/shared/components/DataTable.vue | 4 + src/styles/adminDensity.css | 20 ++ src/styles/adminDensity.test.ts | 48 +++- vite.config.ts | 9 +- 87 files changed, 3774 insertions(+), 233 deletions(-) create mode 100644 src/modules/overseas-master-data/AGENTS.md create mode 100644 src/modules/overseas-master-data/api/drivers.test.ts create mode 100644 src/modules/overseas-master-data/api/drivers.ts create mode 100644 src/modules/overseas-master-data/api/tripBookings.ts create mode 100644 src/modules/overseas-master-data/api/trips.test.ts create mode 100644 src/modules/overseas-master-data/api/trips.ts create mode 100644 src/modules/overseas-master-data/api/vehicleCare.test.ts create mode 100644 src/modules/overseas-master-data/api/vehicleCare.ts create mode 100644 src/modules/overseas-master-data/bookingTypes.ts create mode 100644 src/modules/overseas-master-data/components/DriverEditorDialog.vue create mode 100644 src/modules/overseas-master-data/components/DriverLicensePreview.vue create mode 100644 src/modules/overseas-master-data/components/TripAssignmentPanel.vue create mode 100644 src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts create mode 100644 src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.vue create mode 100644 src/modules/overseas-master-data/components/TripBookingPanel.test.ts create mode 100644 src/modules/overseas-master-data/components/TripBookingPanel.vue create mode 100644 src/modules/overseas-master-data/components/TripLoadingPanel.vue create mode 100644 src/modules/overseas-master-data/components/TripPlanDialog.vue create mode 100644 src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts create mode 100644 src/modules/overseas-master-data/components/VehicleCareEditor.vue create mode 100644 src/modules/overseas-master-data/components/VehicleCareTable.vue create mode 100644 src/modules/overseas-master-data/driverTypes.ts create mode 100644 src/modules/overseas-master-data/pages/DriverDetailView.vue create mode 100644 src/modules/overseas-master-data/pages/DriversBehavior.test.ts create mode 100644 src/modules/overseas-master-data/pages/DriversView.vue create mode 100644 src/modules/overseas-master-data/pages/TripDetailView.vue create mode 100644 src/modules/overseas-master-data/pages/TripsBehavior.test.ts create mode 100644 src/modules/overseas-master-data/pages/TripsView.vue create mode 100644 src/modules/overseas-master-data/tripTypes.ts create mode 100644 src/modules/overseas-master-data/vehicleCareTypes.ts create mode 100644 src/modules/overseas-warehouse/components/DeliveryFeeCoveragePanel.vue create mode 100644 src/modules/overseas-warehouse/dispatchPhysicalWeight.test.ts create mode 100644 src/modules/overseas-warehouse/dispatchPhysicalWeight.ts create mode 100644 src/modules/overseas-warehouse/feeCoverageTypes.test.ts create mode 100644 src/modules/overseas-warehouse/feeCoverageTypes.ts create mode 100644 src/shared/components/AdminSelect.test.ts create mode 100644 src/shared/components/AdminSelect.ts diff --git a/AGENTS.md b/AGENTS.md index 29825dce..3ccd446f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,9 @@ 2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 -4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/统一认证与服务通信契约.md`。 +4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。 -普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 +普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。 ## 项目定位与技术基线 @@ -75,7 +75,7 @@ | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | | 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 | -全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。 +全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。 散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。 @@ -88,6 +88,7 @@ | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | | 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` | | 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` | +| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` | ## 业务、安全与共享装配 diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts index 0c1babf0..ba96e2ff 100644 --- a/src/app/components/AdminSidebarMenu.test.ts +++ b/src/app/components/AdminSidebarMenu.test.ts @@ -63,10 +63,12 @@ describe('AdminSidebarMenu', () => { expect(groupTitles).not.toContain('供应商中心') expect(groupTitles).toContain('通用') expect(groupTitles).toContain('系统管理') + expect(groupTitles).not.toContain('预约管理') expect(groupTitles).not.toContain('财务管理') + expect(paths).not.toContain('/finance/customer-accounts') expect(paths).toContain('/dashboard') expect(paths).toContain('/orders') - expect(paths).toContain('/master-data/express-companies') + expect(paths).not.toContain('/master-data/express-companies') expect(paths).not.toContain('/orders/first-leg') expect(paths).not.toContain('/first-leg-pricing/customer-rules') expect(paths).toContain('/downloads') diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index ec14da23..6b8dfe56 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,79 +8,26 @@ import { } from './adminNavigation' describe('adminNavigation', () => { - it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => { - expect(adminNavigation.map((item) => item.title)).toEqual([ - '首页数据看板', - '客户管理', - '国内仓', - '海外派送', - '货柜管理', - '海外业务', - '海外仓', - '客户服务', - '财务管理', - '销售管理', - '通用', - '系统管理', - ]) - expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([ - '客户列表', - '海外仓阶梯费用', - ]) - expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([ - '订单列表', - '订单测量', - '测量数据审核', - '预装清单', - '库位管理', - '托盘管理', - '整柜业务', - '船期日历', - ]) - expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) - expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ - '订舱列表', - '拖车列表', - '货柜列表', - '清关提柜', - ]) - expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ - '尾程订单', - '入库单', - '库位管理', - '排车列表', - '出库单', - '派送单列表', - '换标单', - '托盘管理', - '库存管理', - '退货库存管理', - '退货箱号管理', - ]) - expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([ - '产品信息', - '供应商列表', - '快递公司', - '往来单位', - '费用项目', - '汇率管理', - '仓库管理', - '海外仓报价', - '船期价格表', - '车辆列表', - '下载中心', - '称重机器', + it('matches the latest prototype sidebar using only implemented menu entries', () => { + // 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56). + const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : [])) + const visible = filterAdminNavigation(adminNavigation, allPermissions) + expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([ + { title: '首页数据看板', children: [] }, + { title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] }, + { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, + { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] }, + { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理'] }, + { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, + { title: '客户服务', children: ['异常订单'] }, + { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '船期价格表', '下载中心'] }, + { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) + expect(visible).toHaveLength(11) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(44) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ - '用户管理', - '角色权限', - '租户管理', - '品牌设置', - '菜单权限', - 'PDA版本', - ]) }) it('assigns every page a unique path and only shares permissions backed by the API contract', () => { @@ -173,7 +120,6 @@ describe('adminNavigation', () => { '/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders', '/overseas-warehouse/exception-orders', - '/overseas-warehouse/customer-visits', '/overseas-warehouse/relabel-orders', '/overseas-warehouse/pallets', '/overseas-warehouse/inventory', @@ -213,12 +159,10 @@ describe('adminNavigation', () => { ])) const paths = getNavigationLeaves(filtered).map((item) => item.path) - expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用']) + expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用']) expect(paths).toEqual([ '/dashboard', '/orders', - '/master-data/express-companies', - '/master-data/counterparties', '/warehouses', ]) expect(paths).toContain('/dashboard') @@ -238,11 +182,12 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items']) }) - it('shows the public supplier list only with the list permission', () => { + it('retains the supplier route permission while omitting its entry from the latest sidebar', () => { const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list'])) const missing = filterAdminNavigation(adminNavigation, new Set()) - expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers']) + expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list') + expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard']) expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard']) }) @@ -256,9 +201,8 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', - '/loading-plans', '/container-loading/domestic', - '/container-loading/overseas', + '/loading-plans', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 91e825cd..6efdcc0b 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -17,6 +17,7 @@ import { } from '@lucide/vue' import { PrototypeContainerIcon, + PrototypeCustomerServiceIcon, PrototypeCustomerIcon, PrototypeDashboardIcon, PrototypeDeliveryIcon, @@ -27,6 +28,7 @@ import { PrototypeOverseasWarehouseIcon, PrototypeSalesIcon, PrototypeSystemIcon, + PrototypeVehicleIcon, } from './prototypeMenuIcons' export interface AdminNavigationNode { @@ -61,17 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [ }, { key: 'domestic-warehouse', - title: '国内仓', + title: '国内头程', icon: PrototypeDomesticWarehouseIcon, children: [ { key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] }, { key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' }, { key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' }, - { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' }, { key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' }, { key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' }, - { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, + { key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, ], }, { @@ -94,7 +95,36 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, { key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' }, - { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' }, + { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, + { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true }, + ], + }, + { + key: 'overseas-inventory', + title: '海外尾程', + icon: PrototypeOverseasWarehouseIcon, + children: [ + { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, + { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, + { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, + { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, + { key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, + { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, + { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, + { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, + { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true }, + { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true }, + ], + }, + { + key: 'vehicle-management', + title: '车辆管理', + icon: PrototypeVehicleIcon, + children: [ + { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, + { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, + { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, + { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, ], }, { @@ -107,31 +137,13 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' }, ], }, - { - key: 'overseas-inventory', - title: '海外仓', - icon: PrototypeOverseasWarehouseIcon, - children: [ - { key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' }, - { key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' }, - { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, - { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, - { key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, - { key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, - { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, - { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, - { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, - { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' }, - { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' }, - ], - }, { key: 'customer-service', title: '客户服务', - icon: ContactRound, + icon: PrototypeCustomerServiceIcon, children: [ { key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' }, - { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' }, + { key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true }, ], }, { @@ -162,18 +174,16 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '通用', icon: PrototypeGeneralIcon, children: [ - { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true }, - { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' }, - { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' }, - { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' }, { key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' }, { key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' }, { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, { key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' }, { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, - { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, - { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' }, + { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true }, + { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true }, + { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true }, + { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true }, ], }, { @@ -186,7 +196,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true }, { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true }, { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, - { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' }, + { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true }, ], }, ] diff --git a/src/core/navigation/prototypeMenuIcons.ts b/src/core/navigation/prototypeMenuIcons.ts index 20f85ce4..0155e6bd 100644 --- a/src/core/navigation/prototypeMenuIcons.ts +++ b/src/core/navigation/prototypeMenuIcons.ts @@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }), ]) +export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [ + h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }), + h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), + h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), +]) + +export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [ + h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), + h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }), + h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), +]) + export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [ h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }), h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }), diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts index 91e7d955..f69fe437 100644 --- a/src/modules/container-loading/api/overseasContainerFees.test.ts +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -22,4 +22,8 @@ describe('overseas container fees API', () => { expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' }) expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' }) }) + it('preserves a pending financial cancellation without marking the source voided', async () => { + vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { ...raw, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' } }) + await expect(voidOverseasContainerFee('81', 2, '重复费用', 'void-1')).resolves.toMatchObject({ sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' }) + }) }) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts index 35e305c3..10979cc6 100644 --- a/src/modules/container-loading/api/overseasContainerFees.ts +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -17,6 +17,7 @@ export interface OverseasContainerFeeSource { paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' reviewReason: string | null; reviewedAt: string | null + voidStatus?: string | null } export interface OverseasContainerFeeSaveRequest { customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER' @@ -39,6 +40,7 @@ function normalizeFee(raw: RawResource): OverseasContainerFeeSource { supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo), paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount), reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt), + voidStatus: text(raw.voidStatus), } } export async function listOverseasContainerFees(containerBookingId: string) { diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts index f00836f6..6eb217d9 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts @@ -37,6 +37,15 @@ describe('尾程付款凭证', () => { mocks.download.mockResolvedValue(new Blob(['image'])) }) + it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => { + mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }]) + const wrapper = await setup() + expect(wrapper.text()).toContain('待财务确认') + expect(wrapper.text()).not.toContain('已作废') + expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false) + wrapper.unmount() + }) + it('显示上传文件名,允许再次上传替换和移除', async () => { const wrapper = await setup() await wrapper.get('button').trigger('click') diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index bd0e05d7..f5ce4469 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -68,6 +68,8 @@ const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [ ] const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value const reviewLabels: Record = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } +const voidLabels: Record = { NOT_REQUESTED: '—', PENDING_FINANCE: '待财务确认', FINANCE_ATTENTION: '待财务处理', CONFIRMED: '已作废' } +function voidPending(row: OverseasContainerFeeSource) { return ['PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(row.voidStatus || '') } const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] }) watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' }) @@ -155,7 +157,7 @@ async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'RE } async function voidFee(row: OverseasContainerFeeSource) { const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' }) - await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废') + await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态') } onMounted(load) @@ -168,7 +170,7 @@ onMounted(load) - + @@ -177,6 +179,7 @@ onMounted(load) + diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 216928b2..5474de40 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -737,9 +737,9 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) }) + diff --git a/src/modules/iam/utils/permissionTree.ts b/src/modules/iam/utils/permissionTree.ts index 03853b1d..19e3fdd3 100644 --- a/src/modules/iam/utils/permissionTree.ts +++ b/src/modules/iam/utils/permissionTree.ts @@ -102,7 +102,8 @@ export function buildNavigationPermissionTree( } function navigationNode(node: AdminNavigationNode): RolePermissionTreeNode | null { - if (node.hidden) return null + // Hiding a formal sidebar entry must not remove its existing assignable permissions. + if (node.hidden && !node.permission && !node.permissionAnyOf?.length) return null if (node.children?.length) { const children = node.children diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md new file mode 100644 index 00000000..fecd66ad --- /dev/null +++ b/src/modules/overseas-master-data/AGENTS.md @@ -0,0 +1,23 @@ +# 海外通用资料与车辆管理 + +- 适用应用规则见 `../../../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存在推导页面能力。 +- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。 +- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,CSV使用车牌业务标识并包含容量字段。 +- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。 +- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。 +- 司机列表 `pages/DriversView.vue`、详情 `pages/DriverDetailView.vue`,编辑和证照预览在 `components/DriverEditorDialog.vue`、`DriverLicensePreview.vue`。详情独立路由;编辑居中1080px三列表单;预览居中620px。 +- 列表与统计使用同一次服务端响应;统计范围与列表筛选一致,30天提醒按证照时区计算。页面不能用当前分页行数替代总统计,也不能在浏览器重算派车资格。 +- 姓名/证照关键字段变化与替换驾驶证重新待审;体检、培训和保险当前仅记录日期,不自动成为必需资质。所属仓库使用真实公共目录,不接受自由文本假关联。 +- `driver:list` 包含详情、历史、资格预览与关联附件读取;创建、编辑、启停、审核、上传权限独立。前端只隐藏/禁用操作,服务端仍需校验。 +- 保存档案成功而上传失败时保留已生成的司机和版本,重试不得再次创建;版本冲突保留表单及服务端错误。审核或修改成功使用返回详情,不无条件再次请求详情。 +- 历史按需分页;附件只在打开预览时读取,关闭/卸载释放Blob URL,忽略已关闭后的迟到响应。文件业务错误不能作为图片/PDF预览。 +- `DriverDetailView` 资格预览随预计结束时间或档案变化失效;只显示待重新校验,不自行构造服务端资格结论。真实派车和发车仍须服务端命令复核。 +- 定向检查:`pnpm test src/modules/overseas-master-data src/core/navigation/adminNavigation.test.ts src/styles/adminDensity.test.ts`;发布前按应用规则执行类型检查、全量测试和构建。 + +- 车次详情“计划容量”页签按需读写`/capacity-plan`,复用`TripLoadingPanel`的计划模式,预计重量/体积与实际装载分别保存;只在该页签展示逐段计划余位。`planValid=false`时不展示旧余位,货物变化后重新核对;保存计划只更新车次版本,不把理货审核变成通过或写入实载。 + +- `TripBookingPanel.vue`在车次详情“外接预订”页签按需读取列表;1080px居中弹框登记/编辑或只读详情,客户聚焦搜索才读取`/fleet/trips/customer-options`。询价只要求客户、既有卸货站和箱数,载荷/价格/联系信息可后补;确认承运由服务端校验余位及完整报价。create/edit/confirm/cancel分别使用独立`trip:booking-*`权限,读操作沿trip:list。未知创建及确认结果保留原请求重试,成功用命令结果更新列表和车次版本;计划容量表展示availableSegments(已扣除外接占位),不使用旧自有货余位覆盖。 + +- 外接履约弹框`TripBookingFulfillmentDialog.vue`为1080px居中:登记来货、按服务器实托/receiptHash核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。 diff --git a/src/modules/overseas-master-data/CONTRACT_GAPS.md b/src/modules/overseas-master-data/CONTRACT_GAPS.md index e104547b..62988101 100644 --- a/src/modules/overseas-master-data/CONTRACT_GAPS.md +++ b/src/modules/overseas-master-data/CONTRACT_GAPS.md @@ -1,6 +1,6 @@ # 海外通用资料真实接口边界 -三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 +三域统一使用共享 `http` 客户端和浏览器业务路径 `/platform/v1/last-leg/master-data`,经当前统一 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。 - 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。 - 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。 @@ -9,7 +9,7 @@ 当前明确边界: - 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。 -- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。 +- 车辆详情已接保养、加油、维修、故障、救援和异常记录,合同见 `fleet-vehicle-records-v1.yaml`;派送履约及跨车次更换历史尚未接入,页面提示从现有排车/派送管理查看派送记录,不拼装其他订单数据。 - 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。 - 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。 - 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index e4b689ab..0e83b6f8 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -13,8 +13,65 @@ | 汇率管理 `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:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 | +| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;派送区说明从现有排车/派送管理查看,更换区明确尚未接入 | 不拼装订单/派送数据;用户提示不展示内部接口说明 | +| 车辆 | 新增/编辑 | `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 | -原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。 +旧版原型未定义的车辆文件预览、审核/确认流程不自行补充;新版保养及加油照片按下表的原型链接行为接入。 + +## 新版车辆保养及加油接入矩阵 + +输入与新版司机相同。范围是车辆详情内的保养、加油记录;维修、故障、救援、异常和财务关联尚未在这一增量实现。 + +| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 | +| --- | --- | --- | --- | +| 车辆详情保养及加油 | `新版.html:9033、9044`,`vehicle-detail-ops`;`22882–22892` | 独立车辆详情内页签,保养/加油两组表格 | 按当前车辆分别分页,进入页签才请求,不把其他车记录带入;操作列第一列 | +| 新增/编辑保养与加油 | `8937` 的 `modal-vehicle-ops-record`、`22903–22916` | 680px 居中弹框,两列表单,底部取消/保存 | 沿用字段分组;明确里程及加油单位,实际时间带时区提交;更正保留原因与版本,不自动改车辆主档里程 | +| 查看记录图片 | `22891–22892` 的 `target="_blank"` 图片链接 | 新标签页查看图片 | 点击时鉴权下载已关联图片,以临时Blob打开,关闭释放;不把JSON错误当图片,不新增预览抽屉 | +| 删除 | `22917` 附近 `deleteVehicleOpsRecord`,浏览器confirm | 删除确认 | 删除/作废语义仍待业务明确,本增量不注册删除端点或展示假动作 | +| 核验状态 | `22906` 加油表单及车辆保养表单 | 表单内字段 | 保存人工核验状态,不替代财务付款或车辆可派资格;不增加审批工作流 | + +真实1920×1080视觉和交互验收待执行;接口与自动测试分别记录,不视为视觉通过。 + +## 新版维修、故障、救援及异常接入矩阵 + +| 页面/操作 | 原型证据 | 原型承载及尺寸 | 实施口径 | +| --- | --- | --- | --- | +| 维修、紧急故障、应急救援、异常列表 | `新版.html:9045–9048` 的四个 `vehicle-detail-*` 分区 | 车辆详情内四个页签,各自表格 | 只查询当前车辆和当前类型,切换时忽略旧响应;不把原型全局数组当作跨车共享记录 | +| 四类新增 | `8938` 的 `modal-vehicle-detail-add`、`23275` 的 `openVehicleDetailAdd` | 620px居中弹框,日期/处理人、描述、费用/状态 | 按类型明确字段;维修/救援项目与地点单独输入并保存,修复原型读写错配;金额含明确币种,处理状态与付款信息分开 | +| 异常编辑 | `23270–23273` 的 `editVehicleException`,`9048` 行操作 | 复用620px居中弹框 | 保留版本与更正原因;操作列按全局要求放第一列 | +| 维修/故障/救援更新处理 | 三类原型仅新增;2026-10-02 用户同意按建议出首版 | 列表“更新处理”,复用620px原弹框 | 已开放独立编辑权限的更新处理;更正说明选填,未填记录标准操作说明 | +| 费用与付款 | `9047` 付款状态列与原弹框处理状态混用 | 付款为列表只读信息 | 当前无财务关联时服务端明确返回UNLINKED,显示“未关联财务”;不把COMPLETED映射为已付款,不显示无来源实付金额 | +| 图片、删除 | 四类弹框未定义图片或删除入口 | 未定义 | 本增量不为这四类新增图片弹框、删除按钮或作废动作 | + +以上仅为代码接入依据,真实界面验收仍待执行。 + +## 新版司机管理接入矩阵 + +输入为用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-09/新版.html`,哈希及业务确认见 `docs/海外仓业务/车辆管理原型梳理与任务规划.md`。下列为本次实现依据,真实页面验收尚待执行。 + +| 页面 | 操作 | 原型证据 | 原型承载方式 | 尺寸/布局与实施结论 | +| --- | --- | --- | --- | --- | +| 司机管理 | 列表/筛选 | `8798–8805`,`page-driver-management` | 独立列表页,三张可见KPI | 保留总数、30天到期、已过期;共用服务端筛选统计,操作列按全局约定移至第一列;状态拆分为启停、审核和证照有效期,不伪造任务中状态 | +| 司机管理 | 详情 | `8807–8813`,`page-driver-management-detail` | 独立详情页 | 基础信息、驾驶资质、证照与派车状态;使用正式详情路由,连续四列字段与返回入口 | +| 司机管理 | 新增/编辑 | `8815`,`modal-driver-edit` | 居中弹框,原宽820px | 多字段按高密度规范使用1080px三列表单,保留字段分组;可先保存待补件档案,证照上传失败可重试,不重复创建司机;审核状态改由独立命令维护 | +| 司机管理 | 文件预览 | `8816`,`modal-driver-license-view` | 620px居中弹框 | 真实图片/PDF预览,当前或历史已关联附件鉴权读取,关闭即释放临时预览地址 | +| 司机管理 | 审核/打回 | 当前对话确认“详情展示、点击通过或者打回” | 原型无独立审核弹框 | 在详情的证照区域显示操作、意见与处理结果;打回必填原因,独立review权限;不在编辑表单里选择已审核 | +| 司机管理 | 启停 | 原型删除使用浏览器confirm;停用为本次明确的服务端命令 | 居中确认 | 保留档案与历史,启停填写原因;不将停用描述成物理删除 | + +## 共享车次新增交互(2026-10-02 用户确认首版方案) + +业务依据为当前车辆管理规划第4.1节。原型出车记录 `新版.html:8941–8987`、`modal-vehicle-trip`(8966)仅展示记录、统计和新增/编辑弹框,没有共享车次关联及实际装载工作台。用户已明确“按你的建议执行,先做一个版本再优化”,采用独立车次列表与详情页,计划居中弹框,分配/关联/装载在详情分区,结案和资源释放分别确认。首版普通说明选填,不增加格式校验清单;保留权限、归属、版本、容量和证照期限门禁。 + +| 页面/操作 | 原型证据 | 建议承载 | 当前结论 | +| --- | --- | --- | --- | +| 车次列表、详情 | 出车记录列表;无共享车次详情定义 | 独立列表与详情页,按已确认密度规范排版 | 已按用户确认接入首版;真实页面验收待执行 | +| 创建、编辑计划 | 出车记录新增/编辑为 `modal-vehicle-trip`;新计划语义另见业务确认 | 居中计划弹框,路线表格整行 | 已按确认方案接入计划弹框;不将原型手填统计值当作计划字段 | +| 分配车辆司机、关联/解除排车 | 新业务规则,无原型对应操作 | 详情页内分区 | 已按用户确认接入首版;真实页面验收待执行 | +| 调度/理货审核 | 原型有详情、通过及打回;已确认调度先于拣货、理货先于发车 | 关联排车行独立审核,实际装载分区整车理货审核,打回居中填写原因 | 首版已接入独立权限和服务端审核状态;真实页面验收待执行 | +| 外接询价、详情与承运占位 | 用户确认运营后台登记外接业务;首版不扩展客户在线撮合 | 车次详情外接页签、1080px居中三列表单,详情只读、确认/取消居中确认 | 已接登记/编辑/确认/取消、独立权限及已确认预订余位;已接真实来货登记、实收复核和排车关联;真实页面仍待验收 | +| 计划容量与剩余运力 | 本轮外接剩余运力方案,原型无完整占位工作台 | 车次详情独立页签,复用托盘核对表,显示预计值和每段剩余托位/kg/m³ | 源码已接计划容量;已扣除确认承运的外接占位;不把预订当实物装载 | +| 实际装载确认 | 新业务规则,无原型对应操作 | 详情页内实物托盘表及确认区 | 已按用户确认接入首版;真实页面验收待执行 | +| 货物结案、结束车辆/司机任务 | 新业务规则,无原型对应操作 | 详情页展示服务端检查结果,提交前居中确认 | 已接独立命令和确认;结案与资源释放独立 | + +| 外接来货与实收复核、关联发运 | 首版外接货自送起运仓、按实际货量确认费用的已确认方案 | 车次外接行操作打开1080px居中弹框;现有PDA转运实收承载现场作业 | 已接真实来源、实测差异与运费复核、版本/hash和客户排车关联;未将本地验证当作环境验收 | diff --git a/src/modules/overseas-master-data/api/drivers.test.ts b/src/modules/overseas-master-data/api/drivers.test.ts new file mode 100644 index 00000000..81292d19 --- /dev/null +++ b/src/modules/overseas-master-data/api/drivers.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError, http } from '@/shared/api/http' +import { changeDriverStatus, createDriver, downloadDriverLicense, getDriver, getDriverQualification, listDriverHistory, listDrivers, reviewDriver, submitDriver, updateDriver, uploadDriverLicense } from './drivers' +import type { DriverDraft } from '../driverTypes' + +vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } })) +const id = '9223372036854775701' +const fileId = '9223372036854775801' +const base = '/platform/v1/last-leg/fleet/drivers' +const details: DriverDraft = { name: '测试司机', phone: 'test-phone', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: null } +const record = { id, driverNo: 'DR-TEST', details, active: true, reviewStatus: 'APPROVED', licenseFileId: fileId, recordVersion: '2', updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-22' } } +const ok = (data: unknown) => ({ code: '200', message: '成功', data }) +beforeEach(() => vi.clearAllMocks()) + +describe('driver APIs', () => { + it('preserves long IDs and date-only values, uses one page request, and normalizes versions', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [record], page: '1', size: '20', total: '1', pages: '1', summary: { total: '1', owned: '1', dispatched: '0', expiringSoon: '1', expired: '0' } })).mockResolvedValueOnce(ok(record)) + const result = await listDrivers({ page: 1, size: 20, active: false }) + expect(result).toMatchObject({ page: 1, total: 1, records: [{ id, licenseFileId: fileId, recordVersion: 2, details }] }) + expect(http.get).toHaveBeenCalledTimes(1) + await getDriver(id) + expect(http.get).toHaveBeenNthCalledWith(1, base, { params: { page: 1, size: 20, active: false } }) + expect(http.get).toHaveBeenNthCalledWith(2, `${base}/${id}`, { params: { expectedEnd: undefined } }) + }) + + it('sends current versions to edits, reviews, status changes, and resubmission', async () => { + vi.mocked(http.post).mockResolvedValue(ok(record)) + vi.mocked(http.put).mockResolvedValue(ok(record)) + vi.mocked(http.patch).mockResolvedValue(ok(record)) + await createDriver(details) + await updateDriver(id, 2, details) + await reviewDriver(id, 3, 'REJECTED', '附件不清楚') + await changeDriverStatus(id, 4, false, '暂停驾驶') + await submitDriver(id, 5) + expect(http.post).toHaveBeenCalledWith(base, details) + expect(http.put).toHaveBeenCalledWith(`${base}/${id}`, { recordVersion: 2, details }) + expect(http.post).toHaveBeenCalledWith(`${base}/${id}/review`, { recordVersion: 3, decision: 'REJECTED', reason: '附件不清楚' }) + expect(http.patch).toHaveBeenCalledWith(`${base}/${id}/status`, { recordVersion: 4, active: false, reason: '暂停驾驶' }) + expect(http.post).toHaveBeenCalledWith(`${base}/${id}/submit`, { recordVersion: 5 }) + }) + + it('uploads a real multipart file with the version and leaves multipart boundary to the browser', async () => { + vi.mocked(http.post).mockResolvedValue(ok(record)) + const file = new File(['%PDF-1.7'], 'license.pdf', { type: 'application/pdf' }) + await uploadDriverLicense(id, 2, file) + const [url, body] = vi.mocked(http.post).mock.calls[0]! + expect(url).toBe(`${base}/${id}/license`) + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('recordVersion')).toBe('2') + expect((body as FormData).get('file')).toBe(file) + }) + + it('takes task qualification from server and keeps history snapshots distinct', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok(record.qualification)).mockResolvedValueOnce(ok({ page: '1', size: '20', total: '1', pages: '1', records: [{ recordVersion: '1', action: 'UPLOAD_LICENSE', snapshot: { ...record, recordVersion: '1', reviewStatus: 'PENDING' } }] })) + const expectedEnd = '2026-10-20T18:00:00Z' + expect(await getDriverQualification(id, expectedEnd)).toEqual(record.qualification) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/qualification`, { params: { expectedEnd } }) + expect((await listDriverHistory(id)).records[0]).toMatchObject({ recordVersion: 1, snapshot: { recordVersion: 1, reviewStatus: 'PENDING' } }) + }) + + it('downloads only real supported blobs and rejects HTTP200 JSON business failures', async () => { + const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' }) + vi.mocked(http.get).mockResolvedValueOnce(pdf).mockResolvedValueOnce({ code: 'A0404', message: '文件未关联', requestId: 'test-request' }).mockResolvedValueOnce(new Blob([''], { type: 'text/html' })) + expect(await downloadDriverLicense(id, fileId)).toBe(pdf) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/license/${fileId}`, { responseType: 'blob' }) + await expect(downloadDriverLicense(id, fileId)).rejects.toMatchObject({ message: '文件未关联', requestId: 'test-request' }) + await expect(downloadDriverLicense(id, fileId)).rejects.toThrow('格式不受支持') + }) + + it('does not turn a failed write into a success or round an unsafe record version', async () => { + vi.mocked(http.post).mockResolvedValueOnce({ code: 'A0409', message: '请刷新后重试' }).mockResolvedValueOnce(ok({ ...record, recordVersion: '9223372036854775807' })) + await expect(reviewDriver(id, 1, 'APPROVED', null)).rejects.toBeInstanceOf(ApiResponseError) + await expect(createDriver(details)).rejects.toThrow('无效的分页或版本信息') + }) +}) diff --git a/src/modules/overseas-master-data/api/drivers.ts b/src/modules/overseas-master-data/api/drivers.ts new file mode 100644 index 00000000..1e9028d3 --- /dev/null +++ b/src/modules/overseas-master-data/api/drivers.ts @@ -0,0 +1,100 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { DriverDetail, DriverDraft, DriverHistory, DriverQualification, DriverReviewStatus, DriverLicenseState, DriverSummary, DriverWarehouseOption } from '../driverTypes' +import type { PageResult } from './overseasMasterData' + +const base = '/platform/v1/last-leg/fleet/drivers' +type Numeric = string | number +type DriverWire = Omit & { recordVersion: Numeric } +type HistoryWire = Omit & { recordVersion: Numeric; snapshot: DriverWire } +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } + +export interface DriverQuery { + page: number + size: number + keyword?: string + active?: boolean + reviewStatus?: DriverReviewStatus + ownership?: DriverDraft['ownership'] + region?: DriverDraft['region'] + licenseState?: DriverLicenseState + expectedEnd?: string +} + +function numeric(value: Numeric): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) throw new Error('司机接口返回了无效的分页或版本信息') + return result +} +const driver = (value: DriverWire): DriverDetail => ({ ...value, recordVersion: numeric(value.recordVersion) }) +function page(value: PageWire, normalize: (record: W) => T): PageResult { + return { records: value.records.map(normalize), page: numeric(value.page), size: numeric(value.size), total: numeric(value.total), pages: numeric(value.pages) } +} +const path = (id: string) => `${base}/${encodeURIComponent(id)}` + +type DriverPageWire = PageWire & { summary: { [K in keyof DriverSummary]: Numeric } } +export async function listDrivers(query: DriverQuery): Promise & { summary: DriverSummary }> { + const result = unwrapApiResponse(await http.get, ApiEnvelope>(base, { params: query })) + return { ...page(result, driver), summary: { total: numeric(result.summary.total), owned: numeric(result.summary.owned), dispatched: numeric(result.summary.dispatched), expiringSoon: numeric(result.summary.expiringSoon), expired: numeric(result.summary.expired) } } +} +export async function listDriverWarehouses(region: DriverDraft['region']): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/warehouse-options`, { params: { region } })) +} +export async function getDriver(id: string, expectedEnd?: string): Promise { + return driver(unwrapApiResponse(await http.get, ApiEnvelope>(path(id), { params: { expectedEnd } }))) +} +export async function getDriverQualification(id: string, expectedEnd: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${path(id)}/qualification`, { params: { expectedEnd } })) +} +export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise> { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${path(id)}/history`, { params: { page: currentPage, size } })) + return page(result, value => ({ ...value, recordVersion: numeric(value.recordVersion), snapshot: driver(value.snapshot) })) +} +export async function createDriver(details: DriverDraft): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(base, details))) +} +export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise { + return driver(unwrapApiResponse(await http.put, ApiEnvelope>(path(id), { recordVersion, details }))) +} +export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise { + return driver(unwrapApiResponse(await http.patch, ApiEnvelope>(`${path(id)}/status`, { recordVersion, active, reason }))) +} +export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/review`, { recordVersion, decision, reason }))) +} +export async function submitDriver(id: string, recordVersion: number): Promise { + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/submit`, { recordVersion }))) +} +export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise { + const form = new FormData() + form.append('recordVersion', String(recordVersion)) + form.append('file', file) + return driver(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(id)}/license`, form))) +} + +async function checkDownloadError(payload: unknown, status: number): Promise { + let value = payload + if (payload instanceof Blob) { + if (!payload.type.includes('json')) return + try { value = JSON.parse(await payload.text()) as unknown } + catch { throw new ApiResponseError('驾驶证接口返回了无法识别的错误响应', 'INVALID_DOWNLOAD_RESPONSE', status) } + } + if (typeof value === 'object' && value !== null && 'code' in value) { + const error = value as Partial> + throw new ApiResponseError(error.message || '驾驶证读取失败', error.code || 'DOWNLOAD_FAILED', status, error.data, error.requestId, error.traceId) + } +} + +export async function downloadDriverLicense(id: string, fileId: string): Promise { + try { + const blob = await http.get(`${path(id)}/license/${encodeURIComponent(fileId)}`, { responseType: 'blob' }) + await checkDownloadError(blob, 200) + if (!(blob instanceof Blob) || blob.size === 0 || !['application/pdf', 'image/jpeg', 'image/png'].includes(blob.type)) { + throw new Error('驾驶证文件为空或格式不受支持') + } + return blob + } catch (error) { + if (axios.isAxiosError(error)) await checkDownloadError(error.response?.data, error.response?.status || 0) + throw error + } +} diff --git a/src/modules/overseas-master-data/api/overseasMasterData.test.ts b/src/modules/overseas-master-data/api/overseasMasterData.test.ts index c69b9cb8..53dad4ad 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.test.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.test.ts @@ -23,7 +23,7 @@ const quoteDetail = { createdBy: 'admin', createdAt: '2026-08-24T09:00:00', } const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null } -const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } +const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' } beforeEach(() => vi.clearAllMocks()) @@ -106,7 +106,7 @@ describe('overseas master data API', () => { expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 }) expect(detail.id).toBe(longId) - const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } + const editable = { plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null } vi.mocked(http.post).mockResolvedValue(ok(vehicle)) vi.mocked(http.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' })) vi.mocked(http.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' })) diff --git a/src/modules/overseas-master-data/api/overseasMasterData.ts b/src/modules/overseas-master-data/api/overseasMasterData.ts index 739a8bba..1046c3b2 100644 --- a/src/modules/overseas-master-data/api/overseasMasterData.ts +++ b/src/modules/overseas-master-data/api/overseasMasterData.ts @@ -17,7 +17,7 @@ type QuoteDetailWire = Omit & { usdToCnyAverage: Numeric; recordVersion: Numeric } type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null } type ExchangeRateSummaryWire = Omit & { latestUsdToCnyAverage: Numeric | null } -type VehicleWire = Omit & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric } +type VehicleWire = Omit & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null } function page(value: PagePayload, normalize: (record: W) => T): PageResult { return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) } @@ -32,7 +32,7 @@ const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unit const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) }) const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) }) const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) }) -const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) }) +const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) }) type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts new file mode 100644 index 00000000..640d3c4c --- /dev/null +++ b/src/modules/overseas-master-data/api/tripBookings.ts @@ -0,0 +1,47 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCreate, TripBookingUpdate, TripBookingCommand, TripBookingResult, TripBookingList, TripIncomingEvidence, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind, TripAcceptedReceipt } from '../bookingTypes' +const base = '/platform/v1/last-leg/fleet/trips' +const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings` +type N = number | string +type WireCargo = Omit & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } +export type WireBooking = Omit & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null } +type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] } +type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] } +type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean } +const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result } +const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result } +const nullable = (value: N | null) => value == null ? null : number(value) +const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) }) +export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, + packageCount: integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) +const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true }) +export async function listFleetCustomers(keyword?: string): Promise { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/customer-options`, { params: { keyword } })) +} +export async function listTripBookings(tripNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>>(path(tripNo))) + return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) } +} +export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), body))) +} +export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise { + return result(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body))) +} +async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body))) +} +export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body) +export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body) + +const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}` +export async function getTripIncoming(tripNo: string, bookingNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`)) + return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) } +} +async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise { + return result(unwrapApiResponse(await http.post, ApiEnvelope>(`${bookingPath(tripNo, bookingNo)}/${action}`, body))) +} +export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body) +export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body) +export const bindTripBookingDispatch = (tripNo: string, bookingNo: string, body: TripBookingBind) => incomingCommand(tripNo, bookingNo, 'dispatch', body) diff --git a/src/modules/overseas-master-data/api/trips.test.ts b/src/modules/overseas-master-data/api/trips.test.ts new file mode 100644 index 00000000..f3cd8b2b --- /dev/null +++ b/src/modules/overseas-master-data/api/trips.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiResponseError, http } from '@/shared/api/http' +import { + listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, + getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan, +} from './trips' + +vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const base = '/platform/v1/last-leg/fleet/trips' +const longId = '9223372036854775801' +const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, warehouseCode: 'WH', warehouseName: '起运仓', + vehicleId: longId, vehiclePlate: 'TEST-1', driverId: longId, driverName: '司机', driverPhone: '123', + maxPalletSpaces: '30', maxLoadKg: '18000.125', maxVolumeCbm: '90.001', recordVersion: '0', + plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null, + stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null } +const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } +const detail = { trip, driverQualification: null, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] } +const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' } +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 envelope = (data: T) => ({ code: '200', message: '成功', data }) + +describe('fleet trip API contract', () => { + beforeEach(() => vi.clearAllMocks()) + + it('uses one paged request and keeps long identifiers as exact strings', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' })) + const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId } + const result = await listTrips(query) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query }) + expect(result.total).toBe(121) + expect(result.records[0]).toMatchObject({ vehicleId: longId, driverId: longId, warehouseId: longId, recordVersion: 0, maxPalletSpaces: 30, maxLoadKg: 18000.125 }) + }) + + it('encodes the business number and reads details without prefetching history or loading', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope(detail)) + const result = await getTrip(trip.tripNo) + expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST`) + expect(result.dispatches[0]).toMatchObject({ packageCount: 12, customerId: longId }) + }) + + it('forwards explicit versions and stable idempotency keys for every write without extra reads', async () => { + vi.mocked(http.post).mockResolvedValue(envelope(detail)); vi.mocked(http.put).mockResolvedValue(envelope(detail)) + const draft = { warehouseId: longId, plannedDepartureAt: trip.plannedDepartureAt, expectedDriverEndAt: trip.expectedDriverEndAt, stops: trip.stops, idempotencyKey: 'same-create-key' } + await createTrip(draft); await createTrip(draft) + expect(http.post).toHaveBeenNthCalledWith(1, base, draft); expect(http.post).toHaveBeenNthCalledWith(2, base, draft) + const plan = { recordVersion: 0, plannedDepartureAt: draft.plannedDepartureAt, expectedDriverEndAt: draft.expectedDriverEndAt, stops: draft.stops, reason: '调整' } + await updateTripPlan(trip.tripNo, plan) + expect(http.put).toHaveBeenCalledWith(`${base}/TR%2FTEST/plan`, plan) + const assignment = { recordVersion: 0, vehicleId: longId, driverId: longId, reason: '分配' } + await assignTrip(trip.tripNo, assignment) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/assignment`, assignment) + const binding = { recordVersion: 1, dispatchNo: 'DP-1', reason: '关联' } + await bindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/bind`, binding) + await unbindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/unbind`, binding) + await cancelTrip(trip.tripNo, { recordVersion: 2, reason: '取消' }) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/cancel`, { recordVersion: 2, reason: '取消' }) + const complete = { recordVersion: 4, idempotencyKey: 'complete-key', reason: '核对' } + await completeTrip(trip.tripNo, complete); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/complete`, complete) + const release = { recordVersion: 5, resourceKind: 'DRIVER' as const, actualEndAt: trip.updatedAt, endLocation: '交接点', idempotencyKey: 'release-key', reason: '返程已结束' } + await releaseTripResource(trip.tripNo, release); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/resources/release`, release) + const review = { recordVersion: 6, decision: 'APPROVED' as const } + await reviewTripDispatch(trip.tripNo, 'DP/1', review) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/DP%2F1/review`, review) + await reviewTripLoading(trip.tripNo, review) + expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/loading-review`, review) + expect(http.get).not.toHaveBeenCalled() + }) + + it('keeps planned capacity separate from actual load and consumes source freshness', async () => { + const plan = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, capacityPlan: confirmed, planValid: false, blockReason: null, availableSegments: confirmed.segments } + vi.mocked(http.get).mockResolvedValueOnce(envelope(plan)); vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...plan, planValid: true, recordVersion: '4' })) + const result = await getTripCapacityPlan(trip.tripNo) + expect(result.planValid).toBe(false); expect(result.capacityPlan?.segments[0]?.remaining.palletSpaces).toBe(29) + expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`) + const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }], reason: '计划核对' } + expect((await confirmTripCapacityPlan(trip.tripNo, body)).recordVersion).toBe(4) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`, body) + }) + + it('preserves source fingerprints and uses server capacity results without recomputing them', async () => { + vi.mocked(http.get).mockResolvedValueOnce(envelope(loading)); vi.mocked(http.post).mockResolvedValueOnce(envelope(loading)) + const result = await getTripLoading(trip.tripNo) + expect(result.confirmedLoad?.segments[0]?.remaining).toEqual({ palletSpaces: 29, weightKg: 17899.875, volumeCbm: 88.995 }) + expect(result.cargo[0]?.warehousePalletId).toBe(longId) + const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100.125, volumeCbm: 1.005, deliveryIndex: 1 }], reason: '核对实物' } + await confirmTripLoading(trip.tripNo, body) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/loading`, body) + }) + + 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 }] }) + 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.trip.recordVersion).toBe(0) + }) + + it('rejects missing or unsafe versions and preserves backend conflict diagnostics', async () => { + for (const version of [null, '', '9007199254740993', '-1']) { + vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, trip: { ...trip, recordVersion: version } })) + await expect(getTrip(trip.tripNo)).rejects.toThrow() + } + const conflict = new ApiResponseError('车次已修改', '409', 200, null, 'request-1', 'trace-1') + vi.mocked(http.post).mockRejectedValueOnce(conflict) + await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict) + }) +}) diff --git a/src/modules/overseas-master-data/api/trips.ts b/src/modules/overseas-master-data/api/trips.ts new file mode 100644 index 00000000..6178ad09 --- /dev/null +++ b/src/modules/overseas-master-data/api/trips.ts @@ -0,0 +1,112 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { normalizeBooking, type WireBooking } from './tripBookings' +import type { PageResult } from './overseasMasterData' +import type { + FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding, + TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation, + TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning, +} from '../tripTypes' + +const base = '/platform/v1/last-leg/fleet/trips' +const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}` +type Numeric = number | string +type WireTrip = Omit & { + recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null +} +type WireReview = Omit & { recordVersion: Numeric | null } +type WireBinding = Omit & { packageCount: Numeric; review: WireReview } +type WireDetail = Omit & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview } +type WirePage = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +type WireCargo = Omit & { packages: Numeric } +type WireLoad = { [K in keyof TripLoad]: Numeric } +type WireMeasurement = Omit & WireLoad & { deliveryIndex: Numeric } +type WireConfirmed = Omit & { + cargo: WireCargo[]; pallets: WireMeasurement[] + segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[] +} +type WireLoading = Omit & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null } +type WirePlanning = Omit & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] } +type WireOutcome = Omit & { packages: Numeric } +type WireInspection = Omit & { recordVersion: Numeric; outcomes: WireOutcome[] } +type WireHistory = Omit & { + recordVersion: Numeric + snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit, 'outcomes'> & { outcomes: WireOutcome[] }) | null } +} + +function number(value: Numeric): number { + if (typeof value !== 'number' && typeof value !== 'string') throw new Error('车次接口缺少有效数值字段') + if (typeof value === 'string' && !value.trim()) throw new Error('车次接口返回了空的数值字段') + const result = Number(value) + if (!Number.isFinite(result) || result < 0) throw new Error('车次接口返回了无效数值') + return result +} +function integer(value: Numeric): number { + const result = number(value) + if (!Number.isSafeInteger(result)) throw new Error('车次接口返回了无效分页、数量或版本') + return result +} +const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer(value.recordVersion), + maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces), + maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) }) +const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) }) +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 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), + pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })), + segments: value.segments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) } +} +const loading = (value: WireLoading): TripLoading => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), confirmedLoad: confirmed(value.confirmedLoad) }) +function page(value: WirePage, map: (row: W) => T): PageResult { + return { records: value.records.map(map), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export interface TripQuery { page: number; size: number; keyword?: string; status?: TripStatus; warehouseId?: string } +export async function listTrips(query: TripQuery): Promise> { + return page(unwrapApiResponse(await http.get>, ApiEnvelope>>(base, { params: query })), trip) +} +export async function getTrip(tripNo: string): Promise { + return detail(unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo)))) +} +async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise { + return detail(unwrapApiResponse(await http.post, ApiEnvelope>(url, body))) +} +export const createTrip = (body: TripDraft) => postDetail(base, body) +export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body) +export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body) +export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body) +export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body) +export const unbindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/unbind`, body) +export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason: string }) => postDetail(`${path(tripNo)}/cancel`, body) +export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body) +export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body) +export async function updateTripPlan(tripNo: string, body: TripPlan): Promise { + return detail(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/plan`, body))) +} +const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) }) +export async function getTripCapacityPlan(tripNo: string): Promise { + return planning(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/capacity-plan`))) +} +export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise { + return planning(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/capacity-plan`, body))) +} +export async function getTripLoading(tripNo: string): Promise { + return loading(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/loading`))) +} +export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise { + return loading(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/loading`, body))) +} +export async function getTripCompletion(tripNo: string): Promise { + const value = unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/completion`)) + return { ...value, canComplete: value.canComplete === true, recordVersion: integer(value.recordVersion), outcomes: value.outcomes.map(outcome) } +} +export async function listTripHistory(tripNo: string, currentPage = 1, size = 20): Promise> { + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${path(tripNo)}/history`, { params: { page: currentPage, size } })) + return page(value, row => ({ ...row, recordVersion: integer(row.recordVersion), snapshot: { + trip: trip(row.snapshot.trip), dispatches: row.snapshot.dispatches.map(binding), confirmedLoad: confirmed(row.snapshot.confirmedLoad), + externalBookings: row.snapshot.externalBookings.map(normalizeBooking), loadingReview: review(row.snapshot.loadingReview), capacityPlan: confirmed(row.snapshot.capacityPlan), + completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) }, + } })) +} diff --git a/src/modules/overseas-master-data/api/vehicleCare.test.ts b/src/modules/overseas-master-data/api/vehicleCare.test.ts new file mode 100644 index 00000000..c83cb523 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleCare.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http, ApiResponseError } from '@/shared/api/http' +import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto } from './vehicleCare' +import type { VehicleCareDraft } from '../vehicleCareTypes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records` +const details: VehicleCareDraft = { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null } +const wire = { recordNo: 'VR-ONE', vehicleId: id, vehiclePlate: 'TEST', recordVersion: '2', details: { ...details, odometer: '100', fuelQuantity: '28.6' }, files: [{ fileId: '9223372036854775702', fileName: 'meter.png', contentType: 'image/png', sizeBytes: '8' }], updatedBy: 'operator', updatedAt: '2026-01-01T00:00:00Z' } +const ok = (data: unknown) => ({ code: '200', message: '成功', data }) +beforeEach(() => vi.clearAllMocks()) +it('keeps vehicle and file IDs exact while normalizing values without changing units', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ records: [wire], page: '1', size: '10', total: '1', pages: '1' })) + const result = await listVehicleCare(id, 'FUEL') + expect(result.records[0]).toMatchObject({ vehicleId: id, recordVersion: 2, details, files: [{ fileId: '9223372036854775702', sizeBytes: 8 }] }) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: { recordType: 'FUEL', page: 1, size: 10 } }) +}) +it('preserves creation and upload keys and sends explicit correction version and reason', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire)) + await createVehicleCare(id, 'same-create-key', details) + await updateVehicleCare(id, 'VR-ONE', 2, '核对里程', details) + const photo = new File(['photo'], 'meter.png', { type: 'image/png' }) + await uploadVehicleCarePhoto(id, 'VR-ONE', 3, 'same-photo-key', photo) + expect(http.post).toHaveBeenNthCalledWith(1, base, { idempotencyKey: 'same-create-key', details }) + expect(http.put).toHaveBeenCalledWith(`${base}/VR-ONE`, { recordVersion: 2, reason: '核对里程', details }) + const [url, form] = vi.mocked(http.post).mock.calls[1]! + expect(url).toBe(`${base}/VR-ONE/files`); expect(form).toBeInstanceOf(FormData) + expect((form as FormData).get('recordVersion')).toBe('3'); expect((form as FormData).get('idempotencyKey')).toBe('same-photo-key') + expect((form as FormData).get('file')).toBe(photo) +}) +it('rejects JSON business errors and non-image bodies instead of opening them as photos', async () => { + vi.mocked(http.get).mockResolvedValueOnce(new Blob(['image'], { type: 'image/png' })) + .mockResolvedValueOnce({ code: 'A0404', message: '图片不属于记录', requestId: 'test-request' }) + .mockResolvedValueOnce(new Blob(['html'], { type: 'text/html' })) + expect((await downloadVehicleCarePhoto(id, 'VR-ONE', '71')).type).toBe('image/png') + await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '72')).rejects.toMatchObject({ name: 'ApiResponseError', code: 'A0404', requestId: 'test-request' } satisfies Partial) + await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '73')).rejects.toThrow('格式无效') +}) +it('keeps unknown and zero incident costs distinct and preserves independent payment state', async () => { + const incident = { description: '救援完成', item: '拖车', place: '现场', handler: '处理人', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: '0.00', currency: 'USD' } + const rescue = { ...wire, paymentStatus: 'UNLINKED', details: { ...details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, incident } } + vi.mocked(http.get).mockResolvedValue(ok({ records: [rescue, { ...rescue, recordNo: 'VR-TWO', details: { ...rescue.details, incident: { ...incident, reportedCost: null, currency: null } } }], page: 1, size: 10, total: 2, pages: 1 })) + const result = await listVehicleCare(id, 'RESCUE') + expect(result.records[0]).toMatchObject({ paymentStatus: 'UNLINKED', details: { odometer: null, incident: { reportedCost: 0, currency: 'USD' } } }) + expect(result.records[1]?.details.incident?.reportedCost).toBeNull() +}) diff --git a/src/modules/overseas-master-data/api/vehicleCare.ts b/src/modules/overseas-master-data/api/vehicleCare.ts new file mode 100644 index 00000000..96d499d3 --- /dev/null +++ b/src/modules/overseas-master-data/api/vehicleCare.ts @@ -0,0 +1,70 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { VehicleCareDraft, VehicleCareRecord, VehicleCareType, VehicleIncident } from '../vehicleCareTypes' +import type { PageResult } from './overseasMasterData' + +type Numeric = number | string +type Wire = Omit & { + recordVersion: Numeric + details: Omit & { + odometer: Numeric | null; lastServiceOdometer: Numeric | null; nextServiceOdometer: Numeric | null; fuelQuantity: Numeric | null + incident: (Omit & { reportedCost: Numeric | null }) | null + } + files: Array & { sizeBytes: Numeric }> +} +const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}` +function integer(value: Numeric): number { + const result = Number(value) + if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本') + return result +} +function decimal(value: Numeric): number { + const result = Number(value) + if (value === '' || !Number.isFinite(result) || result < 0) throw new Error('运维接口返回了无效数值') + return result +} +function record(value: Wire): VehicleCareRecord { + const optional = (number: Numeric | null) => number == null ? null : decimal(number) + return { ...value, recordVersion: integer(value.recordVersion), details: { ...value.details, odometer: optional(value.details.odometer), + lastServiceOdometer: optional(value.details.lastServiceOdometer), nextServiceOdometer: optional(value.details.nextServiceOdometer), fuelQuantity: optional(value.details.fuelQuantity), + incident: value.details.incident ? { ...value.details.incident, reportedCost: optional(value.details.incident.reportedCost) } : null }, + files: value.files.map(file => ({ ...file, sizeBytes: integer(file.sizeBytes) })) } +} +export async function listVehicleCare(vehicleId: string, recordType: VehicleCareType, page = 1, size = 10): Promise> { + type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } + const result = unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId), { params: { recordType, page, size } })) + return { records: result.records.map(record), page: integer(result.page), size: integer(result.size), total: integer(result.total), pages: integer(result.pages) } +} +export async function createVehicleCare(vehicleId: string, idempotencyKey: string, details: VehicleCareDraft): Promise { + return record(unwrapApiResponse(await http.post, ApiEnvelope>(path(vehicleId), { idempotencyKey, details }))) +} +export async function getVehicleCare(vehicleId: string, recordNo: string): Promise { + return record(unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId, recordNo)))) +} +export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise { + return record(unwrapApiResponse(await http.put, ApiEnvelope>(path(vehicleId, recordNo), { recordVersion, reason, details }))) +} +export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise { + const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file) + return record(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/files`, form))) +} +async function downloadError(value: unknown, status: number): Promise { + if (value instanceof Blob && value.type.includes('json')) { + try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } + } + if (value && typeof value === 'object' && 'code' in value) { + const error = value as ApiEnvelope + throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) + } +} +export async function downloadVehicleCarePhoto(vehicleId: string, recordNo: string, fileId: string): Promise { + try { + const blob = await http.get(`${path(vehicleId, recordNo)}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' }) + await downloadError(blob, 200) + if (!(blob instanceof Blob) || !blob.size || !['image/png', 'image/jpeg'].includes(blob.type)) throw new Error('照片为空或格式无效') + return blob + } catch (cause) { + if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0) + throw cause + } +} diff --git a/src/modules/overseas-master-data/bookingTypes.ts b/src/modules/overseas-master-data/bookingTypes.ts new file mode 100644 index 00000000..cd4123a2 --- /dev/null +++ b/src/modules/overseas-master-data/bookingTypes.ts @@ -0,0 +1,24 @@ +export interface FleetCustomerOption { id: string; code: string; name: string; contactName: string | null; contactPhone: string | null } +export interface TripBookingCargo { + destinationKey: string; cargoName: string | null; packageCount: number; palletSpaces: number | null; + weightKg: number | null; volumeCbm: number | null; priceBasis: 'PALLET' | 'ORDER'; unitPrice: number | null; + currency: string | null; contactName: string | null; contactPhone: string | null; remark: string | null; +} +export interface TripBooking { bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string; + status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string; + agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null } +export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string } +export interface TripBookingUpdate { tripRecordVersion: number; recordVersion: number; cargo: TripBookingCargo } +export interface TripBookingCommand { tripRecordVersion: number; recordVersion: number; idempotencyKey: string; reason?: string } +export interface TripBookingResult { tripRecordVersion: number; booking: TripBooking; replayed: boolean } +export interface TripBookingList { tripRecordVersion: number; records: TripBooking[] } +export const bookingStatusLabels = { DRAFT: '询价中', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已关联发运', CANCELLED: '已取消' } as const + +export interface TripIncomingPallet { warehousePalletId: string; palletNo: string; packages: number; weightKg: number | null; volumeCbm: number | null } +export interface TripIncomingEvidence { incomingNo: string; inboundCode: string; warehouseId: string; destinationKey: string; status: string; receiptHash: string; pallets: TripIncomingPallet[]; receivedPackages: number } +export interface TripPalletSpaces { warehousePalletId: string; palletSpaces: number } +export interface TripAcceptedReceipt { source: TripIncomingEvidence; pallets: TripPalletSpaces[]; operatorId: string; confirmedAt: string } +export interface TripIncomingPreview { tripRecordVersion: number; recordVersion: number; incoming: TripIncomingEvidence | null } +export interface TripIncomingStart extends Omit { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null } +export interface TripIncomingAccept extends Omit { receiptHash: string; pallets: TripPalletSpaces[]; unitPrice: number } +export interface TripBookingBind extends Omit { dispatchNo: string } diff --git a/src/modules/overseas-master-data/components/DriverEditorDialog.vue b/src/modules/overseas-master-data/components/DriverEditorDialog.vue new file mode 100644 index 00000000..34228fb9 --- /dev/null +++ b/src/modules/overseas-master-data/components/DriverEditorDialog.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/modules/overseas-master-data/components/DriverLicensePreview.vue b/src/modules/overseas-master-data/components/DriverLicensePreview.vue new file mode 100644 index 00000000..b27bcfd5 --- /dev/null +++ b/src/modules/overseas-master-data/components/DriverLicensePreview.vue @@ -0,0 +1,47 @@ + + +