feat(fleet): integrate trip management and fee coverage workflows

This commit is contained in:
2026-10-03 04:35:55 +08:00
parent bca08f8749
commit dc35288a1b
87 changed files with 3774 additions and 233 deletions
+4 -3
View File
@@ -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` |
## 业务、安全与共享装配
+3 -1
View File
@@ -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')
+23 -79
View File
@@ -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',
])
})
+41 -31
View File
@@ -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 },
],
},
]
+12
View File
@@ -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' }),
@@ -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' })
})
})
@@ -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) {
@@ -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')
@@ -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<string, string> = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' }
const voidLabels: Record<string, string> = { 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)
</script>
@@ -168,7 +170,7 @@ onMounted(load)
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED' && !voidPending(row as OverseasContainerFeeSource)" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
@@ -177,6 +179,7 @@ onMounted(load)
<el-table-column label="付款凭证" min-width="180"><template #default="{ row }"><div v-if="row.paymentVoucherFileIds.length" class="voucher-list"><el-button v-for="(fileId, index) in (row.paymentVoucherFileIds as string[])" :key="fileId" link type="primary" @click="previewVoucher(row.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button></div><span v-else>—</span></template></el-table-column>
<el-table-column prop="businessDate" label="业务日期" width="120" />
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
<el-table-column label="作废确认" min-width="130"><template #default="{ row }">{{ voidLabels[row.voidStatus] || '—' }}</template></el-table-column>
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
</el-table>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
@@ -737,9 +737,9 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
<template #toolbar-start><h1>{{ isDomestic ? '货柜列表' : '清关提柜' }}</h1></template>
<template #filters>
<div class="container-query-panel integrated-query-group">
<div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
<div class="container-scope-tabs density-status-tabs" role="tablist" aria-label="货柜完结状态">
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
</div>
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</div>
@@ -823,7 +823,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
</template>
<style scoped>
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { min-width: 0; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.arrival-time-cell { display: flex; align-items: center; width: 100%; min-width: 0; padding-right: var(--admin-space-1); box-sizing: border-box; }.arrival-time-picker { width: 200px !important; max-width: 100%; min-width: 0; box-sizing: border-box; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
.container-query-panel { width: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; }
.container-query-toolbar :deep(.query-toolbar__tail) { width: 100%; flex: 0 0 100%; }
.container-page .table-panel { width: 100%; max-width: 100%; min-width: 0; }
@@ -8,7 +8,7 @@ describe('DomesticContainersView', () => {
it('gates create actions with backend permissions', () => { expect(source).toContain("access.can('platform:domestic-container:inbound:create')"); expect(source).toContain("access.can('platform:domestic-container:outbound:create')") })
it('loads only active domestic warehouse candidates and surfaces errors', () => { expect(source).toContain("listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })"); expect(source).toContain('国内仓候选加载失败') })
it('keeps idempotency key stable through retry and handles conflicts by refreshing inventory', () => { expect(source).toContain('idempotencyKey.value'); expect(source).toContain("submitError.value.code === 'A0409'"); expect(source).toContain('await loadCandidates()'); expect(source).not.toContain('localStorage') })
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain(".status-tab.is-active::after{background:var(--cc-primary)}"); expect(source).not.toContain('<el-tabs') })
it('reuses the order-list tab panel and keyboard interaction', () => { expect(source).toContain('class="business-query-panel integrated-query-group"'); expect(source).toContain('role="tablist"'); expect(source).toContain('role="tab"'); expect(source).toContain('handleTabKeydown'); expect(source).toContain('class="status-tabs density-status-tabs"'); expect(source).not.toContain('<el-tabs') })
it('keeps desktop filters and actions on one row with explicit field widths', () => { expect(source).toContain("key: 'warehouseId', type: 'select', width: 210"); expect(source).toContain("key: 'orderNo', type: 'input', width: 220"); expect(source).toContain("key: 'containerNo', type: 'input', width: 220"); expect(source).toContain("key: 'cargoName', type: 'input', width: 220"); expect(source).toContain("key: 'status', type: 'select', width: 180") })
it('uses the shared query toolbar flow without a page-level layout override', () => { expect(source).not.toContain('.query-toolbar__filters') })
it('keeps all four inventory facts in the approved compact summary', () => {
@@ -76,10 +76,10 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
<LoadErrorState v-if="warehouseError" compact title="国内仓候选加载失败" :message="warehouseError.message" :loading="warehouseLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><section id="domestic-container-results" ><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'">
<template #toolbar-start><h1>整柜业务</h1></template>
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #filters><div class="business-query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="整柜业务类型"><button v-for="(tab, index) in businessTabs" :key="tab.value" type="button" role="tab" :aria-selected="activeTab === tab.value" aria-controls="domestic-container-results" :tabindex="activeTab === tab.value ? 0 : -1" :class="['status-tab', { 'is-active': activeTab === tab.value }]" @click="switchTab(tab.value)" @keydown="handleTabKeydown($event, index)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span>{{ tab.label }}</button></div><QueryToolbar v-model="filters" class="business-query-toolbar" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-query`" @search="search" /></div></template>
<template #notices><template v-if="loadError"><LoadErrorState title="整柜业务加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
<template #before-results><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div></template>
<template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="canViewInventory(row as ContainerOrder | InventoryRecord)" class="density-action" size="small" @click="viewInventory(row as ContainerOrder | InventoryRecord)">查看</el-button><span v-else>—</span></div></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog admin-density-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入入库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :max="999999999999.9999" :precision="4" controls-position="right" placeholder="请输入体积" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :max="999999999" :precision="0" controls-position="right" placeholder="请输入出库箱数" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.status-tabs{display:flex;max-width:100%;overflow-x:auto;margin-bottom:0;border-bottom:1px solid var(--admin-table-border, #e5e7eb);background:#f9fafb;scrollbar-width:thin}.status-tab{position:relative;flex:0 0 auto;display:inline-flex;align-items:center;gap:var(--admin-space-2, 6px);min-height:var(--admin-tab-height, 38px);padding:0 var(--admin-space-6, 16px);border:0;background:transparent;color:var(--cc-muted);font:inherit;font-size:var(--admin-font-body, 13px);cursor:pointer}.status-tab::after{position:absolute;right:12px;bottom:0;left:12px;height:3px;background:transparent;content:''}.status-tab:hover{color:var(--cc-primary);background:#f6f9fd}.status-tab:focus-visible{outline:2px solid var(--cc-primary);outline-offset:-2px}.status-tab.is-active{color:var(--cc-primary);font-weight:600}.status-tab.is-active::after{background:var(--cc-primary)}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.status-tab{min-height:46px;padding:0 14px}.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.status-tab{padding:0 10px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
<style scoped>.domestic-containers-page{min-width:0}.business-query-panel{min-width:0}.business-query-toolbar{border:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 var(--admin-space-6, 16px) var(--admin-space-5, 12px);border:1px solid var(--cc-border);background:var(--admin-label-bg, #f8fafc)}.inventory-summary>div{display:flex;align-items:baseline;gap:var(--admin-space-3, 8px);min-width:0;padding:9px var(--admin-space-6, 16px);border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>
+2 -1
View File
@@ -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
@@ -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核对实收与约定价格、输入客户排车单号关联;来货详情只读。只有显式打开来货/实收弹框才查询来源,不逐行请求。关联后刷新外接列表及车次详情,更新全部排车成员和理货审核状态;预订取消不删除来货或库存。
@@ -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 保持字符串透传。
@@ -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和客户排车关联;未将本地验证当作环境验收 |
@@ -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<typeof import('@/shared/api/http')>()), 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(['<html>'], { 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('无效的分页或版本信息')
})
})
@@ -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<DriverDetail, 'recordVersion'> & { recordVersion: Numeric }
type HistoryWire = Omit<DriverHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: DriverWire }
type PageWire<T> = { 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<T, W>(value: PageWire<W>, normalize: (record: W) => T): PageResult<T> {
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<DriverWire> & { summary: { [K in keyof DriverSummary]: Numeric } }
export async function listDrivers(query: DriverQuery): Promise<PageResult<DriverDetail> & { summary: DriverSummary }> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<DriverPageWire>, ApiEnvelope<DriverPageWire>>(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<DriverWarehouseOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverWarehouseOption[]>, ApiEnvelope<DriverWarehouseOption[]>>(`${base}/warehouse-options`, { params: { region } }))
}
export async function getDriver(id: string, expectedEnd?: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.get<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { params: { expectedEnd } })))
}
export async function getDriverQualification(id: string, expectedEnd: string): Promise<DriverQualification> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverQualification>, ApiEnvelope<DriverQualification>>(`${path(id)}/qualification`, { params: { expectedEnd } }))
}
export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise<PageResult<DriverHistory>> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${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<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(base, details)))
}
export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.put<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { recordVersion, details })))
}
export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.patch<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/status`, { recordVersion, active, reason })))
}
export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/review`, { recordVersion, decision, reason })))
}
export async function submitDriver(id: string, recordVersion: number): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/submit`, { recordVersion })))
}
export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise<DriverDetail> {
const form = new FormData()
form.append('recordVersion', String(recordVersion))
form.append('file', file)
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/license`, form)))
}
async function checkDownloadError(payload: unknown, status: number): Promise<void> {
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<ApiEnvelope<unknown>>
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<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${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
}
}
@@ -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' }))
@@ -17,7 +17,7 @@ type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouse
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null }
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
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
@@ -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<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt'> & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null }
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { 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<FleetCustomerOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<FleetCustomerOption[]>, ApiEnvelope<FleetCustomerOption[]>>(`${base}/customer-options`, { params: { keyword } }))
}
export async function listTripBookings(tripNo: string): Promise<TripBookingList> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>, 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<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
}
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
}
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${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<TripIncomingPreview> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, 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<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${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)
@@ -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<typeof import('@/shared/api/http')>()), 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 = <T>(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)
})
})
@@ -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<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & {
recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null
}
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
type WireLoad = { [K in keyof TripLoad]: Numeric }
type WireMeasurement = Omit<TripMeasurement, 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'deliveryIndex'> & WireLoad & { deliveryIndex: Numeric }
type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> & {
cargo: WireCargo[]; pallets: WireMeasurement[]
segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[]
}
type WireLoading = Omit<TripLoading, 'recordVersion' | 'cargo' | 'confirmedLoad'> & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null }
type WirePlanning = Omit<TripCapacityPlanning, 'recordVersion' | 'cargo' | 'capacityPlan' | 'availableSegments'> & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] }
type WireOutcome = Omit<TripOutcome, 'packages'> & { packages: Numeric }
type WireInspection = Omit<TripCompletionInspection, 'recordVersion' | 'outcomes'> & { recordVersion: Numeric; outcomes: WireOutcome[] }
type WireHistory = Omit<TripHistory, 'recordVersion' | 'snapshot'> & {
recordVersion: Numeric
snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit<NonNullable<TripHistory['snapshot']['completion']>, '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<T, W>(value: WirePage<W>, map: (row: W) => T): PageResult<T> {
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<PageResult<FleetTrip>> {
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
}
export async function getTrip(tripNo: string): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
}
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(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<TripDetail> {
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${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<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.get<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`)))
}
export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.post<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`, body)))
}
export async function getTripLoading(tripNo: string): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.get<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`)))
}
export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.post<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`, body)))
}
export async function getTripCompletion(tripNo: string): Promise<TripCompletionInspection> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WireInspection>, ApiEnvelope<WireInspection>>(`${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<PageResult<TripHistory>> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireHistory>>, ApiEnvelope<WirePage<WireHistory>>>(`${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) },
} }))
}
@@ -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<typeof import('@/shared/api/http')>()), 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<ApiResponseError>)
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()
})
@@ -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<VehicleCareRecord, 'recordVersion' | 'details' | 'files'> & {
recordVersion: Numeric
details: Omit<VehicleCareDraft, 'odometer' | 'lastServiceOdometer' | 'nextServiceOdometer' | 'fuelQuantity' | 'incident'> & {
odometer: Numeric | null; lastServiceOdometer: Numeric | null; nextServiceOdometer: Numeric | null; fuelQuantity: Numeric | null
incident: (Omit<VehicleIncident, 'reportedCost'> & { reportedCost: Numeric | null }) | null
}
files: Array<Omit<VehicleCareRecord['files'][number], 'sizeBytes'> & { 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<PageResult<VehicleCareRecord>> {
type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire>, ApiEnvelope<PageWire>>(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<VehicleCareRecord> {
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId), { idempotencyKey, details })))
}
export async function getVehicleCare(vehicleId: string, recordNo: string): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo))))
}
export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.put<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo), { recordVersion, reason, details })))
}
export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise<VehicleCareRecord> {
const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file)
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/files`, form)))
}
async function downloadError(value: unknown, status: number): Promise<void> {
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<unknown>
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<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${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
}
}
@@ -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<TripBookingCommand, 'reason'> { estimatedArrivalAt?: string | null; deliveryInstructions?: string | null; privateAddress?: string | null; postalCode?: string | null; recipientName?: string | null; recipientPhone?: string | null }
export interface TripIncomingAccept extends Omit<TripBookingCommand, 'reason'> { receiptHash: string; pallets: TripPalletSpaces[]; unitPrice: number }
export interface TripBookingBind extends Omit<TripBookingCommand, 'reason'> { dispatchNo: string }
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { createDriver, listDriverWarehouses, updateDriver, uploadDriverLicense } from '../api/drivers'
import type { DriverDetail, DriverDraft, DriverProfile, DriverWarehouseOption } from '../driverTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ driver: DriverDetail | null }>()
const emit = defineEmits<{ saved: [driver: DriverDetail] }>()
const access = useAccessPreviewStore()
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseLoading = ref(false), warehouseOptions = ref<DriverWarehouseOption[]>([])
const current = ref<DriverDetail | null>(null), selectedFile = ref<File | null>(null), fileInput = ref<HTMLInputElement | null>(null)
const emptyProfile = (): DriverProfile => ({ warehouseId: null, vehicleModels: null, languagesAndArea: null, medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null })
const form = reactive<DriverDraft>({ name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
const profile = computed(() => form.profile!)
const zones = ['America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'Asia/Shanghai', 'Europe/London', 'UTC']
let warehouseRequest = 0, editSession = 0, lastSaved = ''
async function loadWarehouses() {
const request = ++warehouseRequest
warehouseLoading.value = true; warehouseError.value = null
try { const rows = await listDriverWarehouses(form.region); if (request === warehouseRequest) warehouseOptions.value = rows }
catch (cause) { if (request === warehouseRequest) { warehouseOptions.value = []; warehouseError.value = getApiErrorDetails(cause, '仓库候选加载失败') } }
finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeRegion() { profile.value.warehouseId = null; void loadWarehouses() }
watch([open, () => props.driver?.id], ([value]) => {
editSession++; saving.value = false
if (!value) { warehouseRequest++; return }
current.value = props.driver; selectedFile.value = null; error.value = null; lastSaved = ''
if (fileInput.value) fileInput.value.value = ''
Object.assign(form, props.driver ? { ...props.driver.details, profile: { ...emptyProfile(), ...props.driver.details.profile } }
: { name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
void loadWarehouses()
})
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
}
selectedFile.value = file
}
async function save() {
if (saving.value) return
if (!form.name.trim() || !form.phone.trim() || !form.licenseZone.trim()) { ElMessage.warning('请填写姓名、电话和证照时区'); return }
saving.value = true; error.value = null
const session = editSession, sourceDriverId = props.driver?.id
const isCurrent = () => session === editSession && open.value && props.driver?.id === sourceDriverId
let persisted = false
try {
const details: DriverDraft = { ...form, name: form.name.trim(), phone: form.phone.trim(), profile: { ...profile.value } }
const fingerprint = JSON.stringify(details)
if (!current.value || fingerprint !== lastSaved) {
const savedDriver = current.value ? await updateDriver(current.value.id, current.value.recordVersion, details) : await createDriver(details)
if (!isCurrent()) return
current.value = savedDriver
lastSaved = fingerprint
}
persisted = true
if (selectedFile.value && current.value) {
const uploadedDriver = await uploadDriverLicense(current.value.id, current.value.recordVersion, selectedFile.value)
if (!isCurrent()) return
current.value = uploadedDriver
selectedFile.value = null
}
if (current.value) emit('saved', current.value)
ElMessage.success('司机档案已保存'); open.value = false
} catch (cause) {
if (!isCurrent()) return
if (persisted && current.value) emit('saved', current.value)
const details = getApiErrorDetails(cause, '司机保存失败')
error.value = { ...details, message: persisted ? `档案已保存,驾驶证上传失败:${details.message}。可直接重试。` : details.message }
} finally { if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { warehouseRequest++; editSession++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @confirm="save">
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form driver-form" :disabled="saving" @submit.prevent="save">
<h3 class="full-width">基础信息</h3>
<el-form-item label="司机姓名" required><el-input v-model="form.name" maxlength="128" placeholder="请输入司机姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="form.phone" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="司机归属"><el-select v-model="form.ownership"><el-option label="自有司机" value="OWNED" /><el-option label="外派司机" value="DISPATCHED" /></el-select></el-form-item>
<el-form-item label="区域"><el-select v-model="form.region" @change="changeRegion"><el-option label="国内" value="DOMESTIC" /><el-option label="海外" value="OVERSEAS" /></el-select></el-form-item>
<el-form-item label="所属仓库"><el-select v-model="profile.warehouseId" clearable filterable :loading="warehouseLoading" placeholder="选择所属仓库"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
<h3 class="full-width">驾驶资质</h3>
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
<el-form-item label="驾照签发日"><el-date-picker v-model="form.licenseIssuedOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="驾照到期日"><el-date-picker v-model="form.licenseExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
<h3 class="full-width">证照附件</h3>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
</div>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.driver-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
.full-width { grid-column: 1 / -1; }
.driver-form h3 { margin: 0; }
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadDriverLicense } from '../api/drivers'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ driverId: string; fileId: string; driverName: string }>()
const url = ref(''), contentType = ref(''), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let request = 0
function release() { if (url.value) URL.revokeObjectURL(url.value); url.value = '' }
async function load() {
const current = ++request
release(); error.value = null; loading.value = true
try {
const blob = await downloadDriverLicense(props.driverId, props.fileId)
if (current !== request || !open.value) return
contentType.value = blob.type; url.value = URL.createObjectURL(blob)
} catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '驾驶证读取失败') }
finally { if (current === request) loading.value = false }
}
watch([open, () => props.driverId, () => props.fileId], () => {
if (open.value && props.driverId && props.fileId) void load()
else { request++; release(); loading.value = false }
})
onBeforeUnmount(() => { request++; release() })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" title="驾驶证附件" width="620px" :show-confirm="false" cancel-text="关闭">
<p>{{ driverName }} · 驾驶证附件</p>
<LoadErrorState v-if="error" title="驾驶证读取失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading" class="license-preview">
<iframe v-if="url && contentType === 'application/pdf'" :src="url" title="驾驶证 PDF 预览" />
<img v-else-if="url" :src="url" :alt="`${driverName}的驾驶证`" />
</div>
<a v-if="url" :href="url" :download="`驾驶证.${contentType === 'application/pdf' ? 'pdf' : contentType === 'image/png' ? 'png' : 'jpg'}`">下载驾驶证</a>
</AppDialog>
</template>
<style scoped>
.license-preview { min-height: 160px; }
.license-preview iframe { width: 100%; height: 560px; border: 0; }
.license-preview img { display: block; max-width: 100%; max-height: 560px; margin: auto; object-fit: contain; }
</style>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { assignTrip } from '../api/trips'
import { driverQualificationLabels, type DriverDetail } from '../driverTypes'
import type { VehicleDetail } from '../types'
import type { TripDetail } from '../tripTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleId = ref(''), driverId = ref(''), reason = ref(''), busy = ref(false)
const vehicleLoading = ref(false), driverLoading = ref(false), error = ref<ApiErrorDetails | null>(null)
let vehicleRequest = 0, driverRequest = 0, generation = 0
let vehicleTimer: ReturnType<typeof setTimeout> | undefined, driverTimer: ReturnType<typeof setTimeout> | undefined
watch(() => props.detail.trip, trip => {
generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer)
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
vehicleId.value = trip.vehicleId || ''; driverId.value = trip.driverId || ''; busy.value = false
}, { immediate: true })
function searchVehicles(keyword = '') {
clearTimeout(vehicleTimer); const request = ++vehicleRequest
vehicleTimer = setTimeout(async () => {
vehicleLoading.value = true
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (request === vehicleRequest) vehicles.value = result.records }
catch (cause) { if (request === vehicleRequest) error.value = getApiErrorDetails(cause, '车辆候选加载失败') }
finally { if (request === vehicleRequest) vehicleLoading.value = false }
}, 250)
}
function searchDrivers(keyword = '') {
clearTimeout(driverTimer); const request = ++driverRequest
driverTimer = setTimeout(async () => {
driverLoading.value = true
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword.trim() || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: props.detail.trip.expectedDriverEndAt }); if (request === driverRequest) drivers.value = result.records }
catch (cause) { if (request === driverRequest) error.value = getApiErrorDetails(cause, '司机候选加载失败') }
finally { if (request === driverRequest) driverLoading.value = false }
}, 250)
}
async function save() {
if (busy.value) return
if (!vehicleId.value || !driverId.value) { ElMessage.warning('请选择车辆和司机'); return }
const current = generation; busy.value = true; error.value = null
try {
const result = await assignTrip(props.detail.trip.tripNo, { recordVersion: props.detail.trip.recordVersion, vehicleId: vehicleId.value,
driverId: driverId.value, reason: reason.value.trim() || '分配或调整车辆司机' })
if (current === generation) { emit('saved', result); ElMessage.success('车辆和司机已分配,请重新核对装载') }
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '分配失败') }
finally { if (current === generation) busy.value = false }
}
onBeforeUnmount(() => { generation++; vehicleRequest++; driverRequest++; clearTimeout(vehicleTimer); clearTimeout(driverTimer) })
</script>
<template>
<div>
<LoadErrorState v-if="error" title="分配未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form assignment-form" :disabled="busy" @submit.prevent="save">
<el-form-item label="车辆" required><el-select v-model="vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" placeholder="按车牌搜索" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="`${vehicle.plateNumber} · ${vehicle.model} · ${vehicle.maxPalletSpaces ?? '容量待确认'}托位`" />
</el-select></el-form-item>
<el-form-item label="司机" required><el-select v-model="driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" placeholder="按姓名、电话搜索" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone} · ${driverQualificationLabels[driver.qualification.reason]}`" />
</el-select></el-form-item>
<el-form-item label="调整说明(选填)"><el-input v-model="reason" placeholder="如车辆维修,安排替班" /></el-form-item>
<div><el-button type="primary" :loading="busy" @click="save">保存分配</el-button></div>
</el-form>
</div>
</template>
<style scoped>.assignment-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.assignment-form :deep(.el-select){width:100%}</style>
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } }))
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
const booking: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null,
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode }, global: { directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
})
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('retries the same incoming registration after an uncertain result without creating a new command', async () => {
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', first); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripIncoming, startTripIncoming, acceptTripIncoming, bindTripBookingDispatch } from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripBookingResult, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind } from '../bookingTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip; booking: TripBooking | null; mode: 'incoming' | 'receipt' | 'bind' | 'view' }>()
const emit = defineEmits<{ saved: [value: TripBookingResult] }>()
const loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null), preview = ref<TripIncomingPreview | null>(null)
const spaces = reactive<Record<string, number>>({}), unitPrice = ref<number | null>(null), dispatchNo = ref('')
const form = reactive({ estimatedArrivalAt: '', deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
const pending = ref<{ target: string; body: TripIncomingStart | TripIncomingAccept | TripBookingBind } | null>(null)
let generation = 0
const target = computed(() => `${props.trip.tripNo}:${props.booking?.bookingNo}:${props.mode}`)
const titles = { incoming: '登记外接来货', receipt: '核对外接实收与运费', bind: '关联外接排车单', view: '外接来货详情' }
const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMATION', 'READY_FOR_DISPATCH'].includes(preview.value.incoming.status))
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
async function load() {
if (!props.booking) return
const current = generation; loading.value = true; error.value = null
try {
const value = await getTripIncoming(props.trip.tripNo, props.booking.bookingNo)
if (current !== generation) return
preview.value = value; Object.keys(spaces).forEach(key => delete spaces[key])
for (const pallet of value.incoming?.pallets || []) spaces[pallet.warehousePalletId] = props.booking.receipt?.pallets.find(row => row.warehousePalletId === pallet.warehousePalletId)?.palletSpaces || 1
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '来货信息读取失败') }
finally { if (current === generation) loading.value = false }
}
watch(() => [open.value, target.value], () => {
generation++; busy.value = false; error.value = null; preview.value = null
if (!open.value || !props.booking) return
unitPrice.value = props.booking.cargo.unitPrice; dispatchNo.value = props.booking.dispatchNo || ''
Object.assign(form, { estimatedArrivalAt: new Date(new Date(props.trip.plannedDepartureAt).getTime() - 7200000).toISOString(), deliveryInstructions: '', privateAddress: '', postalCode: '', recipientName: '', recipientPhone: '' })
if (pending.value?.target !== target.value) pending.value = null
if (props.mode === 'receipt' || props.mode === 'view') void load()
}, { immediate: true })
async function save() {
if (busy.value || loading.value || props.mode === 'view' || !props.booking) return
const current = generation, booking = props.booking, mode = props.mode
if (!pending.value) {
const versions = { tripRecordVersion: preview.value?.tripRecordVersion ?? props.trip.recordVersion, recordVersion: preview.value?.recordVersion ?? booking.recordVersion, idempotencyKey: crypto.randomUUID() }
if (mode === 'incoming') pending.value = { target: target.value, body: { ...versions, ...form, estimatedArrivalAt: form.estimatedArrivalAt || null } }
else if (mode === 'bind') {
if (!dispatchNo.value.trim()) { ElMessage.warning('请输入已预排的客户排车单号'); return }
pending.value = { target: target.value, body: { ...versions, dispatchNo: dispatchNo.value.trim() } }
} else {
if (!ready.value || !preview.value?.incoming?.pallets.length || unitPrice.value == null || totalSpaces.value < 1) { ElMessage.warning('请等待实际收货完成,并核对托位及约定价格'); return }
pending.value = { target: target.value, body: { ...versions, receiptHash: preview.value.incoming.receiptHash, unitPrice: unitPrice.value,
pallets: preview.value.incoming.pallets.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: spaces[row.warehousePalletId]! })) } }
}
}
busy.value = true; error.value = null
try {
const body = pending.value.body
const value = mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
if (current === generation) { emit('saved', value); pending.value = null; open.value = false; ElMessage.success('外接履约记录已更新') }
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
finally { busy.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="titles[mode]" width="1080px" :show-confirm="mode !== 'view'" :confirm-text="pending ? '重试原请求' : mode === 'receipt' ? '确认实收与运费' : mode === 'bind' ? '关联排车单' : '生成来货单'" :confirm-disabled="loading || (mode === 'receipt' && !ready)" :confirm-loading="busy" @confirm="save">
<LoadErrorState v-if="error" title="操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="booking" v-loading="loading">
<p>{{ booking.bookingNo }} · {{ booking.customerName }} · {{ booking.destinationLabel }}</p>
<el-form v-if="mode === 'incoming'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending">
<el-form-item label="预计到仓时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="交货说明"><el-input v-model="form.deliveryInstructions" placeholder="选填,默认客户自送至本起运仓" /></el-form-item>
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
</el-form>
<template v-if="mode === 'receipt' || mode === 'view'">
<p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
</template>
<el-form v-if="mode === 'bind'" label-position="top" class="density-form" :disabled="busy || !!pending"><p>先将来货库存预排为客户排车单,再填写排车单号。关联后重新核对整车装载并完成审核。</p><el-form-item label="排车单号"><el-input v-model="dispatchNo" placeholder="输入包含本次实收货物的排车单号" /></el-form-item></el-form>
</div>
</AppDialog>
</template>
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking } from '../bookingTypes'
const access = vi.hoisted(() => ({ writable: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
vi.mock('../api/tripBookings', () => ({ listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
const wrappers: VueWrapper[] = []
function mount() {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElInput: field, ElOption: true,
LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => { vi.clearAllMocks(); access.writable = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
})
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
})
it('does not expose booking writes to a read-only operator', async () => {
access.writable = false; const wrapper = mount(); await flushPromises()
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
})
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
await button(wrapper, '详情').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
})
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking } from '../api/tripBookings'
import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import type { FleetTrip } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip }>()
const emit = defineEmits<{ changed: [version: number]; linked: [] }>()
const access = useAccessPreviewStore()
const records = ref<TripBooking[]>([]), loading = ref(false), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const fulfillmentOpen = ref(false), fulfillmentBooking = ref<TripBooking | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
function fulfill(row: TripBooking, mode: 'incoming' | 'receipt' | 'bind' | 'view') { fulfillmentBooking.value = row; fulfillmentMode.value = mode; fulfillmentOpen.value = true }
function fulfilled(value: TripBookingResult) { accept(value); if (fulfillmentMode.value === 'bind') { emit('linked'); void load() } }
const readOnly = ref(false)
const open = ref(false), editing = ref<TripBooking | null>(null), customers = ref<FleetCustomerOption[]>([]), customersLoading = ref(false)
const pendingCreate = ref<TripBookingCreate | null>(null), pendingCommand = ref<{ bookingNo: string; action: 'confirm' | 'cancel'; body: TripBookingCommand } | null>(null)
const version = ref<number | null>(null), customerId = ref('')
const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
const form = reactive<TripBookingCargo>(blank())
let generation = 0, searchSequence = 0
let searchTimer: ReturnType<typeof setTimeout> | undefined
const planned = computed(() => props.trip.status === 'PLANNED')
const quotedAmount = computed(() => form.unitPrice == null ? null : form.priceBasis === 'ORDER' ? form.unitPrice : form.palletSpaces == null ? null : Math.round(form.unitPrice * form.palletSpaces * 100) / 100)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', slotName: 'actions', width: 440, fixed: 'left' },
{ key: 'bookingNo', prop: 'bookingNo', label: '外接预订号', minWidth: 225 },
{ key: 'customerName', prop: 'customerName', label: '委托客户', minWidth: 170 },
{ key: 'destinationLabel', prop: 'destinationLabel', label: '卸货站', minWidth: 140 },
{ key: 'status', label: '状态', slotName: 'status', width: 130 },
{ key: 'cargo', label: '承运货量', slotName: 'cargo', minWidth: 225 },
{ key: 'amount', label: '约定运费', slotName: 'amount', minWidth: 160 },
{ key: 'documents', label: '来货 / 排车', slotName: 'documents', minWidth: 230 },
]
async function load() {
const current = ++generation; loading.value = true; error.value = null
try { const value = await listTripBookings(props.trip.tripNo); if (current === generation) { records.value = value.records; version.value = value.tripRecordVersion
if (pendingCommand.value) { const row = value.records.find(row => row.bookingNo === pendingCommand.value!.bookingNo); if (row && (row.status === 'CANCELLED' || pendingCommand.value.action === 'confirm' && row.status === 'CONFIRMED')) pendingCommand.value = null } } }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '外接预订加载失败') }
finally { if (current === generation) loading.value = false }
}
function searchCustomers(keyword = '') {
clearTimeout(searchTimer); const current = ++searchSequence
searchTimer = setTimeout(async () => {
customersLoading.value = true
try { const rows = await listFleetCustomers(keyword.trim() || undefined); if (current === searchSequence) customers.value = rows }
catch (cause) { if (current === searchSequence) error.value = getApiErrorDetails(cause, '客户目录加载失败') }
finally { if (current === searchSequence) customersLoading.value = false }
}, 250)
}
function edit(row?: TripBooking, viewOnly = false) {
readOnly.value = viewOnly
if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return }
editing.value = row || null
if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) }
else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) }
open.value = true
}
function accept(value: TripBookingResult) {
version.value = value.tripRecordVersion
const index = records.value.findIndex(row => row.bookingNo === value.booking.bookingNo)
if (index < 0) records.value.push(value.booking); else records.value[index] = value.booking
emit('changed', value.tripRecordVersion)
}
function failure(cause: unknown, fallback: string) {
error.value = getApiErrorDetails(cause, fallback)
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pendingCreate.value = null; pendingCommand.value = null }
}
async function save() {
if (busy.value || readOnly.value || version.value == null) return
if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return }
const current = generation; busy.value = true; error.value = null
try {
let value: TripBookingResult
if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }) })
else {
pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), idempotencyKey: crypto.randomUUID() }
value = await createTripBooking(props.trip.tripNo, pendingCreate.value)
}
if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') }
} catch (cause) { if (current === generation) failure(cause, '外接登记未完成') }
finally { busy.value = false }
}
async function command(row: TripBooking, action: 'confirm' | 'cancel') {
if (busy.value || version.value == null) return
if (pendingCommand.value && (pendingCommand.value.bookingNo !== row.bookingNo || pendingCommand.value.action !== action)) { ElMessage.warning('请先重试上一条操作或刷新核对'); return }
const current = generation
if (!pendingCommand.value) {
try { await ElMessageBox.confirm(action === 'confirm' ? `确认承运 ${row.bookingNo},按约定货量预留本车容量?` : `取消 ${row.bookingNo},释放尚未执行的预订容量?已到仓货物继续保留在仓库。`, action === 'confirm' ? '确认外接承运' : '取消外接预订', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current !== generation) return
pendingCommand.value = { bookingNo: row.bookingNo, action, body: { tripRecordVersion: version.value, recordVersion: row.recordVersion, idempotencyKey: crypto.randomUUID() } }
}
busy.value = true; error.value = null
try {
const value = await (action === 'confirm' ? confirmTripBooking : cancelTripBooking)(props.trip.tripNo, row.bookingNo, pendingCommand.value.body)
if (current === generation) { accept(value); pendingCommand.value = null; ElMessage.success('外接预订状态已更新') }
} catch (cause) { if (current === generation) failure(cause, '外接预订操作未完成') }
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true })
onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) })
</script>
<template>
<div>
<p>询价不占用车辆空间;确认承运后预留容量。实收时核对运费,转派送时按确认报价登记应收,付款状态以财务为准。</p>
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="readOnly || busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${row.agreedAmount.toFixed(2)} ${row.cargo.currency || ''}` }}</template>
</DataTable>
<AppDialog v-model="open" class="admin-density-dialog" :title="readOnly ? '外接预订详情' : editing ? '编辑外接询价' : '登记外接询价'" :show-confirm="!readOnly" width="1080px" :confirm-text="pendingCreate ? '重试原登记' : '保存询价'" :confirm-loading="busy" @confirm="save">
<el-form label-position="top" class="density-form booking-form" :disabled="readOnly || busy || !!pendingCreate">
<el-form-item label="委托客户" required><el-select v-model="customerId" filterable remote :remote-method="searchCustomers" :loading="customersLoading" :disabled="!!editing" placeholder="按客户名称、编号搜索" @focus="searchCustomers()"><el-option v-if="editing" :value="editing.customerId" :label="editing.customerName" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select></el-form-item>
<el-form-item label="本车卸货站" required><el-select v-model="form.destinationKey" placeholder="选择顺路卸货站"><el-option v-for="stop in trip.stops.slice(1).filter(row => row.destinationKey)" :key="stop.destinationKey!" :value="stop.destinationKey!" :label="stop.name" /></el-select></el-form-item>
<el-form-item label="货物名称"><el-input v-model="form.cargoName" placeholder="填写货物名称(选填)" /></el-form-item>
<el-form-item label="箱数" required><el-input-number v-model="form.packageCount" :min="1" :precision="0" placeholder="预计箱数" /></el-form-item>
<el-form-item label="预计标准托位"><el-input-number v-model="form.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
<el-form-item label="报价方式"><el-select v-model="form.priceBasis"><el-option value="PALLET" label="按托位" /><el-option value="ORDER" label="整单" /></el-select></el-form-item>
<el-form-item :label="form.priceBasis === 'PALLET' ? '每托位单价' : '整单价格'"><el-input-number v-model="form.unitPrice" :min="0" :precision="2" placeholder="可先保存询价" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency" filterable allow-create placeholder="选择或输入币种"><el-option v-for="currency in ['USD','CAD','EUR','GBP','CNY']" :key="currency" :value="currency" :label="currency" /></el-select></el-form-item>
<el-form-item label="联系人"><el-input v-model="form.contactName" placeholder="填写联系人(选填)" /></el-form-item>
<el-form-item label="联系方式"><el-input v-model="form.contactPhone" placeholder="填写电话或其他联系方式(选填)" /></el-form-item>
<el-form-item label="备注" class="booking-wide"><el-input v-model="form.remark" type="textarea" placeholder="其他交货说明(选填)" /></el-form-item>
</el-form>
<p>约定运费:{{ quotedAmount == null ? '待报价' : `${quotedAmount.toFixed(2)} ${form.currency || ''}` }}。保存询价不会占位,确认承运时核对本车剩余容量。</p>
</AppDialog>
<TripBookingFulfillmentDialog v-model="fulfillmentOpen" :trip="trip" :booking="fulfillmentBooking" :mode="fulfillmentMode" @saved="fulfilled" />
</div>
</template>
<style scoped>.booking-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.booking-form :deep(.el-select),.booking-form :deep(.el-input-number){width:100%}.booking-wide{grid-column:1/-1}</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripLoading, confirmTripLoading, getTripCapacityPlan, confirmTripCapacityPlan } from '../api/trips'
import type { FleetTrip, TripLoading, TripCapacityPlanning, TripMeasurement } from '../tripTypes'
const props = defineProps<{ trip: FleetTrip; editable: boolean; planning?: boolean }>()
const emit = defineEmits<{ saved: [loading: TripLoading]; planned: [plan: TripCapacityPlanning] }>()
type PalletRow = { warehousePalletId: string; palletNo: string; destinations: string; palletSpaces: number; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }
const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<PalletRow[]>([]), busy = ref(false), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
let sequence = 0
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }])
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
function apply(value: TripLoading | TripCapacityPlanning) {
info.value = value
const pallets = new Map<string, PalletRow>()
for (const cargo of value.cargo) {
if (!cargo.warehousePalletId) continue
const existing = pallets.get(cargo.warehousePalletId)
if (existing) { if (cargo.destinationLabel && !existing.destinations.split(' / ').includes(cargo.destinationLabel)) existing.destinations += ` / ${cargo.destinationLabel}`; continue }
const saved = ('capacityPlan' in value ? value.capacityPlan : value.confirmedLoad)?.pallets.find(row => row.warehousePalletId === cargo.warehousePalletId)
const index = props.trip.stops.findIndex(stop => !!cargo.destinationKey && stop.destinationKey === cargo.destinationKey)
pallets.set(cargo.warehousePalletId, { warehousePalletId: cargo.warehousePalletId, palletNo: cargo.palletNo || '—', destinations: cargo.destinationLabel || '—',
palletSpaces: saved?.palletSpaces ?? 1, weightKg: saved?.weightKg ?? null, volumeCbm: saved?.volumeCbm ?? null, deliveryIndex: saved?.deliveryIndex ?? (index > 0 ? index : null) })
}
rows.value = [...pallets.values()]
}
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try { const result = await (props.planning ? getTripCapacityPlan(props.trip.tripNo) : getTripLoading(props.trip.tripNo)); if (current === sequence) apply(result) }
catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量加载失败' : '装载信息加载失败') }
finally { if (current === sequence) loading.value = false }
}
async function save() {
if (busy.value || !info.value) return
if (!rows.value.length || rows.value.some(row => row.weightKg == null || row.volumeCbm == null || row.deliveryIndex == null)) { ElMessage.warning(props.planning ? '请填写每托预计重量、体积和卸货站' : '请填写每托实测重量、体积和卸货站'); return }
const current = sequence, snapshot = info.value
const pallets: TripMeasurement[] = rows.value.map(row => ({ warehousePalletId: row.warehousePalletId, palletSpaces: row.palletSpaces,
weightKg: row.weightKg!, volumeCbm: row.volumeCbm!, deliveryIndex: row.deliveryIndex! }))
busy.value = true; error.value = null
try {
const body = { recordVersion: snapshot.recordVersion, manifestHash: snapshot.manifestHash, pallets, reason: props.planning ? '核对本车自有货计划载荷' : '核对实物托盘及实际装载' }
if (props.planning) {
const result = await confirmTripCapacityPlan(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('planned', result); ElMessage.success('计划容量已核对') }
} else {
const result = await confirmTripLoading(props.trip.tripNo, body)
if (current === sequence) { apply(result); emit('saved', result); ElMessage.success('实际装载已确认') }
}
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, props.planning ? '计划容量确认失败' : '装载确认失败') }
finally { busy.value = false }
}
watch(() => [props.trip.tripNo, props.trip.recordVersion, props.planning], () => { if (info.value?.tripNo !== props.trip.tripNo || info.value.recordVersion !== props.trip.recordVersion || !!props.planning !== ('capacityPlan' in info.value)) void load() }, { immediate: true })
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<div v-loading="loading">
<LoadErrorState v-if="error" :title="planning ? '计划容量操作未完成' : '装载操作未完成'" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="planning">先核对本车自有货的预计载荷,查看每段剩余托位、载重和容积。实际装载完成后仍需按实测值确认。</p>
<p v-if="stalePlan" role="status">货物明细已变化,请重新核对计划容量。</p>
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
<template #stop="{ row }"><el-select v-model="row.deliveryIndex" :disabled="!editable || busy" placeholder="选择卸货站"><el-option v-for="(stop, index) in trip.stops.slice(1)" :key="index" :value="index + 1" :label="stop.name" /></el-select></template>
<template #toolbar-actions><el-button @click="load">{{ planning ? '重新读取容量计划' : '重新读取装载' }}</el-button><el-button v-if="editable" type="primary" :loading="busy" :disabled="!!info?.blockReason || !rows.length" @click="save">{{ planning ? '确认计划容量' : '确认实际装载' }}</el-button></template>
</DataTable>
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
</div>
</template>
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listDriverWarehouses } from '../api/drivers'
import { createTrip, updateTripPlan } from '../api/trips'
import type { DriverWarehouseOption } from '../driverTypes'
import type { FleetTrip, TripDetail, TripDraft, TripStop } from '../tripTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ trip: FleetTrip | null }>()
const emit = defineEmits<{ saved: [detail: TripDetail] }>()
const form = reactive({ warehouseId: '', plannedDepartureAt: '', expectedDriverEndAt: '', remark: '', reason: '' })
const stops = ref<TripStop[]>([]), warehouses = ref<DriverWarehouseOption[]>([])
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
const pending = ref<TripDraft | null>(null)
let generation = 0
async function loadWarehouses() {
const current = generation
warehouseError.value = null
try { const result = await listDriverWarehouses('OVERSEAS'); if (current === generation) warehouses.value = result }
catch (cause) { if (current === generation) warehouseError.value = getApiErrorDetails(cause, '起运仓加载失败') }
}
watch([open, () => props.trip?.tripNo], ([visible]) => {
generation++; saving.value = false
if (!visible) return
if (pending.value && !props.trip) return
const trip = props.trip
pending.value = null; error.value = null
Object.assign(form, { warehouseId: trip?.warehouseId || '', plannedDepartureAt: trip?.plannedDepartureAt || new Date(Date.now() + 3600000).toISOString(),
expectedDriverEndAt: trip?.expectedDriverEndAt || new Date(Date.now() + 86400000).toISOString(), remark: trip?.remark || '', reason: '' })
stops.value = trip ? trip.stops.map(stop => ({ ...stop })) : [{ name: '起运仓' }, { name: '待确定卸货点' }]
if (!trip) void loadWarehouses()
})
async function save() {
if (saving.value) return
if (!form.warehouseId || !form.plannedDepartureAt || !form.expectedDriverEndAt || stops.value.some(stop => !stop.name.trim())) {
ElMessage.warning('请填写起运仓、计划时间和站点名称'); return
}
const current = generation, trip = props.trip
saving.value = true; error.value = null
try {
const plan = { plannedDepartureAt: new Date(form.plannedDepartureAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString(),
stops: stops.value.map(stop => ({ ...stop, name: stop.name.trim() })), remark: form.remark.trim() || null }
if (!trip) pending.value ||= { ...plan, warehouseId: form.warehouseId, idempotencyKey: crypto.randomUUID() }
const result = trip ? await updateTripPlan(trip.tripNo, { ...plan, recordVersion: trip.recordVersion, reason: form.reason.trim() || '更新车次计划' })
: await createTrip(pending.value!)
if (current !== generation || !open.value) return
pending.value = null; emit('saved', result); open.value = false; ElMessage.success('车次计划已保存')
} catch (cause) {
if (current !== generation) return
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null
error.value = getApiErrorDetails(cause, '计划保存失败')
} finally { if (current === generation) saving.value = false }
}
onBeforeUnmount(() => { generation++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="trip ? '编辑车次计划' : '新建车次'" width="1080px" confirm-text="保存计划" :confirm-loading="saving" @confirm="save">
<LoadErrorState v-if="error" title="计划保存失败" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError && !trip" title="起运仓加载失败" :message="warehouseError.message" @retry="loadWarehouses" />
<p v-if="pending">上次保存结果尚未确定,再次保存会核对原请求。</p>
<el-form label-position="top" class="density-form trip-plan-form" :disabled="saving || !!pending" @submit.prevent="save">
<el-form-item label="起运仓" required><el-input v-if="trip" :model-value="trip.warehouseName" readonly /><el-select v-else v-model="form.warehouseId" filterable placeholder="选择海外起运仓"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :value="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" /></el-select></el-form-item>
<el-form-item label="计划发车时间" required><el-date-picker v-model="form.plannedDepartureAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="包括返程或转场" /></el-form-item>
<div class="full-width"><h3>路线站点</h3><p>先建立计划,关联货物后可在详情中匹配实际卸货站。</p>
<div v-for="(stop, index) in stops" :key="index" class="trip-stop-row"><span>{{ index === 0 ? '起点' : `第 ${index} 站` }}</span><el-input v-model="stop.name" placeholder="站点名称" /><el-input v-model="stop.address" placeholder="地址(选填)" /><el-button :disabled="index === 0 || stops.length <= 2" @click="stops.splice(index, 1)">移除</el-button></div>
<el-button @click="stops.push({ name: '' })">添加站点</el-button>
</div>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" placeholder="补充本次运输安排(选填)" /></el-form-item>
<el-form-item v-if="trip" label="调整说明(选填)" class="full-width"><el-input v-model="form.reason" placeholder="如改期或调整路线(选填)" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.trip-plan-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
.full-width{grid-column:1/-1}.trip-stop-row{display:grid;grid-template-columns:70px 1fr 2fr auto;gap:var(--admin-space-4);align-items:center;margin-bottom:var(--admin-space-4)}
.trip-plan-form :deep(.el-select),.trip-plan-form :deep(.el-date-editor){width:100%}
</style>
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
import { ApiResponseError } from '@/shared/api/http'
import VehicleCareEditor from './VehicleCareEditor.vue'
import VehicleCareTable from './VehicleCareTable.vue'
import VehicleDetailView from '../pages/VehicleDetailView.vue'
import * as api from '../api/vehicleCare'
import { getVehicle } from '../api/overseasMasterData'
import type { VehicleCareRecord } from '../vehicleCareTypes'
const access = vi.hoisted(() => ({ write: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || access.write }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: '71' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() } }))
vi.mock('../api/vehicleCare', () => ({ listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn() }))
vi.mock('../api/overseasMasterData', () => ({ getVehicle: vi.fn() }))
const row: VehicleCareRecord = { recordNo: 'VR-ONE', vehicleId: '71', vehiclePlate: 'VEH-A', recordVersion: 0, details: { 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 }, files: [], updatedAt: '2026-01-01T00:00:00Z', updatedBy: 'operator', paymentStatus: null }
const container = { template: '<div><slot name="header"/><slot/></div>' }
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'columns'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.recordNo"><slot name="actions" :row="row"/><span data-test="status"><slot name="status" :row="row"/></span><span data-test="payment"><slot name="payment" :row="row"/></span><span data-test="cost"><slot name="cost" :row="row"/></span></div></div>' }
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' }
const wrappers: VueWrapper[] = []
function mount(component: object, props: Record<string, unknown>): VueWrapper {
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
AdminPage: container, DetailPageLayout: container, FormSection: container, AppDialog: dialog, LoadErrorState: true, ApiErrorMeta: true,
ElButton: { template: '<button><slot/></button>' }, ElForm: container, ElFormItem: container,
ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('loads care only after the detail tab is selected', async () => {
vi.mocked(getVehicle).mockResolvedValue({ id: '71', plateNumber: 'VEH-A', model: 'Truck', fuelConsumptionPer100Km: 10, mileageKm: 1000, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', recordVersion: 0 } as Awaited<ReturnType<typeof getVehicle>>)
const wrapper = mount(VehicleDetailView, {}); await flushPromises()
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(0)
expect(api.listVehicleCare).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'care'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'VehicleCareTable' })).toHaveLength(2)
})
it('requests one page at a time, never preloads photos, and ignores the old vehicle response', async () => {
let resolveOld!: (value: Awaited<ReturnType<typeof api.listVehicleCare>>) => void
vi.mocked(api.listVehicleCare).mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve }))
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' })
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B' }); await flushPromises()
expect(api.listVehicleCare).toHaveBeenCalledTimes(2)
resolveOld({ records: [{ ...row, recordNo: 'OLD' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].recordNo).toBe('VR-ONE')
expect(api.downloadVehicleCarePhoto).not.toHaveBeenCalled()
})
it('retains the saved record and exact photo key after an upload failure', async () => {
vi.mocked(api.createVehicleCare).mockResolvedValue(structuredClone(row))
vi.mocked(api.uploadVehicleCarePhoto).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...row, recordVersion: 1 })
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
await wrapper.setProps({ modelValue: true })
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
const file = new File(['photo'], 'meter.png', { type: 'image/png' }), picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [file] }); await picker.trigger('change')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('记录已保存')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createVehicleCare).toHaveBeenCalledTimes(1); expect(api.updateVehicleCare).not.toHaveBeenCalled()
expect(api.uploadVehicleCarePhoto).toHaveBeenCalledTimes(2)
expect(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[0]).toEqual(vi.mocked(api.uploadVehicleCarePhoto).mock.calls[1])
})
it('keeps read-only users out of create edit and upload controls', async () => {
access.write = false
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
expect(wrapper.text()).not.toContain('添加加油记录')
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions').every((action: { visible: boolean }) => !action.visible)).toBe(true)
})
it('reads the latest record only when editing instead of reusing an old page version', async () => {
vi.mocked(api.getVehicleCare).mockResolvedValue({ ...row, recordVersion: 5 })
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL' }); await flushPromises()
expect(api.getVehicleCare).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'RowActions' }).vm.$emit('action', 'edit'); await flushPromises()
expect(api.getVehicleCare).toHaveBeenCalledExactlyOnceWith('71', 'VR-ONE')
expect(wrapper.findComponent({ name: 'VehicleCareEditor' }).props('record').recordVersion).toBe(5)
})
it('allows correction after a definitive validation failure instead of locking the invalid draft', async () => {
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new ApiResponseError('请填写加油量', 'A0400', 200)).mockResolvedValueOnce(structuredClone(row))
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
await wrapper.setProps({ modelValue: true }); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.text()).not.toContain('正在核对原保存结果')
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createVehicleCare).toHaveBeenLastCalledWith('71', expect.any(String), expect.objectContaining({ fuelQuantity: 28.6 }))
})
it('does not continue uploading vehicle photos after the editor is unmounted', async () => {
let finish!: (value: VehicleCareRecord) => void
vi.mocked(api.createVehicleCare).mockReturnValueOnce(new Promise(resolve => { finish = resolve }))
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
await wrapper.setProps({ modelValue: true })
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [new File(['png'], 'fuel.png', { type: 'image/png' })] })
await picker.trigger('change')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
wrapper.unmount(); finish(structuredClone(row)); await flushPromises()
expect(api.uploadVehicleCarePhoto).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
})
it('keeps an uncertain create key scoped to its vehicle when the editor is reused', async () => {
vi.mocked(api.createVehicleCare).mockRejectedValueOnce(new Error('unknown network outcome')).mockResolvedValueOnce({ ...row, vehicleId: '72', recordNo: 'VR-TWO' })
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'FUEL', record: null })
await wrapper.setProps({ modelValue: true })
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 28.6)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const oldKey = vi.mocked(api.createVehicleCare).mock.calls[0]![1]
expect(wrapper.text()).toContain('正在核对原保存结果')
await wrapper.setProps({ modelValue: false })
await wrapper.setProps({ vehicleId: '72', plate: 'VEH-B', modelValue: true }); await flushPromises()
expect(wrapper.text()).not.toContain('正在核对原保存结果')
wrapper.findAllComponents({ name: 'ElInputNumber' })[1]!.vm.$emit('update:modelValue', 12)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createVehicleCare).toHaveBeenLastCalledWith('72', expect.any(String), expect.objectContaining({ fuelQuantity: 12 }))
expect(vi.mocked(api.createVehicleCare).mock.calls[1]![1]).not.toBe(oldKey)
})
it('saves repair project and place separately without submitting hidden mileage or fuel fields', async () => {
vi.mocked(api.createVehicleCare).mockImplementation(async (vehicleId, _key, details) => ({ ...row, vehicleId, details, paymentStatus: 'UNLINKED' }))
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'REPAIR', record: null })
await wrapper.setProps({ modelValue: true })
await wrapper.get('input[placeholder="请输入具体项目"]').setValue('更换轮胎')
await wrapper.get('input[placeholder="请输入实际地点"]').setValue('维修站')
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('轮胎损坏,现场维修')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createVehicleCare).toHaveBeenCalledWith('71', expect.any(String), expect.objectContaining({ recordType: 'REPAIR', odometer: null, distanceUnit: null, verificationStatus: null, fuelQuantity: null,
incident: expect.objectContaining({ item: '更换轮胎', place: '维修站', description: '轮胎损坏,现场维修', reportedCost: null, currency: null }) }))
expect(wrapper.find('input[type="file"]').exists()).toBe(false)
})
it('renders completed rescue and zero reported cost separately from payment and provides the approved update action', async () => {
const rescue: VehicleCareRecord = { ...row, paymentStatus: 'UNLINKED', details: { ...row.details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
incident: { item: '拖车', place: '现场', description: '处理完成', handler: '救援人员', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: 0, currency: 'USD' } } }
vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [rescue], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mount(VehicleCareTable, { vehicleId: '71', plate: 'VEH-A', recordType: 'RESCUE' }); await flushPromises()
expect(wrapper.get('[data-test="status"]').text()).toBe('已完成')
expect(wrapper.get('[data-test="payment"]').text()).toBe('未关联财务')
expect(wrapper.get('[data-test="cost"]').text()).toBe('USD 0.00')
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0].key).toBe('actions')
expect(wrapper.findComponent({ name: 'RowActions' }).props('actions')[0].label).toBe('更新处理')
})
it('editing an exception does not mutate the displayed source until a command succeeds', async () => {
const exception: VehicleCareRecord = { ...row, details: { ...row.details, recordType: 'EXCEPTION', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null,
incident: { item: null, place: null, description: '原异常描述', handler: null, processingStatus: 'PENDING', exceptionType: 'TIMEOUT', reportedCost: null, currency: null } } }
const wrapper = mount(VehicleCareEditor, { modelValue: false, vehicleId: '71', plate: 'VEH-A', recordType: 'EXCEPTION', record: exception })
await wrapper.setProps({ modelValue: true })
await wrapper.get('input[placeholder="请说明事件及实际处理情况"]').setValue('尚未保存的修改')
expect(exception.details.incident?.description).toBe('原异常描述')
expect(api.updateVehicleCare).not.toHaveBeenCalled()
})
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto } from '../api/vehicleCare'
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareDraft, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType; record: VehicleCareRecord | null }>()
const emit = defineEmits<{ saved: [record: VehicleCareRecord] }>()
const access = useAccessPreviewStore(), saving = ref(false), error = ref<ApiErrorDetails | null>(null), current = ref<VehicleCareRecord | null>(null)
const reason = ref(''), input = ref<HTMLInputElement | null>(null)
const form = reactive<VehicleCareDraft>({ recordType: 'MAINTENANCE', occurredAt: '', distanceUnit: 'KM', odometer: 0,
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: null, incident: null })
const selected = ref<Array<{ key: string; file: File }>>([])
const pendingCreate = ref<{ key: string; details: VehicleCareDraft } | null>(null)
let lastSaved = '', editSession = 0
const identity = computed(() => JSON.stringify([props.vehicleId, props.recordType, props.record?.recordNo ?? null]))
const canEdit = computed(() => current.value ? access.can(permission.vehicleRecordEdit) : access.can(permission.vehicleRecordCreate))
const locked = computed(() => saving.value || !canEdit.value || (!!pendingCreate.value && !current.value))
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
const costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType))
const distanceLabel = computed(() => form.distanceUnit ? distanceLabels[form.distanceUnit] : '—')
watch([open, identity], ([value, target], [, previousTarget]) => {
editSession++; saving.value = false
if (target !== previousTarget) { pendingCreate.value = null; current.value = null }
if (!value) return
if (pendingCreate.value && !current.value) return
current.value = props.record; error.value = null; selected.value = []; reason.value = ''; pendingCreate.value = null
if (input.value) input.value.value = ''
Object.assign(form, props.record ? { ...props.record.details, incident: props.record.details.incident ? { ...props.record.details.incident } : null }
: { recordType: props.recordType, occurredAt: new Date().toISOString(), distanceUnit: meterRecord.value ? 'KM' : null, odometer: meterRecord.value ? 0 : null,
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: props.recordType === 'FUEL' ? 'L' : null,
verificationStatus: meterRecord.value ? 'PENDING' : null, remark: null, incident: meterRecord.value ? null
: { description: '', item: null, place: null, handler: null, processingStatus: 'PENDING', exceptionType: null, reportedCost: null, currency: null } })
lastSaved = props.record ? JSON.stringify(props.record.details) : ''
})
function selectFiles(event: Event) {
const element = event.target as HTMLInputElement
const files = Array.from(element.files || [])
if (files.length + (current.value?.files.length || 0) > 20 || files.some(file => !['image/png', 'image/jpeg'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
ElMessage.warning('每条记录最多 20 张非空 JPG / PNG,每张最大 10MiB'); element.value = ''; selected.value = []; return
}
selected.value = files.map(file => ({ key: crypto.randomUUID(), file }))
}
async function save() {
if (saving.value) return
const details = pendingCreate.value?.details || { ...form, incident: form.incident ? { ...form.incident } : null }
if (!details.occurredAt || !Number.isFinite(new Date(details.occurredAt).getTime()) || (meterRecord.value && details.odometer == null)) { ElMessage.warning('请填写实际发生时间及本类记录必填字段'); return }
if (details.incident && details.incident.reportedCost == null) details.incident.currency = null
const fingerprint = JSON.stringify(details)
if ((!current.value || fingerprint !== lastSaved) && !canEdit.value) return
if (selected.value.length && !access.can(permission.vehicleRecordUpload)) return
saving.value = true; error.value = null
const session = editSession, target = identity.value, vehicleId = props.vehicleId
const isCurrent = () => session === editSession && open.value && identity.value === target
let persisted = false
try {
if (!current.value) {
pendingCreate.value ||= { key: crypto.randomUUID(), details: { ...details } }
const created = await createVehicleCare(vehicleId, pendingCreate.value.key, pendingCreate.value.details)
if (!isCurrent()) return
current.value = created
pendingCreate.value = null; lastSaved = fingerprint
} else if (fingerprint !== lastSaved) {
const updated = await updateVehicleCare(vehicleId, current.value.recordNo, current.value.recordVersion, reason.value.trim() || '更新车辆运维记录', details)
if (!isCurrent()) return
current.value = updated
lastSaved = fingerprint
}
persisted = true
while (selected.value.length && current.value) {
const next = selected.value[0]!
const uploaded = await uploadVehicleCarePhoto(vehicleId, current.value.recordNo, current.value.recordVersion, next.key, next.file)
if (!isCurrent()) return
current.value = uploaded
selected.value.shift()
}
if (current.value) emit('saved', current.value)
ElMessage.success('记录已保存'); open.value = false
} catch (cause) {
if (!isCurrent()) return
if (persisted && current.value) emit('saved', current.value)
const problem = getApiErrorDetails(cause, '记录保存失败')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
error.value = { ...problem, message: persisted ? `记录已保存,照片上传未完成:${problem.message}。重试会继续上传剩余照片。` : problem.message }
} finally { if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { editSession++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="pendingCreate && !current" class="care-hint">正在核对原保存结果,请保留当前窗口并重试。</p>
<el-form label-position="top" class="density-form care-form" :disabled="locked" @submit.prevent="save">
<el-form-item label="车牌号"><el-input :model-value="plate" readonly /></el-form-item>
<el-form-item label="发生时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item v-if="meterRecord" label="里程单位" required><el-select v-model="form.distanceUnit"><el-option label="公里(km)" value="KM" /><el-option label="英里(mi)" value="MI" /></el-select></el-form-item>
<el-form-item v-if="meterRecord" :label="`记录时里程(${distanceLabel})`" required><el-input-number v-model="form.odometer" :min="0" :max="9999999999.999" :precision="3" placeholder="请输入记录时表盘里程" /></el-form-item>
<template v-if="recordType === 'MAINTENANCE'">
<el-form-item :label="`上次保养里程(${distanceLabel})`" required><el-input-number v-model="form.lastServiceOdometer" :min="0" :precision="3" placeholder="请输入上次保养里程" /></el-form-item>
<el-form-item :label="`下次保养里程(${distanceLabel})`" required><el-input-number v-model="form.nextServiceOdometer" :min="0" :precision="3" placeholder="请输入下次计划保养里程" /></el-form-item>
</template>
<template v-else-if="recordType === 'FUEL'">
<el-form-item label="加油量" required><el-input-number v-model="form.fuelQuantity" :min="0.001" :precision="3" placeholder="请输入实际加油量" /></el-form-item>
<el-form-item label="加油单位" required><el-select v-model="form.fuelUnit"><el-option v-for="(label, key) in fuelLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
</template>
<template v-if="form.incident">
<el-form-item v-if="recordType === 'EXCEPTION'" label="异常类型" required><el-select v-model="form.incident.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, key) in exceptionLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<el-form-item label="处理人" :required="form.incident.processingStatus !== 'PENDING'"><el-input v-model="form.incident.handler" maxlength="128" placeholder="请输入实际处理人" /></el-form-item>
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修项目' : '救援项目'" required><el-input v-model="form.incident.item" maxlength="256" placeholder="请输入具体项目" /></el-form-item>
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修地点' : '救援地点'" required><el-input v-model="form.incident.place" maxlength="512" placeholder="请输入实际地点" /></el-form-item>
<el-form-item label="事件描述" class="full-width" required><el-input v-model="form.incident.description" type="textarea" :rows="3" maxlength="2000" placeholder="请说明事件及实际处理情况" /></el-form-item>
<el-form-item v-if="costRecord" label="费用(申报金额)"><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<p v-if="costRecord" class="care-hint full-width">费用仅作运维记录,尚未关联财务付款。处理完成不会标记为已付款。</p>
</template>
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次保养或加油情况(选填)" /></el-form-item>
<el-form-item v-if="current && canEdit" label="更正说明(选填)" class="full-width"><el-input v-model="reason" maxlength="1000" placeholder="需要补充时填写" /></el-form-item>
</el-form>
<label v-if="meterRecord && access.can(permission.vehicleRecordUpload)" class="file-picker">附件图片(JPG / PNG,每张最大 10MiB)<input ref="input" type="file" accept="image/png,image/jpeg" multiple :disabled="saving || !!pendingCreate" @change="selectFiles" /></label>
<p v-if="meterRecord" class="care-hint">已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。记录使用所选单位,不自动修改车辆公里数。</p>
</AppDialog>
</template>
<style scoped>
.care-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
.full-width { grid-column: 1 / -1; }
.care-form :deep(.el-select), .care-form :deep(.el-input-number), .care-form :deep(.el-date-editor) { width: 100%; }
.file-picker { display: grid; gap: var(--admin-space-3); }
.care-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
</style>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto } from '../api/vehicleCare'
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareFile, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
import VehicleCareEditor from './VehicleCareEditor.vue'
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType }>()
const access = useAccessPreviewStore(), rows = ref<VehicleCareRecord[]>([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editing = ref<VehicleCareRecord | null>(null)
const photoError = ref<ApiErrorDetails | null>(null), openingFile = ref('')
const openingRecord = ref(false)
const name = computed(() => vehicleCareLabels[props.recordType])
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
let request = 0, disposed = false
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
const columns = computed<DataTableColumn[]>(() => {
const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }]
if (props.recordType === 'EXCEPTION') result.push({ key: 'exceptionType', label: '异常类型', minWidth: 135, slotName: 'exceptionType' })
result.push({ key: 'date', label: '发生时间', minWidth: 165, slotName: 'date' })
if (meterRecord.value) result.push(...(props.recordType === 'MAINTENANCE' ? [
{ key: 'last', label: '上次保养', minWidth: 120, slotName: 'last' },
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
{ key: 'next', label: '下次保养', minWidth: 120, slotName: 'next' },
] : [
{ key: 'fuel', label: '加油量', minWidth: 120, slotName: 'fuel' },
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
]), { key: 'files', label: '图片', minWidth: 130, slotName: 'files' })
else if (['REPAIR', 'RESCUE'].includes(props.recordType)) {
const project = { key: 'item', prop: 'details.incident.item', label: props.recordType === 'REPAIR' ? '维修项目' : '救援项目', minWidth: 170 }
const place = { key: 'place', prop: 'details.incident.place', label: props.recordType === 'REPAIR' ? '维修地点' : '救援地点', minWidth: 190 }
result.push(...(props.recordType === 'REPAIR' ? [project, place] : [place, project]), { key: 'cost', label: '费用(申报金额)', minWidth: 160, slotName: 'cost' })
} else result.push({ key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
result.push({ key: 'status', label: meterRecord.value ? '核验状态' : '处理状态', width: 105, slotName: 'status' })
if (!meterRecord.value) result.push({ key: 'handler', prop: 'details.incident.handler', label: '处理人', minWidth: 120 })
if (['REPAIR', 'RESCUE'].includes(props.recordType)) result.push({ key: 'payment', label: '付款状态', minWidth: 130, slotName: 'payment' }, { key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
result.push({ key: 'remark', prop: 'details.remark', label: '备注', minWidth: 150 })
return result
})
async function load() {
const id = ++request; loading.value = true; error.value = null
try { const result = await listVehicleCare(props.vehicleId, props.recordType, page.value, size.value); if (id === request) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (id === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, `${name.value}加载失败`) } }
finally { if (id === request) loading.value = false }
}
watch([() => props.vehicleId, () => props.recordType, page, size], load, { immediate: true })
function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value,
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) }] }
async function edit(row: VehicleCareRecord | null) {
if (openingRecord.value) return
if (!row) { editing.value = null; editorOpen.value = true; return }
const vehicleId = props.vehicleId
openingRecord.value = true
try { const current = await getVehicleCare(vehicleId, row.recordNo); if (!disposed && props.vehicleId === vehicleId) { editing.value = current; editorOpen.value = true } }
catch (cause) { if (!disposed && props.vehicleId === vehicleId) error.value = getApiErrorDetails(cause, '记录详情加载失败') }
finally { openingRecord.value = false }
}
function saved(row: VehicleCareRecord) {
const index = rows.value.findIndex(value => value.recordNo === row.recordNo)
if (index >= 0) rows.value[index] = row
else if (page.value !== 1) page.value = 1
else void load()
}
async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) {
if (openingFile.value) return
const target = window.open('about:blank', '_blank')
if (!target) { ElMessage.warning('请允许浏览器打开图片标签页'); return }
target.opener = null; photoError.value = null; openingFile.value = file.fileId
try {
const blob = await downloadVehicleCarePhoto(props.vehicleId, row.recordNo, file.fileId)
if (disposed || target.closed) { target.close(); return }
const url = URL.createObjectURL(blob); target.location.href = url
const preview = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(preview.timer); URL.revokeObjectURL(url); previews.delete(preview) } }, 1000) }
previews.add(preview)
} catch (cause) { target.close(); if (!disposed) photoError.value = getApiErrorDetails(cause, '图片读取失败') }
finally { openingFile.value = '' }
}
onBeforeUnmount(() => { request++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() })
</script>
<template>
<section class="care-records" :aria-label="name">
<h3>{{ name }}</h3>
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="edit(row as VehicleCareRecord)" /></template>
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
<template #last="{ row }">{{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
<template #current="{ row }">{{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
<template #next="{ row }">{{ row.details.nextServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
<template #fuel="{ row }">{{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}</template>
<template #exceptionType="{ row }">{{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}</template>
<template #cost="{ row }">{{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}</template>
<template #payment="{ row }">{{ row.paymentStatus === 'UNLINKED' ? '未关联财务' : '—' }}</template>
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
</DataTable>
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
</section>
</template>
<style scoped>.care-records { min-width: 0; }.care-records h3 { margin: 0 0 var(--admin-space-4); }</style>
@@ -0,0 +1,74 @@
export type DriverReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export type DriverQualificationReason = 'ELIGIBLE' | 'INACTIVE' | 'MISSING_LICENSE' | 'MISSING_EXPIRY'
| 'PENDING_REVIEW' | 'REJECTED' | 'EXPIRED' | 'MISSING_TRIP_END' | 'INVALID_TRIP_END' | 'EXPIRES_DURING_TRIP'
export interface DriverDraft {
name: string
phone: string
ownership: 'OWNED' | 'DISPATCHED'
region: 'DOMESTIC' | 'OVERSEAS'
licenseNumber: string | null
licenseType: string | null
licenseIssuedOn: string | null
licenseExpiresOn: string | null
licenseZone: string
remark: string | null
profile: DriverProfile | null
}
export interface DriverProfile {
warehouseId: string | null
vehicleModels: string | null
languagesAndArea: string | null
medicalExpiresOn: string | null
trainingExpiresOn: string | null
insuranceExpiresOn: string | null
}
export interface DriverWarehouseOption { id: string; code: string; name: string; region: DriverDraft['region'] }
export type DriverLicenseState = 'MISSING' | 'VALID' | 'EXPIRING' | 'EXPIRED'
export interface DriverSummary { total: number; owned: number; dispatched: number; expiringSoon: number; expired: number }
export interface DriverQualification {
canDispatch: boolean
expiringSoon: boolean
reason: DriverQualificationReason
licenseExpiresOn: string | null
}
export interface DriverDetail {
id: string
driverNo: string
details: DriverDraft
active: boolean
reviewStatus: DriverReviewStatus
licenseFileId: string | null
recordVersion: number
updatedBy: string
updatedAt: string
qualification: DriverQualification
licenseState: DriverLicenseState
warehouseCode: string | null
warehouseName: string | null
}
export interface DriverHistory {
recordVersion: number
action: 'CREATE' | 'UPDATE' | 'ACTIVATE' | 'DEACTIVATE' | 'UPLOAD_LICENSE' | 'APPROVED' | 'REJECTED' | 'RESUBMIT'
reason: string | null
operatorId: string
occurredAt: string
snapshot: DriverDetail
}
export const driverReviewLabels: Record<DriverReviewStatus, string> = {
PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回',
}
export const driverLicenseLabels: Record<DriverLicenseState, string> = {
MISSING: '待补有效期', VALID: '有效', EXPIRING: '30天内到期', EXPIRED: '已过期',
}
export const driverQualificationLabels: Record<DriverQualificationReason, string> = {
ELIGIBLE: '本次任务可派车', INACTIVE: '已停用', MISSING_LICENSE: '待上传驾驶证', MISSING_EXPIRY: '待补有效期',
PENDING_REVIEW: '证照待审核', REJECTED: '证照已打回', EXPIRED: '驾驶证已过期',
MISSING_TRIP_END: '待填写任务结束时间', INVALID_TRIP_END: '预计结束时间无效', EXPIRES_DURING_TRIP: '任务结束前证照将过期',
}
@@ -0,0 +1,168 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getDriver, getDriverQualification, listDriverHistory, reviewDriver, submitDriver, uploadDriverLicense } from '../api/drivers'
import { driverLicenseLabels, driverQualificationLabels, driverReviewLabels, type DriverDetail, type DriverHistory } from '../driverTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { masterDataErrorMessage } from '../apiError'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<DriverDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
const editorOpen = ref(false), previewOpen = ref(false), previewFile = ref(''), reviewReason = ref(''), expectedEnd = ref<Date | null>(null)
const fileInput = ref<HTMLInputElement | null>(null), qualificationLoading = ref(false)
const qualificationStale = ref(false)
const historyOpen = ref(false), historyRows = ref<DriverHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null)
let request = 0, historyRequest = 0, qualificationRequest = 0
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const basic = computed(() => detail.value ? [
['司机编号', detail.value.driverNo], ['司机姓名', detail.value.details.name], ['联系电话', detail.value.details.phone],
['归属', detail.value.details.ownership === 'OWNED' ? '自有司机' : '外派司机'], ['区域', detail.value.details.region === 'OVERSEAS' ? '海外' : '国内'],
['所属仓库', detail.value.warehouseName], ['可驾驶车型', detail.value.details.profile?.vehicleModels], ['语言 / 服务区域', detail.value.details.profile?.languagesAndArea],
] : [])
const credentials = computed(() => detail.value ? [
['驾照类型', detail.value.details.licenseType], ['驾照编号', detail.value.details.licenseNumber], ['签发日期', detail.value.details.licenseIssuedOn],
['驾照到期', detail.value.details.licenseExpiresOn], ['有效时区', detail.value.details.licenseZone], ['有效期状态', driverLicenseLabels[detail.value.licenseState]],
['体检到期', detail.value.details.profile?.medicalExpiresOn], ['培训 / 安全到期', detail.value.details.profile?.trainingExpiresOn], ['保险到期', detail.value.details.profile?.insuranceExpiresOn],
] : [])
const historyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'historyActions' },
{ key: 'occurredAt', label: '操作时间(UTC)', prop: 'occurredAt', minWidth: 180 },
{ key: 'action', label: '动作', slotName: 'historyAction', minWidth: 130 },
{ key: 'reason', label: '意见 / 原因', prop: 'reason', minWidth: 280 },
{ key: 'reviewStatus', label: '当时审核状态', slotName: 'historyReview', minWidth: 120 },
]
const actionLabels: Record<DriverHistory['action'], string> = { CREATE: '创建档案', UPDATE: '编辑档案', ACTIVATE: '启用', DEACTIVATE: '停用', UPLOAD_LICENSE: '上传驾驶证', APPROVED: '审核通过', REJECTED: '审核打回', RESUBMIT: '重新提交' }
async function load() {
const current = ++request
historyRequest++; qualificationRequest++; detail.value = null; error.value = null; loading.value = true
historyOpen.value = false; historyRows.value = []; historyPage.value = 1; reviewReason.value = ''; expectedEnd.value = null
previewOpen.value = false; editorOpen.value = false; qualificationLoading.value = false
qualificationStale.value = false
try { const result = await getDriver(String(route.params.driverId)); if (current === request) detail.value = result }
catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '司机详情加载失败') }
finally { if (current === request) loading.value = false }
}
async function loadHistory() {
if (!detail.value || !historyOpen.value) return
const current = ++historyRequest
historyLoading.value = true; historyError.value = null
try {
const result = await listDriverHistory(detail.value.id, historyPage.value, historySize.value)
if (current === historyRequest) { historyRows.value = result.records; historyTotal.value = result.total }
} catch (cause) { if (current === historyRequest) { historyRows.value = []; historyError.value = getApiErrorDetails(cause, '历史加载失败') } }
finally { if (current === historyRequest) historyLoading.value = false }
}
function updated(value: DriverDetail) {
if (value.id !== detail.value?.id) return
qualificationRequest++; qualificationLoading.value = false; detail.value = value
qualificationStale.value = expectedEnd.value !== null
if (historyOpen.value) void loadHistory()
}
async function review(decision: 'APPROVED' | 'REJECTED') {
if (!detail.value || busy.value) return
if (decision === 'REJECTED' && !reviewReason.value.trim()) { ElMessage.warning('请填写打回原因'); return }
busy.value = true
try {
updated(await reviewDriver(detail.value.id, detail.value.recordVersion, decision, reviewReason.value.trim() || null))
reviewReason.value = ''; ElMessage.success(decision === 'APPROVED' ? '司机证照审核通过' : '司机证照已打回')
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '审核失败')) }
finally { busy.value = false }
}
async function submit() {
if (!detail.value || busy.value) return
busy.value = true
try { updated(await submitDriver(detail.value.id, detail.value.recordVersion)); ElMessage.success('已重新提交审核') }
catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '提交失败')) }
finally { busy.value = false }
}
async function checkQualification() {
if (!detail.value || !expectedEnd.value || qualificationLoading.value) return
const current = ++qualificationRequest, id = detail.value.id
qualificationLoading.value = true; qualificationStale.value = true
try {
const result = await getDriverQualification(id, expectedEnd.value.toISOString())
if (current === qualificationRequest && detail.value?.id === id) { detail.value.qualification = result; qualificationStale.value = false }
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '资格校验失败')) }
finally { if (current === qualificationRequest) qualificationLoading.value = false }
}
function invalidateQualification() {
qualificationRequest++; qualificationLoading.value = false
qualificationStale.value = true
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0], row = detail.value
input.value = ''
if (!file || !row || busy.value) return
if (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || file.size === 0 || file.size > 10485760) { ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); return }
busy.value = true
try {
await ElMessageBox.confirm('上传后驾驶证需要重新审核,确认继续?', '上传驾驶证', { customClass: 'admin-density-confirm', confirmButtonText: '上传', cancelButtonText: '取消' })
updated(await uploadDriverLicense(row.id, row.recordVersion, file)); ElMessage.success('驾驶证已上传,等待审核')
} catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '上传失败')) }
finally { busy.value = false }
}
function showLicense(fileId: string) { previewFile.value = fileId; previewOpen.value = true }
watch(() => route.params.driverId, load, { immediate: true })
watch([historyPage, historySize], () => void loadHistory())
watch(historyOpen, value => { if (value) void loadHistory() })
onBeforeUnmount(() => { request++; historyRequest++; qualificationRequest++ })
</script>
<template>
<AdminPage class="admin-density">
<DetailPageLayout class="density-detail-layout surface-panel" aria-label="司机详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-drivers' })"><ArrowLeft :size="16" />返回司机管理</el-button><h1 class="density-detail-title">{{ detail?.details.name || '司机详情' }}</h1><StatusBadge v-if="detail" :status="detail.active ? '启用' : '停用'" /></div><el-button v-if="detail && access.can(permission.driverEdit)" :disabled="busy" @click="editorOpen = true">编辑档案</el-button></div></template>
<LoadErrorState v-if="error" title="司机详情加载失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading">
<template v-if="detail">
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="证照与派车状态">
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
<p v-if="detail.qualification.expiringSoon" class="driver-note">驾驶证将在 30 天内到期;预计任务结束前仍有效时允许派车,请及时更新。</p>
<div v-if="access.can(permission.driverReview) && detail.reviewStatus === 'PENDING'" class="driver-review">
<el-input v-model="reviewReason" type="textarea" :rows="2" maxlength="1000" aria-label="审核意见,打回时必填" placeholder="审核意见(打回必填)" :disabled="busy" />
<div class="driver-document-actions"><el-button type="success" :loading="busy" @click="review('APPROVED')">审核通过</el-button><el-button type="danger" :loading="busy" @click="review('REJECTED')">打回</el-button></div>
</div>
<el-button v-if="access.can(permission.driverEdit) && detail.reviewStatus === 'REJECTED'" :loading="busy" @click="submit">重新提交审核</el-button>
<div class="driver-qualification"><label>司机预计任务结束时间({{ browserZone }})<el-date-picker v-model="expectedEnd" type="datetime" placeholder="包含返程或转场的结束时间" @change="invalidateQualification" /></label><el-button :loading="qualificationLoading" :disabled="!expectedEnd || busy" @click="checkQualification">校验本次任务资格</el-button><strong :class="{ 'driver-warning': qualificationStale || !detail.qualification.canDispatch }">{{ qualificationStale ? '预计时间或档案已变更,请重新校验' : driverQualificationLabels[detail.qualification.reason] }}</strong></div>
</FormSection>
<FormSection class="density-form-section" title="操作与审核历史" :bordered="false">
<el-button v-if="!historyOpen" @click="historyOpen = true">查看操作历史</el-button>
<template v-else><LoadErrorState v-if="historyError" title="历史加载失败" :message="historyError.message" @retry="loadHistory" ><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
<template #historyActions="{ row }"><el-button v-if="row.snapshot.licenseFileId" link type="primary" @click="showLicense(row.snapshot.licenseFileId)">查看当时驾驶证</el-button><span v-else>—</span></template>
<template #historyAction="{ row }">{{ actionLabels[row.action as DriverHistory['action']] }}</template>
<template #historyReview="{ row }">{{ driverReviewLabels[row.snapshot.reviewStatus as keyof typeof driverReviewLabels] }}</template>
</DataTable></template>
</FormSection>
</template>
</div>
</DetailPageLayout>
<DriverEditorDialog v-model="editorOpen" :driver="detail" @saved="updated" />
<DriverLicensePreview v-model="previewOpen" :driver-id="detail?.id || ''" :file-id="previewFile" :driver-name="detail?.details.name || ''" />
</AdminPage>
</template>
<style scoped>
.wide-field { grid-column: span 4; }
.driver-document-actions, .driver-qualification { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.driver-review { display: grid; gap: var(--admin-space-5); margin: var(--admin-space-6) 0; max-width: 900px; }
.driver-qualification { margin-top: var(--admin-space-6); }
.driver-qualification label { display: flex; align-items: center; gap: var(--admin-space-4); }
.driver-note { color: var(--el-color-warning); }
.driver-warning { color: var(--cc-muted); }
</style>
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
import { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/drivers'
import type { DriverDetail } from '../driverTypes'
import DriversView from './DriversView.vue'
import DriverDetailView from './DriverDetailView.vue'
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
const security = vi.hoisted(() => ({ allowWrite: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || security.allowWrite }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), useRoute: () => ({ params: { driverId: '9223372036854775701' } }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '暂停' }) } }))
vi.mock('../api/drivers', () => ({ listDrivers: vi.fn(), getDriver: vi.fn(), listDriverHistory: vi.fn(), listDriverWarehouses: vi.fn(), createDriver: vi.fn(), updateDriver: vi.fn(), changeDriverStatus: vi.fn(), reviewDriver: vi.fn(), submitDriver: vi.fn(), getDriverQualification: vi.fn(), uploadDriverLicense: vi.fn(), downloadDriverLicense: vi.fn() }))
const driver: DriverDetail = {
id: '9223372036854775701', driverNo: 'DR-TEST', details: { name: '测试司机', phone: '123', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: { warehouseId: '92', vehicleModels: '53尺拖车', languagesAndArea: '中文 / 洛杉矶', medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null } },
active: true, reviewStatus: 'PENDING', licenseFileId: '9223372036854775801', recordVersion: 2, updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: false, expiringSoon: true, reason: 'PENDING_REVIEW', licenseExpiresOn: '2026-10-22' }, licenseState: 'EXPIRING', warehouseCode: 'WH-US', warehouseName: '洛杉矶仓',
}
const layout = { template: '<div><slot name="header"/><slot/></div>' }
const button = { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm', 'update:modelValue'], template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="confirm" @click="$emit(\'confirm\')">保存</button></div>' }
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' }
const table = { name: 'DataTable', props: ['data', 'page', 'pageSize', 'total'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.id || row.recordVersion"><span>{{row.driverNo}}</span><slot name="actions" :row="row"/><slot name="file" :row="row"/></div></div>' }
const stubs = { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, RowActions: true, StatusBadge: true, LoadErrorState: true, AppDialog: dialog, DriverEditorDialog: true, DriverLicensePreview: true, ElButton: button, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElOption: true, ElDatePicker: { ...input, name: 'ElDatePicker' } }
const wrappers: VueWrapper[] = []
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
const wrapper = shallowMount(component, { props, global: { stubs: { ...stubs, DriverEditorDialog: component === DriverEditorDialog ? false : true, DriverLicensePreview: component === DriverLicensePreview ? false : true }, directives: { loading: () => undefined } } })
wrappers.push(wrapper)
return wrapper
}
function findButton(wrapper: VueWrapper, text: string) { const found = wrapper.findAll('button').find(value => value.text().includes(text)); expect(found).toBeDefined(); return found! }
beforeEach(() => {
vi.clearAllMocks(); security.allowWrite = true
vi.mocked(api.listDrivers).mockResolvedValue({ records: [structuredClone(driver)], page: 1, size: 20, total: 24, pages: 2, summary: { total: 24, owned: 20, dispatched: 4, expiringSoon: 3, expired: 1 } })
vi.mocked(api.getDriver).mockResolvedValue(structuredClone(driver))
vi.mocked(api.listDriverWarehouses).mockResolvedValue([{ id: '92', code: 'WH-US', name: '洛杉矶仓', region: 'OVERSEAS' }])
vi.mocked(api.listDriverHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn().mockReturnValue('blob:test-license'), revokeObjectURL: vi.fn() }))
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.unstubAllGlobals() })
describe('driver page workflows', () => {
it('loads one paged request with server summary and does not request each driver or file', async () => {
const wrapper = mount(DriversView)
await flushPromises()
expect(api.listDrivers).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('司机总数24')
expect(api.getDriver).not.toHaveBeenCalled()
expect(api.downloadDriverLicense).not.toHaveBeenCalled()
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2)
await flushPromises()
expect(api.listDrivers).toHaveBeenCalledTimes(2)
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
await findButton(wrapper, '即将到期').trigger('click')
await flushPromises()
expect(api.listDrivers).toHaveBeenCalledTimes(3)
expect(api.listDrivers).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, licenseState: 'EXPIRING' }))
})
it('shows a retryable error instead of stale summary after list failure', async () => {
vi.mocked(api.listDrivers).mockRejectedValueOnce(new Error('服务不可用'))
const wrapper = mount(DriversView)
await flushPromises()
const error = wrapper.findComponent({ name: 'LoadErrorState' })
expect(error.exists()).toBe(true)
expect(wrapper.text()).toContain('司机总数—')
error.vm.$emit('retry'); await flushPromises()
expect(api.listDrivers).toHaveBeenCalledTimes(2)
})
it('does not expose write actions to a read-only user', async () => {
security.allowWrite = false
const list = mount(DriversView), detail = mount(DriverDetailView)
await flushPromises()
expect(list.text()).not.toContain('新增司机')
expect(detail.text()).not.toContain('审核通过')
expect(detail.text()).not.toContain('上传 / 替换驾驶证')
expect(detail.text()).not.toContain('编辑档案')
})
it('loads detail once, requests history only on demand, and requires a rejection reason', async () => {
const wrapper = mount(DriverDetailView)
await flushPromises()
expect(api.getDriver).toHaveBeenCalledTimes(1)
expect(api.listDriverHistory).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).not.toContain(driver.id)
await findButton(wrapper, '打回').trigger('click')
expect(api.reviewDriver).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith('请填写打回原因')
await findButton(wrapper, '查看操作历史').trigger('click'); await flushPromises()
expect(api.listDriverHistory).toHaveBeenCalledTimes(1)
})
it('submits approval with current version and uses returned state without reloading detail', async () => {
vi.mocked(api.reviewDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 3, reviewStatus: 'APPROVED' })
const wrapper = mount(DriverDetailView)
await flushPromises()
await findButton(wrapper, '审核通过').trigger('click'); await flushPromises()
expect(api.reviewDriver).toHaveBeenCalledWith(driver.id, 2, 'APPROVED', null)
expect(api.getDriver).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('button').some(value => value.text() === '审核通过')).toBe(false)
})
it('keeps the editor and version after a failed write', async () => {
vi.mocked(api.updateDriver).mockRejectedValue(new ApiResponseError('版本已变化,请刷新', 'A0409', 200, null, 'req-driver-conflict'))
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
await wrapper.setProps({ modelValue: true }); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.objectContaining({ name: driver.details.name }))
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('版本已变化')
})
it('invalidates an old task preview after the planned end changes without inventing a new server qualification', async () => {
vi.mocked(api.getDriverQualification).mockResolvedValue({ ...driver.qualification, canDispatch: true, reason: 'ELIGIBLE' })
const wrapper = mount(DriverDetailView)
await flushPromises()
const picker = wrapper.findComponent({ name: 'ElDatePicker' })
picker.vm.$emit('update:modelValue', new Date('2026-10-20T12:00:00Z'))
picker.vm.$emit('change'); await flushPromises()
await findButton(wrapper, '校验本次任务资格').trigger('click'); await flushPromises()
expect(api.getDriverQualification).toHaveBeenCalledWith(driver.id, '2026-10-20T12:00:00.000Z')
expect(wrapper.text()).toContain('本次任务可派车')
picker.vm.$emit('update:modelValue', new Date('2026-11-20T12:00:00Z'))
picker.vm.$emit('change'); await flushPromises()
expect(wrapper.text()).not.toContain('本次任务可派车')
expect(wrapper.text()).toContain('请重新校验')
expect(api.getDriverQualification).toHaveBeenCalledTimes(1)
})
it('does not create a second draft when attachment upload fails after saving', async () => {
vi.mocked(api.createDriver).mockResolvedValue({ ...structuredClone(driver), recordVersion: 0 })
vi.mocked(api.uploadDriverLicense).mockRejectedValueOnce(new Error('文件服务不可用')).mockResolvedValueOnce({ ...structuredClone(driver), recordVersion: 1 })
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver: null })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const inputs = wrapper.findAllComponents({ name: 'ElInput' })
inputs[0]!.vm.$emit('update:modelValue', '新司机')
inputs[1]!.vm.$emit('update:modelValue', '123')
const selects = wrapper.findAllComponents({ name: 'ElSelect' })
selects.at(-1)!.vm.$emit('update:modelValue', 'UTC')
const file = new File(['%PDF-test'], 'license.pdf', { type: 'application/pdf' })
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [file] })
await picker.trigger('change')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createDriver).toHaveBeenCalledTimes(1)
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).props('message')).toContain('档案已保存')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createDriver).toHaveBeenCalledTimes(1)
expect(api.updateDriver).not.toHaveBeenCalled()
expect(api.uploadDriverLicense).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false])
})
it('keeps an old save outside the newly opened driver session and uploads only that driver file', async () => {
let finishOld!: (value: DriverDetail) => void
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
const other = { ...structuredClone(driver), id: '9223372036854775702', driverNo: 'DR-OTHER', details: { ...driver.details, name: '另一司机' } }
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
await wrapper.setProps({ modelValue: true }); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateDriver).toHaveBeenCalledWith(driver.id, 2, expect.anything())
await wrapper.setProps({ modelValue: false }); await flushPromises()
await wrapper.setProps({ driver: other, modelValue: true }); await flushPromises()
const file = new File(['%PDF-other'], 'other-driver.pdf', { type: 'application/pdf' })
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [file], configurable: true })
await picker.trigger('change')
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
expect(wrapper.findAllComponents({ name: 'ElInput' })[0]!.props('modelValue')).toBe('另一司机')
vi.mocked(api.updateDriver).mockResolvedValue({ ...other, recordVersion: 3 })
vi.mocked(api.uploadDriverLicense).mockResolvedValue({ ...other, recordVersion: 4 })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateDriver).toHaveBeenLastCalledWith(other.id, 2, expect.objectContaining({ name: '另一司机' }))
expect(api.uploadDriverLicense).toHaveBeenCalledExactlyOnceWith(other.id, 3, file)
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(expect.objectContaining({ id: other.id }))
})
it('does not continue a pending save with an upload after the editor is unmounted', async () => {
let finishOld!: (value: DriverDetail) => void
vi.mocked(api.updateDriver).mockReturnValueOnce(new Promise(resolve => { finishOld = resolve }))
const wrapper = mount(DriverEditorDialog, { modelValue: false, driver })
await wrapper.setProps({ modelValue: true }); await flushPromises()
const picker = wrapper.get('input[type="file"]')
Object.defineProperty(picker.element, 'files', { value: [new File(['%PDF'], 'driver.pdf', { type: 'application/pdf' })] })
await picker.trigger('change')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
wrapper.unmount()
finishOld({ ...structuredClone(driver), recordVersion: 3 }); await flushPromises()
expect(api.uploadDriverLicense).not.toHaveBeenCalled()
expect(ElMessage.success).not.toHaveBeenCalled()
})
it('releases preview blobs when closed and ignores a download completed after close', async () => {
vi.mocked(api.downloadDriverLicense).mockResolvedValueOnce(new Blob(['%PDF'], { type: 'application/pdf' }))
const wrapper = mount(DriverLicensePreview, { modelValue: false, driverId: driver.id, fileId: driver.licenseFileId, driverName: driver.details.name })
await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.find('iframe').exists()).toBe(true)
await wrapper.setProps({ modelValue: false })
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:test-license')
let complete!: (value: Blob) => void
vi.mocked(api.downloadDriverLicense).mockReturnValueOnce(new Promise(resolve => { complete = resolve }))
await wrapper.setProps({ modelValue: true })
await wrapper.setProps({ modelValue: false })
complete(new Blob(['%PDF'], { type: 'application/pdf' })); await flushPromises()
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,145 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { changeDriverStatus, getDriver, listDrivers, type DriverQuery } from '../api/drivers'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { driverLicenseLabels, driverReviewLabels, type DriverDetail, type DriverLicenseState, type DriverSummary } from '../driverTypes'
import { masterDataErrorMessage } from '../apiError'
const router = useRouter(), access = useAccessPreviewStore()
const rows = ref<DriverDetail[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null), summary = ref<DriverSummary | null>(null)
const filters = ref<QueryModel>({ keyword: '', region: '', ownership: '', active: '', reviewStatus: '', licenseState: '' })
let applied: Omit<DriverQuery, 'page' | 'size'> = {}, request = 0
const editorOpen = ref(false), editing = ref<DriverDetail | null>(null), actionId = ref('')
const previewOpen = ref(false), preview = ref<DriverDetail | null>(null)
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '司机关键词', placeholder: '姓名 / 手机 / 司机编号', width: 220 },
{ key: 'region', type: 'select', label: '区域', width: 120, options: [{ label: '国内', value: 'DOMESTIC' }, { label: '海外', value: 'OVERSEAS' }] },
{ key: 'ownership', type: 'select', label: '归属', width: 140, options: [{ label: '自有司机', value: 'OWNED' }, { label: '外派司机', value: 'DISPATCHED' }] },
{ key: 'active', type: 'select', label: '状态', width: 120, options: [{ label: '启用', value: 'true' }, { label: '停用', value: 'false' }] },
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 140, options: Object.entries(driverReviewLabels).map(([value, label]) => ({ value, label })) },
{ key: 'licenseState', type: 'select', label: '驾照有效期', width: 150, options: Object.entries(driverLicenseLabels).map(([value, label]) => ({ value, label })) },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 205, fixed: 'left', slotName: 'actions' },
{ key: 'driverNo', prop: 'driverNo', label: '司机编号', minWidth: 205 },
{ key: 'name', prop: 'details.name', label: '姓名', minWidth: 115 },
{ key: 'ownership', label: '归属', width: 100, slotName: 'ownership' },
{ key: 'phone', prop: 'details.phone', label: '联系电话', minWidth: 140 },
{ key: 'vehicleModels', label: '可驾驶车型', minWidth: 150, slotName: 'vehicleModels' },
{ key: 'warehouseName', prop: 'warehouseName', label: '所属仓库', minWidth: 160 },
{ key: 'license', label: '驾照类型 / 编号', minWidth: 180, slotName: 'license' },
{ key: 'expires', label: '驾照到期', minWidth: 140, slotName: 'expires' },
{ key: 'medical', label: '体检到期', width: 125, slotName: 'medical' },
{ key: 'otherDocs', label: '培训 / 保险到期', minWidth: 185, slotName: 'otherDocs' },
{ key: 'review', label: '审核状态', width: 100, slotName: 'review' },
{ key: 'file', label: '驾驶证附件', width: 110, slotName: 'file' },
{ key: 'active', label: '当前状态', width: 100, slotName: 'active' },
]
async function load() {
const current = ++request
loading.value = true; error.value = null
try {
const result = await listDrivers({ ...applied, page: page.value, size: pageSize.value })
if (current === request) { rows.value = result.records; total.value = result.total; summary.value = result.summary }
} catch (cause) {
if (current === request) { rows.value = []; total.value = 0; summary.value = null; error.value = getApiErrorDetails(cause, '司机列表加载失败') }
} finally { if (current === request) loading.value = false }
}
function search() {
applied = { keyword: String(filters.value.keyword || '').trim() || undefined,
region: (filters.value.region || undefined) as DriverQuery['region'], ownership: (filters.value.ownership || undefined) as DriverQuery['ownership'],
active: filters.value.active === 'true' ? true : filters.value.active === 'false' ? false : undefined,
reviewStatus: (filters.value.reviewStatus || undefined) as DriverQuery['reviewStatus'], licenseState: (filters.value.licenseState || undefined) as DriverQuery['licenseState'] }
if (page.value !== 1) page.value = 1
else void load()
}
function filterExpiry(state: DriverLicenseState) { filters.value.licenseState = state; search() }
function actions(row: DriverDetail): RowActionItem[] {
return [{ key: 'detail', label: '详情', priority: 3 },
{ key: 'edit', label: '编辑', visible: access.can(permission.driverEdit), disabled: !!actionId.value, priority: 2 },
{ key: 'status', label: row.active ? '停用' : '启用', visible: access.can(permission.driverStatus), disabled: !!actionId.value, tone: row.active ? 'danger' : 'success' }]
}
async function action(key: string, row: DriverDetail) {
if (key === 'detail') { await router.push({ name: 'overseas-master-data-driver-detail', params: { driverId: row.id } }); return }
if (actionId.value) return
actionId.value = row.id
try {
if (key === 'edit') { editing.value = await getDriver(row.id); editorOpen.value = true }
else {
const result = await ElMessageBox.prompt(`请填写${row.active ? '停用' : '启用'}司机 ${row.details.name} 的原因`, row.active ? '停用司机' : '启用司机', {
customClass: 'admin-density-confirm', inputValidator: value => !!value?.trim() || '请填写原因', confirmButtonText: '确认', cancelButtonText: '取消', inputType: 'textarea', closeOnClickModal: false,
})
await changeDriverStatus(row.id, row.recordVersion, !row.active, result.value.trim())
ElMessage.success(row.active ? '司机已停用' : '司机已启用'); await load()
}
} catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '司机操作失败')) }
finally { actionId.value = '' }
}
function create() { editing.value = null; editorOpen.value = true }
function showLicense(row: DriverDetail) { preview.value = row; previewOpen.value = true }
function saved(row: DriverDetail) {
const index = rows.value.findIndex(item => item.id === row.id)
if (index >= 0) rows.value[index] = row
}
watch(editorOpen, (value, previous) => { if (!value && previous) void load() })
watch([page, pageSize], () => void load())
onMounted(load)
onBeforeUnmount(() => { request++ })
</script>
<template>
<AdminPage class="admin-density">
<ListPageLayout>
<div class="driver-summary" aria-label="当前筛选范围司机统计">
<section class="surface-panel"><span>司机总数</span><strong>{{ summary?.total ?? '—' }}</strong><small>自有 {{ summary?.owned ?? '—' }} · 外派 {{ summary?.dispatched ?? '—' }}</small></section>
<button class="surface-panel" type="button" @click="filterExpiry('EXPIRING')"><span>即将到期</span><strong>{{ summary?.expiringSoon ?? '—' }}</strong><small>30 天内到期 · 点击筛选</small></button>
<button class="surface-panel" type="button" @click="filterExpiry('EXPIRED')"><span>驾照已过期</span><strong>{{ summary?.expired ?? '—' }}</strong><small>不可派车 · 点击筛选</small></button>
</div>
<section class="surface-panel"><QueryToolbar v-model="filters" :fields="fields" ariaLabel="司机查询" preference-key="overseas-drivers-query" :loading="loading" @search="search" /></section>
<LoadErrorState v-if="error" title="司机列表加载失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="id" empty-text="暂无符合条件的司机">
<template #toolbar-actions><el-button v-if="access.can(permission.driverCreate)" type="primary" @click="create"><Plus :size="16" />新增司机</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions(row as DriverDetail)" text-actions @action="action($event, row as DriverDetail)" /></template>
<template #ownership="{ row }">{{ row.details.ownership === 'OWNED' ? '自有司机' : '外派司机' }}</template>
<template #vehicleModels="{ row }">{{ row.details.profile?.vehicleModels || '—' }}</template>
<template #license="{ row }">{{ row.details.licenseType || '—' }} / {{ row.details.licenseNumber || '—' }}</template>
<template #expires="{ row }"><span>{{ row.details.licenseExpiresOn || '—' }}</span><div :class="{ 'expiry-warning': row.licenseState === 'EXPIRING', 'expiry-danger': row.licenseState === 'EXPIRED' }">{{ driverLicenseLabels[row.licenseState as DriverLicenseState] }}</div></template>
<template #medical="{ row }">{{ row.details.profile?.medicalExpiresOn || '—' }}</template>
<template #otherDocs="{ row }">{{ row.details.profile?.trainingExpiresOn || '—' }} / {{ row.details.profile?.insuranceExpiresOn || '—' }}</template>
<template #review="{ row }"><StatusBadge :status="driverReviewLabels[row.reviewStatus as keyof typeof driverReviewLabels]" /></template>
<template #file="{ row }"><el-button v-if="row.licenseFileId" link type="primary" @click="showLicense(row as DriverDetail)">查看附件</el-button><span v-else>待上传</span></template>
<template #active="{ row }"><StatusBadge :status="row.active ? '启用' : '停用'" /></template>
<template #pagination-left>共 {{ total }} 位司机 · 统计随筛选变化</template>
</DataTable>
</ListPageLayout>
<DriverEditorDialog v-model="editorOpen" :driver="editing" @saved="saved" />
<DriverLicensePreview v-model="previewOpen" :driver-id="preview?.id || ''" :file-id="preview?.licenseFileId || ''" :driver-name="preview?.details.name || ''" />
</AdminPage>
</template>
<style scoped>
.driver-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-6); }
.driver-summary > * { display: grid; gap: var(--admin-space-3); padding: var(--admin-space-6); text-align: left; color: inherit; font: inherit; }
.driver-summary button { cursor: pointer; }
.driver-summary strong { font-size: 24px; }
.driver-summary small { color: var(--cc-muted); }
.expiry-warning { color: var(--el-color-warning); }
.expiry-danger { color: var(--el-color-danger); }
</style>
@@ -16,7 +16,7 @@ vi.mock('../api/overseasMasterData', () => ({
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
@@ -37,5 +37,5 @@ describe('overseas master data real page mounts', () => {
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
})
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(11); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.get('.density-detail-title').text()).toBe('CA-TEST'); expect(wrapper.get('.density-fields').findAll('dt')).toHaveLength(14); expect(wrapper.get('.density-fields').text()).toContain('在役'); expect(wrapper.text()).not.toContain('IN_SERVICE'); expect(wrapper.text()).toContain('派送记录请在海外仓的排车管理或派送管理中查看') })
})
@@ -42,8 +42,8 @@ describe('overseas master data real contract pages', () => {
for (const label of ['自有车辆', '外派车辆', '司机 / 外派单位']) expect(vehicles).toContain(label)
expect(vehicles).toContain("key:'region'")
expect(vehicles).toContain("placeholder:'全部区域'")
expect(vehicles).toContain('class="status-tabs density-tabs"')
expect(vehicles).toContain("'status-tab',{active:ownership===tab[1]}")
expect(vehicles).toContain('class="status-tabs density-status-tabs"')
expect(vehicles).toContain("'status-tab',{'is-active':ownership===tab[1]}")
expect(vehicles).not.toContain('<el-segmented')
expect(vehicles).not.toContain('aria-label="车辆区域"')
})
@@ -76,7 +76,8 @@ describe('overseas master data real contract pages', () => {
expect(detail).toContain('派送订单明细')
expect(detail).toContain('派送更换记录')
expect(detail).toContain('getVehicle(')
expect(detail).toContain('明确不包含派送履约记录')
expect(detail).toContain('派送记录请在海外仓的排车管理或派送管理中查看')
expect(detail).toContain('车辆更换记录尚未接入')
expect(detail).not.toContain('AppDrawer')
expect(detail).not.toContain('const vehicle =')
})
@@ -85,7 +86,10 @@ describe('overseas master data real contract pages', () => {
const matrix = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
for (const id of ['gen-partner-warehouse', 'gen-overseas-quote', 'gen-exchange-rate', 'vehicle-detail']) expect(matrix).toContain(id)
for (const modal of ['modal-gen-overseas-quote-detail', 'modal-gen-overseas-quote', 'modal-exchange-rate-edit', 'modal-vehicle-edit']) expect(matrix).toContain(modal)
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
expect(matrix).toContain('旧版原型未定义的车辆文件预览、审核/确认流程不自行补充')
expect(matrix).toContain('新版车辆保养及加油接入矩阵')
expect(matrix).toContain('modal-vehicle-ops-record')
expect(matrix).toContain('target="_blank"')
})
it('records the real service boundary and remaining unsupported operations', () => {
@@ -23,7 +23,7 @@ const longId = '9223372036854775807'
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, 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.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
@@ -0,0 +1,213 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import TripAssignmentPanel from '../components/TripAssignmentPanel.vue'
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
import TripBookingPanel from '../components/TripBookingPanel.vue'
import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan,
getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { driverQualificationLabels } from '../driverTypes'
import { tripStatusLabels, tripReviewLabels, type TripDetail, type TripLoading, type TripHistory, type TripCompletionInspection,
type TripCapacityPlanning, type TripComplete, type TripResourceRelease, type ReleasedVehicleStatus } from '../tripTypes'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<TripDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), operationError = ref<ApiErrorDetails | null>(null)
const activeTab = ref('overview'), editorOpen = ref(false), busy = ref(''), dispatchNo = ref('')
const inspection = ref<TripCompletionInspection | null>(null), inspectionLoading = ref(false)
const history = ref<TripHistory[]>([]), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
const resourceKind = ref<'VEHICLE' | 'DRIVER'>('VEHICLE'), actualEndAt = ref(new Date().toISOString()), endLocation = ref('')
const vehicleStatus = ref<ReleasedVehicleStatus>('IN_SERVICE')
const completionRequest = ref<TripComplete | null>(null), releaseRequest = ref<TripResourceRelease | null>(null)
let generation = 0, inspectionSequence = 0, historySequence = 0
const planned = computed(() => detail.value?.trip.status === 'PLANNED')
const editable = computed(() => planned.value && access.can(permission.tripEdit))
const time = (value: string | null | undefined) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const bindingsColumns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 210 }, { key: 'packageCount', prop: 'packageCount', label: '计划箱数', width: 120 },
{ key: 'review', label: '调度审核', slotName: 'review', minWidth: 210 },
{ key: 'boundAt', label: '关联时间', slotName: 'boundAt', minWidth: 180 }]
const outcomeColumns: DataTableColumn[] = [{ key: 'deliveryNo', prop: 'deliveryNo', label: '派送单号', minWidth: 220 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 180 }, { key: 'status', label: '状态', slotName: 'status', width: 115 },
{ key: 'packages', prop: 'packages', label: '箱数', width: 110 }, { key: 'terminalAt', label: '完成时间', slotName: 'terminalAt', minWidth: 180 }]
const outcomeLabels: Record<string, string> = { DELIVERED: '已签收', RETURNED: '已退回', RETURN_PENDING: '待退回', DELIVERING: '派送中', WAIT_DELIVERY: '待派送', CANCELLED: '已取消' }
const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作时间', slotName: 'time', minWidth: 175 },
{ key: 'action', label: '操作', slotName: 'action', minWidth: 145 }, { key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
{ key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }]
const actionLabels: Record<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
function accept(value: TripDetail) {
if (value.trip.status === 'COMPLETED' && detail.value?.trip.status !== 'COMPLETED') actualEndAt.value = new Date().toISOString()
detail.value = value; operationError.value = null; endLocation.value ||= value.trip.stops.at(-1)?.name || ''
if (value.trip.vehicleRelease && resourceKind.value === 'VEHICLE') resourceKind.value = 'DRIVER'
}
async function load() {
const current = ++generation; inspectionSequence++; historySequence++
loading.value = true; error.value = null; detail.value = null; operationError.value = null; activeTab.value = 'overview'
editorOpen.value = false; busy.value = ''; dispatchNo.value = ''; inspection.value = null; history.value = []; historyPage.value = 1
completionRequest.value = null; releaseRequest.value = null; endLocation.value = ''
try { const result = await getTrip(String(route.params.tripNo)); if (current === generation) accept(result) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '车次详情加载失败') }
finally { if (current === generation) loading.value = false }
}
async function run(action: string, command: () => Promise<TripDetail>): Promise<boolean> {
if (busy.value) return false
const current = generation; busy.value = action; operationError.value = null
try { const result = await command(); if (current !== generation) return false; accept(result); return true }
catch (cause) {
if (current === generation) {
operationError.value = getApiErrorDetails(cause, '操作未完成')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { completionRequest.value = null; releaseRequest.value = null }
}
return false
} finally { if (current === generation) busy.value = '' }
}
async function refreshLinked() {
const number = detail.value?.trip.tripNo
if (number) await run('linked', () => getTrip(number))
}
async function bind() {
const trip = detail.value?.trip, number = dispatchNo.value.trim()
if (!trip || !number) { ElMessage.warning('请输入排车单号'); return }
if (await run('bind', () => bindTripDispatch(trip.tripNo, { recordVersion: trip.recordVersion, dispatchNo: number, reason: '关联本车发运货物' }))) dispatchNo.value = ''
}
async function unbind(number: string) {
const trip = detail.value?.trip, current = generation
if (!trip) return
try { await ElMessageBox.confirm(`解除排车单 ${number} 与当前车次的关联?已开始拣货的单据需要先处理现场作业。`, '解除关联', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current === generation) await run('unbind', () => unbindTripDispatch(trip.tripNo, { recordVersion: trip.recordVersion, dispatchNo: number, reason: '解除当前车次关联' }))
}
async function matchStops() {
const trip = detail.value?.trip, current = generation
if (!trip) return
await run('stops', async () => {
const loading = await getTripLoading(trip.tripNo)
if (current !== generation) throw new Error('已离开当前车次')
const destinations = new Map(loading.cargo.filter(row => row.destinationKey).map(row => [row.destinationKey!, { name: row.destinationLabel || '卸货点', destinationKey: row.destinationKey }]))
if (!destinations.size) throw new Error('请先关联有明确目的地的排车单')
return updateTripPlan(trip.tripNo, { recordVersion: loading.recordVersion, plannedDepartureAt: trip.plannedDepartureAt,
expectedDriverEndAt: trip.expectedDriverEndAt, remark: trip.remark, stops: [trip.stops[0]!, ...destinations.values()], reason: '按关联货物匹配卸货站' })
})
}
async function cancel() {
const trip = detail.value?.trip, current = generation
if (!trip) return
try { await ElMessageBox.confirm(`取消车次 ${trip.tripNo} 并释放车辆、司机?`, '取消车次', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current === generation) await run('cancel', () => cancelTrip(trip.tripNo, { recordVersion: trip.recordVersion, reason: '取消未执行的车次计划' }))
}
function capacityPlanned(value: TripCapacityPlanning) {
if (detail.value?.trip.tripNo === value.tripNo) detail.value.trip.recordVersion = value.recordVersion
}
function loaded(value: TripLoading) {
if (detail.value?.trip.tripNo !== value.tripNo) return
detail.value.trip.recordVersion = value.recordVersion
detail.value.loadingReview = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
}
async function review(decision: 'APPROVED' | 'REJECTED', number?: string) {
const trip = detail.value?.trip, current = generation
if (!trip || busy.value) return
let reason: string | null = null
if (decision === 'REJECTED') {
try {
const result = await ElMessageBox.prompt('请简要说明需要调整的内容', number ? `打回调度审核 · ${number}` : '打回理货审核', {
customClass: 'admin-density-confirm', inputPlaceholder: '填写打回原因', inputValidator: value => !!value?.trim() || '请填写打回原因',
})
reason = result.value.trim()
} catch { return }
}
if (current !== generation) return
const body = { recordVersion: trip.recordVersion, decision, reason }
await run('review', () => number ? reviewTripDispatch(trip.tripNo, number, body) : reviewTripLoading(trip.tripNo, body))
}
async function inspect() {
const trip = detail.value?.trip
if (!trip || trip.status !== 'IN_TRANSIT') return
const current = ++inspectionSequence; inspectionLoading.value = true; operationError.value = null
try { const result = await getTripCompletion(trip.tripNo); if (current === inspectionSequence) inspection.value = result }
catch (cause) { if (current === inspectionSequence) operationError.value = getApiErrorDetails(cause, '结案检查失败') }
finally { if (current === inspectionSequence) inspectionLoading.value = false }
}
async function complete() {
const trip = detail.value?.trip, current = generation
if (!trip || !inspection.value?.canComplete) return
try { await ElMessageBox.confirm('确认本车货物均已签收或完成异常处置?货物结案后,还需分别结束车辆和司机任务。', '货物结案', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current !== generation) return
completionRequest.value ||= { recordVersion: inspection.value.recordVersion, idempotencyKey: crypto.randomUUID(), reason: '核对全部货物终态后结案' }
if (await run('complete', () => completeTrip(trip.tripNo, completionRequest.value!))) { completionRequest.value = null; inspection.value = null }
}
async function release() {
const trip = detail.value?.trip, current = generation
if (!trip) return
if (!actualEndAt.value || !endLocation.value.trim()) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
try { await ElMessageBox.confirm(`确认${resourceKind.value === 'VEHICLE' ? '车辆' : '司机'}已结束本次任务,可以安排下一车次?`, '结束资源任务', { customClass: 'admin-density-confirm' }) }
catch { return }
if (current !== generation) return
releaseRequest.value ||= { recordVersion: trip.recordVersion, resourceKind: resourceKind.value, actualEndAt: new Date(actualEndAt.value).toISOString(),
endLocation: endLocation.value.trim(), vehicleStatus: resourceKind.value === 'VEHICLE' ? vehicleStatus.value : null, idempotencyKey: crypto.randomUUID(), reason: '确认资源实际任务结束' }
if (await run('release', () => releaseTripResource(trip.tripNo, releaseRequest.value!))) { releaseRequest.value = null; resourceKind.value = detail.value?.trip.vehicleRelease ? 'DRIVER' : 'VEHICLE' }
}
async function loadHistory() {
if (!detail.value) return
const current = ++historySequence; historyLoading.value = true; operationError.value = null
try { const result = await listTripHistory(detail.value.trip.tripNo, historyPage.value, 20); if (current === historySequence) { history.value = result.records; historyTotal.value = result.total } }
catch (cause) { if (current === historySequence) operationError.value = getApiErrorDetails(cause, '历史读取失败') }
finally { if (current === historySequence) historyLoading.value = false }
}
watch(() => route.params.tripNo, load, { immediate: true })
watch(activeTab, tab => { if (tab === 'completion') void inspect(); if (tab === 'history') void loadHistory() })
watch(historyPage, () => { if (activeTab.value === 'history') void loadHistory() })
onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</script>
<template>
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
<div v-loading="loading"><template v-if="detail">
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
</FormSection>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<template v-if="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><el-input v-model="dispatchNo" placeholder="输入待关联的排车单号" :disabled="!!busy" @keyup.enter="bind" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
<p>先分配车辆和司机,关联排车单并匹配卸货站。每单调度审核通过后拣货,确认实际装载并通过理货审核后整车发车。</p>
<DataTable :data="detail.dispatches" :columns="bindingsColumns" row-key="dispatchNo" empty-text="尚未关联排车单"><template #actions="{ row }"><div class="density-actions"><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" class="density-action" @click="router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } })">详情</el-button><el-button v-if="editable" class="density-action" data-tone="danger" :disabled="!!busy" @click="unbind(row.dispatchNo)">解除关联</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" :disabled="!!busy || row.review.status === 'APPROVED'" @click="review('APPROVED', row.dispatchNo)">审核通过</el-button><el-button v-if="planned && access.can(permission.tripDispatchReview)" class="density-action" data-tone="danger" :disabled="!!busy" @click="review('REJECTED', row.dispatchNo)">打回</el-button></div></template><template #review="{ row }"><span>{{ tripReviewLabels[row.review.status as keyof typeof tripReviewLabels] }}</span><p v-if="row.review.reason">{{ row.review.reason }}</p><span v-if="row.review.reviewedAt">{{ time(row.review.reviewedAt) }}</span></template><template #boundAt="{ row }">{{ time(row.boundAt) }}</template></DataTable>
</FormSection>
</template>
<FormSection v-if="activeTab === 'bookings'" class="density-form-section" title="本车外接预订"><TripBookingPanel :key="detail.trip.tripNo" :trip="detail.trip" @changed="detail.trip.recordVersion = $event" @linked="refreshLinked" /></FormSection>
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
<template v-if="activeTab === 'completion'">
<FormSection v-if="detail.trip.status === 'IN_TRANSIT'" v-loading="inspectionLoading" class="density-form-section" title="货物结案检查"><div class="trip-inline"><el-button @click="inspect">重新检查</el-button><el-button v-if="access.can(permission.tripComplete)" type="primary" :disabled="!inspection?.canComplete || !!busy" :loading="busy === 'complete'" @click="complete">确认货物结案</el-button></div><p>{{ inspection?.blockReason || (inspection?.canComplete ? '全部货物已具备结案依据' : '正在读取派送结果') }}</p>
<DataTable :data="inspection?.outcomes || []" :columns="outcomeColumns" row-key="deliveryNo"><template #status="{ row }">{{ outcomeLabels[row.status] || '待处理' }}</template><template #terminalAt="{ row }">{{ time(row.terminalAt) }}</template></DataTable>
</FormSection>
<FormSection class="density-form-section" title="车辆和司机分别结束任务"><p>货物结案与资源释放分别记录;仍有返程或转场任务时,等实际任务结束再释放。</p><dl class="density-fields"><div><dt>车辆任务</dt><dd>{{ detail.trip.vehicleRelease ? `${time(detail.trip.vehicleRelease.actualEndAt)} · ${detail.trip.vehicleRelease.endLocation}` : '尚未结束' }}</dd></div><div><dt>司机任务</dt><dd>{{ detail.trip.driverRelease ? `${time(detail.trip.driverRelease.actualEndAt)} · ${detail.trip.driverRelease.endLocation}` : '尚未结束' }}</dd></div></dl>
<el-form v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" label-position="top" class="density-form release-form" :disabled="!!busy || !!releaseRequest" @submit.prevent="release">
<el-form-item label="结束任务的资源"><el-select v-model="resourceKind"><el-option label="车辆" value="VEHICLE" :disabled="!!detail.trip.vehicleRelease" /><el-option label="司机" value="DRIVER" :disabled="!!detail.trip.driverRelease" /></el-select></el-form-item>
<el-form-item label="实际任务结束时间"><el-date-picker v-model="actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="实际结束地点"><el-input v-model="endLocation" placeholder="请输入实际任务结束地点" /></el-form-item>
<el-form-item v-if="resourceKind === 'VEHICLE'" label="车辆结束状态"><el-select v-model="vehicleStatus"><el-option label="在役可调度" value="IN_SERVICE" /><el-option label="维修" value="MAINTENANCE" /><el-option label="停用" value="INACTIVE" /><el-option label="已归还" value="RETURNED" /></el-select></el-form-item>
</el-form><el-button v-if="detail.trip.status === 'COMPLETED' && access.can(permission.tripRelease) && (!detail.trip.vehicleRelease || !detail.trip.driverRelease)" type="primary" :loading="busy === 'release'" :disabled="!!busy" @click="release">{{ releaseRequest ? '重试原结束请求' : '确认结束资源任务' }}</el-button>
</FormSection>
</template>
<FormSection v-if="activeTab === 'history'" class="density-form-section" title="操作历史"><DataTable v-model:page="historyPage" :page-size="20" :data="history" :total="historyTotal" :loading="historyLoading" :columns="historyColumns" row-key="recordVersion"><template #time="{ row }">{{ time(row.occurredAt) }}</template><template #action="{ row }">{{ actionLabels[row.action] || '更新记录' }}</template></DataTable></FormSection>
<TripPlanDialog v-model="editorOpen" :trip="detail.trip" @saved="accept" />
</template></div>
</DetailPageLayout></AdminPage>
</template>
<style scoped>.trip-inline{display:flex;gap:var(--admin-space-4);align-items:center}.trip-inline :deep(.el-input){max-width:340px}.release-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6);margin-top:var(--admin-space-6)}.release-form :deep(.el-select),.release-form :deep(.el-date-editor){width:100%}</style>
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { ElMessage, ElMessageBox } from 'element-plus'
import TripsView from './TripsView.vue'
import TripDetailView from './TripDetailView.vue'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import TripLoadingPanel from '../components/TripLoadingPanel.vue'
import * as api from '../api/trips'
import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes'
const state = vi.hoisted(() => ({ writable: true, push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' } }), useRouter: () => ({ push: state.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } }))
vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) }))
vi.mock('../api/trips', () => ({ listTrips: vi.fn(), getTrip: vi.fn(), createTrip: vi.fn(), updateTripPlan: vi.fn(), assignTrip: vi.fn(),
bindTripDispatch: vi.fn(), unbindTripDispatch: vi.fn(), cancelTrip: vi.fn(), getTripLoading: vi.fn(), confirmTripLoading: vi.fn(),
getTripCompletion: vi.fn(), completeTrip: vi.fn(), releaseTripResource: vi.fn(), listTripHistory: vi.fn(), reviewTripDispatch: vi.fn(), reviewTripLoading: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn() }))
const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92', warehouseName: '海外仓', warehouseCode: 'WH-US', vehicleId: '71',
vehiclePlate: 'TEST-TRUCK', driverId: '81', driverName: '测试司机', driverPhone: '123', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90,
plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null,
stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null }
const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState }
const layout = { template: '<div><slot name="header"/><slot/></div>' }
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',$event.target.value)" />' }
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }
const wrappers: VueWrapper[] = []
function mount(component: object, props: Record<string, unknown> = {}): VueWrapper {
const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: {
AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true,
ApiErrorMeta: true, LoadErrorState: true, AppDialog: dialog, TripPlanDialog: component === TripPlanDialog ? false : true,
TripAssignmentPanel: true, TripLoadingPanel: component === TripLoadingPanel ? false : true,
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' }, ElForm: layout, ElFormItem: layout,
ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true,
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true,
} } }); wrappers.push(wrapper); return wrapper
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); state.writable = true
vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail))
vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('loads one trip page and opens a real detail route without fetching each row', async () => {
const wrapper = mount(TripsView); await flushPromises()
expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled()
await button(wrapper, '详情').trigger('click')
expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' } })
wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises()
expect(api.listTrips).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
})
it('keeps read-only trip detail free of write actions and reads history only when opened', async () => {
state.writable = false
const wrapper = mount(TripDetailView); await flushPromises()
expect(api.getTrip).toHaveBeenCalledTimes(1); expect(api.listTripHistory).not.toHaveBeenCalled(); expect(api.getTripLoading).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('编辑计划'); expect(wrapper.findComponent({ name: 'TripAssignmentPanel' }).exists()).toBe(false)
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
expect(api.listTripHistory).toHaveBeenCalledExactlyOnceWith('TR-TEST', 1, 20)
})
it('keeps the exact create command for an uncertain retry and accepts optional notes', async () => {
vi.mocked(api.createTrip).mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(structuredClone(detail))
const wrapper = mount(TripPlanDialog, { modelValue: false, trip: null })
await wrapper.setProps({ modelValue: true }); await flushPromises()
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '92'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.createTrip).mock.calls[0]![0]
expect(first.idempotencyKey).toBeTruthy(); expect(first.remark).toBeNull()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTrip).toHaveBeenNthCalledWith(2, first)
expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(detail)
})
it('counts a shared physical pallet once and submits only operator-entered measurements', async () => {
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
const loading: TripLoading = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, dispatchNo: 'DP-B', customerId: 'B', dispatchItemId: '101' }], manifestHash: 'a'.repeat(64), confirmedLoad: null, blockReason: null }
vi.mocked(api.getTripLoading).mockResolvedValue(loading)
vi.mocked(api.confirmTripLoading).mockResolvedValue({ ...loading, recordVersion: 4 })
const wrapper = mount(TripLoadingPanel, { trip, editable: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toHaveLength(1)
await button(wrapper, '确认实际装载').trigger('click'); expect(api.confirmTripLoading).not.toHaveBeenCalled(); expect(ElMessage.warning).toHaveBeenCalled()
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
await button(wrapper, '确认实际装载').trigger('click'); await flushPromises()
expect(api.confirmTripLoading).toHaveBeenCalledExactlyOnceWith('TR-TEST', expect.objectContaining({ recordVersion: 3, manifestHash: loading.manifestHash,
pallets: [{ warehousePalletId: '901', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }))
})
it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => {
const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] }
vi.mocked(api.getTrip).mockResolvedValue(pending)
vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } })
const wrapper = mount(TripDetailView); await flushPromises()
await button(wrapper, '审核通过').trigger('click'); await flushPromises()
expect(api.reviewTripDispatch).toHaveBeenCalledWith('TR-TEST', 'DP-A', { recordVersion: 3, decision: 'APPROVED', reason: null })
await button(wrapper, '打回').trigger('click'); await flushPromises()
expect(ElMessageBox.prompt).toHaveBeenCalled()
expect(api.reviewTripDispatch).toHaveBeenLastCalledWith('TR-TEST', 'DP-A', { recordVersion: 4, decision: 'REJECTED', reason: '请核对预约时间' })
})
it('clears displayed loading approval after new actual load and uses the returned version for review', async () => {
vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), loadingReview: { ...reviewState, status: 'APPROVED' } })
vi.mocked(api.reviewTripLoading).mockResolvedValue(structuredClone(detail))
const wrapper = mount(TripDetailView); await flushPromises()
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'loading'); await flushPromises()
expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeDefined()
wrapper.findComponent({ name: 'TripLoadingPanel' }).vm.$emit('saved', { tripNo: trip.tripNo, recordVersion: 7 }); await flushPromises()
expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeUndefined()
await button(wrapper, '理货审核通过').trigger('click'); await flushPromises()
expect(api.reviewTripLoading).toHaveBeenCalledWith('TR-TEST', { recordVersion: 7, decision: 'APPROVED', reason: null })
})
it('lets an operator plan capacity before picking without confirming actual loading', async () => {
const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'a'.repeat(64), capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis)
vi.mocked(api.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 4, planValid: true })
const wrapper = mount(TripLoadingPanel, { trip, editable: true, planning: true }); await flushPromises()
expect(api.getTripLoading).not.toHaveBeenCalled()
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
await button(wrapper, '确认计划容量').trigger('click'); await flushPromises()
expect(api.confirmTripCapacityPlan).toHaveBeenCalledOnce(); expect(api.confirmTripLoading).not.toHaveBeenCalled()
expect(wrapper.emitted('planned')?.[0]?.[0]).toMatchObject({ recordVersion: 4 })
expect(wrapper.emitted('saved')).toBeUndefined()
})
it('refreshes trip members and loading review after external dispatch binding without leaving the current tab', async () => {
const wrapper = mount(TripDetailView); await flushPromises()
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'bookings'); await flushPromises()
wrapper.findComponent({ name: 'TripBookingPanel' }).vm.$emit('linked'); await flushPromises()
expect(api.getTrip).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings')
})
@@ -0,0 +1,59 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import TripPlanDialog from '../components/TripPlanDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTrips, type TripQuery } from '../api/trips'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { tripStatusLabels, type FleetTrip, type TripDetail } from '../tripTypes'
const router = useRouter(), access = useAccessPreviewStore()
const rows = ref<FleetTrip[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), editorOpen = ref(false)
const error = ref<ApiErrorDetails | null>(null), filters = ref<QueryModel>({ keyword: '', status: '' })
let applied: Omit<TripQuery, 'page' | 'size'> = {}, sequence = 0
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '车次号 / 车牌 / 司机', width: 250 },
{ key: 'status', type: 'select', label: '状态', width: 150, options: Object.entries(tripStatusLabels).map(([value, label]) => ({ value, label })) }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 85, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', prop: 'tripNo', label: '车次号', minWidth: 215 },
{ key: 'status', label: '状态', slotName: 'status', width: 120 }, { key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌', minWidth: 130 }, { key: 'driverName', prop: 'driverName', label: '司机', minWidth: 110 },
{ key: 'departure', label: '计划发车', slotName: 'departure', minWidth: 175 }, { key: 'end', label: '司机预计结束', slotName: 'end', minWidth: 175 },
{ key: 'route', label: '路线', slotName: 'route', minWidth: 240 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
async function load() {
const current = ++sequence; loading.value = true; error.value = null
try { const result = await listTrips({ ...applied, page: page.value, size: size.value }); if (current === sequence) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '车次加载失败') } }
finally { if (current === sequence) loading.value = false }
}
function search() {
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, status: (filters.value.status || undefined) as TripQuery['status'] }
if (page.value !== 1) page.value = 1; else void load()
}
function view(tripNo: string) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo } }) }
function created(detail: TripDetail) { view(detail.trip.tripNo) }
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout>
<LoadErrorState v-if="error" title="车次加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="车次查询" preference-key="fleet-trips-query" @search="search" @reset="search" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="tripNo" empty-text="暂无车次,先建立计划再关联排车单">
<template #toolbar-actions><el-button v-if="access.can(permission.tripCreate)" type="primary" @click="editorOpen = true">新建车次</el-button></template>
<template #actions="{ row }"><el-button class="density-action" @click="view(row.tripNo)">详情</el-button></template>
<template #status="{ row }">{{ tripStatusLabels[row.status as keyof typeof tripStatusLabels] }}</template>
<template #departure="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #end="{ row }">{{ time(row.expectedDriverEndAt) }}</template>
<template #route="{ row }">{{ row.stops.map((stop: FleetTrip['stops'][number]) => stop.name).join(' → ') }}</template>
</DataTable>
<TripPlanDialog v-model="editorOpen" :trip="null" @saved="created" />
</ListPageLayout></AdminPage>
</template>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue'
import { onBeforeUnmount, ref, watch } from 'vue'
import VehicleCareTable from '../components/VehicleCareTable.vue'
import type { VehicleCareType } from '../vehicleCareTypes'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { vehicleStatusLabels } from '../presentation'
import { ArrowLeft } from '@lucide/vue'
@@ -8,8 +10,12 @@ import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'; import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'; import FormSection from '@/shared/components/FormSection.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { getVehicle } from '../api/overseasMasterData'; import type { VehicleDetail } from '../types'
const route=useRoute(),router=useRouter(),detail=ref<VehicleDetail|null>(null),loading=ref(false),error=ref<ApiErrorDetails|null>(null)
async function load(){loading.value=true;error.value=null;try{detail.value=await getVehicle(String(route.params.vehicleId||''))}catch(e){detail.value=null;error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{loading.value=false}}
function back(){void router.push({name:'overseas-master-data-vehicles'})} onMounted(load)
const activeTab = ref('overview')
let request = 0
async function load(){const current=++request;loading.value=true;error.value=null;detail.value=null;try{const result=await getVehicle(String(route.params.vehicleId||''));if(current===request)detail.value=result}catch(e){if(current===request)error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{if(current===request)loading.value=false}}
function back(){void router.push({name:'overseas-master-data-vehicles'})}
watch(() => route.params.vehicleId, () => { activeTab.value='overview';void load() }, { immediate:true })
onBeforeUnmount(() => { request++ })
</script>
<template><AdminPage class="admin-density" ><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><p class="contract-note">当前车辆主数据接口明确不包含派送履约记录。</p></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><p class="contract-note">当前车辆主数据接口明确不包含车辆更换历史。</p></FormSection></section></DetailPageLayout></AdminPage></template>
<style scoped>.detail-grid .wide-field { grid-column: span 2; }.contract-note{margin:0;padding:var(--admin-space-6);color:var(--cc-muted);text-align:center}</style>
<template><AdminPage class="admin-density" ><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车辆详情"><template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ detail?.plateNumber || '—' }}</h1><StatusBadge v-if="detail" :status="vehicleStatusLabels[detail.status]" /></div></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="detail-surface"><el-tabs v-if="detail" v-model="activeTab"><el-tab-pane label="车辆概览" name="overview" /><el-tab-pane label="保养及加油" name="care" /><el-tab-pane label="维修记录" name="REPAIR" /><el-tab-pane label="紧急故障" name="BREAKDOWN" /><el-tab-pane label="应急救援" name="RESCUE" /><el-tab-pane label="异常管理" name="EXCEPTION" /></el-tabs><template v-if="activeTab === 'overview'"><FormSection class="density-form-section" title="车辆信息"><dl v-if="detail" class="detail-grid density-fields"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['最大标准托位',detail.maxPalletSpaces??'待确认'],['最大载重(kg)',detail.maxLoadKg??'待确认'],['可用容积(m³)',detail.maxVolumeCbm??'待确认'],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',vehicleStatusLabels[detail.status]],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]" :class="{ 'wide-field': ['司机 / 外派单位', '备注'].includes(String(item[0])) }"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection class="density-form-section" title="派送订单明细"><p class="contract-note">派送记录请在海外仓的排车管理或派送管理中查看。</p></FormSection><FormSection class="density-form-section" title="派送更换记录" :bordered="false"><p class="contract-note">车辆更换记录尚未接入。</p></FormSection></template><div v-if="detail && activeTab === 'care'" :key="detail.id" class="care-grid"><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="MAINTENANCE" /><VehicleCareTable :vehicle-id="detail.id" :plate="detail.plateNumber" record-type="FUEL" /></div><VehicleCareTable v-if="detail && ['REPAIR','BREAKDOWN','RESCUE','EXCEPTION'].includes(activeTab)" :key="`${detail.id}:${activeTab}`" :vehicle-id="detail.id" :plate="detail.plateNumber" :record-type="activeTab as VehicleCareType" /></section></DetailPageLayout></AdminPage></template>
<style scoped>.care-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-6)}.detail-grid .wide-field { grid-column: span 2; }.contract-note{margin:0;padding:var(--admin-space-6);color:var(--cc-muted);text-align:center}</style>
@@ -12,23 +12,23 @@ import { masterDataErrorMessage } from '../apiError'
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:Object.entries(vehicleStatusLabels).map(([value,label])=>({label,value}))}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0})
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'maxPalletSpaces',prop:'maxPalletSpaces',label:'最大托位',minWidth:100,align:'right'},{key:'maxLoadKg',prop:'maxLoadKg',label:'最大载重(kg)',minWidth:130,align:'right'},{key:'maxVolumeCbm',prop:'maxVolumeCbm',label:'可用容积(m³)',minWidth:130,align:'right'},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0,maxPalletSpaces:null as number|null,maxLoadKg:null as number|null,maxVolumeCbm:null as number|null})
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
async function load(){loading.value=true;error.value=null;try{const r=await listVehicles(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'车辆加载失败')}finally{loading.value=false}}
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;page.value=1;void load()}
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0})}
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;search()}
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0,maxPalletSpaces:null,maxLoadKg:null,maxVolumeCbm:null})}
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}const capacity=[form.maxPalletSpaces,form.maxLoadKg,form.maxVolumeCbm];if(capacity.some(v=>v!=null)&&capacity.some(v=>v==null||v<=0)){ElMessage.warning('托位、载重和容积须同时填写正数,或全部留空');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null,maxPalletSpaces:form.maxPalletSpaces??null,maxLoadKg:form.maxLoadKg??null,maxVolumeCbm:form.maxVolumeCbm??null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
<template #toolbar-start><h1>车辆列表</h1></template>
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{active:ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}</style>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(1080px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="最大标准托位"><el-input-number v-model="form.maxPalletSpaces" :min="1" :max="10000" :precision="0" placeholder="请输入实际托位" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="form.maxLoadKg" :min="0.001" :precision="3" placeholder="请输入核定载重" /></el-form-item><el-form-item label="可用容积(m³)"><el-input-number v-model="form.maxVolumeCbm" :min="0.001" :precision="3" placeholder="请输入实际容积" /></el-form-item><p class="capacity-hint">三项容量须同时确认;未知时可全部留空,确认后才能安排出车。</p><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.capacity-hint{grid-column:1/-1;margin:0;color:var(--cc-muted)}</style>
@@ -1,4 +1,23 @@
export const overseasMasterDataPermissions = {
tripBookingCreate: 'platform:fleet:trip:booking-create',
tripBookingEdit: 'platform:fleet:trip:booking-edit',
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
tripBookingCancel: 'platform:fleet:trip:booking-cancel',
tripList: 'platform:fleet:trip:list',
tripCreate: 'platform:fleet:trip:create',
tripEdit: 'platform:fleet:trip:edit',
tripAssign: 'platform:fleet:trip:assign',
tripCancel: 'platform:fleet:trip:cancel',
tripComplete: 'platform:fleet:trip:complete',
tripRelease: 'platform:fleet:trip:release',
tripDispatchReview: 'platform:fleet:trip:dispatch-review',
tripLoadingReview: 'platform:fleet:trip:loading-review',
driverList: 'platform:overseas-master-data:driver:list',
driverCreate: 'platform:overseas-master-data:driver:create',
driverEdit: 'platform:overseas-master-data:driver:edit',
driverStatus: 'platform:overseas-master-data:driver:status',
driverReview: 'platform:overseas-master-data:driver:review',
driverUpload: 'platform:overseas-master-data:driver:upload',
quoteList: 'platform:overseas-master-data:quote:list',
quoteCreate: 'platform:overseas-master-data:quote:create',
quoteEdit: 'platform:overseas-master-data:quote:edit',
@@ -14,5 +33,8 @@ export const overseasMasterDataPermissions = {
vehicleEdit: 'platform:overseas-master-data:vehicle:edit',
vehicleStatus: 'platform:overseas-master-data:vehicle:status',
vehicleExport: 'platform:overseas-master-data:vehicle:export',
vehicleRecordCreate: 'platform:overseas-master-data:vehicle:record-create',
vehicleRecordEdit: 'platform:overseas-master-data:vehicle:record-edit',
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
} as const
@@ -7,6 +7,10 @@ import { overseasMasterDataRoutes } from './routes'
describe('overseas master data routes', () => {
it('uses the frozen stable paths and list permissions', () => {
expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
['overseas-master-data/drivers/:driverId', overseasMasterDataPermissions.driverList],
['overseas-master-data/quotes', overseasMasterDataPermissions.quoteList],
['overseas-master-data/exchange-rates', overseasMasterDataPermissions.exchangeRateList],
['overseas-master-data/vehicles', overseasMasterDataPermissions.vehicleList],
+25 -1
View File
@@ -2,6 +2,30 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasMasterDataPermissions as permission } from './permissions'
export const overseasMasterDataRoutes = [
{
path: 'overseas-master-data/trips',
name: 'overseas-master-data-trips',
component: () => import('./pages/TripsView.vue'),
meta: { title: '车次管理', description: '安排车辆司机、关联货物并核对整车装载', permission: permission.tripList },
},
{
path: 'overseas-master-data/trips/:tripNo',
name: 'overseas-master-data-trip-detail',
component: () => import('./pages/TripDetailView.vue'),
meta: { title: '车次详情', description: '分配、装载、结案与资源任务结束', permission: permission.tripList, showPageHeading: false },
},
{
path: 'overseas-master-data/drivers',
name: 'overseas-master-data-drivers',
component: () => import('./pages/DriversView.vue'),
meta: { title: '司机管理', description: '维护司机档案、驾驶证及到期提醒', permission: permission.driverList },
},
{
path: 'overseas-master-data/drivers/:driverId',
name: 'overseas-master-data-driver-detail',
component: () => import('./pages/DriverDetailView.vue'),
meta: { title: '司机详情', description: '司机档案、证照审核与任务资格校验', permission: permission.driverList, showPageHeading: false },
},
{
path: 'overseas-master-data/quotes',
name: 'overseas-master-data-quotes',
@@ -24,7 +48,7 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/vehicles/:vehicleId',
name: 'overseas-master-data-vehicle-detail',
component: () => import('./pages/VehicleDetailView.vue'),
meta: { title: '车辆详情', description: '查看车辆档案、派送订单与更换记录', permission: permission.vehicleList, showPageHeading: false },
meta: { title: '车辆详情', description: '查看车辆档案、保养加油及车辆运维记录', permission: permission.vehicleList, showPageHeading: false },
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,66 @@
import type { TripBooking } from './bookingTypes'
import type { DriverQualification } from './driverTypes'
export type TripStatus = 'PLANNED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export const tripStatusLabels: Record<TripStatus, string> = { PLANNED: '计划中', IN_TRANSIT: '运输中', COMPLETED: '货物已结案', CANCELLED: '已取消' }
export interface TripStop { name: string; address?: string | null; destinationKey?: string | null }
export type ReleasedVehicleStatus = 'IN_SERVICE' | 'MAINTENANCE' | 'INACTIVE' | 'RETURNED'
export interface TripReleaseInfo { actualEndAt: string; endLocation: string; vehicleStatus: ReleasedVehicleStatus | null }
export interface FleetTrip {
tripNo: string
status: TripStatus
warehouseId: string
warehouseCode: string
warehouseName: string
vehicleId: string | null
vehiclePlate: string | null
driverId: string | null
driverName: string | null
driverPhone: string | null
maxPalletSpaces: number | null
maxLoadKg: number | null
maxVolumeCbm: number | null
plannedDepartureAt: string
expectedDriverEndAt: string
departedAt: string | null
completedAt: string | null
stops: TripStop[]
remark: string | null
recordVersion: number
updatedAt: string
vehicleRelease: TripReleaseInfo | null
driverRelease: TripReleaseInfo | null
}
export interface TripReviewState { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewerId: string | null; reviewedAt: string | null; recordVersion: number | null }
export interface TripReviewCommand { recordVersion: number; decision: 'APPROVED' | 'REJECTED'; reason?: string | null }
export const tripReviewLabels = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已打回' } as const
export interface TripBinding { dispatchNo: string; customerId: string; packageCount: number; boundBy: string; boundAt: string; review: TripReviewState }
export interface TripDetail { trip: FleetTrip; driverQualification: DriverQualification | null; dispatches: TripBinding[]; loadingReview: TripReviewState }
export interface TripDraft { warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; idempotencyKey: string }
export interface TripPlan { recordVersion: number; plannedDepartureAt: string; expectedDriverEndAt: string; stops: TripStop[]; remark?: string | null; reason: string }
export interface TripAssignment { recordVersion: number; vehicleId: string; driverId: string; reason: string }
export interface TripBindingCommand { recordVersion: number; dispatchNo: string; reason: string }
export interface TripCargo {
dispatchNo: string; customerId: string; dispatchItemId: string; warehousePalletId: string | null
palletNo: string | null; packages: number; destinationKey: string | null; destinationLabel: string | null
}
export interface TripMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
export interface TripLoad { palletSpaces: number; weightKg: number; volumeCbm: number }
export interface TripSegment { fromIndex: number; toIndex: number; occupied: TripLoad; remaining: TripLoad }
export interface TripConfirmedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
export interface TripPlannedLoad { manifestHash: string; cargo: TripCargo[]; pallets: TripMeasurement[]; segments: TripSegment[]; operatorId: string; confirmedAt: string }
export interface TripCapacityPlanning { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; capacityPlan: TripPlannedLoad | null; planValid: boolean; blockReason: string | null; availableSegments: TripSegment[] }
export interface TripLoading { tripNo: string; recordVersion: number; cargo: TripCargo[]; manifestHash: string; confirmedLoad: TripConfirmedLoad | null; blockReason: string | null }
export interface TripLoadConfirmation { recordVersion: number; manifestHash: string; pallets: TripMeasurement[]; reason: string }
export interface TripOutcome { dispatchNo: string; deliveryNo: string; customerId: string; destination: string | null; status: string; packages: number; terminalAt: string | null }
export interface TripCompletionInspection { tripNo: string; recordVersion: number; canComplete: boolean; blockReason: string | null; outcomes: TripOutcome[] }
export interface TripComplete { recordVersion: number; idempotencyKey: string; reason: string }
export interface TripResourceRelease {
recordVersion: number; resourceKind: 'VEHICLE' | 'DRIVER'; actualEndAt: string; endLocation: string
vehicleStatus?: ReleasedVehicleStatus | null; idempotencyKey: string; reason: string
}
export interface TripCompletionEvidence { idempotencyKey: string; requestHash: string; completedAt: string; recordedAt: string; operatorId: string; outcomes: TripOutcome[] }
export interface TripHistory {
recordVersion: number; action: string; reason: string | null; operatorId: string; occurredAt: string
snapshot: { externalBookings: TripBooking[]; capacityPlan: TripPlannedLoad | null; trip: FleetTrip; dispatches: TripBinding[]; confirmedLoad: TripConfirmedLoad | null; completion: TripCompletionEvidence | null; loadingReview: TripReviewState }
}
@@ -55,6 +55,9 @@ export interface MonthlyExchangeRate {
}
export interface VehicleSummary {
maxPalletSpaces: number | null
maxLoadKg: number | null
maxVolumeCbm: number | null
id: string
plateNumber: string
model: string
@@ -0,0 +1,41 @@
export type VehicleCareType = 'MAINTENANCE' | 'FUEL' | 'REPAIR' | 'BREAKDOWN' | 'RESCUE' | 'EXCEPTION'
export interface VehicleIncident {
description: string
item: string | null
place: string | null
handler: string | null
processingStatus: 'PENDING' | 'IN_PROGRESS' | 'COMPLETED'
exceptionType: 'ROUTE' | 'TIMEOUT' | 'DELIVERY' | 'VEHICLE_BREAKDOWN' | null
reportedCost: number | null
currency: string | null
}
export interface VehicleCareDraft {
recordType: VehicleCareType
occurredAt: string
distanceUnit: 'KM' | 'MI' | null
odometer: number | null
lastServiceOdometer: number | null
nextServiceOdometer: number | null
fuelQuantity: number | null
fuelUnit: 'L' | 'US_GAL' | 'IMP_GAL' | null
verificationStatus: 'PENDING' | 'VERIFIED' | null
remark: string | null
incident: VehicleIncident | null
}
export interface VehicleCareFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface VehicleCareRecord {
recordNo: string
vehicleId: string
vehiclePlate: string
details: VehicleCareDraft
files: VehicleCareFile[]
recordVersion: number
updatedBy: string
updatedAt: string
paymentStatus: 'UNLINKED' | null
}
export const distanceLabels = { KM: 'km', MI: 'mi' } as const
export const fuelLabels = { L: 'L', US_GAL: '美制 gal', IMP_GAL: '英制 gal' } as const
export const vehicleCareLabels: Record<VehicleCareType, string> = { MAINTENANCE: '保养记录', FUEL: '加油记录', REPAIR: '维修记录', BREAKDOWN: '紧急故障', RESCUE: '应急救援', EXCEPTION: '异常管理' }
export const processingLabels = { PENDING: '待处理', IN_PROGRESS: '处理中', COMPLETED: '已完成' } as const
export const exceptionLabels = { ROUTE: '轨迹异常', TIMEOUT: '时间超时异常', DELIVERY: '派送异常', VEHICLE_BREAKDOWN: '车辆故障异常' } as const
+2
View File
@@ -43,6 +43,8 @@
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
- 排车转派送弹框打开时并行读取一次`getDispatchExternalFreight`和`getDispatchFeeCoverage`;不逐行查费用。`DeliveryFeeCoveragePanel`按实际各票原费用明确勾选派送/装车/买约,提交来源ID/版本/hash与货物摘要,不按金额推断覆盖。`fullyCoveredServices`只控制输入展示,服务端重新核验;只有全部货物已包含的项目才不提交新增应收,部分覆盖仍需填剩余项目。外接报价只读并提交externalFreightHash。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
- 索引变更从后端根运行 `./scripts/agents-index-contract.ps1`。离线检查不代替真实 HTTP 和人工截图验收。
@@ -57,3 +57,26 @@
| 进销存记录 | 导出 | 页面 989 行“导出Excel”按钮 | 工具栏动作,原型未定义导出任务载体 | File 导出类型与海外接口未冻结,本批不显示可成功导出入口 |
`overseas-inbound`、`overseas-relabel` 等原型库存视图用于联查入仓/换标库存事实,不新增重复菜单页;当前正式入库单、换标单、库存列表与详情已经分别承载对应入口。`purchase-stock*` 和 `fba-return-stock` 是否作为独立正式页面尚无本轮导航与接口契约,不在本批自行扩展。
### 车辆外接运费增量(2026-10-03)
| 页面 | 操作 | 依据 | 承载方式 | 本次变更 | 验收 |
|---|---|---|---|---|---|
| 排车列表 | 转派送费用核对 | 用户新版原型12374行1120px居中弹框、12404行ove-panel-delivery费用分区;当前对话确认原费用覆盖、新增项目收费 | 保留现有1120px三列居中转派送弹框 | 费用分区带出外接实收报价,全部外接不重复填写派送应收;无新增抽屉或页面 | 本地交互测试通过,1920×1080实际页面待验收 |
### 取消费用确认增量(2026-10-03)
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 出库列表 | 取消确认 | 沿现有居中原因确认框;本轮首版费用规则 | 提示先解除车次、费用申请与财务结果分别处理,不再提示库存已自动回滚 | 交互检查通过,真实页面待验收 |
| 派送详情 / 货柜费用面板 | 费用只读及作废 | 沿现有详情费用表与原因弹框;财务确认状态为本轮服务端契约补充 | 展示待财务确认/待财务处理/已作废,待决时关闭重复作废和调整 | 前端检查通过,1920×1080待验收 |
### 原运费覆盖增量(2026-10-03)
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---|
| 排车列表 | 转派送费用确认 | 用户已确认逐票原费用覆盖;复用新版原型12374行1120px三列居中弹框 | DeliveryFeeCoveragePanel逐票显示原费和服务勾选,新增费用仅针对未覆盖部分;不新增抽屉或路由 | 交互测试通过,1920×1080真实页面待验收 |
| 派送详情 | 费用只读 | 沿现有费用页签 | 展示确认时原费版本与服务,原金额不计作本次新增 | 本地展示测试通过,真实页面待验收 |
@@ -65,9 +65,9 @@ export interface DeliveryOrderRow {
}
export interface DeliverySkuRow { inboundCode?: string | null; customerName?: string | null; destinationWarehouseCode?: string | null; receivingAddress?: string | null; shipmentNo?: string | null; fbaTrackingNo?: string | null; skuCode?: string | null; productName?: string | null; packageCount: number; productQuantity?: number | null; weightKg?: number | null; volumeCbm?: number | null }
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null }
export interface DeliveryFeeRow { id: string; feeDirection: string; feeType: string; currency: string; amount: number; remark?: string | null; reviewStatus?: string | null; sourceStatus?: string | null; voidStatus?: string | null }
export interface DeliveryOperationRow { id: string; operatedAt: string; operationNode: string; operatorName?: string | null; result: string }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[] }
export interface DeliveryOrderDetail { order: DeliveryOrderRow; skus: DeliverySkuRow[]; fees: DeliveryFeeRow[]; operations: DeliveryOperationRow[]; feeCoverage?: import('../feeCoverageTypes').FeeCoverageEvidence | null }
export interface DeliveryReturnSource {
outboundPalletItemId: string
@@ -1,10 +1,21 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch } from './dispatches'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
describe('dispatch overseas API', () => {
it('reads external freight from the scoped dispatch without a supplied customer', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
})
it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
const result = await getDispatchFeeCoverage('DP/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
})
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
@@ -235,6 +235,9 @@ export async function releaseDispatchDraft(draftToken: string) {
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
export interface TransferDeliveryPayload {
feeCoverageCargoHash?: string
feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
externalFreightHash?: string
idempotencyKey: string
plannedDeliveryAt: string
deliveryMethod: string
@@ -262,3 +265,24 @@ export async function transferDispatchToDelivery(dispatchNo: string, payload: Tr
)
return unwrapApiResponse(response)
}
export interface ExternalFreightPreview {
snapshotHash: string
requiresDeliveryFee: boolean
quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
}
export async function getDispatchExternalFreight(dispatchNo: string): Promise<ExternalFreightPreview> {
const response = await http.get<ApiEnvelope<ExternalFreightPreview>, ApiEnvelope<ExternalFreightPreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
)
return unwrapApiResponse(response)
}
export async function getDispatchFeeCoverage(dispatchNo: string): Promise<import('../feeCoverageTypes').FeeCoveragePreview> {
const response = await http.get<ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>, ApiEnvelope<import('../feeCoverageTypes').FeeCoveragePreview>>(
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
)
const data = unwrapApiResponse(response)
return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
}
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CoveredService, FeeCoveragePreview, FeeCoverageSelection, OriginalFeeSource } from '../feeCoverageTypes'
import { coveredServiceLabels } from '../feeCoverageTypes'
const props = defineProps<{ preview: FeeCoveragePreview | null; disabled?: boolean; loading?: boolean }>()
const selection = defineModel<FeeCoverageSelection[]>({ default: () => [] })
function selected(id: string) { return selection.value.find(row => row.financeFeeId === id)?.services || [] }
function inboundCode(source: OriginalFeeSource) { return props.preview?.cargo.find(row => row.sourceInboundId === source.sourceInboundId)?.inboundCode || source.inboundCode || '—' }
function change(id: string, services: CoveredService[]) {
const source = props.preview?.feeSources.find(row => row.financeFeeId === id)
if (!source) return
selection.value = [...selection.value.filter(row => row.financeFeeId !== id), ...(services.length ? [{ financeFeeId: id, sourceVersion: source.sourceVersion, sourceHash: source.sourceHash, services: [...services].sort() }] : [])].sort((a, b) => a.financeFeeId.localeCompare(b.financeFeeId))
}
</script>
<template>
<section aria-label="原运费服务覆盖" class="coverage-panel">
<h3>原运费服务覆盖</h3>
<p>仅勾选已与客户明确约定包含的服务。原费用金额供核对,不是本次新增金额;同托多票须分别确认。</p>
<p v-if="loading">正在读取原费用…</p>
<p v-else-if="!preview">原费用尚未读取,请重新打开后核对。</p>
<p v-else-if="!preview.sourceComplete">{{ preview.blockReason }};当前不能引用原费用。</p>
<p v-else-if="!preview?.feeSources.length">暂无可引用的已确认原费用。</p>
<div v-for="source in preview?.feeSources || []" :key="source.financeFeeId" class="coverage-row">
<span>{{ inboundCode(source) }} · {{ source.feeItemName || '—' }} · {{ source.currency }} {{ source.amount }} · 版本 {{ source.sourceVersion }}</span>
<el-checkbox-group :model-value="selected(source.financeFeeId)" :disabled="disabled || !preview?.sourceComplete" @update:model-value="change(source.financeFeeId, $event as CoveredService[])">
<el-checkbox v-for="(label, code) in coveredServiceLabels" :key="code" :value="code">已含{{ label }}</el-checkbox>
</el-checkbox-group>
</div>
</section>
</template>
<style scoped>
.coverage-panel { margin-bottom: 12px; }
.coverage-panel p { margin: 6px 0; color: var(--el-text-color-secondary); font-size: 12px; }
.coverage-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 0; border-bottom: 1px solid var(--el-border-color-lighter); font-size: 12px; }
.coverage-row > span { min-width: 0; overflow-wrap: anywhere; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { GripVertical, Plus } from '@lucide/vue'
@@ -87,15 +88,14 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
function weightKg(item?: DispatchPalletCandidateDto) {
const grossWeightKg = Number(item?.grossWeightKg)
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
const volumeWeightKg = Number(item?.volumeWeightKg)
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
const weightLb = (item?: DispatchPalletCandidateDto) => physicalWeightLb(item?.grossWeightKg)
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => totalPhysicalWeightLb(pallets)
const containerWeightLb = (container: LoadingContainer) => totalPhysicalWeightLb(container.palletIds.map(id => palletSnapshot(id)))
const formatWeightLb = (weight: number | null) => weight === null ? '待确认' : `${number(weight, 0)}LB`
const containerOverweight = (container: LoadingContainer) => {
const weight = containerWeightLb(container)
return weight !== null && weight > container.maxWeightLb * 1.1
}
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
function palletId(item: DispatchPalletCandidateDto) {
return String(item.planPalletId || item.warehousePalletId || '')
@@ -234,9 +234,13 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
ElMessage.warning('该装车托盘已达到设置的托数')
return
}
const nextWeight = containerWeightLb(target) + weightLb(item)
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
const currentWeight = containerWeightLb(target), incomingWeight = weightLb(item)
if (currentWeight === null || incomingWeight === null) ElMessage.warning('存在未确认的实际毛重,请在正式配载前补齐;材积重不能用于载重校验')
else {
const nextWeight = currentWeight + incomingWeight
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
}
const anchor = assigned.value[0]
if (anchor?.customerId && anchor.customerId !== item.customerId) {
ElMessage.warning('一次排车只能装同一客户的托盘')
@@ -434,7 +438,7 @@ watch([warehouseTab, () => query.orderType], () => {
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
><template #default="{ row }">{{ formatWeightLb(orderWeightLb(row.pallets)) }}</template></el-table-column
>
<el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
@@ -479,7 +483,7 @@ watch([warehouseTab, () => query.orderType], () => {
<GripVertical :size="16" class="drag-handle" />
<div class="pallet-copy">
<strong>{{ text(item.palletNo) }}</strong
><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span>
><span>{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}</span>
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
<span>{{ mark }}</span
@@ -518,7 +522,7 @@ watch([warehouseTab, () => query.orderType], () => {
:key="container.id"
class="loading-container"
:class="{
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
'loading-container--overweight': containerOverweight(container),
}"
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
@@ -527,7 +531,7 @@ watch([warehouseTab, () => query.orderType], () => {
<strong>{{ container.vehicleLabel }}</strong
><span
>托数 <b>{{ container.palletIds.length }}</b
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
>/{{ container.capacity }} · 重量 <b>{{ formatWeightLb(containerWeightLb(container)) }}</b>/{{ container.maxWeightLb }}LB <em v-if="containerOverweight(container)">超限10%</em></span
><el-button class="density-action" data-tone="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
</div>
<div class="container-body">
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { physicalWeightKg, physicalWeightLb, totalPhysicalWeightLb } from './dispatchPhysicalWeight'
describe('dispatch physical weight', () => {
it('keeps absent or invalid physical weight unknown', () => {
for (const value of [null, undefined, '', 0, -1, Number.NaN, Number.POSITIVE_INFINITY]) expect(physicalWeightKg(value)).toBeNull()
expect(physicalWeightLb(10)).toBeCloseTo(22.046226218)
})
it('never substitutes billing volume weight and does not publish partial totals', () => {
const unknown = { grossWeightKg: null, volumeWeightKg: 999 }
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, unknown])).toBeNull()
expect(totalPhysicalWeightLb([undefined])).toBeNull()
expect(totalPhysicalWeightLb([{ grossWeightKg: 20 }, { grossWeightKg: 30 }])).toBeCloseTo(110.23113109)
expect(totalPhysicalWeightLb([])).toBe(0)
})
})
@@ -0,0 +1,22 @@
const kilogramsToPounds = 2.2046226218
export function physicalWeightKg(value: unknown): number | null {
if (value == null || value === '') return null
const number = Number(value)
return Number.isFinite(number) && number > 0 ? number : null
}
export function physicalWeightLb(value: unknown): number | null {
const weight = physicalWeightKg(value)
return weight === null ? null : weight * kilogramsToPounds
}
export function totalPhysicalWeightLb(items: readonly ({ grossWeightKg?: number | null } | undefined)[]): number | null {
let total = 0
for (const item of items) {
const weight = physicalWeightLb(item?.grossWeightKg)
if (weight === null) return null
total += weight
}
return total
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { fullyCoveredServices, type FeeCoveragePreview, type FeeCoverageSelection } from './feeCoverageTypes'
const preview: FeeCoveragePreview = {
dispatchNo: 'DP-1', sourceComplete: true, blockReason: null, cargoHash: 'hash',
cargo: ['11', '12'].map(id => ({ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: id, sourceRegion: 'FIRSTLEG', sourceInboundId: id, inboundCode: `IN-${id}`, packages: 10, dispatchInboundCode: 'IN-11' })),
feeSources: ['11', '12'].map(id => ({ financeFeeId: `7${id}`, sourceFeeId: `8${id}`, sourceType: 'INBOUND_FEE', sourceVersion: 1, customerId: '101', sourceInboundId: id, inboundCode: `IN-${id}`, feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'source-hash', status: 'AVAILABLE' })),
}
describe('per-inbound service coverage', () => {
it('does not hide new fees when only one of two shared-pallet sources is covered', () => {
const selections: FeeCoverageSelection[] = [{ financeFeeId: '711', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] }]
expect(fullyCoveredServices(preview, selections, [])).toEqual([])
selections.push({ financeFeeId: '712', sourceVersion: 1, sourceHash: 'source-hash', services: ['DELIVERY_FEE'] })
expect(fullyCoveredServices(preview, selections, [])).toEqual(['DELIVERY_FEE'])
})
it('external freight covers delivery only, and incomplete sources never imply original fee coverage', () => {
expect(fullyCoveredServices(preview, [], ['IN-11'])).toEqual(['DELIVERY_FEE'])
expect(fullyCoveredServices({ ...preview, sourceComplete: false }, [], ['IN-11'])).toEqual([])
})
})
@@ -0,0 +1,29 @@
export type CoveredService = 'DELIVERY_FEE' | 'LOADING_FEE' | 'APPOINTMENT_FEE'
export const coveredServiceLabels: Record<CoveredService, string> = { DELIVERY_FEE: '派送', LOADING_FEE: '装车', APPOINTMENT_FEE: '买约' }
export interface CoverageCargo {
dispatchItemId: string; warehousePalletId: string; palletNo: string | null; orderId: string | null
sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; packages: number; dispatchInboundCode: string | null
}
export interface OriginalFeeSource {
financeFeeId: string; sourceFeeId: string; sourceType: string; sourceVersion: number; customerId: string
sourceInboundId: string; inboundCode: string | null; feeItemCode: string; feeItemName: string; currency: string
amount: number; sourceHash: string; status: string
}
export interface FeeCoveragePreview { dispatchNo: string; sourceComplete: boolean; blockReason: string | null; cargoHash: string; cargo: CoverageCargo[]; feeSources: OriginalFeeSource[] }
export interface FeeCoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: CoveredService[] }
export interface FeeCoverageEvidence { cargoHash: string; cargo: CoverageCargo[]; references: Array<{ source: OriginalFeeSource; services: CoveredService[] }>; confirmedAt: string }
export function fullyCoveredServices(preview: FeeCoveragePreview | null, selections: FeeCoverageSelection[], externalIncomingNos: string[]): CoveredService[] {
if (!preview?.sourceComplete || !preview.cargo.length) return []
const byOrigin = new Map<string, Set<CoveredService>>()
for (const selection of selections) {
const source = preview.feeSources.find(fee => fee.financeFeeId === selection.financeFeeId)
if (!source) continue
const included = byOrigin.get(source.sourceInboundId) || new Set<CoveredService>()
selection.services.forEach(service => included.add(service))
byOrigin.set(source.sourceInboundId, included)
}
return (Object.keys(coveredServiceLabels) as CoveredService[]).filter(service => preview.cargo.every(cargo =>
service === 'DELIVERY_FEE' && externalIncomingNos.includes(cargo.dispatchInboundCode || '')
|| cargo.sourceRegion === 'FIRSTLEG' && !!cargo.sourceInboundId && byOrigin.get(cargo.sourceInboundId)?.has(service)))
}
@@ -19,8 +19,8 @@ describe('DeliveryOrderDetailView', () => {
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 },
{ id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'PENDING_FINANCE' },
{ id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', voidStatus: 'FINANCE_ATTENTION' },
], operations: [],
})
})
@@ -36,6 +36,9 @@ describe('DeliveryOrderDetailView', () => {
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
expect(wrapper.text()).toContain('作废待财务确认')
expect(wrapper.text()).toContain('作废待财务处理')
expect(wrapper.text()).not.toContain('已作废')
wrapper.unmount()
})
@@ -47,4 +50,17 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('缺少派送单客户范围')
wrapper.unmount()
})
it('shows frozen original coverage separately from new fees', async () => {
const value = await vi.mocked(getDeliveryOrder)('DEL/1', '100')
value.feeCoverage = { cargoHash: 'hash', cargo: [], confirmedAt: '2026-10-03T00:00:00Z', references: [{
source: { financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '100', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original', status: 'AVAILABLE' }, services: ['DELIVERY_FEE', 'LOADING_FEE'],
}] }
vi.mocked(getDeliveryOrder).mockResolvedValue(value)
const wrapper = mount(DeliveryOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('原费用金额供核对,不计作本次新增')
expect(wrapper.text()).toContain('IN-11 · 原运费 · CNY 100 · 版本 3 · 已含派送、装车')
wrapper.unmount()
})
})
@@ -10,7 +10,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { getDeliveryOrder, type DeliveryOrderDetail } from '../api/deliveryOrders'
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryFeeRow } from '../api/deliveryOrders'
import { coveredServiceLabels } from '../feeCoverageTypes'
const route = useRoute()
const router = useRouter()
@@ -26,10 +27,17 @@ const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER
const pickupLabels: Record<string, string> = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' }
const loadingLabels: Record<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库', CANCEL_OUTBOUND_REASON: '取消原因', CONFIRM_EXTERNAL_FREIGHT: '确认外接运费' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
function feeStatus(fee: DeliveryFeeRow) {
if (fee.voidStatus === 'CONFIRMED') return '已作废'
if (fee.voidStatus === 'PENDING_FINANCE') return '作废待财务确认'
if (fee.voidStatus === 'FINANCE_ATTENTION') return '作废待财务处理'
if (fee.sourceStatus === 'SUPERSEDED') return '已替代'
return ({ APPROVED: '业务已审核', PENDING: '待审核', DRAFT: '草稿', REJECTED: '已驳回' } as Record<string, string>)[fee.reviewStatus || ''] || '—'
}
function displayPerson(value: unknown) { const text = String(value ?? '').trim(); return text && !/^\d+$/.test(text) ? text : '—' }
function quantity(value: number | null | undefined, unit: string) { return value === null || value === undefined ? '—' : `${value} ${unit}` }
function customerConfirmation() { const signedAt = detail.value?.order.signedAt; return signedAt ? `已签收 · ${formatOverseasDateTime(signedAt)}` : '—' }
@@ -95,10 +103,14 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<template v-if="detail?.feeCoverage?.references.length">
<p>本次已确认的原费用覆盖({{ formatOverseasDateTime(detail.feeCoverage.confirmedAt) }}):原费用金额供核对,不计作本次新增。</p>
<p v-for="reference in detail.feeCoverage.references" :key="`${reference.source.financeFeeId}-${reference.services.join('-')}`">{{ detail.feeCoverage.cargo.find(cargo => cargo.sourceInboundId === reference.source.sourceInboundId)?.inboundCode || reference.source.inboundCode || '—' }} · {{ reference.source.feeItemName || '—' }} · {{ reference.source.currency }} {{ reference.source.amount }} · 版本 {{ reference.source.sourceVersion }} · 已含{{ reference.services.map(service => coveredServiceLabels[service]).join('、') }}</p>
</template>
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column prop="remark" label="备注" min-width="240" />
<el-table-column prop="amount" label="金额" min-width="140" align="right" /><el-table-column label="费用状态" min-width="165"><template #default="scope">{{ feeStatus(scope.row as DeliveryFeeRow) }}</template></el-table-column><el-table-column prop="remark" label="备注" min-width="240" />
</el-table>
</section>
</el-tab-pane>
@@ -29,4 +29,15 @@ describe('DispatchDetailView', () => {
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
wrapper.unmount()
})
it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', status: 'PENDING_DEPARTURE', items: [
{ id: '11', planPalletId: '31', orderNo: 'LM-1', warehousePalletId: '21', grossWeightKg: 88.5, volumeCbm: 1 },
{ id: '12', planPalletId: '32', orderNo: 'LM-1', warehousePalletId: '22', grossWeightKg: null, volumeWeightKg: 999, volumeCbm: 1 },
] })
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
await flushPromises()
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,7 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number
weight: number | null
palletCount: number
fbaWarehouse: string
volume: number
@@ -84,21 +85,22 @@ const documentFields = computed<DetailField[]>(() => [
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number | null; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
current.weight += Number(item.grossWeightKg || 0)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: Number(value.weight.toFixed(3)),
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
@@ -1,16 +1,18 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
@@ -52,6 +54,8 @@ describe('DispatchesView', () => {
vi.mocked(removeDispatchItem).mockReset()
vi.mocked(cancelDispatch).mockReset()
vi.mocked(transferDispatchToDelivery).mockReset()
vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
canPermission.mockReset().mockReturnValue(true)
})
@@ -150,6 +154,54 @@ describe('DispatchesView', () => {
expect(source).toContain('请确认海外尾程排车接口已发布')
})
it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
{ bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
] })
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchExternalFreight).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchExternalFreight).toHaveBeenCalledOnce()
expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1')
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
expect(dialog.text()).toContain('FB-1')
expect(dialog.text()).not.toContain('请输入派送费用')
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' }))
})
it('confirms an original delivery fee without submitting a new receivable', async () => {
vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo',
cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
})
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
await flushPromises()
expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1)
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true)
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
}
await flushPromises()
expect(dialog.text()).toContain('本次全部货物已覆盖:派送')
dialog.vm.$emit('confirm')
await flushPromises()
expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
})
it('gates edit, cancel and transfer with independent permissions and service statuses', async () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
expect(source).toContain('permission.dispatchEdit')
@@ -14,7 +14,9 @@ import StatusBadge from '@/shared/components/StatusBadge.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto, type ExternalFreightPreview } from '../api/dispatches'
import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
import { fullyCoveredServices, coveredServiceLabels, type FeeCoveragePreview, type FeeCoverageSelection } from '../feeCoverageTypes'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
@@ -38,6 +40,15 @@ const filters = ref<QueryModel>({
const transferOpen = ref(false)
const transferRow = ref<DispatchListItem | null>(null)
const transferSaving = ref(false)
const externalFreight = ref<ExternalFreightPreview | null>(null)
const coveragePreview = ref<FeeCoveragePreview | null>(null)
const coverageSelection = ref<FeeCoverageSelection[]>([])
const coveredServices = computed(() => fullyCoveredServices(coveragePreview.value, coverageSelection.value, externalFreight.value?.quotes.map(row => row.incomingNo) || []))
const requiresDeliveryFee = computed(() => !!externalFreight.value?.requiresDeliveryFee && !coveredServices.value.includes('DELIVERY_FEE'))
const requiresLoadingFee = computed(() => needsLoadingFee.value && !coveredServices.value.includes('LOADING_FEE'))
const requiresAppointmentFee = computed(() => isBuyAppointment.value && !coveredServices.value.includes('APPOINTMENT_FEE'))
const freightLoading = ref(false)
let freightGeneration = 0
const mutationError = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editRow = ref<DispatchListItem | null>(null)
@@ -373,7 +384,7 @@ function openEdit(row: DispatchListItem) {
mutationError.value = null
editOpen.value = true
}
function openTransfer(row: DispatchListItem) {
async function openTransfer(row: DispatchListItem) {
if (!canTransfer(row)) return
transferRow.value = row
Object.assign(transferForm, {
@@ -397,6 +408,19 @@ function openTransfer(row: DispatchListItem) {
})
mutationError.value = null
transferOpen.value = true
externalFreight.value = null
coveragePreview.value = null
coverageSelection.value = []
freightLoading.value = true
const generation = ++freightGeneration
try {
const [preview, coverage] = await Promise.all([getDispatchExternalFreight(row.dispatchNo), getDispatchFeeCoverage(row.dispatchNo)])
if (generation === freightGeneration && transferOpen.value) { externalFreight.value = preview; coveragePreview.value = coverage }
} catch (cause) {
if (generation === freightGeneration && transferOpen.value) mutationError.value = getApiErrorDetails(cause, '约定运费读取失败,请重新打开转派送')
} finally {
if (generation === freightGeneration) freightLoading.value = false
}
}
async function submitEdit() {
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
@@ -494,26 +518,27 @@ function handleRowAction(command: string, row: DispatchListItem) {
}
async function submitTransfer() {
if (!transferRow.value || !canTransfer(transferRow.value)) return
if (transferSaving.value || freightLoading.value || !externalFreight.value) return
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
return
}
if (transferForm.deliveryFee == null) {
if (requiresDeliveryFee.value && transferForm.deliveryFee == null) {
ElMessage.warning('请填写派送费用')
return
}
if (needsLoadingFee.value && transferForm.loadingFee == null) {
if (requiresLoadingFee.value && transferForm.loadingFee == null) {
ElMessage.warning('当前装车类型需要填写装车费')
return
}
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || requiresAppointmentFee.value && transferForm.appointmentFee == null)) {
ElMessage.warning('买约时请填写卖家姓名和买约费用')
return
}
const feeInputs = [
['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'],
['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用'],
['DELIVERY_FEE', requiresDeliveryFee.value ? transferForm.deliveryFee : undefined, '派送费用'],
['LOADING_FEE', requiresLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
['APPOINTMENT_FEE', requiresAppointmentFee.value ? transferForm.appointmentFee : undefined, '买约费用'],
] as const
const fees = feeInputs
.filter(([, amount]) => amount != null)
@@ -544,6 +569,9 @@ async function submitTransfer() {
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
externalFreightHash: externalFreight.value.snapshotHash,
feeCoverageCargoHash: coverageSelection.value.length ? coveragePreview.value?.cargoHash : undefined,
feeCoverage: coverageSelection.value,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
@@ -569,7 +597,7 @@ onMounted(load)
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" :confirm-disabled="freightLoading || !externalFreight" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><DeliveryFeeCoveragePanel v-model="coverageSelection" :preview="coveragePreview" :loading="freightLoading" :disabled="transferSaving || freightLoading" /><p v-if="coveredServices.length">本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。</p><h3>本次新增费用</h3><p v-if="freightLoading">正在读取约定运费…</p><p v-for="quote in externalFreight?.quotes || []" :key="quote.bookingNo">{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)</p><div class="transfer-grid"><el-form-item v-if="requiresDeliveryFee" :label="externalFreight?.quotes.length || coverageSelection.length ? '派送费(未覆盖部分,USD)' : '派送费用(USD)'" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="requiresLoadingFee" label="装车费(USD)" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="requiresAppointmentFee" label="买约费用(USD)" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="原运费仅按上方确认的服务范围引用;外接运费按实收约定带入。下方只登记未覆盖货物或服务的新增应收。" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template>
<style scoped>
@@ -118,7 +118,7 @@ async function mountView() {
async function openPending() {
const wrapper = await mountView()
const pendingTab = wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')
const pendingTab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')
await pendingTab!.trigger('click')
await flushPromises()
return wrapper
@@ -159,6 +159,74 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '待分配'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
await selectRows(wrapper, [selectedRow()])
await tabs[0]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.classes()).toContain('is-active')
expect(document.activeElement).toBe(tabs[1]!.element)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, currentStatus: 'PENDING_ASSIGNMENT' }))
expect((wrapper.vm as unknown as { selectedRows: unknown[] }).selectedRows).toEqual([])
const callCount = vi.mocked(listLastMileOrders).mock.calls.length
await tabs[1]!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(callCount)
await tabs[1]!.trigger('keydown', { key: 'Home' })
await flushPromises()
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: undefined }))
await tabs[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
})
it('preserves advanced conditions when collapsed and clears them on reset', async () => {
const wrapper = await mountView()
const toolbar = wrapper.findComponent(QueryToolbar)
expect(toolbar.props('fields').map((field) => field.key)).toEqual(['orderNo', 'inboundCode', 'warehouse'])
const initialCalls = vi.mocked(listLastMileOrders).mock.calls.length
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('fields')).toHaveLength(13)
toolbar.vm.$emit('update:modelValue', {
...toolbar.props('modelValue'), containerNo: ' CONT-15 ', abnormal: 'false',
createdAt: ['2026-09-01', '2026-09-30'],
})
await wrapper.vm.$nextTick()
await button(wrapper, '收起筛选')!.trigger('click')
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls)
expect(toolbar.props('modelValue').containerNo).toBe(' CONT-15 ')
await button(wrapper, '查询')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
containerNo: 'CONT-15', abnormal: false,
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-30T23:59:59',
}))
await button(wrapper, '重置')!.trigger('click')
await flushPromises()
expect(vi.mocked(listLastMileOrders)).toHaveBeenCalledTimes(initialCalls + 2)
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({
page: 1, containerNo: undefined, abnormal: undefined, createdFrom: undefined, createdTo: undefined,
}))
await button(wrapper, '高级筛选')!.trigger('click')
expect(toolbar.props('modelValue').containerNo).toBe('')
expect(toolbar.props('modelValue').createdAt).toEqual([])
})
it('keeps container filters separate from the advanced order fields', async () => {
const wrapper = await mountView()
await button(wrapper, '高级筛选')!.trigger('click')
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.findComponent(QueryToolbar).props('fields').map((field) => field.key)).toEqual(['containerNo', 'createdAt'])
expect(button(wrapper, '收起筛选')).toBeUndefined()
expect(button(wrapper, '高级筛选')).toBeUndefined()
})
it('keeps the original carton count and adds only the signed difference in the list', async () => {
canAccess.mockReturnValue(false)
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
@@ -300,7 +368,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
]))
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '待分配')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'PENDING_ASSIGNMENT', lxCode: 'LX-SEARCH' }))
@@ -310,7 +378,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
createdFrom: '2026-09-01T00:00:00', createdTo: '2026-09-07T23:59:59',
})
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '全部')!.trigger('click')
await wrapper.findAll('[role="tab"]').find((item) => item.text() === '全部')!.trigger('click')
await flushPromises()
expect(listLastMileOrders).toHaveBeenLastCalledWith(expect.objectContaining({ currentStatus: 'DELIVERED', lxCode: 'LX-SEARCH' }))
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, PackagePlus } from '@lucide/vue'
import { ChevronDown, ChevronUp, Download, Pencil, PackagePlus, SlidersHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -143,6 +143,8 @@ const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
let receivingWarehouseIdempotencyKey = ''
let containerOptionRequest = 0
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
const statusTabs = ['全部', '待分配'] as const
const showAdvancedFilters = ref(false)
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const allFields: QueryField[] = [
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
@@ -176,7 +178,9 @@ const allFields: QueryField[] = [
]
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
? allFields.filter((field) => ['containerNo', 'createdAt'].includes(field.key))
: allFields)
: showAdvancedFilters.value
? allFields
: allFields.filter((field) => ['orderNo', 'inboundCode', 'warehouse'].includes(field.key)))
const selectedContainer = computed(() => containerOptions.value.find((row) => row.sourceContainerBookingId === selectedContainerBookingId.value))
const selectedReceivingWarehouse = computed(() => receivingWarehouseOptions.value.find((row) => (
row.warehouseId === receivingWarehouseForm.warehouseId
@@ -381,12 +385,35 @@ async function load(model: QueryModel = filters.value) {
}
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function reset() {
for (const field of allFields) filters.value[field.key] = field.type === 'daterange' ? [] : ''
page.value = 1
void load()
}
function changeStatusTab() {
selectedRows.value = []
page.value = 1
void load()
}
function selectStatusTab(value: typeof statusTabs[number]) {
if (statusTab.value === value) return
statusTab.value = value
changeStatusTab()
}
function handleStatusTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % statusTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + statusTabs.length) % statusTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = statusTabs.length - 1
else return
event.preventDefault()
const tab = statusTabs[nextIndex]
if (!tab) return
selectStatusTab(tab)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
async function openContainerDetail(row: ContainerPlanProgressSummaryDto) {
containerDetailOpen.value = true
@@ -944,13 +971,22 @@ onMounted(() => { void load() })
<template #toolbar-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
<el-radio-button value="全部">全部</el-radio-button>
<el-radio-button value="待分配">待分配</el-radio-button>
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@click="selectStatusTab(tab)" @keydown="handleStatusTabKeydown($event, index)">{{ tab }}</button>
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</el-radio-group>
<QueryToolbar v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset" />
</div>
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="allFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
<ChevronUp v-if="showAdvancedFilters" :size="15" /><ChevronDown v-else :size="15" />
</el-button>
</template>
</QueryToolbar>
</section>
</template>
<template #notices><template v-if="loadError">
@@ -208,7 +208,7 @@ async function submit() {
}
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(row.status || '')) }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消;派送应收撤销结果请查看费用明细,排车占用需回排车列表取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
@@ -98,7 +98,7 @@ const tableColumns = computed(() => props.mode === 'cartons' ? cartonColumns : i
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" v-model:page="query.page" v-model:page-size="query.size" :data="tableRows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :preference-key="`overseas-return-${mode === 'inventory' ? inventoryTab : 'cartons'}`" @update:page="load" @update:page-size="query.page = 1; load()">
<template #toolbar-start><h1>{{ mode === 'inventory' ? '退货库存管理' : '退货箱号管理' }}</h1></template>
<template #toolbar-actions><el-button v-if="mode==='inventory'" type="warning" @click="openPackingPlan">创建合箱计划</el-button></template>
<template #filters><el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #filters><el-tabs class="density-element-tabs density-status-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs><section class="query-toolbar query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section></template>
<template #stock-0="{ row }">{{ customerName(row.customerId) }}</template>
<template #stock-1="{ row }">{{ warehouseName(row.overseasWarehouseId) }}</template>
<template #movement-0="{ row }">{{ customerName(row.customerId) }}</template>
@@ -41,7 +41,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
])
const keys = [...expected.entries()].map(([fileName, preferenceKey]) => {
const value = source(fileName)
const toolbar = value.match(/<QueryToolbar\b[^>]*\/>/)?.[0]
const toolbar = value.match(/<QueryToolbar\b[^>]*>/)?.[0]
expect(toolbar, `${fileName} should render a QueryToolbar`).toBeDefined()
expect(toolbar).toContain(`preference-key="${preferenceKey}"`)
return preferenceKey
@@ -374,8 +374,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择目的 FBA 仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
expect(value).toContain("const statusTabs = ['全部', '待分配'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))
@@ -30,7 +30,7 @@ describe('supplier formal pages', () => {
expect(source).toContain('class="supplier-list-top"')
expect(source).toContain('class="supplier-query-panel integrated-query-group"')
expect(source).toContain('class="supplier-query-toolbar"')
expect(source).toContain('class="supplier-tabs density-tabs"')
expect(source).toContain('class="supplier-tabs density-status-tabs"')
expect(source).toContain('({{ item.value }})')
expect(source).not.toContain('class="supplier-metrics"')
expect(source).toContain('@click="switchCategory(item.category)"')
@@ -131,8 +131,8 @@ onActivated(() => {
<template #filters>
<div class="supplier-list-top">
<div class="supplier-query-panel integrated-query-group">
<nav class="supplier-tabs density-tabs" role="tablist" aria-label="供应商分类统计">
<button role="tab" :aria-selected="activeCategory === item.category" v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)"><span class="density-tab-icon" aria-hidden="true">{{ ({ BOOKING: '🚢', CUSTOMS: '📋', TRUCKING: '🚚', CERTIFICATE: '📄' })[item.category] }}</span>{{ categoryLabels[item.category] }}({{ item.value }})</button>
<nav class="supplier-tabs density-status-tabs" role="tablist" aria-label="供应商分类统计">
<button role="tab" :aria-selected="activeCategory === item.category" v-for="item in metricItems" :key="item.category" type="button" :class="{ 'is-active': activeCategory === item.category }" @click="switchCategory(item.category)"><span class="density-tab-icon" aria-hidden="true">{{ ({ BOOKING: '🚢', CUSTOMS: '📋', TRUCKING: '🚚', CERTIFICATE: '📄' })[item.category] }}</span>{{ categoryLabels[item.category] }}({{ item.value }})</button>
</nav>
<QueryToolbar v-model="filters" class="supplier-query-toolbar" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
</div>
@@ -2701,7 +2701,7 @@ onBeforeUnmount(() => {
<template #toolbar-start><h1>订单列表</h1></template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<div class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
@@ -2977,13 +2977,6 @@ onBeforeUnmount(() => {
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { min-width: 0; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--admin-table-border, #e5e7eb); background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: var(--admin-tab-height, 38px); padding: 0 var(--admin-space-6, 16px); border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: var(--admin-font-body, 13px); cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.orders-query-toolbar { border: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.table-card { overflow: hidden; border-radius: var(--admin-radius, 4px); box-shadow: none; }
.coload-error { margin: var(--admin-space-3, 8px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--el-color-danger-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: var(--admin-font-caption, 12px); }
@@ -3139,7 +3132,7 @@ onBeforeUnmount(() => {
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
@media (max-width: 960px) { .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--cc-border); border-left: 0; } }
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
+75
View File
@@ -0,0 +1,75 @@
import { h } from 'vue'
import { mount } from '@vue/test-utils'
import { ElOption, ElSelect } from 'element-plus'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AdminSelect } from './AdminSelect'
const mounted: ReturnType<typeof mount>[] = []
const options = [
{ label: '洛杉矶仓', value: 'LA' },
{ label: '纽约仓', value: 'NY' },
]
function mountSelect(props: Record<string, unknown> = {}) {
const wrapper = mount(AdminSelect, {
attachTo: document.body,
props: { modelValue: '', teleported: false, ...props },
slots: {
default: () => options.map(option => h(ElOption, { ...option, key: option.value })),
header: () => h('span', { class: 'business-header' }, '选择仓库'),
},
})
mounted.push(wrapper)
return wrapper
}
afterEach(() => {
mounted.splice(0).forEach(wrapper => wrapper.unmount())
})
describe('Admin Element Plus select defaults', () => {
it('filters by the displayed label and emits the original value only after selection', async () => {
const wrapper = mountSelect()
await wrapper.find('.el-select__wrapper').trigger('click')
await wrapper.find('input').setValue('洛杉矶')
await vi.waitFor(() => {
const items = wrapper.findAll('.el-select-dropdown__item')
expect(items.filter(item => item.isVisible()).map(item => item.text())).toEqual(['洛杉矶仓'])
})
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await wrapper.findAll('.el-select-dropdown__item')[0]!.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['LA']])
expect(wrapper.emitted('change')).toEqual([['LA']])
expect(wrapper.find('.business-header').text()).toBe('选择仓库')
expect(typeof wrapper.vm.focus).toBe('function')
expect(typeof wrapper.vm.blur).toBe('function')
})
it('keeps multiple selection and disabled controls provided by Element Plus', async () => {
const wrapper = mountSelect({ multiple: true, modelValue: ['LA'], disabled: true, size: 'small' })
expect(wrapper.classes()).toContain('el-select--small')
expect(wrapper.find('input').attributes('disabled')).toBeDefined()
await vi.waitFor(() => expect(wrapper.find('.el-tag').text()).toBe('洛杉矶仓'))
await wrapper.find('.el-select__wrapper').trigger('click')
expect(wrapper.find('input').attributes('aria-expanded')).toBe('false')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('continues to pass search terms to existing remote search handlers', async () => {
const remoteMethod = vi.fn()
const wrapper = mountSelect({ remote: true, remoteMethod, debounce: 0 })
await wrapper.find('.el-select__wrapper').trigger('click')
await wrapper.find('input').setValue('合作车队')
await vi.waitFor(() => expect(remoteMethod).toHaveBeenCalledWith('合作车队'))
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('does not mutate Element Plus itself or discard an explicit field setting', () => {
const native = mount(ElSelect, { props: { modelValue: '', teleported: false } })
mounted.push(native)
expect(native.props('filterable')).toBe(false)
expect(mountSelect({ filterable: false }).props('filterable')).toBe(false)
})
})
+9
View File
@@ -0,0 +1,9 @@
import { ElSelect, selectProps } from 'element-plus'
// Keep Element Plus rendering, events, slots and exposed methods; only change the default.
export const AdminSelect = Object.assign({}, ElSelect, {
props: {
...selectProps,
filterable: { type: Boolean, default: true },
},
})
+4
View File
@@ -524,6 +524,10 @@ function setPage(value: number) {
.data-table--integrated .data-table__filters :deep(.el-input__wrapper),
.data-table--integrated .data-table__filters :deep(.el-select__wrapper),
.data-table--integrated .data-table__filters :deep(.el-button) { border-radius: 8px; }
/* Select's default wrapper uses a fixed 32px, unlike input and button sizing. */
.data-table--integrated .data-table__filters :deep(.el-select:not(.el-select--small):not(.el-select--large) .el-select__wrapper) {
min-height: var(--el-component-size);
}
.data-table--integrated .data-table__filters :deep(.el-button--primary:not(:disabled)) {
--el-button-bg-color: #1677ff;
--el-button-border-color: #1677ff;
+20
View File
@@ -154,6 +154,26 @@
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__nav-wrap::after { height: 1px; background: var(--cc-border); }
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__active-bar { display: none; }
.admin-density .density-element-tabs:not(.density-contained-tabs) > .el-tabs__content { display: none; }
/* List status tabs share the approved domestic order appearance. */
.admin-density .density-status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--admin-table-border, #e5e7eb); background: #f9fafb; scrollbar-width: thin; }
.admin-density .density-status-tabs > button,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item { position: relative; display: inline-flex; align-items: center; gap: var(--admin-space-2); flex: 0 0 auto; min-height: var(--admin-tab-height, 38px); padding: 0 var(--admin-space-6, 16px); border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: var(--admin-font-body, 13px); cursor: pointer; white-space: nowrap; }
.admin-density .density-status-tabs > button::after,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item::after { position: absolute; right: var(--admin-space-5, 12px); bottom: 0; left: var(--admin-space-5, 12px); height: 3px; background: transparent; content: ''; }
.admin-density .density-status-tabs > button:hover,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item:hover { color: var(--cc-primary); background: #f6f9fd; }
.admin-density .density-status-tabs > button:focus-visible,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.admin-density .density-status-tabs > button.is-active,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item.is-active { background: transparent; color: var(--cc-primary); font-weight: 600; }
.admin-density .density-status-tabs > button.is-active::after,
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item.is-active::after { background: var(--cc-primary); }
.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav-wrap::after { height: 0; }
@media (max-width: 720px) { .admin-density .density-status-tabs > button { min-height: 46px; padding: 0 14px; } }
@media (max-width: 360px) { .admin-density .density-status-tabs > button { padding: 0 10px; } }
.admin-density .density-contained-tabs > .el-tabs__content { padding: var(--admin-space-5); }
/* Explicit page descriptions share the teleported detail table scale. */
+37 -11
View File
@@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
import { compileStyle, parse } from 'vue/compiler-sfc'
import { adminNavigation, getNavigationLeaves } from '@/core/navigation/adminNavigation'
// Approved entry/consumer inventory. Do not discover unrelated menus recursively.
const cargoConsumers = [
@@ -61,6 +62,13 @@ const generalCatalogConsumers = [
'overseas-master-data/pages/OverseasQuotesView.vue',
'overseas-master-data/pages/VehiclesView.vue',
'overseas-master-data/pages/VehicleDetailView.vue',
'overseas-master-data/pages/DriversView.vue',
'overseas-master-data/pages/TripsView.vue',
'overseas-master-data/pages/TripDetailView.vue',
'overseas-master-data/components/TripPlanDialog.vue',
'overseas-master-data/pages/DriverDetailView.vue',
'overseas-master-data/components/DriverEditorDialog.vue',
'overseas-master-data/components/DriverLicensePreview.vue',
'master-data/express-companies/pages/ExpressCompaniesView.vue',
'master-data/counterparties/pages/PartnersView.vue',
'fee-items/pages/FeeItemsView.vue',
@@ -151,6 +159,29 @@ const cssRoot = (css: string) => {
}
describe('approved Admin density ownership', () => {
it('shares list status and category tab styles across registered list consumers', () => {
for (const file of [
'unified-orders/pages/UnifiedOrdersView.vue', 'overseas-warehouse/pages/LastMileOrdersView.vue',
'overseas-master-data/pages/VehiclesView.vue', 'suppliers/pages/SuppliersView.vue',
'domestic-containers/pages/DomesticContainersView.vue', 'container-loading/pages/ContainersView.vue',
]) {
const page = parse(read(`modules/${file}`)).descriptor
expect(page.template!.content).toMatch(/class="[^"]*density-status-tabs[^"]*" role="tablist"/)
expect(page.styles.map((style) => style.content).join('\n')).not.toMatch(/\.(status-tabs?|supplier-tabs|container-scope-tabs)\s*[{:.]/)
}
expect(read('modules/overseas-warehouse/pages/ReturnInventoryManagementView.vue')).toContain('class="density-element-tabs density-status-tabs"')
const selectors: string[] = []
cssRoot(shared).walkRules((rule) => { selectors.push(...rule.selectors) })
expect(selectors).toContain('.admin-density .density-status-tabs > button.is-active::after')
expect(selectors).toContain('.admin-density .density-status-tabs > button:focus-visible')
const element = '.admin-density .density-element-tabs.density-status-tabs > .el-tabs__header .el-tabs__nav .el-tabs__item'
const rules: string[][] = []
cssRoot(shared).walkRules((rule) => { rules.push(rule.selectors) })
for (const suffix of ['', '::after', ':hover', ':focus-visible', '.is-active', '.is-active::after']) {
expect(rules.some((rule) => rule.includes('.admin-density .density-status-tabs > button' + suffix) && rule.includes(element + suffix))).toBe(true)
}
})
it('keeps dashboard visualizations outside detail-field table styling', () => {
const dashboard = read('modules/dashboard/pages/DashboardView.vue')
expect(dashboard).toContain('class="dashboard-metrics"')
@@ -248,11 +279,9 @@ describe('approved Admin density ownership', () => {
expect(read('modules/overseas-warehouse/pages/LastMileOrderDetailView.vue')).toContain('density-detail-overview')
})
it('covers all eleven overseas warehouse menus, details and both return inventory modes', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const section = navigation.slice(navigation.indexOf("key: 'overseas-inventory'"), navigation.indexOf("key: 'customer-service'"))
for (const path of ['last-mile-orders', 'inbound-orders', 'locations', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons']) expect(section).toContain("path: '/overseas-warehouse/" + path + "'")
expect([...section.matchAll(/path: '/g)]).toHaveLength(11)
it('covers the approved overseas pages across the latest menu groups, details and both return inventory modes', () => {
const paths = getNavigationLeaves(adminNavigation).map(node => node.path)
expect(paths).toEqual(expect.arrayContaining(['last-mile-orders', 'inbound-orders', 'locations', 'dispatches', 'outbounds', 'delivery-orders', 'relabel-orders', 'pallets', 'inventory', 'return-inventory', 'return-cartons'].map(path => '/overseas-warehouse/' + path)))
const routes = read('modules/overseas-warehouse/routes.ts')
for (const mode of ['inventory', 'cartons']) expect(routes).toContain("mode: '" + mode + "'")
for (const file of overseasConsumers) {
@@ -377,13 +406,10 @@ describe('approved Admin density ownership', () => {
}
})
it('covers all eight domestic menu entries and loads density CSS on direct entry', () => {
const navigation = read('core/navigation/adminNavigation.ts')
const domestic = navigation.slice(navigation.indexOf("key: 'domestic-warehouse'"), navigation.indexOf("key: 'overseas-delivery'"))
expect([...domestic.matchAll(/path: '/g)]).toHaveLength(domesticPages.length)
it('covers approved domestic pages across the latest menu groups and loads density CSS on direct entry', () => {
const paths = getNavigationLeaves(adminNavigation).map(node => node.path)
expect(paths).toEqual(expect.arrayContaining(domesticPages.map(page => page.path)))
for (const page of domesticPages) {
expect(domestic).toContain(`title: '${page.title}'`)
expect(domestic).toContain(`path: '${page.path}'`)
if (page.entry) expect(read(`modules/${page.entry}`)).toContain(`<${page.component} mode="${page.mode}"`)
}
for (const file of densityPages) expect(read(`modules/${file}`), `${file}: direct entry CSS`).toContain('@/styles/adminDensity.css')
+8 -1
View File
@@ -17,7 +17,14 @@ export default defineConfig(({ mode }) => {
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [ElementPlusResolver()],
resolvers: [
name => name === 'ElSelect' ? {
name: 'AdminSelect',
from: '@/shared/components/AdminSelect',
sideEffects: 'element-plus/es/components/select/style/css',
} : undefined,
ElementPlusResolver(),
],
dts: 'src/components.d.ts',
}),
],