feat(fleet): connect operations quotes costs and handover workspaces

This commit is contained in:
2026-10-03 12:43:25 +08:00
parent 863ad5249c
commit 0b5094dd8f
41 changed files with 2091 additions and 22 deletions
+3 -3
View File
@@ -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()
})
+2
View File
@@ -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 },
@@ -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`,新增司机页面使用其中用户提供的新版原型入口。
@@ -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)仅展示记录、统计和新增/编辑弹框,没有共享车次关联及实际装载工作台。用户已明确“按你的建议执行,先做一个版本再优化”,采用独立车次列表与详情页,计划居中弹框,分配/关联/装载在详情分区,结案和资源释放分别确认。首版普通说明选填,不增加格式校验清单;保留权限、归属、版本、容量和证照期限门禁。
| 页面/操作 | 原型证据 | 建议承载 | 当前结论 |
@@ -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<typeof import('@/shared/api/http')>()), 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('精确展示')
})
@@ -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<CompanyCost, 'recordVersion' | 'paidAmount' | 'outstandingAmount' | 'source'> & {
recordVersion: Numeric; paidAmount: Numeric; outstandingAmount?: Numeric | null
source: Omit<CompanyCost['source'], 'sourceVersion' | 'supplierId' | 'originalAmount' | 'exchangeRate' | 'amount'> & {
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<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
function page<T, R>(value: PageWire<T>, convert: (value: T) => R): PageResult<R> {
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<CompanyCost> {
return cost(unwrapApiResponse(await http.get<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(base(no))), no)
}
export async function commandCompanyCost(no: string, action: 'approve' | 'void', key: string, request: CompanyCostCommand): Promise<CompanyCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/${action}`, request, { headers: { 'Idempotency-Key': key } })), no)
}
export async function payCompanyCost(no: string, key: string, request: CompanyCostPayment): Promise<CompanyCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${base(no)}/payments`, request, { headers: { 'Idempotency-Key': key } })), no)
}
export async function listCompanyCostHistory(no: string, currentPage = 1): Promise<PageResult<CompanyCostHistory>> {
type HistoryWire = Omit<CompanyCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: CostWire }
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${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<PageResult<CompanyCostPaid>> {
type PaidWire = Omit<CompanyCostPaid, 'amount' | 'voucherFileIds'> & { amount: Numeric; voucherFileIds: Numeric[] }
const value = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<PaidWire>>, ApiEnvelope<PageWire<PaidWire>>>(`${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<CompanyCostVoucher> {
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<Wire>, ApiEnvelope<Wire>>(`${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<void> {
if (value instanceof Blob && value.type.includes('json')) {
try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('付款凭证返回了无法识别的错误') }
}
if (value && typeof value === 'object' && 'code' in value) {
const error = value as ApiEnvelope<unknown>
throw new ApiResponseError(error.message || '付款凭证读取失败', error.code, status, error.data, error.requestId, error.traceId)
}
}
export async function downloadCompanyCostVoucher(no: string, fileId: string): Promise<Blob> {
try {
const value = await http.get<Blob, Blob>(`${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
}
}
@@ -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<typeof import('@/shared/api/http')>()), 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')
})
@@ -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<FleetQuote, 'details' | 'recordVersion'> & { recordVersion: Numeric; details: Omit<FleetQuoteDraft, 'distanceMiles' | 'selfPickupPrice' | 'fullDeliveryPrice' | 'looseDeliveryPrice'> & { 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<T>(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult<T> {
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<PageResult<WireQuote>>, ApiEnvelope<PageResult<WireQuote>>>(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<WireQuote>, ApiEnvelope<WireQuote>>(base, { idempotencyKey, details }))) }
export async function updateFleetQuote(value: FleetQuote, details: FleetQuoteDraft) { return quote(unwrapApiResponse(await http.put<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { recordVersion: value.recordVersion, details }))) }
export async function setFleetQuoteStatus(value: FleetQuote, status: string) { return quote(unwrapApiResponse(await http.patch<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(`${path(value.quoteNo)}/status`, { recordVersion: value.recordVersion, status }))) }
export async function deleteFleetQuote(value: FleetQuote) { return quote(unwrapApiResponse(await http.delete<ApiEnvelope<WireQuote>, ApiEnvelope<WireQuote>>(path(value.quoteNo), { params: { recordVersion: value.recordVersion } }))) }
export async function importFleetQuotes(idempotencyKey: string, rows: FleetQuoteDraft[]) { return unwrapApiResponse(await http.post<ApiEnvelope<{ importedRows: number }>, 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 }[]>, 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<PageResult<{ id: string; name: string }>>, ApiEnvelope<PageResult<{ id: string; name: string }>>>('/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<Blob> {
function check(bytes: ArrayBuffer, status: number) {
const text = new TextDecoder().decode(bytes)
if (!/^\s*[{[]/.test(text)) return
let error: ApiEnvelope<unknown>
try { error = JSON.parse(text) as ApiEnvelope<unknown> } 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<ArrayBuffer, ArrayBuffer>(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) }
@@ -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<typeof import('@/shared/api/http')>()), 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(/无效金额|精确展示范围/)
})
@@ -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<TripCost>; 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<TripCost, 'recordVersion' | 'details'> & { recordVersion: Numeric; details: Omit<TripCostDraft, 'amount' | 'remark'> & { amount: Numeric; remark?: string | null } }
type SummaryWire = Omit<CostSummary, 'departureVersion' | 'totals'> & { departureVersion?: Numeric | null; totals: Array<Omit<CostTotal, 'directCost' | 'ownCost' | 'externalCost' | 'allocations'> & {
directCost: Numeric; ownCost?: Numeric | null; externalCost?: Numeric | null; allocations: Array<Omit<CostAllocation, 'amount' | 'palletSpaces'> & { amount: Numeric; palletSpaces: Numeric }>
}> }
type PageWire<T> = { 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<T, R>(value: PageWire<T>, transform: (row: T) => R): PageResult<R> {
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<TripCosts> {
type Wire = { page: PageWire<CostWire>; summary: SummaryWire }
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(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<TripCost> {
return cost(unwrapApiResponse(await http.post<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(path(tripNo), { idempotencyKey, details })))
}
export async function updateTripCost(tripNo: string, record: Pick<TripCost, 'costNo' | 'recordVersion'>, details: TripCostDraft, reason?: string): Promise<TripCost> {
return cost(unwrapApiResponse(await http.put<ApiEnvelope<CostWire>, ApiEnvelope<CostWire>>(`${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<PageResult<TripCostHistory>> {
type HistoryWire = Omit<TripCostHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: { cost: CostWire; summary: SummaryWire } }
type Wire = PageWire<HistoryWire>
const result = unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${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) } }))
}
@@ -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<typeof import('@/shared/api/http')>()), 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()
})
@@ -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<PageResult<HandoverRecord>> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ records: HandoverRecord[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }>, 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<HandoverPreview> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverPreview>, ApiEnvelope<HandoverPreview>>(`${path(tripNo)}/preview`, selection))
}
export async function confirmTripHandover(tripNo: string, command: HandoverCommand): Promise<HandoverRecord> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(path(tripNo), command))
}
export async function releaseHandoverResource(tripNo: string, handoverNo: string, command: HandoverRelease): Promise<HandoverRecord> {
return unwrapApiResponse(await http.post<ApiEnvelope<HandoverRecord>, ApiEnvelope<HandoverRecord>>(`${path(tripNo)}/${encodeURIComponent(handoverNo)}/resources/release`, command))
}
@@ -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<typeof import('@/shared/api/http')>()), 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 } })
})
@@ -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<CostTotal, 'currency' | 'directCost' | 'ownCost' | 'externalCost'>
export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] }
export type OperationsRowCosts = Pick<CostSummary, 'allocationBlockReason'> & { 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<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
type WireNumbers<T> = { [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<WireNumbers<TripOperationsRow>, '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<WireNumbers<TripOperationsSummary>, '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<TripOperationsSeries, 'points'> & { 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<TripOperationsResult> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<{ page: PageResult<WireRow>; summary: WireSummary; charts: WireCharts }>, ApiEnvelope<{ page: PageResult<WireRow>; 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<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
type WireHistory = Omit<TripOperationsRecordHistory, 'recordVersion' | 'details'> & { 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<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo)))) }
export async function saveTripOperationsRecord(tripNo: string, request: TripOperationsRecordSave) { return record(unwrapApiResponse(await http.put<ApiEnvelope<WireRecord>, ApiEnvelope<WireRecord>>(recordPath(tripNo), request))) }
export async function listTripOperationsRecordHistory(tripNo: string, page = 1, size = 20) {
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<WireHistory>>, ApiEnvelope<PageResult<WireHistory>>>(`${recordPath(tripNo)}/history`, { params: { page, size } }))
return fleetPage({ ...result, records: result.records.map(r => ({ ...r, recordVersion: fleetVersion(r.recordVersion), details: measured(r.details) })) })
}
@@ -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<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records`
@@ -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' } } }
@@ -13,6 +13,66 @@ type Wire = Omit<VehicleCareRecord, 'recordVersion' | 'details' | 'files'> & {
files: Array<Omit<VehicleCareRecord['files'][number], 'sizeBytes'> & { sizeBytes: Numeric }>
}
const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}`
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<VehicleCareFinanceState, string> = {
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<VehicleCareFinanceStatus[]> {
if (!recordNos.length) return []
type Cost = NonNullable<VehicleCareFinanceStatus['finance']>
type StatusWire = Omit<VehicleCareFinanceStatus, 'sourceVersion' | 'submittedRecordVersion' | 'finance'> & {
sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null
finance?: (Omit<Cost, 'paidAmount' | 'outstandingAmount' | 'source'> & { paidAmount: Numeric; outstandingAmount?: Numeric | null;
source: Omit<Cost['source'], 'originalAmount' | 'amount' | 'exchangeRate'> & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null
}
const response = unwrapApiResponse(await http.post<ApiEnvelope<StatusWire[]>, ApiEnvelope<StatusWire[]>>(`${path(vehicleId)}/finance-status`, { recordNos }))
const seen = new Set<string>()
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<VehicleCareFinanceSubmitted> {
type Wire = Omit<VehicleCareFinanceSubmitted, 'sourceVersion' | 'submittedRecordVersion'> & { sourceVersion: Numeric; submittedRecordVersion: Numeric }
const response = unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${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<Array<{ id: string; name: string }>> {
type Supplier = { id: Numeric; name: string; status: string }
const response = unwrapApiResponse(await http.get<ApiEnvelope<{ records: Supplier[] }>, 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('运维接口返回了无效的分页或版本')
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { createFleetQuote, updateFleetQuote, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>()
const emit = defineEmits<{ saved: [] }>()
type QuoteForm = Omit<FleetQuoteDraft, 'distanceMiles'> & { distanceMiles: number | null }
const empty = (): QuoteForm => ({ kind: props.kind, partyId: '', originCity: '', destinationCode: '', distanceMiles: null, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: null, looseDeliveryPrice: null, validFrom: new Date().toISOString().slice(0, 10), validTo: '', remark: null })
const form = reactive<QuoteForm>(empty()), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
let pending: { idempotencyKey: string; details: FleetQuoteDraft } | null = null
const uncertain = ref(false)
watch([open, () => props.quote?.quoteNo], () => {
if (!open.value) return
uncertain.value = !props.quote && pending !== null
Object.assign(form, props.quote?.details || pending?.details || empty()); error.value = null
})
async function save() {
if (props.readonly || saving.value) return
const editing = props.quote
saving.value = true; error.value = null
try {
if (!form.partyId || !form.originCity.trim() || !form.destinationCode.trim() || !form.validFrom || !form.validTo) throw new Error('请选择往来单位并填写路线和有效期')
if (form.distanceMiles == null || !Number.isFinite(form.distanceMiles)) throw new Error('请填写有效的预估距离')
if (form.selfPickupPrice == null && form.fullDeliveryPrice == null && form.looseDeliveryPrice == null) throw new Error('至少填写一种价格,零表示明确零价')
const details: FleetQuoteDraft = { ...form, distanceMiles: form.distanceMiles }
if (editing) await updateFleetQuote(editing, details)
else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) }
if (!editing) pending = null
uncertain.value = false; open.value = false; emit('saved')
} catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending = null; uncertain.value = !editing && pending !== null }
finally { saving.value = false }
}
</script>
<template><AppDialog v-model="open" :title="readonly ? '报价详情' : quote ? '编辑报价' : '新增报价'" width="780px" class="admin-density-dialog" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="uncertain">保存结果尚未确认,重试将发送原请求和原内容。</p>
<el-form label-position="top" class="density-form quote-form" :disabled="readonly || saving || uncertain">
<el-form-item label="用途"><el-input :model-value="form.kind === 'PURCHASE' ? '车队采购报价' : '外接销售报价'" disabled /></el-form-item>
<el-form-item :label="form.kind === 'PURCHASE' ? '海外车队供应商' : '委托客户'" required><FleetQuotePartySelect v-model="form.partyId" :kind="form.kind" :party-name="quote?.partyName" :disabled="readonly || saving || uncertain" /></el-form-item>
<el-form-item label="发货城市" required><el-input v-model="form.originCity" maxlength="128" placeholder="请输入发货城市" /></el-form-item>
<el-form-item label="目的仓码" required><el-input v-model="form.destinationCode" maxlength="64" placeholder="请输入目的仓码,如 ONT8" /></el-form-item>
<el-form-item label="预估距离(mile)" required><el-input-number :model-value="form.distanceMiles" @update:model-value="form.distanceMiles = $event ?? null" :min="0" :precision="1" placeholder="请输入预估英里数" /></el-form-item>
<el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="整车自提 / 每单"><el-input-number :model-value="form.selfPickupPrice" @update:model-value="form.selfPickupPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="整车派送 / 每单"><el-input-number :model-value="form.fullDeliveryPrice" @update:model-value="form.fullDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="散板派送 / 每托"><el-input-number :model-value="form.looseDeliveryPrice" @update:model-value="form.looseDeliveryPrice = $event ?? null" :min="0" :precision="2" placeholder="选填,留空表示未报价" /></el-form-item>
<el-form-item label="生效日" required><el-date-picker v-model="form.validFrom" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="失效日(含当天)" required><el-date-picker v-model="form.validTo" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充报价说明" /></el-form-item>
</el-form><p>留空表示未报价;报价变更不自动覆盖已确认承运价格,也不生成应收或应付。</p>
<template #footer><el-button @click="open = false">关闭</el-button><el-button v-if="!readonly" type="primary" :loading="saving" @click="save">{{ uncertain ? '按原请求重试' : '保存报价' }}</el-button></template>
</AppDialog></template>
<style scoped>.quote-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import FleetQuotePartySelect from './FleetQuotePartySelect.vue'
import { importFleetQuotes, saveFleetCsv, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from '../fleetQuoteCsv'
const open = defineModel<boolean>({ default: false }), props = defineProps<{ kind: FleetQuoteKind }>(), emit = defineEmits<{ saved: [] }>()
const partyId = ref(''), rows = ref<FleetQuoteDraft[]>([]), source = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{ key: string; rows: FleetQuoteDraft[] } | null>(null)
const activeKind = computed(() => pending.value?.rows[0]?.kind || props.kind)
watch([partyId, () => props.kind], () => { if (!pending.value) preview() })
function preview() { try { rows.value = source.value ? parseFleetQuoteCsv(source.value, props.kind, partyId.value) : []; error.value = null } catch (e) { rows.value = []; error.value = getApiErrorDetails(e, 'CSV校验失败') } }
async function pick(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }; source.value = await file.text(); preview() }
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
async function save() { saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.value = null } finally { saving.value = false } }
</script>
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
<FleetQuotePartySelect v-model="partyId" :kind="activeKind" :disabled="saving || !!pending" />
<el-button @click="template">下载CSV模板</el-button><input type="file" accept=".csv" aria-label="选择报价CSV" :disabled="saving || !!pending" @change="pick" />
<el-alert v-if="error" type="error" :title="error.message" :closable="false"><ApiErrorMeta :details="error" /></el-alert>
<p v-if="pending">已保留原批次和原内容。结果未确认时请按原请求重试,避免重复导入。</p>
<p aria-live="polite">共 {{ rows.length }} 条,预览前10条;目录有效性与重复组合由服务端核对。</p>
<table class="density-table"><thead><tr><th>发货城市</th><th>目的仓码</th><th>距离(mile)</th><th>币种</th><th>整车自提</th><th>整车派送</th><th>散板派送</th><th>有效期</th></tr></thead><tbody><tr v-for="(row, index) in rows.slice(0,10)" :key="index"><td>{{ row.originCity }}</td><td>{{ row.destinationCode }}</td><td>{{ row.distanceMiles }}</td><td>{{ row.currency }}</td><td>{{ row.selfPickupPrice ?? '—' }}</td><td>{{ row.fullDeliveryPrice ?? '—' }}</td><td>{{ row.looseDeliveryPrice ?? '—' }}</td><td>{{ row.validFrom }} — {{ row.validTo }}</td></tr></tbody></table>
<template #footer><el-button @click="open = false">关闭</el-button><el-button type="primary" :disabled="!rows.length || !partyId" :loading="saving" @click="save">{{ pending ? '按原请求重试' : '确认导入' }}</el-button></template>
</AppDialog></template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { fleetQuoteCustomers, fleetQuoteSuppliers, type FleetQuoteKind } from '../api/fleetQuotes'
const value = defineModel<string>({ default: '' })
const props = defineProps<{ kind: FleetQuoteKind; partyName?: string; disabled?: boolean }>()
const access = useAccessPreviewStore(), rows = ref<{ id: string; name: string }[]>([]), loading = ref(false), error = ref('')
let sequence = 0
watch(() => props.kind, () => { rows.value = []; error.value = ''; sequence++ })
async function search(keyword = '') {
if (props.disabled || (props.kind === 'PURCHASE' && !access.can('platform:supplier:list'))) return
const current = ++sequence; loading.value = true; error.value = ''
try { const result = props.kind === 'SALE' ? await fleetQuoteCustomers(keyword) : await fleetQuoteSuppliers(keyword); if (sequence === current) rows.value = result }
catch { if (sequence === current) error.value = '目录加载失败,请重新搜索' } finally { if (sequence === current) loading.value = false }
}
onBeforeUnmount(() => { sequence++ })
</script>
<template><el-select v-model="value" filterable remote :remote-method="search" :loading="loading" :disabled="disabled || (kind === 'PURCHASE' && !access.can('platform:supplier:list'))" placeholder="搜索并选择往来单位" @focus="search()">
<el-option v-if="value && partyName && !rows.some(r => r.id === value)" :value="value" :label="partyName" />
<el-option v-for="row in rows" :key="row.id" :value="row.id" :label="row.name" />
</el-select><p v-if="error" role="alert">{{ error }}</p><p v-if="kind === 'PURCHASE' && !access.can('platform:supplier:list')">选择车队需要供应商列表权限。</p></template>
@@ -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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const wrapper = shallowMount(TripCostPanel, { props: { tripNo: 'TR-ONE' }, global: { stubs: {
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
ElInput: { ...input, name: 'ElInput' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElSelect: input, ElDatePicker: input,
AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存</button></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page', 'update:pageSize'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.costNo"><slot name="actions" :row="row"/></div></div>' },
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions.filter(a => a.visible !== false)" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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('尚无实际发车装载记录')
})
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { listTripCosts, createTripCost, updateTripCost, listTripCostHistory,
type CostCategory, type CostCurrency, type TripCost, type TripCostDraft, type CostSummary, type TripCostHistory } from '../api/tripCosts'
const props = defineProps<{ tripNo: string }>()
const access = useAccessPreviewStore()
const allowed = computed(() => access.can(permission.tripList))
const rows = ref<TripCost[]>([]), summary = ref<CostSummary | null>(null), page = ref(1), size = ref(10), total = ref(0), loading = ref(false)
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false), editing = ref<TripCost | null>(null), opening = ref(false), saving = ref(false), reason = ref('')
const form = reactive<{ category: CostCategory; currency: CostCurrency; amount: number | null; incurredAt: string; remark: string }>({ category: 'FUEL', currency: 'USD', amount: null, incurredAt: '', remark: '' })
const pendingCreate = ref<{ key: string; details: TripCostDraft } | null>(null)
const writable = computed(() => allowed.value && access.can(editing.value ? permission.tripCostEdit : permission.tripCostCreate))
const historyOpen = ref(false), historyNo = ref(''), historyPage = ref(1), historyTotal = ref(0), historyLoading = ref(false)
const histories = ref<TripCostHistory[]>([]), historyError = ref<ApiErrorDetails | null>(null)
const categories: Record<CostCategory, string> = { FUEL: '燃油', DRIVER: '司机费用', TOLL: '通行费', PARKING: '停车费', OTHER: '其他直接费用' }
let generation = 0, historyGeneration = 0, scopeGeneration = 0, lastSaved = ''
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const money = (value: number | null) => value == null ? '暂未分摊' : value.toFixed(2)
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'costNo', prop: 'costNo', label: '成本编号', minWidth: 220 },
{ key: 'category', label: '费用类别', width: 140, slotName: 'category' },
{ key: 'amount', label: '登记金额', width: 160, slotName: 'amount' },
{ key: 'incurredAt', label: '发生时间', minWidth: 180, slotName: 'time' },
{ key: 'remark', label: '备注', minWidth: 260, slotName: 'remark' },
]
const allocationColumns: DataTableColumn[] = [
{ key: 'sourceType', label: '货物来源', width: 120, slotName: 'source' },
{ key: 'businessNo', prop: 'businessNo', label: '业务编号', minWidth: 220 },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车', minWidth: 220 },
{ key: 'palletSpaces', prop: 'palletSpaces', label: '实际标准托位', minWidth: 140 },
{ key: 'amount', label: '分摊金额', minWidth: 160, slotName: 'amount' },
]
const historyColumns: DataTableColumn[] = [
{ key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'time' },
{ key: 'action', label: '操作', width: 90, slotName: 'action' },
{ key: 'recordVersion', prop: 'recordVersion', label: '版本', width: 80 },
{ key: 'amount', label: '当时金额', minWidth: 150, slotName: 'amount' },
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 230 },
]
function draft(): TripCostDraft { return { category: form.category, currency: form.currency, amount: form.amount!, incurredAt: form.incurredAt, remark: form.remark.trim() || null } }
function actions(): RowActionItem[] { return [
{ key: 'edit', label: '更正', visible: access.can(permission.tripCostEdit), disabled: opening.value || saving.value },
{ key: 'history', label: '历史', disabled: opening.value || saving.value },
] }
async function load() {
const request = ++generation
rows.value = []; total.value = 0; summary.value = null; error.value = null
if (!allowed.value) { loading.value = false; return }
loading.value = true
try {
const result = await listTripCosts(props.tripNo, page.value, size.value)
if (request === generation) { rows.value = result.page.records; total.value = result.page.total; summary.value = result.summary }
} catch (cause) { if (request === generation) error.value = getApiErrorDetails(cause, '车次成本加载失败') }
finally { if (request === generation) loading.value = false }
}
watch([() => props.tripNo, page, size, allowed], (current, previous) => {
if (previous?.length && current[0] !== previous[0]) { scopeGeneration++; generation++; historyGeneration++; editorOpen.value = false; historyOpen.value = false; pendingCreate.value = null; opening.value = false; saving.value = false }
if (!current[3]) { scopeGeneration++; editorOpen.value = false; historyOpen.value = false; histories.value = []; historyGeneration++; opening.value = false; saving.value = false }
if (previous?.length && (current[0] !== previous[0] || current[2] !== previous[2]) && page.value !== 1) { page.value = 1; return }
void load()
}, { immediate: true })
async function beginEdit(row: TripCost | null) {
if (opening.value || saving.value) return
if (row && pendingCreate.value) { ElMessage.warning('请先重新打开登记窗口,核对上次保存结果'); return }
if (!allowed.value || !access.can(row ? permission.tripCostEdit : permission.tripCostCreate)) return
const scope = props.tripNo, request = scopeGeneration
opening.value = true; saveError.value = null
try {
let current = row
if (row) {
const result = await listTripCostHistory(scope, row.costNo, 1, 1)
current = result.records[0]?.snapshot.cost ?? null
if (!current) throw new Error('成本记录最新版本不存在,请刷新列表')
}
if (request !== scopeGeneration || scope !== props.tripNo) return
editing.value = current; reason.value = ''
if (!pendingCreate.value) Object.assign(form, current ? { ...current.details, remark: current.details.remark ?? '' }
: { category: 'FUEL', currency: 'USD', amount: null, incurredAt: new Date().toISOString(), remark: '' })
lastSaved = current ? JSON.stringify(draft()) : ''
editorOpen.value = true
} catch (cause) { if (scope === props.tripNo && request === scopeGeneration) error.value = getApiErrorDetails(cause, '成本详情加载失败') }
finally { if (scope === props.tripNo && request === scopeGeneration) opening.value = false }
}
async function save() {
if (saving.value || !writable.value) return
if (form.amount == null || !Number.isFinite(form.amount) || form.amount < 0 || !form.incurredAt) { ElMessage.warning('请填写金额和实际发生时间'); return }
const details = pendingCreate.value?.details ?? draft()
if (editing.value && JSON.stringify(details) === lastSaved) { editorOpen.value = false; return }
const scope = props.tripNo, request = scopeGeneration
saving.value = true; saveError.value = null
try {
if (editing.value) await updateTripCost(scope, editing.value, details, reason.value)
else {
pendingCreate.value ??= { key: crypto.randomUUID(), details }
await createTripCost(scope, pendingCreate.value.key, pendingCreate.value.details)
}
if (scope !== props.tripNo || request !== scopeGeneration) return
pendingCreate.value = null; editorOpen.value = false; ElMessage.success('车次成本已保存')
if (page.value !== 1) page.value = 1
else await load()
} catch (cause) {
if (scope !== props.tripNo || request !== scopeGeneration) return
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
saveError.value = getApiErrorDetails(cause, '成本保存失败')
} finally { if (scope === props.tripNo && request === scopeGeneration) saving.value = false }
}
function showHistory(row: TripCost) { historyNo.value = row.costNo; historyPage.value = 1; historyOpen.value = true }
async function loadHistory() {
const request = ++historyGeneration
histories.value = []; historyTotal.value = 0; historyError.value = null
if (!historyOpen.value || !allowed.value) return
historyLoading.value = true
try {
const result = await listTripCostHistory(props.tripNo, historyNo.value, historyPage.value, 10)
if (request === historyGeneration) { histories.value = result.records; historyTotal.value = result.total }
} catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '成本历史加载失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
watch([historyOpen, historyNo, historyPage], loadHistory)
onBeforeUnmount(() => { scopeGeneration++; generation++; historyGeneration++ })
</script>
<template>
<section aria-label="车次直接成本">
<p v-if="!allowed">暂无车次成本查看权限。</p>
<template v-else>
<p class="cost-note">按实际费用登记,金额按币种分别汇总。运营分摊用于单趟分析,不代表已付款,也不生成客户账单。</p>
<LoadErrorState v-if="error" title="车次成本加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="summary">
<p v-if="!summary.totals.length" class="cost-note">尚未登记直接成本。</p>
<p v-if="summary.allocationBlockReason" class="cost-note">{{ summary.allocationBlockReason }}</p>
<div v-for="value in summary.totals" :key="value.currency" class="cost-summary">
<strong>{{ value.currency }} · 已登记直接成本 {{ value.directCost.toFixed(2) }}</strong>
<span>自有货分摊 {{ money(value.ownCost) }} · 外接货分摊 {{ money(value.externalCost) }}</span>
<DataTable v-if="value.allocations.length" :data="value.allocations" :columns="allocationColumns" row-key="businessNo">
<template #source="{ row }">{{ row.sourceType === 'EXTERNAL' ? '外接货' : '自有业务货物' }}</template>
<template #amount="{ row }">{{ value.currency }} {{ row.amount.toFixed(2) }}</template>
</DataTable>
</div>
</template>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="costNo" empty-text="暂无直接成本记录">
<template #toolbar-actions><el-button :disabled="loading || saving" @click="load">刷新</el-button><el-button v-if="access.can(permission.tripCostCreate)" type="primary" :disabled="opening || saving" @click="beginEdit(null)">登记成本</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'edit' ? beginEdit(row as TripCost) : showHistory(row as TripCost)" /></template>
<template #category="{ row }">{{ categories[row.details.category as CostCategory] }}</template>
<template #amount="{ row }">{{ row.details.currency }} {{ row.details.amount.toFixed(2) }}</template>
<template #time="{ row }">{{ time(row.details.incurredAt) }}</template>
<template #remark="{ row }">{{ row.details.remark || '—' }}</template>
</DataTable>
</template>
<AppDialog v-model="editorOpen" :title="editing ? '更正车次直接成本' : '登记车次直接成本'" width="680px" class="admin-density-dialog" confirm-text="保存成本" :confirm-loading="saving" :confirm-disabled="!writable" @confirm="save">
<LoadErrorState v-if="saveError" title="成本保存未完成" :message="saveError.message" @retry="save"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
<p v-if="pendingCreate" class="cost-note">正在核对上次登记结果,重试会使用同一请求,不重复记账。</p>
<el-form label-position="top" :disabled="saving || !writable || !!pendingCreate" class="cost-form">
<el-form-item label="费用类别" required><el-select v-model="form.category" placeholder="请选择费用类别"><el-option v-for="(label, key) in categories" :key="key" :value="key" :label="label" /></el-select></el-form-item>
<el-form-item label="币种" required><el-select v-model="form.currency" placeholder="请选择原始币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
<el-form-item label="实际金额" required><el-input-number v-model="form.amount" :min="0" :max="999999999999.99" :precision="2" placeholder="请输入金额,可明确登记为零" /></el-form-item>
<el-form-item label="发生时间" required><el-date-picker v-model="form.incurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择实际发生时间" /></el-form-item>
<el-form-item label="备注(选填)" class="cost-wide"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充费用用途或凭据说明" /></el-form-item>
<el-form-item v-if="editing" label="更正说明(选填)" class="cost-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次更正原因" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="historyOpen" :title="`${historyNo} · 成本历史`" width="1080px" class="admin-density-dialog" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="historyError" title="成本历史加载失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState>
<DataTable v-model:page="historyPage" :page-size="10" :data="histories" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
<template #time="{ row }">{{ time(row.occurredAt) }}</template>
<template #action="{ row }">{{ row.action === 'CREATE' ? '登记' : '更正' }}</template>
<template #amount="{ row }">{{ row.snapshot.cost.details.currency }} {{ row.snapshot.cost.details.amount.toFixed(2) }}</template>
</DataTable>
</AppDialog>
</section>
</template>
<style scoped>
.cost-note{color:var(--cc-muted);margin:0 0 16px}.cost-summary{display:grid;gap:12px;margin:0 0 20px}.cost-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.cost-wide{grid-column:1/-1}.cost-form :deep(.el-select),.cost-form :deep(.el-input-number),.cost-form :deep(.el-date-editor){width:100%}
</style>
@@ -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<typeof api>()), 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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const wrapper = shallowMount(TripHandoverPanel, { props: { detail: structuredClone(detail) }, global: { stubs: {
ElButton: { template: '<button><slot/></button>' }, ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElOption: true,
ElInput: { ...input, name: 'ElInput' }, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: input,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.handoverNo"><slot name="actions" :row="row"/></div></div>' },
RowActions: { name: 'RowActions', props: ['actions'], emits: ['action'], template: '<div><button v-for="a in actions" :key="a.key" @click="$emit(\'action\', a.key)">{{ a.label }}</button></div>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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<ReturnType<typeof api.listTripHandovers>>) => 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)
})
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { listVehicles } from '../api/overseasMasterData'
import { listDrivers } from '../api/drivers'
import { confirmTripHandover, previewTripHandover, listTripHandovers, releaseHandoverResource, handoverNumber, handoverVersion,
type HandoverCommand, type HandoverPreview, type HandoverRecord, type HandoverRelease, type HandoverSelection } from '../api/tripHandovers'
import type { TripDetail } from '../tripTypes'
import type { VehicleDetail } from '../types'
import type { DriverDetail } from '../driverTypes'
const props = defineProps<{ detail: TripDetail }>()
const emit = defineEmits<{ saved: [] }>()
const access = useAccessPreviewStore()
const readable = computed(() => access.can('platform:fleet:trip:list'))
const writable = computed(() => readable.value && access.can('platform:fleet:trip:handover'))
const releasable = computed(() => readable.value && access.can('platform:fleet:trip:release'))
const rows = ref<HandoverRecord[]>([]), page = ref(1), size = ref(10), total = ref(0), loading = ref(false), busy = ref(false)
const error = ref<ApiErrorDetails | null>(null), commandError = ref<ApiErrorDetails | null>(null)
const preview = ref<HandoverPreview | null>(null), selected = ref<HandoverRecord | null>(null)
const pending = ref<HandoverCommand | null>(null), pendingRelease = ref<{ number: string; command: HandoverRelease } | null>(null)
const vehicles = ref<VehicleDetail[]>([]), drivers = ref<DriverDetail[]>([])
const vehicleLoading = ref(false), driverLoading = ref(false)
const form = reactive({ vehicleId: '', driverId: '', handedOverAt: '', expectedDriverEndAt: '', location: '', reason: '' })
const ending = ref<'VEHICLE' | 'DRIVER' | null>(null)
const endForm = reactive({ actualEndAt: '', location: '', vehicleStatus: 'IN_SERVICE' })
let scope = 0, query = 0, vehicleQuery = 0, driverQuery = 0, previewQuery = 0
const time = (value: string) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
{ key: 'handoverNo', prop: 'handoverNo', label: '交接编号', minWidth: 220 },
{ key: 'time', label: '实际交接时间', minWidth: 180, slotName: 'time' },
{ key: 'vehicles', label: '交接前后车辆', minWidth: 200, slotName: 'vehicles' },
{ key: 'drivers', label: '交接前后司机', minWidth: 180, slotName: 'drivers' },
{ key: 'location', label: '交接地点', minWidth: 200, slotName: 'location' },
]
function canEnd(row: HandoverRecord, kind: 'VEHICLE' | 'DRIVER') {
const vehicle = kind === 'VEHICLE', before = row.snapshot.previous, after = row.snapshot.replacement
return releasable.value && (vehicle ? before.vehicleId !== after.vehicleId && !row.vehicleRelease && before.vehicleId !== props.detail.trip.vehicleId
: before.driverId !== after.driverId && !row.driverRelease && before.driverId !== props.detail.trip.driverId)
}
function actions(row: HandoverRecord): RowActionItem[] {
return [{ key: 'detail', label: '查看交接' }, ...(canEnd(row, 'VEHICLE') ? [{ key: 'VEHICLE', label: '结束原车辆任务' }] : []),
...(canEnd(row, 'DRIVER') ? [{ key: 'DRIVER', label: '结束原司机任务' }] : [])]
}
function show(row: HandoverRecord, action: string) {
if (busy.value || pending.value || pendingRelease.value) return
selected.value = row; ending.value = action === 'VEHICLE' || action === 'DRIVER' ? action : null
endForm.actualEndAt = new Date().toISOString(); endForm.location = ''; endForm.vehicleStatus = 'IN_SERVICE'; commandError.value = null
}
async function load() {
const token = ++query
if (!readable.value) { rows.value = []; total.value = 0; return }
loading.value = true; error.value = null
try { const result = await listTripHandovers(props.detail.trip.tripNo, page.value, size.value); if (token === query) { rows.value = result.records; total.value = result.total } }
catch (cause) { if (token === query) error.value = getApiErrorDetails(cause, '交接记录加载失败') }
finally { if (token === query) loading.value = false }
}
watch(() => props.detail.trip.tripNo, () => {
scope++; query++; previewQuery++; vehicleQuery++; driverQuery++; pending.value = null; pendingRelease.value = null
busy.value = false; preview.value = null; selected.value = null; ending.value = null; commandError.value = null
vehicles.value = []; drivers.value = []; vehicleLoading.value = false; driverLoading.value = false
Object.assign(form, { vehicleId: props.detail.trip.vehicleId || '', driverId: props.detail.trip.driverId || '', handedOverAt: new Date().toISOString(), expectedDriverEndAt: props.detail.trip.expectedDriverEndAt, location: '', reason: '' })
page.value = 1
}, { immediate: true })
watch([() => props.detail.trip.tripNo, page, size, readable], load, { immediate: true })
watch([() => props.detail.trip.recordVersion, () => form.vehicleId, () => form.driverId, () => form.handedOverAt, () => form.expectedDriverEndAt], () => { previewQuery++; preview.value = null })
async function searchVehicles(keyword = '') {
const token = ++vehicleQuery; vehicleLoading.value = true
try { const result = await listVehicles({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', status: 'IN_SERVICE' }); if (token === vehicleQuery) vehicles.value = result.records }
catch (cause) { if (token === vehicleQuery) commandError.value = getApiErrorDetails(cause, '车辆候选读取失败') }
finally { if (token === vehicleQuery) vehicleLoading.value = false }
}
async function searchDrivers(keyword = '') {
const token = ++driverQuery; driverLoading.value = true
try { const result = await listDrivers({ page: 1, size: 30, keyword: keyword || undefined, region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: form.expectedDriverEndAt }); if (token === driverQuery) drivers.value = result.records }
catch (cause) { if (token === driverQuery) commandError.value = getApiErrorDetails(cause, '司机候选读取失败') }
finally { if (token === driverQuery) driverLoading.value = false }
}
function selection(): HandoverSelection {
return { recordVersion: props.detail.trip.recordVersion, vehicleId: form.vehicleId, driverId: form.driverId,
handedOverAt: new Date(form.handedOverAt).toISOString(), expectedDriverEndAt: new Date(form.expectedDriverEndAt).toISOString() }
}
async function inspect() {
if (busy.value || pending.value || !writable.value) return
if (!form.vehicleId || !form.driverId || !form.handedOverAt || !form.expectedDriverEndAt) { ElMessage.warning('请选择接替资源并填写交接及预计任务结束时间'); return }
const token = ++previewQuery, generation = scope; busy.value = true; commandError.value = null; preview.value = null
try { const result = await previewTripHandover(props.detail.trip.tripNo, selection()); if (token === previewQuery && generation === scope) preview.value = result }
catch (cause) { if (generation === scope) commandError.value = getApiErrorDetails(cause, '交接核对未通过') }
finally { if (generation === scope) busy.value = false }
}
async function confirm() {
if (busy.value || !writable.value || (!pending.value && !preview.value)) return
if (!pending.value && !form.location.trim()) { ElMessage.warning('请填写实际交接地点'); return }
const generation = scope; busy.value = true; commandError.value = null
try {
pending.value ??= { selection: selection(), cargoHash: preview.value!.cargoHash, location: form.location.trim(), reason: form.reason.trim() || undefined, idempotencyKey: crypto.randomUUID() }
const result = await confirmTripHandover(props.detail.trip.tripNo, pending.value)
if (generation !== scope) return
pending.value = null; preview.value = null; selected.value = result; emit('saved'); ElMessage.success('交接已记录,请分别核对原资源任务结束')
await load()
} catch (cause) { if (generation === scope) { if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) { pending.value = null; preview.value = null } commandError.value = getApiErrorDetails(cause, '交接未确认,请核对或重试') } }
finally { if (generation === scope) busy.value = false }
}
async function release() {
if (busy.value || !releasable.value || (!pendingRelease.value && (!selected.value || !ending.value))) return
if (!pendingRelease.value && (!endForm.actualEndAt || !endForm.location.trim())) { ElMessage.warning('请填写实际任务结束时间和地点'); return }
const generation = scope; busy.value = true; commandError.value = null
try {
pendingRelease.value ??= { number: selected.value!.handoverNo, command: { recordVersion: handoverVersion(selected.value!.recordVersion), resourceKind: ending.value!, actualEndAt: new Date(endForm.actualEndAt).toISOString(), location: endForm.location.trim(), vehicleStatus: ending.value === 'VEHICLE' ? endForm.vehicleStatus : undefined, idempotencyKey: crypto.randomUUID() } }
const result = await releaseHandoverResource(props.detail.trip.tripNo, pendingRelease.value.number, pendingRelease.value.command)
if (generation !== scope) return
pendingRelease.value = null; selected.value = result; ending.value = null; emit('saved'); ElMessage.success('原资源任务已结束'); await load()
} catch (cause) {
if (generation === scope) {
commandError.value = getApiErrorDetails(cause, '任务结束未确认,请核对或重试')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) {
pendingRelease.value = null
const number = selected.value?.handoverNo
await load()
if (generation === scope) {
selected.value = rows.value.find(row => row.handoverNo === number) || null
if (!selected.value) ending.value = null
}
}
}
}
finally { if (generation === scope) busy.value = false }
}
onBeforeUnmount(() => { scope++; query++; previewQuery++; vehicleQuery++; driverQuery++ })
</script>
<template>
<section aria-label="在途交接" class="handover-panel">
<p v-if="!readable">暂无车次交接查看权限。</p>
<template v-else>
<p>交接后原车辆和司机分别结束实际任务。已经签收或完成退回的货物保留原运输记录。</p>
<LoadErrorState v-if="error" title="交接记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="writable && (detail.trip.status === 'IN_TRANSIT' || pending)">
<p v-if="pending">上次交接结果尚待确认;重试使用原请求及原内容。</p>
<el-form label-position="top" :disabled="busy || !!pending || !!pendingRelease" class="density-form handover-form">
<el-form-item label="接替车辆" required><el-select v-model="form.vehicleId" filterable remote :remote-method="searchVehicles" :loading="vehicleLoading" @focus="searchVehicles()">
<el-option v-if="detail.trip.vehicleId && !vehicles.some(row => row.id === detail.trip.vehicleId)" :value="detail.trip.vehicleId" :label="detail.trip.vehiclePlate || '当前车辆'" />
<el-option v-for="vehicle in vehicles" :key="vehicle.id" :value="vehicle.id" :label="vehicle.plateNumber" />
</el-select></el-form-item>
<el-form-item label="接替司机" required><el-select v-model="form.driverId" filterable remote :remote-method="searchDrivers" :loading="driverLoading" @focus="searchDrivers()">
<el-option v-if="detail.trip.driverId && !drivers.some(row => row.id === detail.trip.driverId)" :value="detail.trip.driverId" :label="detail.trip.driverName || '当前司机'" />
<el-option v-for="driver in drivers" :key="driver.id" :value="driver.id" :label="`${driver.details.name} · ${driver.details.phone}`" />
</el-select></el-form-item>
<el-form-item label="实际交接时间" required><el-date-picker v-model="form.handedOverAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="司机预计任务结束" required><el-date-picker v-model="form.expectedDriverEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="实际交接地点" required><el-input v-model="form.location" maxlength="500" placeholder="请输入实际交接地点" /></el-form-item>
<el-form-item label="交接说明(选填)"><el-input v-model="form.reason" maxlength="1000" placeholder="如车辆故障或司机替班(选填)" /></el-form-item>
</el-form>
<el-button :loading="busy" :disabled="!!pending || !!pendingRelease" @click="inspect">核对交接货物</el-button>
<el-button :disabled="busy || !!pending || !!pendingRelease" @click="emit('saved')">刷新车次</el-button>
<template v-if="preview">
<p v-if="preview.driverQualification.expiringSoon">驾驶证将在30天内到期,当前预计任务结束仍在有效期内。</p>
<p>本次交接 {{ preview.cargo.remainingPalletIds.length }} 个实托,占 {{ handoverNumber(preview.cargo.occupied.palletSpaces) }} 托位、{{ handoverNumber(preview.cargo.occupied.weightKg) }} kg、{{ handoverNumber(preview.cargo.occupied.volumeCbm) }} m³。</p>
<table class="density-table"><thead><tr><th>客户排车单</th><th>交接箱数</th></tr></thead><tbody><tr v-for="(cargo, index) in preview.cargo.remainingCargo" :key="index"><td>{{ cargo.dispatchNo }}</td><td>{{ cargo.packages }}</td></tr></tbody></table>
</template>
<el-button v-if="preview || pending" type="primary" :loading="busy" @click="confirm">{{ pending ? '重试原交接请求' : '确认实际交接' }}</el-button>
</div>
<div v-if="commandError" role="alert"><p>{{ commandError.message }}</p><ApiErrorMeta :details="commandError" /></div>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="handoverNo" empty-text="暂无在途交接记录">
<template #actions="{ row }"><RowActions :actions="actions(row as HandoverRecord)" @action="show(row as HandoverRecord, $event)" /></template>
<template #time="{ row }">{{ time(row.snapshot.handedOverAt) }}</template>
<template #vehicles="{ row }">{{ row.snapshot.previous.vehiclePlate || '—' }} → {{ row.snapshot.replacement.vehiclePlate || '—' }}</template>
<template #drivers="{ row }">{{ row.snapshot.previous.driverName || '—' }} → {{ row.snapshot.replacement.driverName || '—' }}</template>
<template #location="{ row }">{{ row.snapshot.location }}</template>
</DataTable>
<div v-if="selected" class="handover-detail">
<h4>交接记录 {{ selected.handoverNo }}</h4>
<p>{{ selected.snapshot.reason }} · {{ selected.snapshot.location }}</p>
<p>原车辆任务:{{ selected.snapshot.previous.vehicleId === selected.snapshot.replacement.vehicleId ? '车辆继续执行' : selected.vehicleRelease ? `已结束 · ${time(selected.vehicleRelease.actualEndAt)} · ${selected.vehicleRelease.location}` : '尚未结束' }}</p>
<p>原司机任务:{{ selected.snapshot.previous.driverId === selected.snapshot.replacement.driverId ? '司机继续执行' : selected.driverRelease ? `已结束 · ${time(selected.driverRelease.actualEndAt)} · ${selected.driverRelease.location}` : '尚未结束' }}</p>
<p>交接前已完成派送:{{ selected.snapshot.cargo.completedDeliveries.map(row => row.deliveryNo).join('、') || '—' }}</p>
<template v-if="ending || pendingRelease">
<p v-if="pendingRelease">上次任务结束结果尚待确认;重试不会释放后续车次占用。</p>
<el-form label-position="top" class="density-form handover-form" :disabled="busy || !!pendingRelease">
<el-form-item label="实际任务结束时间" required><el-date-picker v-model="endForm.actualEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="实际任务结束地点" required><el-input v-model="endForm.location" maxlength="500" placeholder="请输入原车辆或司机实际结束任务的地点" /></el-form-item>
<el-form-item v-if="ending === 'VEHICLE'" label="车辆实际状态" required><el-select v-model="endForm.vehicleStatus"><el-option value="IN_SERVICE" label="在役" /><el-option value="MAINTENANCE" label="维修中" /><el-option value="INACTIVE" label="停用" /><el-option value="RETURNED" label="已归还" /></el-select></el-form-item>
</el-form>
<el-button v-if="releasable" type="primary" :loading="busy" @click="release">{{ pendingRelease ? '重试原任务结束请求' : '确认原资源任务结束' }}</el-button>
</template>
</div>
</template>
</section>
</template>
<style scoped>
.handover-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}
.handover-form :deep(.el-select){width:100%}.handover-detail{margin-top:var(--admin-space-6)}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
const props = defineProps<{ charts: TripOperationsCharts }>()
const sections = computed(() => [
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
])
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
</script>
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
<div class="chart-values"><div v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><b>{{ series.label }}({{ series.unit }})</b>
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
</div></div>
</article></section></template>
<style scoped>
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
.chart { padding:var(--admin-space-3); border:1px solid var(--el-border-color); border-radius:var(--admin-radius); background:var(--admin-surface); }
.chart p { color:var(--el-text-color-secondary); font-size:var(--admin-font-caption); }
.chart-values { max-height:220px; overflow:auto; }
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
</style>
@@ -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: '<div><slot/></div>' }
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
async function mount(readonly = false) {
const wrapper = shallowMount(TripOperationsRecordDialog, { props: { modelValue: false, row, readonly }, global: { stubs: {
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: slot, ElFormItem: slot,
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: slot, ElInputNumber: input,
ElInput: { ...input, name: 'ElInput' }, ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
ElAlert: { props: ['title'], template: '<div>{{ title }}<slot/></div>' }, 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()
})
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null }), reason = ref('')
const record = ref<TripOperationsRecord | null>(null), loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), tab = ref('measured')
const pending = new Map<string, TripOperationsRecordSave>(), uncertain = ref(false)
const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
let sequence = 0
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }]
async function load() {
if (!open.value || !props.row) return
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); reason.value = retry?.reason || '' }
catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
}
async function loadHistory() {
if (!open.value || !props.row || tab.value !== 'history') return
const current = ++sequence; loading.value = true; error.value = null
try { const result = await listTripOperationsRecordHistory(props.row.tripNo, page.value, size.value); if (current === sequence) { history.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '更正历史读取失败') } finally { if (current === sequence) loading.value = false }
}
async function save() {
if (!record.value || !props.row || props.readonly || saving.value) return
const no = props.row.tripNo; saving.value = true; error.value = null
const request = pending.get(no) || { recordVersion: record.value.recordVersion, idempotencyKey: crypto.randomUUID(), details: { actualDistanceMiles: form.actualDistanceMiles ?? null, actualFuelLitres: form.actualFuelLitres ?? null, remark: form.remark || null }, reason: reason.value || null }
pending.set(no, request)
try { await saveTripOperationsRecord(no, request); pending.delete(no); uncertain.value = false; open.value = false; emit('saved') }
catch (e) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.delete(no); uncertain.value = pending.has(no) }
finally { saving.value = false }
}
watch([open, () => props.row?.tripNo], () => { sequence++; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
watch([tab, page, size], () => { if (tab.value === 'history') void loadHistory() }); onBeforeUnmount(() => { sequence++ })
</script>
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
<el-tabs v-model="tab" class="density-element-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
<el-form label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uncertain">
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
</el-form><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template></DataTable></el-tab-pane></el-tabs>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
</AppDialog></template>
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
@@ -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<typeof import('../api/vehicleCare')>()), 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: '<div><slot name="header"/><slot/></div>' }
@@ -27,7 +27,10 @@ function mount(component: object, props: Record<string, unknown>): VueWrapper {
ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot/></div>' }, ElTabPane: true, DataTable: table, RowActions: true, VehicleCareTable: component === VehicleCareTable ? false : true, VehicleCareEditor: component === VehicleCareEditor ? false : true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.clearAllMocks(); access.write = true; vi.mocked(api.listVehicleCare).mockResolvedValue({ records: [structuredClone(row)], page: 1, size: 10, total: 1, pages: 1 }) })
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('更新处理')
@@ -120,7 +120,7 @@ onBeforeUnmount(() => { editSession++ })
<el-form-item v-if="costRecord" label="费用(申报金额)"><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<p v-if="costRecord" class="care-hint full-width">费用仅作运维记录,尚未关联财务付款。处理完成不会标记为已付款。</p>
<p v-if="costRecord" class="care-hint full-width">此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p>
</template>
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次记录的其他情况(选填)" /></el-form-item>
@@ -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<string>() }))
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<typeof import('../api/companyVehicleCosts')>()), 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: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)"/>' }
function mount() {
const w = shallowMount(VehicleCareFinanceLedger, { props: { costNo: 'VC-ONE' }, global: { stubs: {
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, 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: '<div/>' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, 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()
})
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { companyCostPermissions as permissions, getCompanyCost, commandCompanyCost, payCompanyCost,
listCompanyCostHistory, listCompanyCostPayments, uploadCompanyCostVoucher, downloadCompanyCostVoucher,
type CompanyCost, type CompanyCostCommand, type CompanyCostPayment, type CompanyCostHistory, type CompanyCostPaid, type CompanyCostVoucher } from '../api/companyVehicleCosts'
const props = withDefaults(defineProps<{ costNo: string; active?: boolean }>(), { active: true })
const emit = defineEmits<{ changed: []; busy: [value: boolean] }>()
const access = useAccessPreviewStore(), allowed = computed(() => access.can(permissions.list))
const cost = ref<CompanyCost | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const action = ref<'approve' | 'void' | 'pay' | null>(null), saving = ref(false), uploading = ref(false), writeError = ref<ApiErrorDetails | null>(null)
const reason = ref(''), vouchers = ref<CompanyCostVoucher[]>([]), pendingFiles = ref<File[]>([])
const form = reactive<{ amount: number | null; paidAt: string; method: CompanyCostPayment['method']; referenceNo: string; remark: string }>({ amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
type Pending = { action: 'approve' | 'void'; key: string; request: CompanyCostCommand } | { action: 'pay'; key: string; request: CompanyCostPayment }
const pending = ref<Pending | null>(null)
const tab = ref<'current' | 'history' | 'payments'>('current'), historyPage = ref(1), paymentPage = ref(1)
const history = ref<CompanyCostHistory[]>([]), payments = ref<CompanyCostPaid[]>([]), historyTotal = ref(0), paymentTotal = ref(0)
const recordsLoading = ref(false), recordsError = ref<ApiErrorDetails | null>(null)
let epoch = 0, readRequest = 0, recordsRequest = 0, disposed = false
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
const busy = computed(() => saving.value || uploading.value || !!pending.value)
watch(busy, value => emit('busy', value))
const canApprove = computed(() => allowed.value && access.can(permissions.approve) && cost.value?.status === 'PENDING')
const canVoid = computed(() => allowed.value && access.can(permissions.void) && !!cost.value && ['PENDING', 'APPROVED'].includes(cost.value.status) && cost.value.paidAmount === 0)
const canPay = computed(() => allowed.value && access.can(permissions.pay) && !!cost.value && ['APPROVED', 'PARTIALLY_PAID'].includes(cost.value.status) && (cost.value.outstandingAmount ?? 0) > 0)
const statusLabels = { PENDING: '待财务入账', APPROVED: '已入账 · 未付款', PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废' }
const methods = { BANK_TRANSFER: '银行转账', ONLINE: '线上支付', CASH: '现金', OTHER: '其他' }
const money = (value: number | null) => value == null ? '未形成应付' : `CNY ${value.toFixed(2)}`
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { hour12: false })
const historyColumns: DataTableColumn[] = [
{ key: 'action', label: '操作', width: 90, slotName: 'action' }, { key: 'occurredAt', label: '时间', minWidth: 170, slotName: 'time' },
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 100 }, { key: 'amount', label: '当时金额', minWidth: 130, slotName: 'amount' },
{ key: 'reason', prop: 'reason', label: '说明', minWidth: 160 },
]
const paymentColumns: DataTableColumn[] = [
{ key: 'paymentNo', prop: 'paymentNo', label: '付款编号', minWidth: 170 }, { key: 'amount', label: '已付金额', minWidth: 130, slotName: 'amount' },
{ key: 'paidAt', label: '实际付款时间', minWidth: 170, slotName: 'time' }, { key: 'method', label: '方式', width: 100, slotName: 'method' },
{ key: 'referenceNo', prop: 'referenceNo', label: '流水号', minWidth: 150 }, { key: 'files', label: '付款凭证', minWidth: 130, slotName: 'files' },
]
async function load() {
const request = ++readRequest, scope = epoch; error.value = null; cost.value = null
if (!allowed.value) { loading.value = false; return }
loading.value = true
try { const value = await getCompanyCost(props.costNo); if (!disposed && scope === epoch && request === readRequest) cost.value = value }
catch (cause) { if (!disposed && scope === epoch && request === readRequest) error.value = getApiErrorDetails(cause, '公司费用读取失败') }
finally { if (scope === epoch && request === readRequest) loading.value = false }
}
watch([() => props.costNo, allowed], () => {
epoch++; readRequest++; recordsRequest++; pending.value = null; action.value = null; saving.value = false; uploading.value = false
vouchers.value = []; pendingFiles.value = []; history.value = []; payments.value = []; tab.value = 'current'; historyPage.value = 1; paymentPage.value = 1
writeError.value = null; recordsError.value = null; void load()
}, { immediate: true })
watch(() => props.active, value => { if (value && !busy.value) void load() })
function choose(next: 'approve' | 'void' | 'pay') {
if (busy.value || !(next === 'approve' ? canApprove.value : next === 'void' ? canVoid.value : canPay.value)) return
action.value = next; tab.value = 'current'; writeError.value = null; reason.value = ''
if (next === 'pay' && !form.paidAt) form.paidAt = new Date().toISOString()
}
function selectFiles(event: Event) {
if (busy.value || !access.can(permissions.upload)) return
const input = event.target as HTMLInputElement, files = Array.from(input.files ?? [])
if (vouchers.value.length + pendingFiles.value.length + files.length > 20 || files.some(file => !file.size || file.size > 10 * 1024 * 1024 || !['image/png', 'image/jpeg'].includes(file.type))) {
ElMessage.warning('最多20张非空JPG/PNG付款凭证,每张不超过10MiB'); input.value = ''; return
}
pendingFiles.value.push(...files); input.value = ''; void uploadFiles()
}
async function uploadFiles() {
if (uploading.value || saving.value || pending.value || !access.can(permissions.upload)) return
const scope = epoch, no = props.costNo; uploading.value = true; writeError.value = null
try {
while (pendingFiles.value.length) {
const file = await uploadCompanyCostVoucher(no, pendingFiles.value[0]!)
if (disposed || scope !== epoch) return
if (!vouchers.value.some(value => value.fileId === file.fileId)) vouchers.value.push(file)
pendingFiles.value.shift()
}
} catch (cause) { if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '凭证上传未完成;已成功上传的凭证已保留,尚未登记付款') }
finally { if (scope === epoch) uploading.value = false }
}
async function submit() {
const chosen = pending.value?.action ?? action.value
if (!chosen || saving.value || uploading.value || !allowed.value || !access.can(permissions[chosen])) return
if (!pending.value) {
if (!cost.value || !(chosen === 'approve' ? canApprove.value : chosen === 'void' ? canVoid.value : canPay.value)) return
if (chosen === 'pay') {
if (form.amount == null || !Number.isFinite(form.amount) || form.amount <= 0 || form.amount > (cost.value.outstandingAmount ?? 0)
|| !form.paidAt || !Number.isFinite(Date.parse(form.paidAt)) || !vouchers.value.length || pendingFiles.value.length) {
ElMessage.warning('请填写不超过未付余额的正数金额、实际付款时间,并完成凭证上传'); return
}
pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, amount: form.amount,
paidAt: form.paidAt, method: form.method, referenceNo: form.referenceNo.trim() || null, voucherFileIds: vouchers.value.map(v => v.fileId), remark: form.remark.trim() || null } }
} else pending.value = { action: chosen, key: crypto.randomUUID(), request: { expectedVersion: cost.value.recordVersion, reason: reason.value.trim() || null } }
}
const command = pending.value, scope = epoch, no = props.costNo
saving.value = true; writeError.value = null; readRequest++
try {
const result = command.action === 'pay' ? await payCompanyCost(no, command.key, command.request) : await commandCompanyCost(no, command.action, command.key, command.request)
if (disposed || scope !== epoch) return
cost.value = result; pending.value = null; action.value = null; vouchers.value = []; pendingFiles.value = []
Object.assign(form, { amount: null, paidAt: '', method: 'BANK_TRANSFER', referenceNo: '', remark: '' })
ElMessage.success(chosen === 'pay' ? '实际付款已登记' : chosen === 'void' ? '公司费用已作废' : '公司费用已入账')
emit('changed')
if (tab.value !== 'current') void loadRecords()
} catch (cause) {
if (disposed || scope !== epoch) return
if (cause instanceof ApiResponseError && (/^A04/.test(cause.code) || ['FIN_400', 'FIN_404', 'FIN_409'].includes(cause.code))) pending.value = null
writeError.value = getApiErrorDetails(cause, '财务操作未完成,请保留输入并重试')
} finally { if (scope === epoch) saving.value = false }
}
async function loadRecords() {
const request = ++recordsRequest, scope = epoch, current = tab.value
history.value = []; payments.value = []; historyTotal.value = 0; paymentTotal.value = 0; recordsError.value = null
if (!allowed.value || current === 'current') { recordsLoading.value = false; return }
recordsLoading.value = true
try {
if (current === 'history') {
const result = await listCompanyCostHistory(props.costNo, historyPage.value)
if (!disposed && scope === epoch && request === recordsRequest) { history.value = result.records; historyTotal.value = result.total }
} else {
const result = await listCompanyCostPayments(props.costNo, paymentPage.value)
if (!disposed && scope === epoch && request === recordsRequest) { payments.value = result.records; paymentTotal.value = result.total }
}
} catch (cause) { if (!disposed && scope === epoch && request === recordsRequest) recordsError.value = getApiErrorDetails(cause, '财务记录读取失败') }
finally { if (scope === epoch && request === recordsRequest) recordsLoading.value = false }
}
watch([tab, historyPage, paymentPage], loadRecords)
async function preview(fileId: string) {
if (!allowed.value) return
const target = window.open('about:blank', '_blank')
if (!target) { ElMessage.warning('请允许浏览器打开付款凭证'); return }
target.opener = null; const scope = epoch
try {
const blob = await downloadCompanyCostVoucher(props.costNo, fileId)
if (disposed || scope !== epoch || target.closed) { target.close(); return }
const url = URL.createObjectURL(blob); target.location.href = url
const item = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(item.timer); URL.revokeObjectURL(url); previews.delete(item) } }, 1000) }
previews.add(item)
} catch (cause) { target.close(); if (!disposed && scope === epoch) writeError.value = getApiErrorDetails(cause, '付款凭证读取失败') }
}
onBeforeUnmount(() => { disposed = true; epoch++; readRequest++; recordsRequest++; previews.forEach(item => { clearInterval(item.timer); URL.revokeObjectURL(item.url) }); emit('busy', false) })
</script>
<template>
<section aria-label="公司成本财务处理">
<p v-if="!allowed">暂无公司成本财务查询权限。</p>
<template v-else>
<div class="ledger-actions"><el-button :disabled="loading || saving || uploading" @click="load">刷新费用</el-button><el-button @click="tab = 'current'">费用详情</el-button><el-button @click="tab = 'history'">操作历史</el-button><el-button @click="tab = 'payments'">付款流水</el-button></div>
<LoadErrorState v-if="error" title="公司费用读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<LoadErrorState v-if="writeError" title="财务操作未完成" :message="writeError.message" @retry="pendingFiles.length ? uploadFiles() : submit()"><template #details><ApiErrorMeta :details="writeError" /></template></LoadErrorState>
<p v-if="pending">上次操作结果尚未确认。重试使用原版本、原内容和原请求键。</p>
<template v-if="tab === 'current' && cost">
<p>{{ cost.costNo }} · {{ statusLabels[cost.status] }} · {{ cost.supplierName }}</p>
<p>{{ cost.source.feeName }} · {{ cost.source.businessDate }} · 原币 {{ cost.source.originalCurrency }} {{ cost.source.originalAmount.toFixed(2) }}</p>
<p>汇率月份 {{ cost.source.exchangeRateMonth }} · 汇率 {{ cost.source.exchangeRate }} · 折算 {{ money(cost.source.amount) }}</p>
<p>已付 {{ money(cost.paidAmount) }} · 未付 {{ money(cost.outstandingAmount) }}</p>
<div class="ledger-actions"><el-button v-if="canApprove" :disabled="busy" type="primary" @click="choose('approve')">确认入账</el-button><el-button v-if="canVoid" :disabled="busy" type="danger" plain @click="choose('void')">作废费用</el-button><el-button v-if="canPay" :disabled="busy" type="primary" @click="choose('pay')">登记付款</el-button></div>
<el-form v-if="action" label-position="top" :disabled="saving || uploading || !!pending" class="ledger-form">
<template v-if="action === 'pay'">
<el-form-item label="实际付款金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :max="cost.outstandingAmount ?? 0" :precision="2" placeholder="请输入本次实际付款金额" /></el-form-item>
<el-form-item label="实际付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="付款方式" required><el-select v-model="form.method"><el-option v-for="(label, key) in methods" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<el-form-item label="付款流水号(选填)"><el-input v-model="form.referenceNo" maxlength="128" placeholder="填写银行流水号或支付凭据号" /></el-form-item>
<el-form-item label="付款凭证" required class="ledger-wide">
<input v-if="access.can(permissions.upload)" type="file" accept="image/png,image/jpeg" multiple :disabled="busy" aria-label="上传公司付款凭证" @change="selectFiles" />
<p v-else>登记新付款需公司付款凭证上传权限。</p>
<p v-for="file in vouchers" :key="file.fileId">已上传:{{ file.fileName }}</p>
<p v-if="pendingFiles.length">{{ pendingFiles.length }} 张凭证待完成上传。</p>
<el-button v-if="pendingFiles.length" :disabled="busy" @click="uploadFiles">重试剩余凭证</el-button>
</el-form-item>
<el-form-item label="付款说明(选填)" class="ledger-wide"><el-input v-model="form.remark" type="textarea" maxlength="1000" placeholder="补充本次付款用途或说明" /></el-form-item>
</template>
<el-form-item v-else label="说明(选填)" class="ledger-wide"><el-input v-model="reason" maxlength="1000" placeholder="补充本次入账或作废原因" /></el-form-item>
</el-form>
<template v-if="action">
<p>{{ action === 'pay' ? '确认实际付款及凭证后登记。此操作不会发起银行转账。' : action === 'void' ? '确认作废这笔未付款费用;历史记录保留,重新登记须重新入账。' : '确认将这笔公司费用入账为应付;不会自动付款。' }}</p>
<el-button :loading="saving" :disabled="uploading || !access.can(permissions[action])" :type="action === 'void' ? 'danger' : 'primary'" @click="submit">{{ pending ? '重试原操作' : '确认操作' }}</el-button>
<el-button :disabled="busy" @click="action = null">取消操作</el-button>
</template>
</template>
<LoadErrorState v-if="recordsError" title="财务记录读取失败" :message="recordsError.message" @retry="loadRecords"><template #details><ApiErrorMeta :details="recordsError" /></template></LoadErrorState>
<DataTable v-if="tab === 'history'" v-model:page="historyPage" :page-size="10" :data="history" :columns="historyColumns" :total="historyTotal" :loading="recordsLoading" row-key="recordVersion">
<template #action="{ row }">{{ ({ RECEIVE: '接收', APPROVE: '入账', VOID: '作废', PAY: '付款' } as Record<string, string>)[row.action] || '—' }}</template>
<template #time="{ row }">{{ time(row.occurredAt) }}</template><template #amount="{ row }">{{ money(row.snapshot.source.amount) }}</template>
</DataTable>
<DataTable v-if="tab === 'payments'" v-model:page="paymentPage" :page-size="10" :data="payments" :columns="paymentColumns" :total="paymentTotal" :loading="recordsLoading" row-key="paymentNo">
<template #amount="{ row }">{{ money(row.amount) }}</template><template #time="{ row }">{{ time(row.paidAt) }}</template>
<template #method="{ row }">{{ methods[row.method as keyof typeof methods] || '—' }}</template>
<template #files="{ row }"><el-button v-for="(fileId, index) in row.voucherFileIds" :key="fileId" link type="primary" @click="preview(fileId)">凭证 {{ Number(index) + 1 }}</el-button></template>
</DataTable>
</template>
</section>
</template>
<style scoped>
.ledger-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-2); margin-bottom:var(--admin-space-3); }
.ledger-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }
.ledger-wide { grid-column:1/-1; }
.ledger-form :deep(.el-select), .ledger-form :deep(.el-input-number), .ledger-form :deep(.el-date-editor) { width:100%; }
</style>
@@ -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<VehicleCareRecord[]>([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false)
@@ -20,10 +23,25 @@ const photoError = ref<ApiErrorDetails | null>(null), openingFile = ref('')
const openingRecord = ref(false)
const name = computed(() => vehicleCareLabels[props.recordType])
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
let request = 0, disposed = false
const costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType))
const financeStates = ref<Record<string, VehicleCareFinanceStatus>>({}), financeError = ref<ApiErrorDetails | null>(null), financeLoading = ref(false)
const financeOpen = ref(false), financeTarget = ref<VehicleCareRecord | null>(null), financeSaving = ref(false), financeOpening = ref(false)
const ledgerCostNo = ref(''), ledgerBusy = ref(false)
const financeSaveError = ref<ApiErrorDetails | null>(null), supplierId = ref(''), businessDate = ref('')
const suppliers = ref<Array<{ id: string; name: string }>>([]), 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<typeof setInterval> }>()
const columns = computed<DataTableColumn[]>(() => {
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<DataTableColumn[]>(() => {
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() })
</script>
<template>
<section class="care-records" :aria-label="name">
<h3>{{ name }}</h3>
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
<LoadErrorState v-if="financeError" title="财务状态未确认" :message="financeError.message" @retry="loadFinance"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState>
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="edit(row as VehicleCareRecord)" /></template>
<template #actions="{ row }"><RowActions :actions="actions()" @action="$event === 'finance' ? openFinance(row as VehicleCareRecord) : edit(row as VehicleCareRecord)" /></template>
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
<template #last="{ row }">{{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
<template #current="{ row }">{{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
@@ -100,11 +175,29 @@ onBeforeUnmount(() => { request++; disposed = true; for (const item of previews)
<template #fuel="{ row }">{{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}</template>
<template #exceptionType="{ row }">{{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}</template>
<template #cost="{ row }">{{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}</template>
<template #payment="{ row }">{{ row.paymentStatus === 'UNLINKED' ? '未关联财务' : '—' }}</template>
<template #payment="{ row }">{{ financeLoading ? '查询中…' : financeError ? '状态未确认' : financeStates[row.recordNo] ? vehicleCareFinanceLabels[financeStates[row.recordNo]!.status] : '未查询' }}</template>
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
</DataTable>
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
<AppDialog v-model="financeOpen" title="公司车辆成本" width="620px" class="admin-density-dialog" confirm-text="提交财务"
:show-confirm="canSubmitFinance || !!pendingFinance" :confirm-loading="financeSaving" :confirm-disabled="ledgerBusy || !access.can(vehicleCareFinanceSubmitPermission)" @confirm="submitFinance">
<p>按公司成本登记,不生成客户应收。财务接收、入账和付款分别确认。</p>
<p v-if="financeTarget">{{ financeTarget.recordNo }} · {{ financeTarget.vehiclePlate }} · 申报费用 {{ financeTarget.details.incident?.currency || '—' }} {{ financeTarget.details.incident?.reportedCost?.toFixed(2) ?? '—' }}</p>
<p v-if="financeStatus">{{ vehicleCareFinanceLabels[financeStatus.status] }}</p>
<template v-if="financeStatus?.finance">
<p>{{ financeStatus.finance.costNo }} · {{ financeStatus.finance.supplierName }} · CNY {{ financeStatus.finance.source.amount.toFixed(2) }}</p>
<p>已付 CNY {{ financeStatus.finance.paidAmount.toFixed(2) }} · 未付 {{ financeStatus.finance.outstandingAmount == null ? '未形成应付' : `CNY ${financeStatus.finance.outstandingAmount.toFixed(2)}` }}</p>
</template>
<LoadErrorState v-if="financeSaveError" title="财务提交未完成" :message="financeSaveError.message" @retry="submitFinance"><template #details><ApiErrorMeta :details="financeSaveError" /></template></LoadErrorState>
<p v-if="pendingFinance">提交结果待核对;重试使用原请求,不重复登记。</p>
<el-form v-if="canSubmitFinance || pendingFinance" label-position="top" :disabled="financeSaving || !!pendingFinance">
<el-form-item label="供应商" required><el-select v-model="supplierId" filterable remote :remote-method="searchSuppliers" :loading="suppliersLoading" placeholder="搜索启用的供应商" @focus="searchSuppliers()"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></el-form-item>
<el-form-item label="费用业务日期" required><el-date-picker v-model="businessDate" type="date" value-format="YYYY-MM-DD" placeholder="用于确定原币折算月份" /></el-form-item>
</el-form>
<p v-if="access.can(vehicleCareFinanceSubmitPermission) && !access.can('platform:supplier:list')">提交公司成本还需供应商查询权限。</p>
<VehicleCareFinanceLedger v-if="ledgerCostNo" :cost-no="ledgerCostNo" :active="financeOpen" @changed="loadFinance" @busy="ledgerBusy = $event" />
</AppDialog>
</section>
</template>
<style scoped>.care-records { min-width: 0; }.care-records h3 { margin: 0 0 var(--admin-space-4); }</style>
@@ -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')
})
@@ -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
})
}
@@ -0,0 +1,52 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import 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 FleetQuoteEditor from '../components/FleetQuoteEditor.vue'
import FleetQuoteImport from '../components/FleetQuoteImport.vue'
import { listFleetQuotes, setFleetQuoteStatus, deleteFleetQuote, exportFleetQuotes, saveFleetCsv, type FleetQuote, type FleetQuoteKind, type FleetQuoteQuery } from '../api/fleetQuotes'
const access = useAccessPreviewStore(), rows = ref<FleetQuote[]>([]), total = ref(0), page = ref(1), size = ref(20), loading = ref(false), busy = ref(false)
const kind = ref<FleetQuoteKind>('PURCHASE'), editor = ref(false), importer = ref(false), selected = ref<FleetQuote | null>(null), readonly = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ keyword: '', status: '', effectiveOn: '' }); let applied: FleetQuoteQuery = {}, sequence = 0
const fields: QueryField[] = [{ key: 'keyword', label: '关键词', type: 'input', width: 300, placeholder: '发货城市 / 目的仓码 / 往来单位 / 报价号' }, { key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'ACTIVE', label: '启用' }, { value: 'INACTIVE', label: '停用' }] }, { key: 'effectiveOn', label: '有效日期', type: 'date', width: 170, placeholder: '有效日期(选填)' }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' }, { key: 'quoteNo', label: '报价号', minWidth: 210 },
{ key: 'origin', label: '发货城市', minWidth: 150, slotName: 'origin' }, { key: 'destination', label: '目的仓码', minWidth: 115, slotName: 'destination' }, { key: 'miles', label: '距离(mile)', width: 110, slotName: 'miles' },
{ key: 'partyName', label: '车队 / 委托客户', minWidth: 180 }, { key: 'currency', label: '币种', width: 80, slotName: 'currency' },
{ key: 'pickup', label: '整车自提 / 单', width: 140, slotName: 'pickup' }, { key: 'full', label: '整车派送 / 单', width: 140, slotName: 'full' }, { key: 'loose', label: '散板派送 / 托', width: 140, slotName: 'loose' },
{ key: 'validity', label: '有效期', minWidth: 220, slotName: 'validity' }, { key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedAt', label: '更新时间', minWidth: 190 }]
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const result = await listFleetQuotes({ ...applied, kind: kind.value, page: page.value, size: size.value }); if (current === sequence) { rows.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(e, '报价加载失败') } } finally { if (current === sequence) loading.value = false } }
function search() { applied = { keyword: String(filters.value.keyword || '') || undefined, status: String(filters.value.status || '') || undefined, effectiveOn: String(filters.value.effectiveOn || '') || undefined }; if (page.value !== 1) page.value = 1; else void load() }
function open(row: FleetQuote | null, view = false) { selected.value = row; readonly.value = view; editor.value = true }
function actions(quoteNo: unknown): RowActionItem[] { const row = rows.value.find(item => item.quoteNo === quoteNo); if (!row) return []; return [{ key: 'view', label: '详情', priority: 3 }, { key: 'edit', label: '编辑', priority: 2, visible: access.can('platform:fleet:quote:edit') }, { key: 'status', label: row.status === 'ACTIVE' ? '停用' : '启用', priority: 1, visible: access.can('platform:fleet:quote:status'), disabled: busy.value }, { key: 'delete', label: '删除', tone: 'danger', visible: access.can('platform:fleet:quote:delete'), disabled: busy.value }] }
async function action(key: string, quoteNo: unknown) {
const row = rows.value.find(item => item.quoteNo === quoteNo); if (!row) return
if (key === 'view' || key === 'edit') { open(row, key === 'view'); return }
try { await ElMessageBox.confirm(key === 'delete' ? `删除报价 ${row.quoteNo}?历史报价版本仍保留,已确认承运金额不变。` : `确认${row.status === 'ACTIVE' ? '停用' : '启用'}报价 ${row.quoteNo}?`, '确认操作', { type: 'warning' }) } catch { return }
busy.value = true
try { if (key === 'delete') await deleteFleetQuote(row); else await setFleetQuoteStatus(row, row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'); ElMessage.success('报价已更新'); await load() } catch (e) { error.value = getApiErrorDetails(e, '报价操作失败') } finally { busy.value = false }
}
async function download() { busy.value = true; try { saveFleetCsv(await exportFleetQuotes({ ...applied, kind: kind.value }), '车队报价.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { busy.value = false } }
watch(kind, () => { if (page.value !== 1) page.value = 1; else void load() }); watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout>
<div class="density-status-tabs" role="tablist" aria-label="报价用途"><button v-for="tab in ([['PURCHASE','车队采购报价'],['SALE','外接销售报价']] as const)" :key="tab[0]" role="tab" :aria-selected="kind === tab[0]" :class="{ 'is-active': kind === tab[0] }" @click="kind = tab[0]">{{ tab[1] }}</button></div>
<LoadErrorState v-if="error" title="报价操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="车队报价查询" @search="search" @reset="search" />
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="quoteNo" empty-text="暂无报价">
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:quote:create')" type="primary" @click="open(null)">新增报价</el-button><el-button v-if="access.can('platform:fleet:quote:import')" @click="importer = true">批量导入</el-button><el-button v-if="access.can('platform:fleet:quote:export')" :loading="busy" @click="download">导出CSV</el-button></template>
<template #actions="{ row }"><RowActions :actions="actions(row.quoteNo)" @action="action($event,row.quoteNo)" /></template>
<template #origin="{ row }">{{ row.details.originCity }}</template><template #destination="{ row }">{{ row.details.destinationCode }}</template><template #miles="{ row }">{{ row.details.distanceMiles }}</template><template #currency="{ row }">{{ row.details.currency }}</template>
<template #pickup="{ row }">{{ row.details.selfPickupPrice ?? '—' }}</template><template #full="{ row }">{{ row.details.fullDeliveryPrice ?? '—' }}</template><template #loose="{ row }">{{ row.details.looseDeliveryPrice ?? '—' }}</template>
<template #validity="{ row }">{{ row.details.validFrom }} — {{ row.details.validTo }}</template><template #status="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
</DataTable>
<FleetQuoteEditor v-model="editor" :quote="selected" :kind="kind" :readonly="readonly" @saved="load" /><FleetQuoteImport v-model="importer" :kind="kind" @saved="load" />
</ListPageLayout></AdminPage></template>
@@ -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: '<div><slot/></div>' }
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-actions"/></div>' }, ElButton: { template: '<button><slot/></button>' },
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')
})
@@ -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<string, string> = { CREATE: '新建计划', PLAN: '调整计划', ASSIGN: '分配车辆司机', BIND_DISPATCH: '关联排车', UNBIND_DISPATCH: '解除关联',
BOOKING_CREATE: '登记外接询价', BOOKING_EDIT: '更新外接询价', BOOKING_CONFIRM: '确认外接承运', BOOKING_CANCEL: '取消外接预订', BOOKING_INCOMING: '登记外接来货', BOOKING_RECEIPT: '确认外接实收', PLAN_CAPACITY: '核对计划容量', REMARK: '更新备注', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货审核',
CONFIRM_LOAD: '确认装载', DEPART: '整车发车', COMPLETE: '货物结案', CANCEL: '取消计划', RELEASE_VEHICLE: '结束车辆任务', RELEASE_DRIVER: '结束司机任务' }
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++ })
<FormSection class="density-form-section" title="车次概况"><dl class="density-fields"><div v-for="[label, value] in [['起运仓',detail.trip.warehouseName],['车辆',detail.trip.vehiclePlate],['司机',detail.trip.driverName],['联系电话',detail.trip.driverPhone],['计划发车',time(detail.trip.plannedDepartureAt)],['司机预计结束',time(detail.trip.expectedDriverEndAt)],['实际发车',time(detail.trip.departedAt)],['货物结案',time(detail.trip.completedAt)]]" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl>
<p>{{ detail.trip.stops.map(stop => stop.name).join(' → ') }}</p><p v-if="detail.driverQualification">{{ driverQualificationLabels[detail.driverQualification.reason] }}<span v-if="detail.driverQualification.expiringSoon"> · 证照将在 30 天内到期,请留意任务延期</span></p>
</FormSection>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<el-tabs v-model="activeTab" class="density-element-tabs"><el-tab-pane name="overview"><template #label><span>🚚 分配与货物</span></template></el-tab-pane><el-tab-pane name="bookings"><template #label><span>📦 外接预订</span></template></el-tab-pane><el-tab-pane name="capacity"><template #label><span>📋 计划容量</span></template></el-tab-pane><el-tab-pane name="loading"><template #label><span>📦 实际装载</span></template></el-tab-pane><el-tab-pane name="completion"><template #label><span>✅ 结案与任务结束</span></template></el-tab-pane><el-tab-pane name="handovers"><template #label><span>在途交接</span></template></el-tab-pane><el-tab-pane name="costs"><template #label><span>直接成本</span></template></el-tab-pane><el-tab-pane name="history"><template #label><span>🕘 操作历史</span></template></el-tab-pane></el-tabs>
<template v-if="activeTab === 'overview'">
<FormSection v-if="planned && access.can(permission.tripAssign)" class="density-form-section" title="车辆与司机"><TripAssignmentPanel :key="detail.trip.tripNo" :detail="detail" @saved="accept" /></FormSection>
<FormSection class="density-form-section" title="本车排车单"><div v-if="editable" class="trip-inline"><el-input v-model="dispatchNo" placeholder="输入待关联的排车单号" :disabled="!!busy" @keyup.enter="bind" /><el-button type="primary" :loading="busy === 'bind'" :disabled="!!busy || !detail.trip.vehicleId" @click="bind">关联排车单</el-button><el-button :loading="busy === 'stops'" :disabled="!!busy || !detail.dispatches.length" @click="matchStops">按货物匹配卸货站</el-button></div>
@@ -191,6 +193,8 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
</FormSection>
</template>
<FormSection v-if="activeTab === 'bookings'" class="density-form-section" title="本车外接预订"><TripBookingPanel :key="detail.trip.tripNo" :trip="detail.trip" @changed="detail.trip.recordVersion = $event" @linked="refreshLinked" /></FormSection>
<FormSection v-if="activeTab === 'handovers'" class="density-form-section" title="在途车辆与司机交接"><TripHandoverPanel :key="detail.trip.tripNo" :detail="detail" @saved="refreshLinked" /></FormSection>
<FormSection v-if="activeTab === 'costs'" class="density-form-section" title="本趟直接成本"><TripCostPanel :key="detail.trip.tripNo" :trip-no="detail.trip.tripNo" /></FormSection>
<FormSection v-if="activeTab === 'capacity'" class="density-form-section" title="核对本车计划载荷"><TripLoadingPanel :key="`${detail.trip.tripNo}-capacity`" :trip="detail.trip" :editable="editable" planning @planned="capacityPlanned" /></FormSection>
<FormSection v-if="activeTab === 'loading'" class="density-form-section" title="按实物托盘确认装载"><TripLoadingPanel :key="detail.trip.tripNo" :trip="detail.trip" :editable="editable" @saved="loaded" /><div class="trip-review"><p>理货审核:{{ tripReviewLabels[detail.loadingReview.status] }}<span v-if="detail.loadingReview.reason"> · {{ detail.loadingReview.reason }}</span><span v-if="detail.loadingReview.reviewedAt"> · {{ time(detail.loadingReview.reviewedAt) }}</span></p><div v-if="planned && access.can(permission.tripLoadingReview)" class="density-actions"><el-button type="primary" :disabled="!!busy || detail.loadingReview.status === 'APPROVED'" @click="review('APPROVED')">理货审核通过</el-button><el-button type="danger" plain :disabled="!!busy" @click="review('REJECTED')">理货打回</el-button></div></div></FormSection>
<template v-if="activeTab === 'completion'">
@@ -0,0 +1,68 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listTripOperations, exportTripOperations, type TripOperationsQuery, type TripOperationsResult, type TripOperationsMoney, type TripOperationsRow } from '../api/tripOperations'
import { saveFleetCsv } from '../api/fleetQuotes'
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import TripOperationsRecordDialog from '../components/TripOperationsRecordDialog.vue'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
let applied: TripOperationsQuery = range(), sequence = 0
const fields: QueryField[] = [
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', width: 140, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
function range(): TripOperationsQuery {
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
}
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { saveFleetCsv(await exportTripOperations(applied), '车次运营.csv') } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout>
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" @search="search" @reset="search" />
<TripOperationsCharts v-if="result" :charts="result.charts" />
<div v-if="result" class="operations-summary" aria-live="polite">
<span>实际出车 {{ result.summary.trips }} 次</span><span>在途 {{ result.summary.inTransit }} 次</span><span>货物结案 {{ result.summary.completed }} 次</span>
<span>实物托盘 {{ result.summary.physicalPallets }} 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
</div>
<p v-if="result?.summary.costs.visible">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
<p>按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
<DataTable v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export')" :loading="exporting" @click="download">导出CSV</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></template>
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
<template #distance="{ row }">{{ row.measured?.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row }">{{ row.measured?.actualFuelLitres ?? '—' }}</template>
<template #agreed="{ row }">{{ money(row.externalAgreedAmounts) }}</template><template #finance>未查询财务</template>
<template #cost="{ row }"><span v-if="!row.costs">无成本读取权限</span><span v-else-if="!row.costs.totals.length">未登记成本</span><span v-else>{{ row.costs.totals.map((c: NonNullable<TripOperationsRow['costs']>['totals'][number]) => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') }}</span></template>
<template #allocated="{ row }"><span v-if="!row.costs">—</span><span v-else-if="row.costs.allocationBlockReason">{{ row.costs.allocationBlockReason }}</span><span v-else>{{ row.costs.totals.map((c: NonNullable<TripOperationsRow['costs']>['totals'][number]) => `${c.currency} ${c.ownCost == null ? '—' : c.ownCost.toFixed(2)} / ${c.externalCost == null ? '—' : c.externalCost.toFixed(2)}`).join(';') || '—' }}</span></template>
</DataTable>
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
</ListPageLayout></AdminPage></template>
<style scoped>.operations-summary { display:flex; flex-wrap:wrap; gap:var(--admin-space-4); padding:var(--admin-space-3); background:var(--admin-surface); }</style>
@@ -46,6 +46,13 @@ beforeEach(() => {
})
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('mounts direct costs only when the costs tab is selected', async () => {
const wrapper = mount(TripDetailView); await flushPromises()
expect(wrapper.findComponent({ name: 'TripCostPanel' }).exists()).toBe(false)
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'costs'); await flushPromises()
expect(wrapper.findComponent({ name: 'TripCostPanel' }).props('tripNo')).toBe('TR-TEST')
})
it('loads one trip page and opens a real detail route without fetching each row', async () => {
const wrapper = mount(TripsView); await flushPromises()
expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled()
@@ -1,4 +1,14 @@
export const overseasMasterDataPermissions = {
fleetQuoteList: 'platform:fleet:quote:list',
fleetQuoteCreate: 'platform:fleet:quote:create',
fleetQuoteEdit: 'platform:fleet:quote:edit',
fleetQuoteStatus: 'platform:fleet:quote:status',
fleetQuoteDelete: 'platform:fleet:quote:delete',
fleetQuoteImport: 'platform:fleet:quote:import',
fleetQuoteExport: 'platform:fleet:quote:export',
operationsList: 'platform:fleet:operations:list',
operationsExport: 'platform:fleet:operations:export',
operationsEdit: 'platform:fleet:operations:edit',
tripBookingCreate: 'platform:fleet:trip:booking-create',
tripBookingEdit: 'platform:fleet:trip:booking-edit',
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
@@ -6,7 +16,10 @@ export const overseasMasterDataPermissions = {
tripList: 'platform:fleet:trip:list',
tripCreate: 'platform:fleet:trip:create',
tripEdit: 'platform:fleet:trip:edit',
tripCostCreate: 'platform:fleet:trip:cost-create',
tripCostEdit: 'platform:fleet:trip:cost-edit',
tripAssign: 'platform:fleet:trip:assign',
tripHandover: 'platform:fleet:trip:handover',
tripCancel: 'platform:fleet:trip:cancel',
tripComplete: 'platform:fleet:trip:complete',
tripRelease: 'platform:fleet:trip:release',
@@ -36,5 +49,6 @@ export const overseasMasterDataPermissions = {
vehicleRecordCreate: 'platform:overseas-master-data:vehicle:record-create',
vehicleRecordEdit: 'platform:overseas-master-data:vehicle:record-edit',
vehicleRecordUpload: 'platform:overseas-master-data:vehicle:record-upload',
vehicleRecordFinanceSubmit: 'platform:overseas-master-data:vehicle:record-finance-submit',
} as const
@@ -7,6 +7,8 @@ import { overseasMasterDataRoutes } from './routes'
describe('overseas master data routes', () => {
it('uses the frozen stable paths and list permissions', () => {
expect(overseasMasterDataRoutes.map((route) => [route.path, route.meta?.permission])).toEqual([
['overseas-master-data/fleet-quotes', overseasMasterDataPermissions.fleetQuoteList],
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
@@ -2,6 +2,18 @@ import type { RouteRecordRaw } from 'vue-router'
import { overseasMasterDataPermissions as permission } from './permissions'
export const overseasMasterDataRoutes = [
{
path: 'overseas-master-data/fleet-quotes',
name: 'overseas-master-data-fleet-quotes',
component: () => import('./pages/FleetQuotesView.vue'),
meta: { title: '车队报价', description: '维护车队采购与外接销售报价', permission: permission.fleetQuoteList, showPageHeading: false },
},
{
path: 'overseas-master-data/trip-operations',
name: 'overseas-master-data-trip-operations',
component: () => import('./pages/TripOperationsView.vue'),
meta: { title: '出车记录', description: '按实际发车记录查询车辆运营统计', permission: permission.operationsList, showPageHeading: false },
},
{
path: 'overseas-master-data/trips',
name: 'overseas-master-data-trips',