From 0b5094dd8f594cee521db6fda229d4b63ff0f6ff Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 12:43:25 +0800 Subject: [PATCH] feat(fleet): connect operations quotes costs and handover workspaces --- src/core/navigation/adminNavigation.test.ts | 6 +- src/core/navigation/adminNavigation.ts | 2 + src/modules/overseas-master-data/AGENTS.md | 6 + .../overseas-master-data/PROTOTYPE_MATRIX.md | 9 + .../api/companyVehicleCosts.test.ts | 56 +++++ .../api/companyVehicleCosts.ts | 111 ++++++++++ .../api/fleetQuotes.test.ts | 35 +++ .../overseas-master-data/api/fleetQuotes.ts | 67 ++++++ .../api/tripCosts.test.ts | 43 ++++ .../overseas-master-data/api/tripCosts.ts | 64 ++++++ .../api/tripHandovers.test.ts | 28 +++ .../overseas-master-data/api/tripHandovers.ts | 44 ++++ .../api/tripOperations.test.ts | 32 +++ .../api/tripOperations.ts | 50 +++++ .../api/vehicleCare.test.ts | 21 +- .../overseas-master-data/api/vehicleCare.ts | 60 ++++++ .../components/FleetQuoteEditor.vue | 57 +++++ .../components/FleetQuoteImport.vue | 28 +++ .../components/FleetQuotePartySelect.vue | 21 ++ .../components/TripCostPanel.test.ts | 102 +++++++++ .../components/TripCostPanel.vue | 186 ++++++++++++++++ .../components/TripHandoverPanel.test.ts | 95 ++++++++ .../components/TripHandoverPanel.vue | 200 +++++++++++++++++ .../components/TripOperationsCharts.vue | 27 +++ .../TripOperationsRecordDialog.test.ts | 43 ++++ .../components/TripOperationsRecordDialog.vue | 53 +++++ .../components/VehicleCareBehavior.test.ts | 9 +- .../components/VehicleCareEditor.vue | 2 +- .../VehicleCareFinanceLedger.test.ts | 100 +++++++++ .../components/VehicleCareFinanceLedger.vue | 204 ++++++++++++++++++ .../components/VehicleCareTable.vue | 117 ++++++++-- .../fleetQuoteCsv.test.ts | 14 ++ .../overseas-master-data/fleetQuoteCsv.ts | 31 +++ .../pages/FleetQuotesView.vue | 52 +++++ .../pages/FleetReportingViews.test.ts | 27 +++ .../pages/TripDetailView.vue | 8 +- .../pages/TripOperationsView.vue | 68 ++++++ .../pages/TripsBehavior.test.ts | 7 + .../overseas-master-data/permissions.ts | 14 ++ .../overseas-master-data/routes.test.ts | 2 + src/modules/overseas-master-data/routes.ts | 12 ++ 41 files changed, 2091 insertions(+), 22 deletions(-) create mode 100644 src/modules/overseas-master-data/api/companyVehicleCosts.test.ts create mode 100644 src/modules/overseas-master-data/api/companyVehicleCosts.ts create mode 100644 src/modules/overseas-master-data/api/fleetQuotes.test.ts create mode 100644 src/modules/overseas-master-data/api/fleetQuotes.ts create mode 100644 src/modules/overseas-master-data/api/tripCosts.test.ts create mode 100644 src/modules/overseas-master-data/api/tripCosts.ts create mode 100644 src/modules/overseas-master-data/api/tripHandovers.test.ts create mode 100644 src/modules/overseas-master-data/api/tripHandovers.ts create mode 100644 src/modules/overseas-master-data/api/tripOperations.test.ts create mode 100644 src/modules/overseas-master-data/api/tripOperations.ts create mode 100644 src/modules/overseas-master-data/components/FleetQuoteEditor.vue create mode 100644 src/modules/overseas-master-data/components/FleetQuoteImport.vue create mode 100644 src/modules/overseas-master-data/components/FleetQuotePartySelect.vue create mode 100644 src/modules/overseas-master-data/components/TripCostPanel.test.ts create mode 100644 src/modules/overseas-master-data/components/TripCostPanel.vue create mode 100644 src/modules/overseas-master-data/components/TripHandoverPanel.test.ts create mode 100644 src/modules/overseas-master-data/components/TripHandoverPanel.vue create mode 100644 src/modules/overseas-master-data/components/TripOperationsCharts.vue create mode 100644 src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts create mode 100644 src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue create mode 100644 src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts create mode 100644 src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue create mode 100644 src/modules/overseas-master-data/fleetQuoteCsv.test.ts create mode 100644 src/modules/overseas-master-data/fleetQuoteCsv.ts create mode 100644 src/modules/overseas-master-data/pages/FleetQuotesView.vue create mode 100644 src/modules/overseas-master-data/pages/FleetReportingViews.test.ts create mode 100644 src/modules/overseas-master-data/pages/TripOperationsView.vue diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 6b8dfe56..0b8a161b 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -18,15 +18,15 @@ describe('adminNavigation', () => { { 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(visible.flatMap(node => node.children ?? [])).toHaveLength(46) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 6efdcc0b..72cdbb8c 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -125,6 +125,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { 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' }, + { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, { @@ -178,6 +179,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { 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: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' }, { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true }, diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index 6123eeb4..c87d23fd 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -1,5 +1,11 @@ # 海外通用资料与车辆管理 +- 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。 + +- 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。 + +- 车次“直接成本”页签:`components/TripCostPanel.vue`、`api/tripCosts.ts`,选择页签才读取一次分页及整趟汇总;登记/更正分别使用trip:cost-create/cost-edit,历史沿trip:list。编辑先取最新历史快照版本;不修改内容不写入,未知新增结果保留原键及内容重试。按原币种展示,不将未登记或未分摊当零,不推定财务付款;费用表操作列在左,分摊表只读。后端合同为`fleet-trip-costs-v1.yaml`。 + - 车辆概览中的派送与更换记录由`components/VehicleFulfillmentTable.vue`、`api/vehicleFulfillment.ts`读取,替代原提示文字。派送要求vehicle:list与delivery:list,更换要求vehicle:list与trip:list;无对应权限不请求接口。两个可见分区各一次分页请求,不逐行加载;换车/换页忽略旧响应,错误保留重试及诊断。更换显示原/新车辆与司机、历史车次及排车业务号;现有快照没有操作人姓名时显示“—”,不用内部操作者ID回退。 - 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。 diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 84fb542b..f6232f7c 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -46,6 +46,13 @@ 以上仅为代码接入依据,真实界面验收仍待执行。 +## 新版报价与出车记录接入 + +| 入口 | 原型依据 | 当前承载 | 核对边界 | +| --- | --- | --- | --- | +| 车队报价 | `新版.html:8989–9021`,通用菜单`1527` | 独立列表,780px居中编辑,1040px居中CSV导入;采购/销售分开 | 列表权限含详情历史,写入与导出独立;整批导入,未知价不转零;真实1920×1080截图待发布后确认 | +| 出车记录 | `新版.html:8941–8987`,车辆菜单`1457` | 独立列表与统计,同一实际发车筛选口径,详情链接沿车次权限 | 不用原型内存记录创建发车事实;成本/财务未查明时明确显示未查询;首版CSV导出不声称原生Excel工作簿,页面与请求量待真实验收 | + ## 新版司机管理接入矩阵 输入为用户提供的 `C:/Users/BY-OY/Documents/WXWork/1688854456873012/Cache/File/2026-09/新版.html`,哈希及业务确认见 `docs/海外仓业务/车辆管理原型梳理与任务规划.md`。下列为本次实现依据,真实页面验收尚待执行。 @@ -61,6 +68,8 @@ ## 共享车次新增交互(2026-10-02 用户确认首版方案) +成本工作台沿车辆规划第4.1节“一趟直接成本及自有/外接分摊”的已确认方案,在车次详情增加“直接成本”页签。费用登记/更正使用680px居中两列表单,历史使用1080px居中只读表;原型出车记录的笼统价格不直接当成本或利润。分页查询同时返回服务端同币种汇总和基于实运托位的分摊,未知值明确显示未登记/暂未分摊。对应`TripCostPanel.vue`及`tripCosts.ts`;真实1920×1080页面、交互和请求量待结构准备及发布后验证。 + 业务依据为当前车辆管理规划第4.1节。原型出车记录 `新版.html:8941–8987`、`modal-vehicle-trip`(8966)仅展示记录、统计和新增/编辑弹框,没有共享车次关联及实际装载工作台。用户已明确“按你的建议执行,先做一个版本再优化”,采用独立车次列表与详情页,计划居中弹框,分配/关联/装载在详情分区,结案和资源释放分别确认。首版普通说明选填,不增加格式校验清单;保留权限、归属、版本、容量和证照期限门禁。 | 页面/操作 | 原型证据 | 建议承载 | 当前结论 | diff --git a/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts b/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts new file mode 100644 index 00000000..65d24e72 --- /dev/null +++ b/src/modules/overseas-master-data/api/companyVehicleCosts.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http, ApiResponseError } from '@/shared/api/http' +import { getCompanyCost, commandCompanyCost, payCompanyCost, listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher } from './companyVehicleCosts' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn() } })) +const source = { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '90071992547409931', vehiclePlate: 'VEH-A', tripNo: null, + supplierId: '90071992547409932', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: '10.00', exchangeRate: '7.000000', exchangeRateMonth: '2026-01', amount: '70.00', remark: null } +const wire = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '90071992547409933', paidAmount: '0.00', outstandingAmount: '70.00', source } +const base = '/platform/finance/v1/vehicle-costs/VC-ONE' +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.resetAllMocks()) +it('reads real company detail while preserving long versions and original-currency facts', async () => { + vi.mocked(http.get).mockResolvedValue(ok(wire)) + const result = await getCompanyCost('VC-ONE') + expect(result).toMatchObject({ recordVersion: wire.recordVersion, paidAmount: 0, outstandingAmount: 70, + source: { supplierId: source.supplierId, originalAmount: 10, amount: 70, exchangeRate: 7 } }) + expect(http.get).toHaveBeenCalledExactlyOnceWith(base) +}) +it('sends exact action versions and stable Idempotency-Key headers for booking, void and payment', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)) + const command = { expectedVersion: wire.recordVersion, reason: '核对' } + await commandCompanyCost('VC-ONE', 'approve', 'approve-key', command) + await commandCompanyCost('VC-ONE', 'void', 'void-key', command) + const payment = { expectedVersion: wire.recordVersion, amount: 20, paidAt: '2026-01-02T00:00:00Z', method: 'BANK_TRANSFER' as const, referenceNo: null, voucherFileIds: ['90071992547409934'], remark: null } + await payCompanyCost('VC-ONE', 'payment-key', payment) + expect(http.post).toHaveBeenNthCalledWith(1, `${base}/approve`, command, { headers: { 'Idempotency-Key': 'approve-key' } }) + expect(http.post).toHaveBeenNthCalledWith(2, `${base}/void`, command, { headers: { 'Idempotency-Key': 'void-key' } }) + expect(http.post).toHaveBeenNthCalledWith(3, `${base}/payments`, payment, { headers: { 'Idempotency-Key': 'payment-key' } }) +}) +it('loads separately paged history and payment records without treating unknown payable as zero', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ recordVersion: '0', action: 'RECEIVE', reason: null, operatorName: '系统', occurredAt: '2026-01-01T00:00:00Z', snapshot: { ...wire, status: 'PENDING', outstandingAmount: null } }], page: 2, size: 10, total: 11, pages: 2 })) + const history = await listCompanyCostHistory('VC-ONE', 2) + expect(history.records[0]?.snapshot.outstandingAmount).toBeNull() + vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [{ paymentNo: 'VP-ONE', currency: 'CNY', amount: '20.00', voucherFileIds: ['90071992547409934'] }], page: 1, size: 10, total: 1, pages: 1 })) + const payments = await listCompanyCostPayments('VC-ONE') + expect(payments.records[0]).toMatchObject({ amount: 20, voucherFileIds: ['90071992547409934'] }) + expect(http.get).toHaveBeenNthCalledWith(1, `${base}/history`, { params: { page: 2, size: 10 } }) + expect(http.get).toHaveBeenNthCalledWith(2, `${base}/payments`, { params: { page: 1, size: 10 } }) +}) +it('uploads only to the selected cost and rejects customer or operational photographs as payment evidence', async () => { + const file = new File(['png'], 'payment.png', { type: 'image/png' }) + vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '90071992547409934', fileName: 'payment.png', contentType: 'image/png', sizeBytes: '3', ownerScope: 'PLATFORM', tenantId: '0', businessType: 'FINANCE_VEHICLE_VOUCHER' })) + expect(await uploadCompanyCostVoucher('VC-ONE', file)).toMatchObject({ fileId: '90071992547409934', sizeBytes: 3 }) + expect(vi.mocked(http.post).mock.calls[0]?.[0]).toBe(`${base}/vouchers`) + expect((vi.mocked(http.post).mock.calls[0]?.[1] as FormData).get('file')).toBe(file) + vi.mocked(http.post).mockResolvedValueOnce(ok({ fileId: '1', ownerScope: 'CUSTOMER', tenantId: '12', businessType: 'FINANCE_VOUCHER' })) + await expect(uploadCompanyCostVoucher('VC-ONE', file)).rejects.toThrow('凭证返回内容无效') +}) +it('keeps JSON download errors as diagnostic errors rather than successful images', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'FIN_404', message: '不存在', requestId: 'request-one', traceId: 'trace-one' })], { type: 'application/json' })) + await expect(downloadCompanyCostVoucher('VC-ONE', '11')).rejects.toBeInstanceOf(ApiResponseError) +}) +it('rejects mismatched costs and imprecise money instead of displaying successful facts', async () => { + vi.mocked(http.get).mockResolvedValueOnce(ok({ ...wire, costNo: 'VC-OTHER' })).mockResolvedValueOnce(ok({ ...wire, paidAmount: '90071992547409.92' })) + await expect(getCompanyCost('VC-ONE')).rejects.toThrow('归属') + await expect(getCompanyCost('VC-ONE')).rejects.toThrow('精确展示') +}) diff --git a/src/modules/overseas-master-data/api/companyVehicleCosts.ts b/src/modules/overseas-master-data/api/companyVehicleCosts.ts new file mode 100644 index 00000000..459da0f0 --- /dev/null +++ b/src/modules/overseas-master-data/api/companyVehicleCosts.ts @@ -0,0 +1,111 @@ +import axios from 'axios' +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export const companyCostPermissions = { + list: 'platform:finance:vehicle-cost:list', approve: 'platform:finance:vehicle-cost:approve', + void: 'platform:finance:vehicle-cost:void', pay: 'platform:finance:vehicle-cost:pay', upload: 'platform:finance:vehicle-cost:upload', +} as const +export type CompanyCostState = 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' +export interface CompanyCost { + costNo: string; subjectCode: 'PLATFORM_DEFAULT'; supplierName: string; currency: 'CNY'; status: CompanyCostState + recordVersion: string; paidAmount: number; outstandingAmount: number | null + source: { sourceType: string; sourceNo: string; sourceVersion: string; vehicleId: string; vehiclePlate: string; tripNo: string | null; + supplierId: string; feeName: string; businessDate: string; originalCurrency: 'CNY' | 'USD'; originalAmount: number; + exchangeRate: number; exchangeRateMonth: string; amount: number; remark: string | null } +} +export interface CompanyCostCommand { expectedVersion: string; reason: string | null } +export interface CompanyCostPayment { + expectedVersion: string; amount: number; paidAt: string; method: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER' + referenceNo: string | null; voucherFileIds: string[]; remark: string | null +} +export interface CompanyCostHistory { recordVersion: string; action: string; reason: string | null; operatorName: string; occurredAt: string; snapshot: CompanyCost } +export interface CompanyCostPaid { paymentNo: string; currency: 'CNY'; amount: number; paidAt: string; method: string; referenceNo: string | null; voucherFileIds: string[]; operatorName: string; recordedAt: string; remark: string | null } +export interface CompanyCostVoucher { fileId: string; fileName: string; contentType: string; sizeBytes: number } +type Numeric = string | number +type CostWire = Omit & { + recordVersion: Numeric; paidAmount: Numeric; outstandingAmount?: Numeric | null + source: Omit & { + sourceVersion: Numeric; supplierId: Numeric; originalAmount: Numeric; exchangeRate: Numeric; amount: Numeric } +} +const base = (no: string) => `/platform/finance/v1/vehicle-costs/${encodeURIComponent(no)}` +function version(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('公司成本版本或标识无效') + return String(value) +} +function money(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('公司成本金额无效') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('公司成本金额超出精确展示范围') + return Number(value) +} +function integer(value: Numeric): number { + const result = Number(version(value)) + if (!Number.isSafeInteger(result)) throw new Error('公司成本分页数值无效') + return result +} +function cost(value: CostWire, expected?: string): CompanyCost { + if ((expected && value.costNo !== expected) || value.subjectCode !== 'PLATFORM_DEFAULT' || value.currency !== 'CNY' + || !['PENDING', 'APPROVED', 'PARTIALLY_PAID', 'PAID', 'VOIDED'].includes(value.status)) throw new Error('公司成本响应归属或状态无效') + const rate = Number(value.source.exchangeRate) + if (!Number.isFinite(rate) || rate <= 0 || !['CNY', 'USD'].includes(value.source.originalCurrency)) throw new Error('公司成本汇率或原币无效') + return { ...value, recordVersion: version(value.recordVersion), paidAmount: money(value.paidAmount), + outstandingAmount: value.outstandingAmount == null ? null : money(value.outstandingAmount), source: { ...value.source, + sourceVersion: version(value.source.sourceVersion), supplierId: version(value.source.supplierId), originalAmount: money(value.source.originalAmount), + exchangeRate: rate, amount: money(value.source.amount), tripNo: value.source.tripNo ?? null, remark: value.source.remark ?? null } } +} +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +function page(value: PageWire, convert: (value: T) => R): PageResult { + return { records: value.records.map(convert), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export async function getCompanyCost(no: string): Promise { + return cost(unwrapApiResponse(await http.get, ApiEnvelope>(base(no))), no) +} +export async function commandCompanyCost(no: string, action: 'approve' | 'void', key: string, request: CompanyCostCommand): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/${action}`, request, { headers: { 'Idempotency-Key': key } })), no) +} +export async function payCompanyCost(no: string, key: string, request: CompanyCostPayment): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/payments`, request, { headers: { 'Idempotency-Key': key } })), no) +} +export async function listCompanyCostHistory(no: string, currentPage = 1): Promise> { + type HistoryWire = Omit & { recordVersion: Numeric; snapshot: CostWire } + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(no)}/history`, { params: { page: currentPage, size: 10 } })) + return page(value, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: cost(row.snapshot, no) })) +} +export async function listCompanyCostPayments(no: string, currentPage = 1): Promise> { + type PaidWire = Omit & { amount: Numeric; voucherFileIds: Numeric[] } + const value = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${base(no)}/payments`, { params: { page: currentPage, size: 10 } })) + return page(value, row => { + if (row.currency !== 'CNY') throw new Error('公司付款币种无效') + return { ...row, amount: money(row.amount), voucherFileIds: row.voucherFileIds.map(version) } + }) +} +export async function uploadCompanyCostVoucher(no: string, file: File): Promise { + type Wire = { fileId: Numeric; fileName: string; contentType: string; sizeBytes: Numeric; ownerScope: string; tenantId: Numeric; businessType: string } + const form = new FormData(); form.append('file', file) + const value = unwrapApiResponse(await http.post, ApiEnvelope>(`${base(no)}/vouchers`, form)) + if (value.ownerScope !== 'PLATFORM' || String(value.tenantId) !== '0' || value.businessType !== 'FINANCE_VEHICLE_VOUCHER' + || !['image/jpeg', 'image/png'].includes(value.contentType) || integer(value.sizeBytes) <= 0) throw new Error('公司付款凭证返回内容无效') + return { fileId: version(value.fileId), fileName: value.fileName, contentType: value.contentType, sizeBytes: integer(value.sizeBytes) } +} +async function rejectFileError(value: unknown, status: number): Promise { + if (value instanceof Blob && value.type.includes('json')) { + try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('付款凭证返回了无法识别的错误') } + } + if (value && typeof value === 'object' && 'code' in value) { + const error = value as ApiEnvelope + throw new ApiResponseError(error.message || '付款凭证读取失败', error.code, status, error.data, error.requestId, error.traceId) + } +} +export async function downloadCompanyCostVoucher(no: string, fileId: string): Promise { + try { + const value = await http.get(`${base(no)}/vouchers/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' }) + await rejectFileError(value, 200) + if (!(value instanceof Blob) || !value.size || !['image/png', 'image/jpeg'].includes(value.type)) throw new Error('付款凭证为空或格式无效') + return value + } catch (cause) { + if (axios.isAxiosError(cause)) await rejectFileError(cause.response?.data, cause.response?.status || 0) + throw cause + } +} diff --git a/src/modules/overseas-master-data/api/fleetQuotes.test.ts b/src/modules/overseas-master-data/api/fleetQuotes.test.ts new file mode 100644 index 00000000..8f185125 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuotes.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, type FleetQuoteDraft } from './fleetQuotes' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null } +const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' } +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('preserves long IDs and versions, explicit zero and absent prices', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ records: [quote], total: '1', size: '20', page: '1', pages: '1' })) + const result = await listFleetQuotes({ kind: 'SALE' }) + expect(result.records[0]).toMatchObject({ details, recordVersion: '90071992547409931' }) + expect(result.total).toBe(1) + vi.mocked(http.put).mockResolvedValue(ok(quote)); await updateFleetQuote(result.records[0]!, details) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes/FQ-ONE', { recordVersion: '90071992547409931', details }) +}) +it('sends stable creation and batch keys without converting business references', async () => { + vi.mocked(http.post).mockResolvedValue(ok(quote)); await createFleetQuote('stable-key', details) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/quotes', { idempotencyKey: 'stable-key', details }) + vi.mocked(http.post).mockResolvedValue(ok({ importedRows: 1 })); await importFleetQuotes('batch-key', [details]) + expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/quotes/import', { idempotencyKey: 'batch-key', rows: [details] }) +}) +it('does not download a business error JSON as a CSV file', async () => { + vi.mocked(http.get).mockResolvedValue(new TextEncoder().encode('{"code":"CONFLICT","message":"缩小范围","requestId":"r1"}').buffer) + await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '缩小范围', requestId: 'r1' }) +}) +it('preserves diagnostics from a non-200 export response', async () => { + vi.mocked(http.get).mockRejectedValue({ isAxiosError: true, response: { status: 403, data: new TextEncoder().encode('{"code":"403","message":"无导出权限","traceId":"t1"}').buffer } }) + await expect(fleetCsv('/export', {})).rejects.toMatchObject({ message: '无导出权限', status: 403, traceId: 't1' }) +}) +it('rejects lossy money and numeric versions instead of rounding them', () => { + expect(() => fleetMoney('99999999999999999.01')).toThrow('精确展示') + expect(() => fleetVersion(9007199254740992)).toThrow('版本无效') + expect(fleetVersion('90071992547409931')).toBe('90071992547409931') +}) diff --git a/src/modules/overseas-master-data/api/fleetQuotes.ts b/src/modules/overseas-master-data/api/fleetQuotes.ts new file mode 100644 index 00000000..d8e28581 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuotes.ts @@ -0,0 +1,67 @@ +import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' +import axios from 'axios' +import type { PageResult } from './overseasMasterData' + +export type FleetQuoteKind = 'PURCHASE' | 'SALE' +export interface FleetQuoteDraft { + kind: FleetQuoteKind; partyId: string; originCity: string; destinationCode: string; distanceMiles: number + currency: 'USD' | 'CNY'; selfPickupPrice: number | null; fullDeliveryPrice: number | null; looseDeliveryPrice: number | null + validFrom: string; validTo: string; remark: string | null +} +export interface FleetQuote { quoteNo: string; details: FleetQuoteDraft; partyName: string; status: 'ACTIVE' | 'INACTIVE' | 'DELETED'; recordVersion: string; updatedAt: string } +export interface FleetQuoteQuery { page?: number; size?: number; keyword?: string; kind?: FleetQuoteKind; status?: string; effectiveOn?: string } +type Numeric = string | number +type WireQuote = Omit & { recordVersion: Numeric; details: Omit & { distanceMiles: Numeric; selfPickupPrice: Numeric | null; fullDeliveryPrice: Numeric | null; looseDeliveryPrice: Numeric | null } } +export function fleetNumber(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || String(value).trim() === '' || !Number.isFinite(Number(value)) || Number(value) < 0) throw new Error('接口返回了无效数值') + return Number(value) +} +export function fleetMoney(value: Numeric): number { + const text = String(value) + if (!/^\d+(\.\d{1,2})?$/.test(text)) throw new Error('金额格式无效') + const [whole, fraction = ''] = text.split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('金额超出精确展示范围') + return Number(value) +} +export function fleetVersion(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('接口版本无效') + return String(value) +} +export function fleetPage(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult { + const integer = (n: Numeric) => { const result = fleetNumber(n); if (!Number.isSafeInteger(result)) throw new Error('接口分页超出有效范围'); return result } + return { ...value, page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +const base = '/platform/v1/last-leg/fleet/quotes' +const path = (no: string) => `${base}/${encodeURIComponent(no)}` +const quote = (v: WireQuote): FleetQuote => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: { ...v.details, partyId: String(v.details.partyId), distanceMiles: fleetNumber(v.details.distanceMiles), + selfPickupPrice: v.details.selfPickupPrice == null ? null : fleetNumber(v.details.selfPickupPrice), fullDeliveryPrice: v.details.fullDeliveryPrice == null ? null : fleetNumber(v.details.fullDeliveryPrice), looseDeliveryPrice: v.details.looseDeliveryPrice == null ? null : fleetNumber(v.details.looseDeliveryPrice) } }) +export async function listFleetQuotes(query: FleetQuoteQuery) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(base, { params: query })) + return fleetPage({ ...result, records: result.records.map(quote) }) +} +export async function createFleetQuote(idempotencyKey: string, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.post, ApiEnvelope>(base, { idempotencyKey, details }))) } +export async function updateFleetQuote(value: FleetQuote, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.put, ApiEnvelope>(path(value.quoteNo), { recordVersion: value.recordVersion, details }))) } +export async function setFleetQuoteStatus(value: FleetQuote, status: string) { return quote(unwrapApiResponse(await http.patch, ApiEnvelope>(`${path(value.quoteNo)}/status`, { recordVersion: value.recordVersion, status }))) } +export async function deleteFleetQuote(value: FleetQuote) { return quote(unwrapApiResponse(await http.delete, ApiEnvelope>(path(value.quoteNo), { params: { recordVersion: value.recordVersion } }))) } +export async function importFleetQuotes(idempotencyKey: string, rows: FleetQuoteDraft[]) { return unwrapApiResponse(await http.post, ApiEnvelope<{ importedRows: number }>>(`${base}/import`, { idempotencyKey, rows })) } +export async function fleetQuoteCustomers(keyword: string) { return unwrapApiResponse(await http.get, ApiEnvelope<{ id: string; name: string; code: string }[]>>(`${base}/customer-options`, { params: { keyword } })) } +export async function fleetQuoteSuppliers(keyword: string) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>('/platform/v1/suppliers', { params: { page: 1, size: 30, keyword, status: 'ACTIVE', category: 'TRUCKING', truckingScope: 'OVERSEAS' } })) + return result.records.map(row => ({ ...row, id: String(row.id) })) +} +export async function fleetCsv(path: string, params: object): Promise { + function check(bytes: ArrayBuffer, status: number) { + const text = new TextDecoder().decode(bytes) + if (!/^\s*[{[]/.test(text)) return + let error: ApiEnvelope + try { error = JSON.parse(text) as ApiEnvelope } catch { throw new ApiResponseError('导出接口返回了无效响应', 'INVALID_EXPORT_RESPONSE', status) } + throw new ApiResponseError(error.message || '导出失败', error.code || 'EXPORT_FAILED', status, error.data, error.requestId, error.traceId) + } + try { + const bytes = await http.get(path, { params, responseType: 'arraybuffer' }); check(bytes, 200) + return new Blob([bytes], { type: 'text/csv;charset=utf-8' }) + } catch (e) { if (axios.isAxiosError(e) && Object.prototype.toString.call(e.response?.data) === '[object ArrayBuffer]') check(e.response!.data as ArrayBuffer, e.response!.status); throw e } +} +export const exportFleetQuotes = (query: FleetQuoteQuery) => fleetCsv(`${base}/export`, query) +export function saveFleetCsv(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000) } diff --git a/src/modules/overseas-master-data/api/tripCosts.test.ts b/src/modules/overseas-master-data/api/tripCosts.test.ts new file mode 100644 index 00000000..ab1d2976 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripCosts.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory } from './tripCosts' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +const details = { category: 'FUEL' as const, currency: 'USD' as const, amount: 12.5, incurredAt: '2026-01-01T00:00:00Z', remark: null } +const wire = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { ...details, amount: '12.50' }, recordVersion: '90071992547409931', updatedAt: details.incurredAt } +const summary = { totals: [{ currency: 'USD', directCost: '12.50', allocations: [] }], allocationBlockReason: '尚无实际发车装载记录' } +const ok = (data: unknown) => ({ code: '200', data }) +beforeEach(() => vi.clearAllMocks()) +it('normalizes numbers without turning missing allocation into zero or rounding long versions', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [wire], page: '1', size: '10', total: '1', pages: '1' }, summary })) + const result = await listTripCosts('TR-ONE') + expect(result.page.records[0]).toMatchObject({ details, recordVersion: wire.recordVersion }) + expect(result.summary).toMatchObject({ departureVersion: null, manifestHash: null, totals: [{ directCost: 12.5, ownCost: null, externalCost: null }] }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { params: { page: 1, size: 10 } }) +}) +it('preserves explicit zero and keeps currencies in separate server totals', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [ + { currency: 'CNY', directCost: '0.00', ownCost: '0.00', externalCost: '0.00', allocations: [] }, + { currency: 'USD', directCost: '2.00', allocations: [] }] } })) + const result = await listTripCosts('TR-ONE') + expect(result.summary.totals.map(r => [r.currency, r.directCost])).toEqual([['CNY', 0], ['USD', 2]]) + expect(result.summary.totals[0]?.ownCost).toBe(0) +}) +it('preserves creation key, exact correction version and source-specific history path', async () => { + vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire)) + vi.mocked(http.get).mockResolvedValue(ok({ records: [{ recordVersion: wire.recordVersion, action: 'UPDATE', reason: '核对', occurredAt: details.incurredAt, snapshot: { cost: wire, summary } }], page: 1, size: 1, total: 1, pages: 1 })) + await createTripCost('TR-ONE', 'fixed-key', details) + await updateTripCost('TR-ONE', wire, details) + const history = await listTripCostHistory('TR-ONE', 'TC-ONE', 1, 1) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs', { idempotencyKey: 'fixed-key', details }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE', { recordVersion: wire.recordVersion, details, reason: '更正本趟直接成本' }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/trips/TR-ONE/costs/TC-ONE/history', { params: { page: 1, size: 1 } }) + expect(history.records[0]?.snapshot.cost.recordVersion).toBe(wire.recordVersion) +}) +it('rejects invalid amounts instead of reporting successful zero costs', async () => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: null, allocations: [] }] } })) + await expect(listTripCosts('TR-ONE')).rejects.toThrow('无效金额') +}) +it.each([' ', true, '0.001', 'NaN', '90071992547409.92'])('rejects malformed or imprecise money %s', async amount => { + vi.mocked(http.get).mockResolvedValue(ok({ page: { records: [], page: 1, size: 10, total: 0, pages: 0 }, summary: { totals: [{ currency: 'USD', directCost: amount, allocations: [] }] } })) + await expect(listTripCosts('TR-ONE')).rejects.toThrow(/无效金额|精确展示范围/) +}) diff --git a/src/modules/overseas-master-data/api/tripCosts.ts b/src/modules/overseas-master-data/api/tripCosts.ts new file mode 100644 index 00000000..d388e3ac --- /dev/null +++ b/src/modules/overseas-master-data/api/tripCosts.ts @@ -0,0 +1,64 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export type CostCategory = 'FUEL' | 'DRIVER' | 'TOLL' | 'PARKING' | 'OTHER' +export type CostCurrency = 'CNY' | 'USD' +export interface TripCostDraft { category: CostCategory; currency: CostCurrency; amount: number; incurredAt: string; remark: string | null } +export interface TripCost { costNo: string; tripNo: string; details: TripCostDraft; recordVersion: string; updatedAt: string } +export interface CostAllocation { businessNo: string; sourceType: 'OWN' | 'EXTERNAL'; dispatchNo: string; palletSpaces: number; amount: number } +export interface CostTotal { currency: CostCurrency; directCost: number; allocations: CostAllocation[]; ownCost: number | null; externalCost: number | null } +export interface CostSummary { departureVersion: string | null; manifestHash: string | null; allocationMethod: string | null; allocationBlockReason: string | null; totals: CostTotal[] } +export interface TripCosts { page: PageResult; summary: CostSummary } +export interface TripCostHistory { recordVersion: string; action: string; reason: string; occurredAt: string; snapshot: { cost: TripCost; summary: CostSummary } } +type Numeric = string | number +type CostWire = Omit & { recordVersion: Numeric; details: Omit & { amount: Numeric; remark?: string | null } } +type SummaryWire = Omit & { departureVersion?: Numeric | null; totals: Array & { + directCost: Numeric; ownCost?: Numeric | null; externalCost?: Numeric | null; allocations: Array & { amount: Numeric; palletSpaces: Numeric }> +}> } +type PageWire = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/costs` +function decimal(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('车次成本接口返回了无效金额') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('车次成本金额超出精确展示范围') + return Number(value) +} +function integer(value: Numeric): number { + const number = Number(value) + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+$/.test(String(value))) throw new Error('车次成本接口返回了无效分页或托位') + if (!Number.isSafeInteger(number)) throw new Error('车次成本接口返回了无效分页或托位') + return number +} +function version(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('车次成本版本无效') + return String(value) +} +function cost(value: CostWire): TripCost { return { ...value, recordVersion: version(value.recordVersion), details: { ...value.details, amount: decimal(value.details.amount), remark: value.details.remark ?? null } } } +function summary(value: SummaryWire): CostSummary { + return { departureVersion: value.departureVersion == null ? null : version(value.departureVersion), manifestHash: value.manifestHash ?? null, + allocationMethod: value.allocationMethod ?? null, allocationBlockReason: value.allocationBlockReason ?? null, + totals: value.totals.map(row => ({ ...row, directCost: decimal(row.directCost), ownCost: row.ownCost == null ? null : decimal(row.ownCost), + externalCost: row.externalCost == null ? null : decimal(row.externalCost), + allocations: row.allocations.map(part => ({ ...part, amount: decimal(part.amount), palletSpaces: integer(part.palletSpaces) })) })) } +} +function page(value: PageWire, transform: (row: T) => R): PageResult { + return { records: value.records.map(transform), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } +} +export async function listTripCosts(tripNo: string, currentPage = 1, size = 10): Promise { + type Wire = { page: PageWire; summary: SummaryWire } + const result = unwrapApiResponse(await http.get, ApiEnvelope>(path(tripNo), { params: { page: currentPage, size } })) + return { page: page(result.page, cost), summary: summary(result.summary) } +} +export async function createTripCost(tripNo: string, idempotencyKey: string, details: TripCostDraft): Promise { + return cost(unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), { idempotencyKey, details }))) +} +export async function updateTripCost(tripNo: string, record: Pick, details: TripCostDraft, reason?: string): Promise { + return cost(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(record.costNo)}`, { recordVersion: record.recordVersion, details, reason: reason?.trim() || '更正本趟直接成本' }))) +} +export async function listTripCostHistory(tripNo: string, costNo: string, currentPage = 1, size = 10): Promise> { + type HistoryWire = Omit & { recordVersion: Numeric; snapshot: { cost: CostWire; summary: SummaryWire } } + type Wire = PageWire + const result = unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(costNo)}/history`, { params: { page: currentPage, size } })) + return page(result, row => ({ ...row, recordVersion: version(row.recordVersion), snapshot: { cost: cost(row.snapshot.cost), summary: summary(row.snapshot.summary) } })) +} diff --git a/src/modules/overseas-master-data/api/tripHandovers.test.ts b/src/modules/overseas-master-data/api/tripHandovers.test.ts new file mode 100644 index 00000000..7344cabb --- /dev/null +++ b/src/modules/overseas-master-data/api/tripHandovers.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripHandovers, previewTripHandover, confirmTripHandover, releaseHandoverResource, handoverVersion } from './tripHandovers' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn() } })) +beforeEach(() => vi.clearAllMocks()) +it('encodes trip scope and normalizes pagination without per-row requests', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [], page: '1', size: '10', total: '0', pages: '0' } }) + expect(await listTripHandovers('TR/ONE')).toEqual({ records: [], page: 1, size: 10, total: 0, pages: 0 }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR%2FONE/handovers', { params: { page: 1, size: 10 } }) +}) +it('keeps preview separate from the exact immutable confirmation payload', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) + const selection = { recordVersion: 7, vehicleId: '90071992547409931', driverId: '90071992547409932', handedOverAt: '2026-10-03T01:00:00Z', expectedDriverEndAt: '2026-10-04T01:00:00Z' } + await previewTripHandover('TR-ONE', selection) + const command = { selection, cargoHash: 'a'.repeat(64), location: '交接点', idempotencyKey: 'original-key' } + await confirmTripHandover('TR-ONE', command) + expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/preview', selection) + expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/trips/TR-ONE/handovers', command) +}) +it('ends only the selected old resource within its handover and trip', async () => { + vi.mocked(http.post).mockResolvedValue({ code: '200', data: {} }) + const command = { recordVersion: 0, resourceKind: 'DRIVER' as const, actualEndAt: '2026-10-03T02:00:00Z', location: '司机任务结束点', idempotencyKey: 'end-original' } + await releaseHandoverResource('TR-ONE', 'HO/ONE', command) + expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-ONE/handovers/HO%2FONE/resources/release', command) +}) +it('refuses blank fractional or unsafe versions', () => { + for (const value of ['', ' ', '1.5', '90071992547409931']) expect(() => handoverVersion(value)).toThrow() +}) diff --git a/src/modules/overseas-master-data/api/tripHandovers.ts b/src/modules/overseas-master-data/api/tripHandovers.ts new file mode 100644 index 00000000..2a077403 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripHandovers.ts @@ -0,0 +1,44 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' + +export interface HandoverSelection { recordVersion: number; vehicleId: string; driverId: string; expectedDriverEndAt: string; handedOverAt: string } +export interface HandoverCommand { selection: HandoverSelection; cargoHash: string; location: string; reason?: string; idempotencyKey: string } +export interface HandoverRelease { recordVersion: number; resourceKind: 'VEHICLE' | 'DRIVER'; actualEndAt: string; location: string; vehicleStatus?: string; idempotencyKey: string } +export interface HandoverResource { vehicleId: string; vehiclePlate: string | null; driverId: string; driverName: string | null; driverPhone: string | null } +type Numeric = number | string +export interface HandoverCargo { customerId: string; dispatchNo: string; itemId: string; palletId: string; destinationKey: string; packages: Numeric } +export interface HandoverDelivery { customerId: string; dispatchNo: string; deliveryNo: string; destinationKey: string; packages: Numeric; terminalAt: string | null } +export interface HandoverLoad { palletSpaces: Numeric; weightKg: Numeric; volumeCbm: Numeric } +export interface HandoverInspection { remainingCargo: HandoverCargo[]; completedDeliveries: HandoverDelivery[]; remainingPalletIds: string[]; occupied: HandoverLoad } +export interface HandoverPreview { tripNo: string; recordVersion: Numeric; cargoHash: string; previous: HandoverResource; replacement: HandoverResource; capacity: HandoverLoad; cargo: HandoverInspection; driverQualification: { canDispatch: boolean; expiringSoon: boolean; reason: string; licenseExpiresOn: string | null } } +export interface HandoverEnded { actualEndAt: string; location: string; vehicleStatus: string | null } +export interface HandoverRecord { + handoverNo: string; tripNo: string; recordVersion: Numeric + snapshot: { previous: HandoverResource; replacement: HandoverResource; handedOverAt: string; location: string; reason: string; expectedDriverEndAt: string; capacity: HandoverLoad; cargo: HandoverInspection; recordedAt: string } + vehicleRelease: HandoverEnded | null; driverRelease: HandoverEnded | null +} +const path = (tripNo: string) => `/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}/handovers` +export function handoverNumber(value: Numeric): number { + if ((typeof value !== 'number' && typeof value !== 'string') || String(value).trim() === '') throw new Error('交接接口缺少数值') + const result = Number(value) + if (!Number.isFinite(result) || result < 0) throw new Error('交接接口数值无效') + return result +} +export function handoverVersion(value: Numeric): number { + const result = handoverNumber(value) + if (!Number.isSafeInteger(result)) throw new Error('交接接口版本或数量无效') + return result +} +export async function listTripHandovers(tripNo: string, page = 1, size = 10): Promise> { + const result = unwrapApiResponse(await http.get, ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>>(path(tripNo), { params: { page, size } })) + return { records: result.records, page: handoverVersion(result.page), size: handoverVersion(result.size), total: handoverVersion(result.total), pages: handoverVersion(result.pages) } +} +export async function previewTripHandover(tripNo: string, selection: HandoverSelection): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/preview`, selection)) +} +export async function confirmTripHandover(tripNo: string, command: HandoverCommand): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(path(tripNo), command)) +} +export async function releaseHandoverResource(tripNo: string, handoverNo: string, command: HandoverRelease): Promise { + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(tripNo)}/${encodeURIComponent(handoverNo)}/resources/release`, command)) +} diff --git a/src/modules/overseas-master-data/api/tripOperations.test.ts b/src/modules/overseas-master-data/api/tripOperations.test.ts new file mode 100644 index 00000000..0374f520 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperations.test.ts @@ -0,0 +1,32 @@ +import { expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory } from './tripOperations' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), put: vi.fn() } })) +it('uses one aggregate response, keeps currency groups and never invents finance/cost amounts', async () => { + const counts = { physicalPallets: '1', palletSpaces: '2', ownPackages: '11', externalPackages: '4', externalAgreedAmounts: [{ currency: 'USD', amount: '0' }, { currency: 'CNY', amount: '10.25' }] } + const totals = [{ currency: 'USD', directCost: '10.00', ownCost: null, externalCost: null }] + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'TR1', destinations: [{ key: 'FBA:ONT8', label: 'ONT8' }, { key: 'FBA:LAX9', label: 'LAX9' }], externalPalletRates: [], measured: { actualDistanceMiles: '0.0', actualFuelLitres: null, remark: null }, financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { allocationBlockReason: '路线不同', totals } }], total: '20', page: '1', size: '1', pages: '20' }, summary: { ...counts, trips: '20', inTransit: '12', completed: '8', costs: { visible: true, unrecordedTrips: '1', unallocatedTrips: '1', totals } }, charts: { physicalPallets: [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-01', value: '1' }] }], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } }) + const query = { from: '2026-10-01T00:00:00Z', to: '2026-10-03T00:00:00Z', sourceType: 'EXTERNAL' as const } + const result = await listTripOperations(query) + expect(result.summary.trips).toBe(20); expect(result.page.records).toHaveLength(1) + expect(result.summary.externalAgreedAmounts).toEqual([{ currency: 'USD', amount: 0 }, { currency: 'CNY', amount: 10.25 }]) + expect(result.page.records[0]).toMatchObject({ financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { totals: [{ directCost: 10, ownCost: null, externalCost: null }] } }) + expect(result.summary.costs).toMatchObject({ unrecordedTrips: 1, unallocatedTrips: 1 }) + expect(result.page.records[0]?.measured).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null }) + expect(result.charts.physicalPallets[0]?.points[0]?.value).toBe(1) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations', { params: query }) +}) +it('keeps measured versions and retries exact requests, without converting absent fuel into zero', async () => { + vi.clearAllMocks() + const wire = { tripNo: 'TR1', recordVersion: '90071992547409931', details: { actualDistanceMiles: '0', actualFuelLitres: null, remark: null }, updatedAt: null } + vi.mocked(http.get).mockResolvedValue({ code: '200', data: wire }) + const record = await getTripOperationsRecord('TR1') + expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null }) + const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: record.details, reason: null } + vi.mocked(http.put).mockResolvedValue({ code: '200', data: wire }); await saveTripOperationsRecord('TR1', request) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record', request) + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...wire, occurredAt: '2026-10-03T00:00:00Z', reason: '更正' }], total: '1', page: '1', size: '20', pages: '1' } }) + const history = await listTripOperationsRecordHistory('TR1') + expect(history.records[0]?.recordVersion).toBe('90071992547409931') + expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record/history', { params: { page: 1, size: 20 } }) +}) diff --git a/src/modules/overseas-master-data/api/tripOperations.ts b/src/modules/overseas-master-data/api/tripOperations.ts new file mode 100644 index 00000000..25da0f2e --- /dev/null +++ b/src/modules/overseas-master-data/api/tripOperations.ts @@ -0,0 +1,50 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' +import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes' +import type { CostSummary, CostTotal } from './tripCosts' +export interface TripOperationsQuery { page?: number; size?: number; from: string; to: string; keyword?: string; warehouseId?: string; sourceType?: 'OWN' | 'EXTERNAL'; status?: string } +export interface TripOperationsMoney { currency: string; amount: number } +type OperationsCostTotal = Pick +export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] } +export type OperationsRowCosts = Pick & { totals: OperationsCostTotal[] } +export interface TripOperationsMeasured { actualDistanceMiles: number | null; actualFuelLitres: number | null; remark: string | null } +export interface TripOperationsSeries { label: string; unit: string; points: { label: string; value: number }[] } +export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] } +export interface TripOperationsRow { + tripNo: string; status: string; vehiclePlate: string | null; driverName: string | null; warehouseName: string | null; departedAt: string; completedAt: string | null; destinations: { key: string; label: string }[] + physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; financeStatus: string; costStatus: string + externalPalletRates: { currency: string; pricedPalletSpaces: number; agreedAmount: number }[]; measured: TripOperationsMeasured | null; costs: OperationsRowCosts | null +} +export interface TripOperationsSummary { trips: number; inTransit: number; completed: number; physicalPallets: number; palletSpaces: number; ownPackages: number; externalPackages: number; externalAgreedAmounts: TripOperationsMoney[]; costs: OperationsCosts } +export interface TripOperationsResult { page: PageResult; summary: TripOperationsSummary; charts: TripOperationsCharts } +type WireNumbers = { [K in keyof T]: T[K] extends number ? number | string : T[K] } +type WireMoney = { currency: string; amount: string | number } +type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null } +type WireCostTotal = { currency: CostTotal['currency']; directCost: string | number; ownCost: string | number | null; externalCost: string | number | null } +type WireRow = Omit, 'externalAgreedAmounts' | 'measured' | 'externalPalletRates' | 'costs'> & { externalAgreedAmounts: WireMoney[]; measured: WireMeasured | null; externalPalletRates: { currency: string; pricedPalletSpaces: number | string; agreedAmount: number | string }[]; costs: { allocationBlockReason: string | null; totals: WireCostTotal[] } | null } +type WireSummary = Omit, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } } +const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) })) +const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) })) +const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) }) +const measured = (r: WireMeasured): TripOperationsMeasured => ({ ...r, actualDistanceMiles: r.actualDistanceMiles == null ? null : fleetNumber(r.actualDistanceMiles), actualFuelLitres: r.actualFuelLitres == null ? null : fleetNumber(r.actualFuelLitres) }) +type WireSeries = Omit & { points: { label: string; value: string | number }[] } +type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] } +const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) })) +export async function listTripOperations(query: TripOperationsQuery): Promise { + const result = unwrapApiResponse(await http.get; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult; summary: WireSummary; charts: WireCharts }>>('/platform/v1/last-leg/fleet/operations', { params: query })) + return { page: fleetPage({ ...result.page, records: result.page.records.map(r => ({ ...r, ...counts(r), measured: r.measured == null ? null : measured(r.measured), externalPalletRates: r.externalPalletRates.map(p => ({ ...p, pricedPalletSpaces: fleetNumber(p.pricedPalletSpaces), agreedAmount: fleetMoney(p.agreedAmount) })), costs: r.costs == null ? null : { allocationBlockReason: r.costs.allocationBlockReason, totals: costTotals(r.costs.totals) } })) }), summary: { ...result.summary, ...counts(result.summary), trips: fleetNumber(result.summary.trips), inTransit: fleetNumber(result.summary.inTransit), completed: fleetNumber(result.summary.completed), costs: { ...result.summary.costs, unrecordedTrips: result.summary.costs.unrecordedTrips == null ? null : fleetNumber(result.summary.costs.unrecordedTrips), unallocatedTrips: result.summary.costs.unallocatedTrips == null ? null : fleetNumber(result.summary.costs.unallocatedTrips), totals: costTotals(result.summary.costs.totals) } }, charts: { physicalPallets: series(result.charts.physicalPallets), destinationTrips: series(result.charts.destinationTrips), externalAgreedAmounts: series(result.charts.externalAgreedAmounts), externalPalletRates: series(result.charts.externalPalletRates) } } +} +export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query) +export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null } +export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasured; reason: string | null } +export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured } +type WireRecord = Omit & { recordVersion: string | number; details: WireMeasured } +type WireHistory = Omit & { recordVersion: string | number; details: WireMeasured } +const recordPath = (tripNo: string) => `/platform/v1/last-leg/fleet/operations/${encodeURIComponent(tripNo)}/record` +const record = (v: WireRecord): TripOperationsRecord => ({ ...v, recordVersion: fleetVersion(v.recordVersion), details: measured(v.details) }) +export async function getTripOperationsRecord(tripNo: string) { return record(unwrapApiResponse(await http.get, ApiEnvelope>(recordPath(tripNo)))) } +export async function saveTripOperationsRecord(tripNo: string, request: TripOperationsRecordSave) { return record(unwrapApiResponse(await http.put, ApiEnvelope>(recordPath(tripNo), request))) } +export async function listTripOperationsRecordHistory(tripNo: string, page = 1, size = 20) { + const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${recordPath(tripNo)}/history`, { params: { page, size } })) + return fleetPage({ ...result, records: result.records.map(r => ({ ...r, recordVersion: fleetVersion(r.recordVersion), details: measured(r.details) })) }) +} diff --git a/src/modules/overseas-master-data/api/vehicleCare.test.ts b/src/modules/overseas-master-data/api/vehicleCare.test.ts index f09b6ea5..3908cae4 100644 --- a/src/modules/overseas-master-data/api/vehicleCare.test.ts +++ b/src/modules/overseas-master-data/api/vehicleCare.test.ts @@ -1,6 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest' import { http, ApiResponseError } from '@/shared/api/http' -import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto } from './vehicleCare' +import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto, listVehicleCareFinance, submitVehicleCareFinance } from './vehicleCare' import type { VehicleCareDraft } from '../vehicleCareTypes' vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records` @@ -8,6 +8,25 @@ const details: VehicleCareDraft = { recordType: 'FUEL', occurredAt: '2026-01-01T 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('queries financial status once for the visible records and preserves unknown amounts', async () => { + vi.mocked(http.post).mockResolvedValue(ok([{ recordNo: 'VR-ONE', status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', submittedRecordVersion: '2' }])) + const result = await listVehicleCareFinance(id, ['VR-ONE']) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/finance-status`, { recordNos: ['VR-ONE'] }) + expect(result[0]).toMatchObject({ status: 'PENDING_FINANCE', sourceVersion: '90071992547409931', finance: null }) + await listVehicleCareFinance(id, []); expect(http.post).toHaveBeenCalledTimes(1) +}) +it('rejects incomplete or cross-record financial status instead of pretending it is unlinked', async () => { + vi.mocked(http.post).mockResolvedValueOnce(ok([])).mockResolvedValueOnce(ok([{ recordNo: 'VR-OTHER', status: 'PAID' }])) + await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('全部') + await expect(listVehicleCareFinance(id, ['VR-ONE'])).rejects.toThrow('归属') +}) +it('submits the saved version and stable key without sending amounts or a customer', async () => { + vi.mocked(http.post).mockResolvedValue(ok({ recordNo: 'VR-ONE', sourceVersion: '1', submittedRecordVersion: '2', replayed: false, status: 'PENDING_FINANCE' })) + const request = { recordVersion: 2, supplierId: '9223372036854775703', businessDate: '2026-01-01', idempotencyKey: 'same-finance-key' } + const result = await submitVehicleCareFinance(id, 'VR-ONE', request) + expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/VR-ONE/finance`, request) + expect(result.status).toBe('PENDING_FINANCE') +}) it('normalizes omitted nullable incident fields without injecting maintenance defaults', async () => { const repair = { ...wire, details: { recordType: 'REPAIR', occurredAt: details.occurredAt, incident: { description: '轮胎维修', item: '换胎', place: '现场', processingStatus: 'PENDING', reportedCost: '125.50', currency: 'USD' } } } diff --git a/src/modules/overseas-master-data/api/vehicleCare.ts b/src/modules/overseas-master-data/api/vehicleCare.ts index dcfe5c54..13ae2bd5 100644 --- a/src/modules/overseas-master-data/api/vehicleCare.ts +++ b/src/modules/overseas-master-data/api/vehicleCare.ts @@ -13,6 +13,66 @@ type Wire = Omit & { files: Array & { sizeBytes: Numeric }> } const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}` +export const vehicleCareFinanceSubmitPermission = 'platform:overseas-master-data:vehicle:record-finance-submit' +export type VehicleCareFinanceState = 'UNLINKED' | 'PENDING_FINANCE' | 'FINANCE_ATTENTION' | 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' +export interface VehicleCareFinanceStatus { + recordNo: string; status: VehicleCareFinanceState; sourceVersion: string | null; submittedRecordVersion: string | null + finance: { costNo: string; status: string; currency: 'CNY'; supplierName: string; paidAmount: number; outstandingAmount: number | null; + source: { originalCurrency: 'CNY' | 'USD'; originalAmount: number; amount: number; businessDate: string; exchangeRate: number; exchangeRateMonth: string } } | null +} +export interface VehicleCareFinanceSubmit { recordVersion: number; supplierId: string; businessDate: string; idempotencyKey: string } +export interface VehicleCareFinanceSubmitted { recordNo: string; sourceVersion: string; submittedRecordVersion: string; replayed: boolean; status: 'PENDING_FINANCE' } +export const vehicleCareFinanceLabels: Record = { + UNLINKED: '未提交财务', PENDING_FINANCE: '待财务接收', FINANCE_ATTENTION: '财务接收需处理', PENDING: '待财务入账', APPROVED: '已入账 · 未付款', + PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废', +} +function financeVersion(value: Numeric): string { + if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('财务版本无效') + return String(value) +} +function financeAmount(value: Numeric): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('财务金额无效') + const [whole, fraction = ''] = String(value).split('.') + const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) + if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('财务金额超过精确展示范围') + return Number(value) +} +export async function listVehicleCareFinance(vehicleId: string, recordNos: string[]): Promise { + if (!recordNos.length) return [] + type Cost = NonNullable + type StatusWire = Omit & { + sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null + finance?: (Omit & { paidAmount: Numeric; outstandingAmount?: Numeric | null; + source: Omit & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null + } + const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId)}/finance-status`, { recordNos })) + const seen = new Set() + const result = response.map(value => { + if (!recordNos.includes(value.recordNo) || seen.has(value.recordNo) || !Object.hasOwn(vehicleCareFinanceLabels, value.status)) throw new Error('财务状态归属或内容无效') + seen.add(value.recordNo) + const cost = value.finance + if (!['UNLINKED', 'PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(value.status) && (!cost || cost.status !== value.status)) throw new Error('财务入账或付款状态缺少真实依据') + if (cost && cost.currency !== 'CNY') throw new Error('财务结算币种无效') + return { ...value, sourceVersion: value.sourceVersion == null ? null : financeVersion(value.sourceVersion), + submittedRecordVersion: value.submittedRecordVersion == null ? null : financeVersion(value.submittedRecordVersion), + finance: cost ? { ...cost, paidAmount: financeAmount(cost.paidAmount), outstandingAmount: cost.outstandingAmount == null ? null : financeAmount(cost.outstandingAmount), + source: { ...cost.source, originalAmount: financeAmount(cost.source.originalAmount), amount: financeAmount(cost.source.amount), exchangeRate: decimal(cost.source.exchangeRate) } } : null } + }) + if (seen.size !== new Set(recordNos).size) throw new Error('财务状态未返回全部所选记录') + return result +} +export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise { + type Wire = Omit & { sourceVersion: Numeric; submittedRecordVersion: Numeric } + const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/finance`, request)) + if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试') + return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) } +} +export async function listVehicleCareSuppliers(keyword = ''): Promise> { + type Supplier = { id: Numeric; name: string; status: string } + const response = unwrapApiResponse(await http.get, ApiEnvelope<{ records: Supplier[] }>>('/platform/v1/suppliers', + { params: { page: 1, size: 50, status: 'ACTIVE', keyword } })) + return response.records.filter(row => row.status === 'ACTIVE').map(row => ({ id: financeVersion(row.id), name: row.name })) +} function integer(value: Numeric): number { const result = Number(value) if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本') diff --git a/src/modules/overseas-master-data/components/FleetQuoteEditor.vue b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue new file mode 100644 index 00000000..9d517def --- /dev/null +++ b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/modules/overseas-master-data/components/FleetQuoteImport.vue b/src/modules/overseas-master-data/components/FleetQuoteImport.vue new file mode 100644 index 00000000..92e16de4 --- /dev/null +++ b/src/modules/overseas-master-data/components/FleetQuoteImport.vue @@ -0,0 +1,28 @@ + + diff --git a/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue b/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue new file mode 100644 index 00000000..7d59ea9a --- /dev/null +++ b/src/modules/overseas-master-data/components/FleetQuotePartySelect.vue @@ -0,0 +1,21 @@ + + diff --git a/src/modules/overseas-master-data/components/TripCostPanel.test.ts b/src/modules/overseas-master-data/components/TripCostPanel.test.ts new file mode 100644 index 00000000..bd6193c3 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripCostPanel.test.ts @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import TripCostPanel from './TripCostPanel.vue' +import * as api from '../api/tripCosts' +const access = vi.hoisted(() => ({ read: true, write: true })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) +vi.mock('../api/tripCosts', () => ({ listTripCosts: vi.fn(), createTripCost: vi.fn(), updateTripCost: vi.fn(), listTripCostHistory: vi.fn() })) +const cost: api.TripCost = { costNo: 'TC-ONE', tripNo: 'TR-ONE', details: { category: 'FUEL', currency: 'USD', amount: 10, incurredAt: '2026-01-01T00:00:00Z', remark: null }, recordVersion: '1', updatedAt: '2026-01-01T00:00:00Z' } +const result: api.TripCosts = { page: { records: [cost], page: 1, size: 10, total: 1, pages: 1 }, summary: { departureVersion: null, manifestHash: null, allocationMethod: null, allocationBlockReason: '尚无实际发车装载记录', totals: [] } } +const wrappers: VueWrapper[] = [] +const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } +function mount() { + const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: { + ElButton: { template: '' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, + ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input, + AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '
' }, + DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '
' }, + RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '
' }, + LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(wrapper); return wrapper +} +function button(w: VueWrapper, label: string) { return w.findAll('button').find(b => b.text() === label)! } +beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true; vi.mocked(api.listTripCosts).mockResolvedValue(structuredClone(result)); vi.mocked(api.createTripCost).mockResolvedValue(cost); vi.mocked(api.updateTripCost).mockResolvedValue(cost) + vi.mocked(api.listTripCostHistory).mockResolvedValue({ records: [{ recordVersion: '9', action: 'UPDATE', reason: '更正', occurredAt: cost.updatedAt, snapshot: { cost: { ...cost, recordVersion: '9' }, summary: result.summary } }], page: 1, size: 1, total: 1, pages: 1 }) }) +afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) +it('loads one scoped page and summary without per-row reads or invented zero totals', async () => { + const w = mount(); await flushPromises() + expect(api.listTripCosts).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10) + expect(api.listTripCostHistory).not.toHaveBeenCalled(); expect(w.text()).toContain('尚未登记直接成本') +}) +it('does not request cost data without list permission, and hides writes for read-only users', async () => { + access.read = false; const denied = mount(); await flushPromises(); expect(api.listTripCosts).not.toHaveBeenCalled(); expect(denied.text()).toContain('暂无车次成本查看权限') + access.read = true; access.write = false; const readOnly = mount(); await flushPromises() + expect(readOnly.text()).not.toContain('登记成本'); expect(readOnly.text()).not.toContain('更正'); expect(readOnly.text()).toContain('历史') +}) +it('ignores late responses after switching trips and resets later pagination once', async () => { + const w = mount(); await flushPromises(); w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 3); await flushPromises() + let finish!: (value: api.TripCosts) => void + vi.mocked(api.listTripCosts).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises() + vi.mocked(api.listTripCosts).mockResolvedValueOnce({ ...result, page: { ...result.page, records: [{ ...cost, costNo: 'TC-THREE', tripNo: 'TR-THREE' }] } }) + await w.setProps({ tripNo: 'TR-THREE' }); await flushPromises(); finish(result); await flushPromises() + expect(api.listTripCosts).toHaveBeenNthCalledWith(3, 'TR-TWO', 1, 10) + expect(api.listTripCosts).toHaveBeenNthCalledWith(4, 'TR-THREE', 1, 10) + expect(w.findComponent({ name: 'DataTable' }).props('data')[0].costNo).toBe('TC-THREE') +}) +it('retains the exact key and contents when a creation response is uncertain', async () => { + vi.mocked(api.createTripCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce(cost) + const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5); await button(w, '保存').trigger('click'); await flushPromises() + const first = vi.mocked(api.createTripCost).mock.calls[0]! + expect(w.text()).toContain('正在核对上次登记结果') + await button(w, '保存').trigger('click'); await flushPromises() + expect(vi.mocked(api.createTripCost).mock.calls[1]).toEqual(first) +}) +it('keeps the successful save authoritative when pagination changes while it is pending', async () => { + let finish!: (value: api.TripCost) => void + vi.mocked(api.createTripCost).mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5) + await button(w, '保存').trigger('click'); await flushPromises() + w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() + finish(cost); await flushPromises() + expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false) + expect(w.text()).not.toContain('正在核对上次登记结果') + expect(api.listTripCosts).toHaveBeenLastCalledWith('TR-ONE', 1, 10) + expect(api.createTripCost).toHaveBeenCalledTimes(1) +}) +it('does not let an old save clear a new pending save after switching away and back', async () => { + let finishFirst!: (value: api.TripCost) => void + let finishSecond!: (value: api.TripCost) => void + vi.mocked(api.createTripCost) + .mockImplementationOnce(() => new Promise(resolve => { finishFirst = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { finishSecond = resolve })) + const w = mount(); await flushPromises(); await button(w, '登记成本').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 12.5) + await button(w, '保存').trigger('click'); await flushPromises() + await w.setProps({ tripNo: 'TR-TWO' }); await flushPromises() + await w.setProps({ tripNo: 'TR-ONE' }); await flushPromises() + await button(w, '登记成本').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20) + await button(w, '保存').trigger('click'); await flushPromises() + finishFirst(cost); await flushPromises() + await button(w, '保存').trigger('click'); await flushPromises() + expect(api.createTripCost).toHaveBeenCalledTimes(2) + finishSecond(cost); await flushPromises() + expect(w.findAllComponents({ name: 'AppDialog' })[0]!.props('modelValue')).toBe(false) +}) +it('reads the current version before editing and avoids writing unchanged contents', async () => { + const w = mount(); await flushPromises(); await button(w, '更正').trigger('click'); await flushPromises() + expect(api.listTripCostHistory).toHaveBeenCalledExactlyOnceWith('TR-ONE', 'TC-ONE', 1, 1) + await button(w, '保存').trigger('click'); await flushPromises(); expect(api.updateTripCost).not.toHaveBeenCalled() + await button(w, '更正').trigger('click'); await flushPromises(); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 15) + await button(w, '保存').trigger('click'); await flushPromises() + expect(api.updateTripCost).toHaveBeenCalledWith('TR-ONE', expect.objectContaining({ recordVersion: '9' }), expect.objectContaining({ amount: 15 }), '') +}) +it('failed refresh clears both rows and summary instead of keeping stale evidence', async () => { + const w = mount(); await flushPromises(); vi.mocked(api.listTripCosts).mockRejectedValueOnce(new Error('查询失败')) + await button(w, '刷新').trigger('click'); await flushPromises() + expect(w.findComponent({ name: 'DataTable' }).props('data')).toEqual([]); expect(w.text()).toContain('车次成本加载失败'); expect(w.text()).not.toContain('尚无实际发车装载记录') +}) diff --git a/src/modules/overseas-master-data/components/TripCostPanel.vue b/src/modules/overseas-master-data/components/TripCostPanel.vue new file mode 100644 index 00000000..fc208454 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripCostPanel.vue @@ -0,0 +1,186 @@ + + + + diff --git a/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts b/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts new file mode 100644 index 00000000..9735612f --- /dev/null +++ b/src/modules/overseas-master-data/components/TripHandoverPanel.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import TripHandoverPanel from './TripHandoverPanel.vue' +import * as api from '../api/tripHandovers' +import type { TripDetail } from '../tripTypes' +import { ApiResponseError } from '@/shared/api/http' +const access = vi.hoisted(() => ({ read: true, write: true })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') ? access.read : access.write }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) +vi.mock('../api/tripHandovers', async original => ({ ...(await original()), listTripHandovers: vi.fn(), previewTripHandover: vi.fn(), confirmTripHandover: vi.fn(), releaseHandoverResource: vi.fn() })) +vi.mock('../api/overseasMasterData', () => ({ listVehicles: vi.fn() })) +vi.mock('../api/drivers', () => ({ listDrivers: vi.fn() })) +const date = '2026-10-03T01:00:00Z' +const detail: TripDetail = { trip: { tripNo: 'TR-ONE', status: 'IN_TRANSIT', warehouseId: '1', warehouseCode: 'WH', warehouseName: '海外仓', vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone', maxPalletSpaces: 2, maxLoadKg: 200, maxVolumeCbm: 4, plannedDepartureAt: date, expectedDriverEndAt: '2026-10-04T01:00:00Z', departedAt: date, completedAt: null, stops: [], remark: null, recordVersion: 7, updatedAt: date, vehicleRelease: null, driverRelease: null }, driverQualification: null, dispatches: [], loadingReview: { status: 'APPROVED', reason: null, reviewerId: null, reviewedAt: date, recordVersion: 7 } } +const previous = { vehicleId: '11', vehiclePlate: 'OLD-PLATE', driverId: '21', driverName: '原司机', driverPhone: 'phone' } +const replacement = { vehicleId: '12', vehiclePlate: 'NEW-PLATE', driverId: '22', driverName: '接替司机', driverPhone: 'phone' } +const load = { palletSpaces: 1, weightKg: 100, volumeCbm: 2 } +const cargo = { remainingCargo: [{ customerId: 'secret-customer-id', dispatchNo: 'PC-A', itemId: 'secret-item-id', palletId: 'secret-pallet-id', destinationKey: 'private-hash', packages: 10 }], completedDeliveries: [], remainingPalletIds: ['secret-pallet-id'], occupied: load } +const preview: api.HandoverPreview = { tripNo: 'TR-ONE', recordVersion: 7, cargoHash: 'a'.repeat(64), previous, replacement, capacity: load, cargo, driverQualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-15' } } +const record: api.HandoverRecord = { handoverNo: 'HO-ONE', tripNo: 'TR-ONE', recordVersion: 0, snapshot: { previous, replacement, handedOverAt: date, location: '实际交接点', reason: '交接', expectedDriverEndAt: date, capacity: load, cargo, recordedAt: date }, vehicleRelease: null, driverRelease: null } +const wrappers: VueWrapper[] = [] +const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } +function mount() { + const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: { + ElButton: { template: '' }, ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, + ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input, + DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, + RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '
' }, + LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(wrapper); return wrapper +} +const button = (w: VueWrapper, text: string) => w.findAll('button').find(row => row.text() === text)! +beforeEach(() => { vi.clearAllMocks(); access.read = true; access.write = true + vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [record], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(api.previewTripHandover).mockResolvedValue(preview); vi.mocked(api.confirmTripHandover).mockResolvedValue(record) +}) +afterEach(() => wrappers.splice(0).forEach(row => row.unmount())) +it('only loads one page and does not expose internal IDs or write controls without permission', async () => { + access.write = false; const w = mount(); await flushPromises() + expect(api.listTripHandovers).toHaveBeenCalledExactlyOnceWith('TR-ONE', 1, 10) + expect(api.previewTripHandover).not.toHaveBeenCalled(); expect(w.text()).not.toContain('核对交接货物') + expect(w.text()).not.toContain('结束原车辆任务'); expect(w.text()).not.toContain('secret-') +}) +it('does not request data without list permission', async () => { + access.read = false; const w = mount(); await flushPromises() + expect(api.listTripHandovers).not.toHaveBeenCalled(); expect(w.text()).toContain('暂无车次交接查看权限') +}) +it('invalidates preview when replacement or trip version changes', async () => { + const w = mount(); await flushPromises(); await button(w, '核对交接货物').trigger('click'); await flushPromises() + expect(w.text()).toContain('PC-A'); expect(w.text()).not.toContain('secret-'); expect(w.text()).not.toContain('private-hash') + await w.setProps({ detail: { ...detail, trip: { ...detail.trip, recordVersion: 8 } } }); await flushPromises() + expect(w.text()).not.toContain('确认实际交接') +}) +it('retries exactly the original request after an unknown confirmation response', async () => { + vi.mocked(api.confirmTripHandover).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(record) + const w = mount(); await flushPromises() + w.findAllComponents({ name: 'ElInput' })[0]!.vm.$emit('update:modelValue', '实际交接点') + await button(w, '核对交接货物').trigger('click'); await flushPromises() + await button(w, '确认实际交接').trigger('click'); await flushPromises() + const first = vi.mocked(api.confirmTripHandover).mock.calls[0] + expect(w.text()).toContain('上次交接结果尚待确认') + await button(w, '重试原交接请求').trigger('click'); await flushPromises() + expect(vi.mocked(api.confirmTripHandover).mock.calls[1]).toEqual(first) + expect(w.emitted('saved')).toHaveLength(1) +}) +it('ignores old trip response after switching scope', async () => { + let complete!: (value: Awaited>) => void + vi.mocked(api.listTripHandovers).mockImplementationOnce(() => new Promise(resolve => { complete = resolve })) + const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, tripNo: 'TR-TWO' } } }); await flushPromises() + complete({ records: [{ ...record, handoverNo: 'OLD-RESULT' }], page: 1, size: 10, total: 1, pages: 1 }); await flushPromises() + expect(w.findComponent({ name: 'DataTable' }).props('data')[0].handoverNo).toBe('HO-ONE') +}) +it('retains the original old-driver release request when its response is unknown', async () => { + vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new Error('unknown response')).mockResolvedValueOnce({ ...record, recordVersion: 1, driverRelease: { actualEndAt: date, location: '结束地点', vehicleStatus: null } }) + const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises() + await button(w, '结束原司机任务').trigger('click'); await flushPromises() + w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点') + await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() + const first = vi.mocked(api.releaseHandoverResource).mock.calls[0] + expect(first?.[2].resourceKind).toBe('DRIVER'); expect(first?.[2].vehicleStatus).toBeUndefined() + expect(w.text()).toContain('上次任务结束结果尚待确认') + await button(w, '重试原任务结束请求').trigger('click'); await flushPromises() + expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]).toEqual(first) +}) +it('reloads the handover version after a definite release conflict', async () => { + vi.mocked(api.releaseHandoverResource).mockRejectedValueOnce(new ApiResponseError('交接记录已变化', 'A0409')).mockResolvedValueOnce({ ...record, recordVersion: 2 }) + const w = mount(); await w.setProps({ detail: { ...detail, trip: { ...detail.trip, vehicleId: '12', driverId: '22', recordVersion: 8 } } }); await flushPromises() + await button(w, '结束原司机任务').trigger('click'); await flushPromises() + w.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', '结束地点') + vi.mocked(api.listTripHandovers).mockResolvedValue({ records: [{ ...record, recordVersion: 1 }], page: 1, size: 10, total: 1, pages: 1 }) + await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() + await button(w, '确认原资源任务结束').trigger('click'); await flushPromises() + expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].recordVersion).toBe(1) + expect(vi.mocked(api.releaseHandoverResource).mock.calls[1]?.[2].idempotencyKey).not.toBe(vi.mocked(api.releaseHandoverResource).mock.calls[0]?.[2].idempotencyKey) +}) diff --git a/src/modules/overseas-master-data/components/TripHandoverPanel.vue b/src/modules/overseas-master-data/components/TripHandoverPanel.vue new file mode 100644 index 00000000..78c70060 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripHandoverPanel.vue @@ -0,0 +1,200 @@ + + + + diff --git a/src/modules/overseas-master-data/components/TripOperationsCharts.vue b/src/modules/overseas-master-data/components/TripOperationsCharts.vue new file mode 100644 index 00000000..ce61a1af --- /dev/null +++ b/src/modules/overseas-master-data/components/TripOperationsCharts.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts new file mode 100644 index 00000000..6c92f786 --- /dev/null +++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue' +import * as api from '../api/tripOperations' +vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn() })) +const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null } +const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null }, updatedAt: null } +const wrappers: VueWrapper[] = [] +const slot = { template: '
' } +const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '' } +async function mount(readonly = false) { + const wrapper = shallowMount(TripOperationsRecordDialog, { props: { modelValue: false, row, readonly }, global: { stubs: { + AppDialog: { props: ['modelValue'], template: '
' }, ElForm: slot, ElFormItem: slot, + ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, ElTabPane: slot, ElInputNumber: input, + ElInput: { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: '' }, + ElAlert: { props: ['title'], template: '
{{ title }}
' }, ApiErrorMeta: true, DataTable: true, + } } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises(); return wrapper +} +beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record); vi.mocked(api.listTripOperationsRecordHistory).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) }) +afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) +it('loads current record on open, history only when selected, and hides writes for readers', async () => { + const wrapper = await mount(true) + expect(api.getTripOperationsRecord).toHaveBeenCalledExactlyOnceWith('TR1'); expect(api.listTripOperationsRecordHistory).not.toHaveBeenCalled() + expect(wrapper.text()).not.toContain('保存记录') + wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises() + expect(api.listTripOperationsRecordHistory).toHaveBeenCalledExactlyOnceWith('TR1', 1, 20) +}) +it('unknown write retry preserves original key, version and measured values', async () => { + const wrapper = await mount(); vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network')) + wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 12.5); await flushPromises() + await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises() + const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1] + expect(original).toMatchObject({ recordVersion: '0', details: { actualDistanceMiles: 12.5, actualFuelLitres: null } }) + wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 99) + vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' }) + await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises() + expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original); expect(wrapper.emitted('saved')).toHaveLength(1) +}) +it('a failed initial read cannot save a guessed record version', async () => { + vi.mocked(api.getTripOperationsRecord).mockRejectedValue(new Error('unavailable')); const wrapper = await mount() + expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined() + expect(api.saveTripOperationsRecord).not.toHaveBeenCalled() +}) diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue new file mode 100644 index 00000000..fd8287aa --- /dev/null +++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts b/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts index f6d78bce..5596837d 100644 --- a/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts +++ b/src/modules/overseas-master-data/components/VehicleCareBehavior.test.ts @@ -11,7 +11,7 @@ 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/vehicleCare', async original => ({ ...(await original()), listVehicleCare: vi.fn(), getVehicleCare: vi.fn(), createVehicleCare: vi.fn(), updateVehicleCare: vi.fn(), uploadVehicleCarePhoto: vi.fn(), downloadVehicleCarePhoto: vi.fn(), listVehicleCareFinance: vi.fn(), submitVehicleCareFinance: vi.fn(), listVehicleCareSuppliers: 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: '
' } @@ -27,7 +27,10 @@ function mount(component: object, props: Record): VueWrapper { ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, 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 }) }) +beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 }) + vi.mocked(api.listVehicleCareFinance).mockImplementation(async (_vehicle, numbers) => numbers.map(recordNo => ({ recordNo, status: 'UNLINKED', sourceVersion: null, submittedRecordVersion: null, finance: null }))) + vi.mocked(api.listVehicleCareSuppliers).mockResolvedValue([]) +}) afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) it('saving an unchanged record does not create a correction due to JSON property order', async () => { const record = structuredClone(row) @@ -146,7 +149,7 @@ it('renders completed rescue and zero reported cost separately from payment and 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="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('更新处理') diff --git a/src/modules/overseas-master-data/components/VehicleCareEditor.vue b/src/modules/overseas-master-data/components/VehicleCareEditor.vue index 039cdcd6..39cdef5e 100644 --- a/src/modules/overseas-master-data/components/VehicleCareEditor.vue +++ b/src/modules/overseas-master-data/components/VehicleCareEditor.vue @@ -120,7 +120,7 @@ onBeforeUnmount(() => { editSession++ }) -

费用仅作运维记录,尚未关联财务付款。处理完成不会标记为已付款。

+

此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。

diff --git a/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts new file mode 100644 index 00000000..f85bdf97 --- /dev/null +++ b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue' +import * as api from '../api/companyVehicleCosts' +const access = vi.hoisted(() => ({ permissions: new Set() })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => access.permissions.has(permission) }) })) +vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) +vi.mock('../api/companyVehicleCosts', async original => ({ ...(await original()), getCompanyCost: vi.fn(), commandCompanyCost: vi.fn(), payCompanyCost: vi.fn(), listCompanyCostHistory: vi.fn(), listCompanyCostPayments: vi.fn(), uploadCompanyCostVoucher: vi.fn(), downloadCompanyCostVoucher: vi.fn() })) +const detail: api.CompanyCost = { costNo: 'VC-ONE', subjectCode: 'PLATFORM_DEFAULT', supplierName: '维修供应商', currency: 'CNY', status: 'APPROVED', recordVersion: '2', paidAmount: 0, outstandingAmount: 70, + source: { sourceType: 'REPAIR', sourceNo: 'VR-ONE', sourceVersion: '1', vehicleId: '71', vehiclePlate: 'VEH-A', tripNo: null, supplierId: '8', feeName: '维修', businessDate: '2026-01-01', originalCurrency: 'USD', originalAmount: 10, exchangeRate: 7, exchangeRateMonth: '2026-01', amount: 70, remark: null } } +const empty = { records: [], page: 1, size: 10, total: 0, pages: 0 } +const voucher = { fileId: '11', fileName: 'payment.png', contentType: 'image/png', sizeBytes: 3 } +const wrappers: VueWrapper[] = [] +const input = { props: ['modelValue'], emits: ['update:modelValue'], template: '' } +function mount() { + const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: { + ElButton: { props: ['disabled'], template: '' }, + ElForm: { template: '
' }, ElFormItem: { template: '
' }, ElOption: true, + ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input, + DataTable: { name: 'DataTable', props: ['data', 'total', 'page'], emits: ['update:page'], template: '
' }, + LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(w); return w +} +const button = (w: VueWrapper, label: string) => w.findAll('button').find(value => value.text() === label)! +async function files(w: VueWrapper, names = ['payment.png']) { + const element = w.get('input[type="file"]') + Object.defineProperty(element.element, 'files', { configurable: true, value: names.map(name => new File(['png'], name, { type: 'image/png' })) }) + await element.trigger('change'); await flushPromises() +} +beforeEach(() => { + vi.resetAllMocks(); access.permissions = new Set(Object.values(api.companyCostPermissions)) + vi.mocked(api.getCompanyCost).mockResolvedValue(structuredClone(detail)) + vi.mocked(api.commandCompanyCost).mockResolvedValue({ ...detail, status: 'VOIDED', outstandingAmount: null, recordVersion: '3' }) + vi.mocked(api.payCompanyCost).mockResolvedValue({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' }) + vi.mocked(api.listCompanyCostHistory).mockResolvedValue(empty); vi.mocked(api.listCompanyCostPayments).mockResolvedValue(empty) + vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValue(voucher) +}) +afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) +it('requests detail once and reads separately paged history only on demand', async () => { + const w = mount(); await flushPromises() + expect(api.getCompanyCost).toHaveBeenCalledExactlyOnceWith('VC-ONE') + expect(api.listCompanyCostHistory).not.toHaveBeenCalled(); expect(api.listCompanyCostPayments).not.toHaveBeenCalled() + await button(w, '操作历史').trigger('click'); await flushPromises() + expect(api.listCompanyCostHistory).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1) + w.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() + expect(api.listCompanyCostHistory).toHaveBeenLastCalledWith('VC-ONE', 2) + await button(w, '付款流水').trigger('click'); await flushPromises() + expect(api.listCompanyCostPayments).toHaveBeenCalledExactlyOnceWith('VC-ONE', 1) +}) +it('has no requests without Finance list permission and does not give readers write buttons', async () => { + access.permissions.clear(); const denied = mount(); await flushPromises(); expect(api.getCompanyCost).not.toHaveBeenCalled() + expect(denied.text()).toContain('暂无公司成本财务查询权限') + access.permissions.add(api.companyCostPermissions.list); const w = mount(); await flushPromises() + expect(button(w, '登记付款')).toBeUndefined(); expect(button(w, '作废费用')).toBeUndefined(); expect(button(w, '确认入账')).toBeUndefined() +}) +it('keeps one immutable payment request and key after an unknown response', async () => { + vi.mocked(api.payCompanyCost).mockRejectedValueOnce(new Error('unknown outcome')).mockResolvedValueOnce({ ...detail, status: 'PARTIALLY_PAID', paidAmount: 20, outstandingAmount: 50, recordVersion: '3' }) + const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 20); await files(w) + await button(w, '确认操作').trigger('click'); await flushPromises() + const first = vi.mocked(api.payCompanyCost).mock.calls[0]! + expect(first).toEqual(['VC-ONE', expect.any(String), expect.objectContaining({ expectedVersion: '2', amount: 20, voucherFileIds: ['11'] })]) + expect(w.text()).toContain('上次操作结果尚未确认') + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 60) + await button(w, '重试原操作').trigger('click'); await flushPromises() + expect(vi.mocked(api.payCompanyCost).mock.calls[1]).toEqual(first) + expect(w.text()).toContain('部分付款'); expect(w.text()).toContain('未付 CNY 50.00') + expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(1); expect(w.emitted('changed')).toHaveLength(1) +}) +it('preserves successfully uploaded vouchers and resumes remaining uploads without paying', async () => { + vi.mocked(api.uploadCompanyCostVoucher).mockResolvedValueOnce(voucher).mockRejectedValueOnce(new Error('upload failed')) + .mockResolvedValueOnce({ ...voucher, fileId: '12', fileName: 'second.png' }) + const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() + await files(w, ['payment.png', 'second.png']) + expect(w.text()).toContain('已上传:payment.png'); expect(w.text()).toContain('1 张凭证待完成上传') + expect(api.payCompanyCost).not.toHaveBeenCalled() + await button(w, '重试剩余凭证').trigger('click'); await flushPromises() + expect(api.uploadCompanyCostVoucher).toHaveBeenCalledTimes(3) + expect((vi.mocked(api.uploadCompanyCostVoucher).mock.calls[2]![1]).name).toBe('second.png') + expect(w.text()).toContain('已上传:second.png'); expect(api.payCompanyCost).not.toHaveBeenCalled() +}) +it('refuses zero, excess or unproven payments locally and sends void only after explicit confirmation', async () => { + const w = mount(); await flushPromises(); await button(w, '登记付款').trigger('click'); await flushPromises() + w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 0); await button(w, '确认操作').trigger('click'); await flushPromises() + await files(w); w.findComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 71) + await button(w, '确认操作').trigger('click'); await flushPromises(); expect(api.payCompanyCost).not.toHaveBeenCalled() + await button(w, '取消操作').trigger('click'); await button(w, '作废费用').trigger('click'); await flushPromises() + expect(api.commandCompanyCost).not.toHaveBeenCalled() + await button(w, '确认操作').trigger('click'); await flushPromises() + expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'void', expect.any(String), { expectedVersion: '2', reason: null }) + expect(w.text()).toContain('已作废') +}) +it('cannot void or pay again when the server says paid, and booking is an independent action', async () => { + vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PAID', paidAmount: 70, outstandingAmount: 0 }) + const paid = mount(); await flushPromises(); expect(button(paid, '作废费用')).toBeUndefined(); expect(button(paid, '登记付款')).toBeUndefined() + vi.mocked(api.getCompanyCost).mockResolvedValueOnce({ ...detail, status: 'PENDING', recordVersion: '0', outstandingAmount: null }) + const pending = mount(); await flushPromises(); await button(pending, '确认入账').trigger('click'); await button(pending, '确认操作').trigger('click'); await flushPromises() + expect(api.commandCompanyCost).toHaveBeenCalledWith('VC-ONE', 'approve', expect.any(String), { expectedVersion: '0', reason: null }) + expect(api.payCompanyCost).not.toHaveBeenCalled() +}) diff --git a/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue new file mode 100644 index 00000000..2d4c12ae --- /dev/null +++ b/src/modules/overseas-master-data/components/VehicleCareFinanceLedger.vue @@ -0,0 +1,204 @@ + + + + diff --git a/src/modules/overseas-master-data/components/VehicleCareTable.vue b/src/modules/overseas-master-data/components/VehicleCareTable.vue index b0cb5fbc..75db4d3c 100644 --- a/src/modules/overseas-master-data/components/VehicleCareTable.vue +++ b/src/modules/overseas-master-data/components/VehicleCareTable.vue @@ -6,12 +6,15 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v 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 AppDialog from '@/shared/components/AppDialog.vue' +import { ApiResponseError, 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 { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto, listVehicleCareFinance, submitVehicleCareFinance, listVehicleCareSuppliers, + vehicleCareFinanceSubmitPermission, vehicleCareFinanceLabels, type VehicleCareFinanceStatus, type VehicleCareFinanceSubmit } from '../api/vehicleCare' import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareFile, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes' import VehicleCareEditor from './VehicleCareEditor.vue' +import VehicleCareFinanceLedger from './VehicleCareFinanceLedger.vue' const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType }>() const access = useAccessPreviewStore(), rows = ref([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false) @@ -20,10 +23,25 @@ const photoError = ref(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 costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType)) +const financeStates = ref>({}), financeError = ref(null), financeLoading = ref(false) +const financeOpen = ref(false), financeTarget = ref(null), financeSaving = ref(false), financeOpening = ref(false) +const ledgerCostNo = ref(''), ledgerBusy = ref(false) +const financeSaveError = ref(null), supplierId = ref(''), businessDate = ref('') +const suppliers = ref>([]), suppliersLoading = ref(false) +const pendingFinance = ref<{ vehicleId: string; recordNo: string; request: VehicleCareFinanceSubmit } | null>(null) +const financeStatus = computed(() => financeTarget.value ? financeStates.value[financeTarget.value.recordNo] : null) +const canSubmitFinance = computed(() => access.can(vehicleCareFinanceSubmitPermission) && access.can('platform:supplier:list') + && !!financeStatus.value && ['UNLINKED', 'VOIDED'].includes(financeStatus.value.status)) +let request = 0, operationEpoch = 0, supplierRequest = 0, disposed = false +watch([() => props.vehicleId, () => props.recordType], () => { + operationEpoch++; supplierRequest++; financeOpen.value = false; financeTarget.value = null + financeOpening.value = false; financeSaving.value = false; pendingFinance.value = null + ledgerCostNo.value = ''; ledgerBusy.value = false +}) const previews = new Set<{ target: Window; url: string; timer: ReturnType }>() const columns = computed(() => { - const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }] + const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: costRecord.value ? 170 : 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' ? [ @@ -46,14 +64,70 @@ const columns = computed(() => { 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 } } + const id = ++request; loading.value = true; error.value = null; financeStates.value = {}; financeError.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; void loadFinance() } } 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)) }] } +function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value || financeSaving.value || ledgerBusy.value, + visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) }, + { key: 'finance', label: '财务', visible: costRecord.value, disabled: financeOpening.value || financeSaving.value }] } +async function loadFinance() { + if (!costRecord.value || !rows.value.length) { financeLoading.value = false; return } + const scope = request; financeLoading.value = true; financeError.value = null; financeStates.value = {} + try { const result = await listVehicleCareFinance(props.vehicleId, rows.value.map(row => row.recordNo)) + if (!disposed && scope === request) financeStates.value = Object.fromEntries(result.map(row => [row.recordNo, row])) + } catch (cause) { if (!disposed && scope === request) financeError.value = getApiErrorDetails(cause, '财务状态未确认') } + finally { if (scope === request) financeLoading.value = false } +} +async function openFinance(row: VehicleCareRecord) { + if (financeOpening.value || financeSaving.value) return + if (ledgerBusy.value) { + if (financeTarget.value?.recordNo === row.recordNo) financeOpen.value = true + else ElMessage.warning('请先核对当前财务操作结果') + return + } + if (pendingFinance.value && (pendingFinance.value.recordNo !== row.recordNo || pendingFinance.value.vehicleId !== props.vehicleId)) { ElMessage.warning('请先核对上次财务提交结果'); return } + const scope = operationEpoch, vehicleId = props.vehicleId + financeOpening.value = true; financeSaveError.value = null + try { + const current = await getVehicleCare(vehicleId, row.recordNo) + const statuses = await listVehicleCareFinance(vehicleId, [row.recordNo]) + if (disposed || scope !== operationEpoch) return + financeTarget.value = current; financeStates.value[row.recordNo] = statuses[0]! + ledgerCostNo.value = statuses[0]?.finance?.costNo ?? '' + if (!pendingFinance.value) { supplierId.value = ''; businessDate.value = new Date(current.details.occurredAt).toLocaleDateString('en-CA'); suppliers.value = [] } + financeOpen.value = true + } catch (cause) { if (!disposed && scope === operationEpoch) financeError.value = getApiErrorDetails(cause, '财务详情读取失败') } + finally { if (scope === operationEpoch) financeOpening.value = false } +} +async function searchSuppliers(keyword = '') { + if (!access.can('platform:supplier:list')) return + const scope = ++supplierRequest; suppliersLoading.value = true + try { const result = await listVehicleCareSuppliers(keyword); if (!disposed && scope === supplierRequest) suppliers.value = result } + catch (cause) { if (!disposed && scope === supplierRequest) financeSaveError.value = getApiErrorDetails(cause, '供应商读取失败') } + finally { if (scope === supplierRequest) suppliersLoading.value = false } +} +async function submitFinance() { + if (financeSaving.value || ledgerBusy.value || !financeTarget.value || !access.can(vehicleCareFinanceSubmitPermission)) return + if (!pendingFinance.value && !canSubmitFinance.value) return + if (!pendingFinance.value && (!supplierId.value || !businessDate.value)) { ElMessage.warning('请选择供应商和费用业务日期'); return } + const target = financeTarget.value, vehicleId = props.vehicleId, scope = operationEpoch + pendingFinance.value ??= { vehicleId, recordNo: target.recordNo, request: { recordVersion: target.recordVersion, supplierId: supplierId.value, businessDate: businessDate.value, idempotencyKey: crypto.randomUUID() } } + const pending = pendingFinance.value + financeSaving.value = true; financeSaveError.value = null + try { + await submitVehicleCareFinance(pending.vehicleId, pending.recordNo, pending.request) + if (disposed || scope !== operationEpoch) return + pendingFinance.value = null; financeOpen.value = false; ElMessage.success('提交请求已登记,等待财务接收;尚未入账或付款') + await loadFinance() + } catch (cause) { + if (disposed || scope !== operationEpoch) return + if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingFinance.value = null + financeSaveError.value = getApiErrorDetails(cause, '财务提交未完成') + } finally { if (scope === operationEpoch) financeSaving.value = false } +} async function edit(row: VehicleCareRecord | null) { if (openingRecord.value) return if (!row) { editing.value = null; editorOpen.value = true; return } @@ -65,7 +139,7 @@ async function edit(row: VehicleCareRecord | null) { } function saved(row: VehicleCareRecord) { const index = rows.value.findIndex(value => value.recordNo === row.recordNo) - if (index >= 0) rows.value[index] = row + if (index >= 0) { rows.value[index] = row; if (costRecord.value) void loadFinance() } else if (page.value !== 1) page.value = 1 else void load() } @@ -83,16 +157,17 @@ async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) { } 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() }) +onBeforeUnmount(() => { request++; operationEpoch++; supplierRequest++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() }) diff --git a/src/modules/overseas-master-data/fleetQuoteCsv.test.ts b/src/modules/overseas-master-data/fleetQuoteCsv.test.ts new file mode 100644 index 00000000..7e7c2c67 --- /dev/null +++ b/src/modules/overseas-master-data/fleetQuoteCsv.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from './fleetQuoteCsv' +const csv = (row: string) => '\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n' + row +it('preserves zero, missing prices, quoted cities and multiline notes', () => { + const rows = parseFleetQuoteCsv(csv('"Los Angeles, CA",ONT8,10.5,USD,0,,20,2026-01-01,2026-12-31,"first\nsecond"'), 'SALE', '90071992547409931') + expect(rows[0]).toMatchObject({ kind: 'SALE', partyId: '90071992547409931', originCity: 'Los Angeles, CA', selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 20, remark: 'first\nsecond' }) +}) +it.each(['LA,ONT8,10,USD,,,,2026-01-01,2026-12-31,', 'LA,ONT8,10,USD,1,,,2026-02-30,2026-12-31,', 'LA,ONT8,10,USD,1,,,2026-12-31,2026-01-01,', 'LA,ONT8,10,USD,-1,,,2026-01-01,2026-12-31,'])('rejects invalid data before submission: %s', row => { + expect(() => parseFleetQuoteCsv(csv(row), 'PURCHASE', '1')).toThrow('第2行') +}) +it('rejects truncated CSV and oversized batch', () => { + expect(() => parseFleetQuoteCsv(csv('"LA'), 'SALE', '1')).toThrow('引号') + expect(() => parseFleetQuoteCsv(csv(Array(201).fill('LA,ONT8,10,USD,1,,,2026-01-01,2026-12-31,').join('\n')), 'SALE', '1')).toThrow('200') +}) diff --git a/src/modules/overseas-master-data/fleetQuoteCsv.ts b/src/modules/overseas-master-data/fleetQuoteCsv.ts new file mode 100644 index 00000000..f7cfa1fc --- /dev/null +++ b/src/modules/overseas-master-data/fleetQuoteCsv.ts @@ -0,0 +1,31 @@ +import type { FleetQuoteDraft, FleetQuoteKind } from './api/fleetQuotes' +export const fleetQuoteCsvHeader = ['发货城市', '目的仓码', '距离(mile)', '币种', '整车自提', '整车派送', '散板派送', '生效日', '失效日', '备注'] +export function parseFleetQuoteCsv(text: string, kind: FleetQuoteKind, partyId: string): FleetQuoteDraft[] { + if (text.length > 500000) throw new Error('CSV不能超过500KB') + const rows: string[][] = []; let row: string[] = [], cell = '', quoted = false + const input = text.replace(/^\uFEFF/, '') + for (let i = 0; i < input.length; i++) { + const c = input[i] + if (c === '"') { if (quoted && input[i + 1] === '"') { cell += '"'; i++ } else quoted = !quoted } + else if (!quoted && (c === ',' || c === '\n')) { row.push(cell.trim()); cell = ''; if (c === '\n') { rows.push(row); row = [] } } + else if (!quoted && c === '\r') continue + else cell += c + } + if (quoted) throw new Error('CSV引号未闭合') + if (cell || row.length) { row.push(cell.trim()); rows.push(row) } + if (rows.shift()?.join('|') !== fleetQuoteCsvHeader.join('|')) throw new Error('CSV表头不匹配,请下载模板') + const data = rows.filter(r => r.some(Boolean)) + if (!data.length || data.length > 200) throw new Error('每次导入1至200条报价') + return data.map((r, index) => { + const fail = (message: string): never => { throw new Error(`第${index + 2}行:${message}`) } + if (r.length !== 10 || !r[0] || !r[1]) fail('请填写发货城市、目的仓码,并核对列数') + const amount = (v: string | undefined): number | null => { if (!v) return null; if (!/^\d{1,10}(\.\d{1,2})?$/.test(v)) return fail('报价须为非负金额,最多两位小数'); return Number(v) } + if (!/^\d{1,7}(\.\d)?$/.test(r[2] || '')) fail('请填写非负距离,最多一位小数') + if (r[3] !== 'USD' && r[3] !== 'CNY') fail('币种仅支持USD或CNY') + const date = (v: string | undefined): string => { if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v)) || new Date(v).toISOString().slice(0, 10) !== v) return fail('日期须为有效的YYYY-MM-DD'); return v } + const draft: FleetQuoteDraft = { kind, partyId, originCity: r[0]!, destinationCode: r[1]!, distanceMiles: Number(r[2]), currency: r[3] as 'USD' | 'CNY', selfPickupPrice: amount(r[4]), fullDeliveryPrice: amount(r[5]), looseDeliveryPrice: amount(r[6]), validFrom: date(r[7]), validTo: date(r[8]), remark: r[9] || null } + if (draft.validTo < draft.validFrom) fail('失效日不得早于生效日') + if (draft.selfPickupPrice == null && draft.fullDeliveryPrice == null && draft.looseDeliveryPrice == null) fail('至少填写一种价格') + return draft + }) +} diff --git a/src/modules/overseas-master-data/pages/FleetQuotesView.vue b/src/modules/overseas-master-data/pages/FleetQuotesView.vue new file mode 100644 index 00000000..bf0e2475 --- /dev/null +++ b/src/modules/overseas-master-data/pages/FleetQuotesView.vue @@ -0,0 +1,52 @@ + + diff --git a/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts new file mode 100644 index 00000000..1c6a5e70 --- /dev/null +++ b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import FleetQuotesView from './FleetQuotesView.vue' +import TripOperationsView from './TripOperationsView.vue' +import * as quotes from '../api/fleetQuotes' +import * as reports from '../api/tripOperations' +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) +vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() })) +vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() })) +const wrappers: VueWrapper[] = [] +const layout = { template: '
' } +function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true, + DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, ElButton: { template: '' }, + LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper } +afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() }) +it('quotes read-only view fetches a page once and hides all write/export tools', async () => { + vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 }) + const wrapper = mount(FleetQuotesView); await flushPromises() + expect(quotes.listFleetQuotes).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('新增报价'); expect(wrapper.text()).not.toContain('批量导入') + expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')[0]).toMatchObject({ key: 'actions', fixed: 'left' }) +}) +it('operations uses server totals rather than displayed page size', async () => { + vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } }) + const wrapper = mount(TripOperationsView); await flushPromises() + expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV') +}) diff --git a/src/modules/overseas-master-data/pages/TripDetailView.vue b/src/modules/overseas-master-data/pages/TripDetailView.vue index 31148b51..3adf8058 100644 --- a/src/modules/overseas-master-data/pages/TripDetailView.vue +++ b/src/modules/overseas-master-data/pages/TripDetailView.vue @@ -15,6 +15,8 @@ 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 TripCostPanel from '../components/TripCostPanel.vue' +import TripHandoverPanel from '../components/TripHandoverPanel.vue' import { getTrip, bindTripDispatch, unbindTripDispatch, cancelTrip, getTripLoading, updateTripPlan, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading } from '../api/trips' import { overseasMasterDataPermissions as permission } from '../permissions' @@ -47,7 +49,7 @@ const historyColumns: DataTableColumn[] = [{ key: 'occurredAt', label: '操作 { key: 'vehicle', prop: 'snapshot.trip.vehiclePlate', label: '当时车辆', minWidth: 145 }, { key: 'driver', prop: 'snapshot.trip.driverName', label: '当时司机', minWidth: 125 }] const actionLabels: Record = { 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: '结束司机任务' } + CONFIRM_LOAD: '确认装载', DEPART: '整车发车', HANDOVER: '在途交接', 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 || '' @@ -182,7 +184,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
{{ label }}
{{ value || '—' }}

{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}

{{ driverQualificationLabels[detail.driverQualification.reason] }} · 证照将在 30 天内到期,请留意任务延期

- + + +

理货审核:{{ tripReviewLabels[detail.loadingReview.status] }} · {{ detail.loadingReview.reason }} · {{ time(detail.loadingReview.reviewedAt) }}

理货审核通过理货打回