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

This commit is contained in:
2026-10-03 04:35:55 +08:00
parent bca08f8749
commit dc35288a1b
87 changed files with 3774 additions and 233 deletions
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import { changeDriverStatus, createDriver, downloadDriverLicense, getDriver, getDriverQualification, listDriverHistory, listDrivers, reviewDriver, submitDriver, updateDriver, uploadDriverLicense } from './drivers'
import type { DriverDraft } from '../driverTypes'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
const id = '9223372036854775701'
const fileId = '9223372036854775801'
const base = '/platform/v1/last-leg/fleet/drivers'
const details: DriverDraft = { name: '测试司机', phone: 'test-phone', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LIC-1', licenseType: 'Class A', licenseIssuedOn: null, licenseExpiresOn: '2026-10-22', licenseZone: 'America/Los_Angeles', remark: null, profile: null }
const record = { id, driverNo: 'DR-TEST', details, active: true, reviewStatus: 'APPROVED', licenseFileId: fileId, recordVersion: '2', updatedBy: 'operator', updatedAt: '2026-10-02T12:00:00', qualification: { canDispatch: true, expiringSoon: true, reason: 'ELIGIBLE', licenseExpiresOn: '2026-10-22' } }
const ok = (data: unknown) => ({ code: '200', message: '成功', data })
beforeEach(() => vi.clearAllMocks())
describe('driver APIs', () => {
it('preserves long IDs and date-only values, uses one page request, and normalizes versions', async () => {
vi.mocked(http.get).mockResolvedValueOnce(ok({ records: [record], page: '1', size: '20', total: '1', pages: '1', summary: { total: '1', owned: '1', dispatched: '0', expiringSoon: '1', expired: '0' } })).mockResolvedValueOnce(ok(record))
const result = await listDrivers({ page: 1, size: 20, active: false })
expect(result).toMatchObject({ page: 1, total: 1, records: [{ id, licenseFileId: fileId, recordVersion: 2, details }] })
expect(http.get).toHaveBeenCalledTimes(1)
await getDriver(id)
expect(http.get).toHaveBeenNthCalledWith(1, base, { params: { page: 1, size: 20, active: false } })
expect(http.get).toHaveBeenNthCalledWith(2, `${base}/${id}`, { params: { expectedEnd: undefined } })
})
it('sends current versions to edits, reviews, status changes, and resubmission', async () => {
vi.mocked(http.post).mockResolvedValue(ok(record))
vi.mocked(http.put).mockResolvedValue(ok(record))
vi.mocked(http.patch).mockResolvedValue(ok(record))
await createDriver(details)
await updateDriver(id, 2, details)
await reviewDriver(id, 3, 'REJECTED', '附件不清楚')
await changeDriverStatus(id, 4, false, '暂停驾驶')
await submitDriver(id, 5)
expect(http.post).toHaveBeenCalledWith(base, details)
expect(http.put).toHaveBeenCalledWith(`${base}/${id}`, { recordVersion: 2, details })
expect(http.post).toHaveBeenCalledWith(`${base}/${id}/review`, { recordVersion: 3, decision: 'REJECTED', reason: '附件不清楚' })
expect(http.patch).toHaveBeenCalledWith(`${base}/${id}/status`, { recordVersion: 4, active: false, reason: '暂停驾驶' })
expect(http.post).toHaveBeenCalledWith(`${base}/${id}/submit`, { recordVersion: 5 })
})
it('uploads a real multipart file with the version and leaves multipart boundary to the browser', async () => {
vi.mocked(http.post).mockResolvedValue(ok(record))
const file = new File(['%PDF-1.7'], 'license.pdf', { type: 'application/pdf' })
await uploadDriverLicense(id, 2, file)
const [url, body] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/${id}/license`)
expect(body).toBeInstanceOf(FormData)
expect((body as FormData).get('recordVersion')).toBe('2')
expect((body as FormData).get('file')).toBe(file)
})
it('takes task qualification from server and keeps history snapshots distinct', async () => {
vi.mocked(http.get).mockResolvedValueOnce(ok(record.qualification)).mockResolvedValueOnce(ok({ page: '1', size: '20', total: '1', pages: '1', records: [{ recordVersion: '1', action: 'UPLOAD_LICENSE', snapshot: { ...record, recordVersion: '1', reviewStatus: 'PENDING' } }] }))
const expectedEnd = '2026-10-20T18:00:00Z'
expect(await getDriverQualification(id, expectedEnd)).toEqual(record.qualification)
expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/qualification`, { params: { expectedEnd } })
expect((await listDriverHistory(id)).records[0]).toMatchObject({ recordVersion: 1, snapshot: { recordVersion: 1, reviewStatus: 'PENDING' } })
})
it('downloads only real supported blobs and rejects HTTP200 JSON business failures', async () => {
const pdf = new Blob(['%PDF-1.7'], { type: 'application/pdf' })
vi.mocked(http.get).mockResolvedValueOnce(pdf).mockResolvedValueOnce({ code: 'A0404', message: '文件未关联', requestId: 'test-request' }).mockResolvedValueOnce(new Blob(['<html>'], { type: 'text/html' }))
expect(await downloadDriverLicense(id, fileId)).toBe(pdf)
expect(http.get).toHaveBeenNthCalledWith(1, `${base}/${id}/license/${fileId}`, { responseType: 'blob' })
await expect(downloadDriverLicense(id, fileId)).rejects.toMatchObject({ message: '文件未关联', requestId: 'test-request' })
await expect(downloadDriverLicense(id, fileId)).rejects.toThrow('格式不受支持')
})
it('does not turn a failed write into a success or round an unsafe record version', async () => {
vi.mocked(http.post).mockResolvedValueOnce({ code: 'A0409', message: '请刷新后重试' }).mockResolvedValueOnce(ok({ ...record, recordVersion: '9223372036854775807' }))
await expect(reviewDriver(id, 1, 'APPROVED', null)).rejects.toBeInstanceOf(ApiResponseError)
await expect(createDriver(details)).rejects.toThrow('无效的分页或版本信息')
})
})
@@ -0,0 +1,100 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import type { DriverDetail, DriverDraft, DriverHistory, DriverQualification, DriverReviewStatus, DriverLicenseState, DriverSummary, DriverWarehouseOption } from '../driverTypes'
import type { PageResult } from './overseasMasterData'
const base = '/platform/v1/last-leg/fleet/drivers'
type Numeric = string | number
type DriverWire = Omit<DriverDetail, 'recordVersion'> & { recordVersion: Numeric }
type HistoryWire = Omit<DriverHistory, 'recordVersion' | 'snapshot'> & { recordVersion: Numeric; snapshot: DriverWire }
type PageWire<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
export interface DriverQuery {
page: number
size: number
keyword?: string
active?: boolean
reviewStatus?: DriverReviewStatus
ownership?: DriverDraft['ownership']
region?: DriverDraft['region']
licenseState?: DriverLicenseState
expectedEnd?: string
}
function numeric(value: Numeric): number {
const result = Number(value)
if (!Number.isSafeInteger(result) || result < 0) throw new Error('司机接口返回了无效的分页或版本信息')
return result
}
const driver = (value: DriverWire): DriverDetail => ({ ...value, recordVersion: numeric(value.recordVersion) })
function page<T, W>(value: PageWire<W>, normalize: (record: W) => T): PageResult<T> {
return { records: value.records.map(normalize), page: numeric(value.page), size: numeric(value.size), total: numeric(value.total), pages: numeric(value.pages) }
}
const path = (id: string) => `${base}/${encodeURIComponent(id)}`
type DriverPageWire = PageWire<DriverWire> & { summary: { [K in keyof DriverSummary]: Numeric } }
export async function listDrivers(query: DriverQuery): Promise<PageResult<DriverDetail> & { summary: DriverSummary }> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<DriverPageWire>, ApiEnvelope<DriverPageWire>>(base, { params: query }))
return { ...page(result, driver), summary: { total: numeric(result.summary.total), owned: numeric(result.summary.owned), dispatched: numeric(result.summary.dispatched), expiringSoon: numeric(result.summary.expiringSoon), expired: numeric(result.summary.expired) } }
}
export async function listDriverWarehouses(region: DriverDraft['region']): Promise<DriverWarehouseOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverWarehouseOption[]>, ApiEnvelope<DriverWarehouseOption[]>>(`${base}/warehouse-options`, { params: { region } }))
}
export async function getDriver(id: string, expectedEnd?: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.get<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { params: { expectedEnd } })))
}
export async function getDriverQualification(id: string, expectedEnd: string): Promise<DriverQualification> {
return unwrapApiResponse(await http.get<ApiEnvelope<DriverQualification>, ApiEnvelope<DriverQualification>>(`${path(id)}/qualification`, { params: { expectedEnd } }))
}
export async function listDriverHistory(id: string, currentPage = 1, size = 20): Promise<PageResult<DriverHistory>> {
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire<HistoryWire>>, ApiEnvelope<PageWire<HistoryWire>>>(`${path(id)}/history`, { params: { page: currentPage, size } }))
return page(result, value => ({ ...value, recordVersion: numeric(value.recordVersion), snapshot: driver(value.snapshot) }))
}
export async function createDriver(details: DriverDraft): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(base, details)))
}
export async function updateDriver(id: string, recordVersion: number, details: DriverDraft): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.put<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(path(id), { recordVersion, details })))
}
export async function changeDriverStatus(id: string, recordVersion: number, active: boolean, reason: string): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.patch<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/status`, { recordVersion, active, reason })))
}
export async function reviewDriver(id: string, recordVersion: number, decision: 'APPROVED' | 'REJECTED', reason: string | null): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/review`, { recordVersion, decision, reason })))
}
export async function submitDriver(id: string, recordVersion: number): Promise<DriverDetail> {
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/submit`, { recordVersion })))
}
export async function uploadDriverLicense(id: string, recordVersion: number, file: File): Promise<DriverDetail> {
const form = new FormData()
form.append('recordVersion', String(recordVersion))
form.append('file', file)
return driver(unwrapApiResponse(await http.post<ApiEnvelope<DriverWire>, ApiEnvelope<DriverWire>>(`${path(id)}/license`, form)))
}
async function checkDownloadError(payload: unknown, status: number): Promise<void> {
let value = payload
if (payload instanceof Blob) {
if (!payload.type.includes('json')) return
try { value = JSON.parse(await payload.text()) as unknown }
catch { throw new ApiResponseError('驾驶证接口返回了无法识别的错误响应', 'INVALID_DOWNLOAD_RESPONSE', status) }
}
if (typeof value === 'object' && value !== null && 'code' in value) {
const error = value as Partial<ApiEnvelope<unknown>>
throw new ApiResponseError(error.message || '驾驶证读取失败', error.code || 'DOWNLOAD_FAILED', status, error.data, error.requestId, error.traceId)
}
}
export async function downloadDriverLicense(id: string, fileId: string): Promise<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${path(id)}/license/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
await checkDownloadError(blob, 200)
if (!(blob instanceof Blob) || blob.size === 0 || !['application/pdf', 'image/jpeg', 'image/png'].includes(blob.type)) {
throw new Error('驾驶证文件为空或格式不受支持')
}
return blob
} catch (error) {
if (axios.isAxiosError(error)) await checkDownloadError(error.response?.data, error.response?.status || 0)
throw error
}
}
@@ -23,7 +23,7 @@ const quoteDetail = {
createdBy: 'admin', createdAt: '2026-08-24T09:00:00',
}
const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
beforeEach(() => vi.clearAllMocks())
@@ -106,7 +106,7 @@ describe('overseas master data API', () => {
expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 })
expect(detail.id).toBe(longId)
const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
const editable = { plateNumber: 'CA-01', model: 'Truck', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
vi.mocked(http.post).mockResolvedValue(ok(vehicle))
vi.mocked(http.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' }))
vi.mocked(http.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' }))
@@ -17,7 +17,7 @@ type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouse
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null }
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
@@ -32,7 +32,7 @@ const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unit
const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) })
const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) })
const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) })
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) })
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) })
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
@@ -0,0 +1,47 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCreate, TripBookingUpdate, TripBookingCommand, TripBookingResult, TripBookingList, TripIncomingEvidence, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind, TripAcceptedReceipt } from '../bookingTypes'
const base = '/platform/v1/last-leg/fleet/trips'
const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings`
type N = number | string
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt'> & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null }
type WireIncoming = Omit<TripIncomingEvidence, 'receivedPackages' | 'pallets'> & { receivedPackages: N; pallets: (Omit<TripIncomingEvidence['pallets'][number], 'packages' | 'weightKg' | 'volumeCbm'> & { packages: N; weightKg: N | null; volumeCbm: N | null })[] }
type WireReceipt = Omit<TripAcceptedReceipt, 'source' | 'pallets'> & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] }
type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean }
const number = (value: N) => { const result = Number(value); if (value == null || String(value).trim() === '' || !Number.isFinite(result) || result < 0) throw new Error('外接预订数值无效'); return result }
const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result }
const nullable = (value: N | null) => value == null ? null : number(value)
const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) })
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo,
packageCount: integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } })
const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true })
export async function listFleetCustomers(keyword?: string): Promise<FleetCustomerOption[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<FleetCustomerOption[]>, ApiEnvelope<FleetCustomerOption[]>>(`${base}/customer-options`, { params: { keyword } }))
}
export async function listTripBookings(tripNo: string): Promise<TripBookingList> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>, ApiEnvelope<{ tripRecordVersion: N; records: WireBooking[] }>>(path(tripNo)))
return { tripRecordVersion: integer(value.tripRecordVersion), records: value.records.map(normalizeBooking) }
}
export async function createTripBooking(tripNo: string, body: TripBookingCreate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(path(tripNo), body)))
}
export async function updateTripBooking(tripNo: string, bookingNo: string, body: TripBookingUpdate): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.put<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}`, body)))
}
async function command(tripNo: string, bookingNo: string, action: string, body: TripBookingCommand): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${path(tripNo)}/${encodeURIComponent(bookingNo)}/${action}`, body)))
}
export const confirmTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'confirm', body)
export const cancelTripBooking = (tripNo: string, bookingNo: string, body: TripBookingCommand) => command(tripNo, bookingNo, 'cancel', body)
const bookingPath = (tripNo: string, bookingNo: string) => `${path(tripNo)}/${encodeURIComponent(bookingNo)}`
export async function getTripIncoming(tripNo: string, bookingNo: string): Promise<TripIncomingPreview> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>, ApiEnvelope<{ tripRecordVersion: N; recordVersion: N; incoming: WireIncoming | null }>>(`${bookingPath(tripNo, bookingNo)}/incoming`))
return { tripRecordVersion: integer(value.tripRecordVersion), recordVersion: integer(value.recordVersion), incoming: value.incoming == null ? null : incoming(value.incoming) }
}
async function incomingCommand(tripNo: string, bookingNo: string, action: string, body: TripIncomingStart | TripIncomingAccept | TripBookingBind): Promise<TripBookingResult> {
return result(unwrapApiResponse(await http.post<ApiEnvelope<WireResult>, ApiEnvelope<WireResult>>(`${bookingPath(tripNo, bookingNo)}/${action}`, body)))
}
export const startTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingStart) => incomingCommand(tripNo, bookingNo, 'incoming', body)
export const acceptTripIncoming = (tripNo: string, bookingNo: string, body: TripIncomingAccept) => incomingCommand(tripNo, bookingNo, 'receipt-confirmation', body)
export const bindTripBookingDispatch = (tripNo: string, bookingNo: string, body: TripBookingBind) => incomingCommand(tripNo, bookingNo, 'dispatch', body)
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
} from './trips'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const base = '/platform/v1/last-leg/fleet/trips'
const longId = '9223372036854775801'
const trip = { tripNo: 'TR/TEST', status: 'PLANNED', warehouseId: longId, warehouseCode: 'WH', warehouseName: '起运仓',
vehicleId: longId, vehiclePlate: 'TEST-1', driverId: longId, driverName: '司机', driverPhone: '123',
maxPalletSpaces: '30', maxLoadKg: '18000.125', maxVolumeCbm: '90.001', recordVersion: '0',
plannedDepartureAt: '2026-10-02T10:00:00Z', expectedDriverEndAt: '2026-10-03T10:00:00Z', departedAt: null, completedAt: null,
stops: [{ name: '起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, updatedAt: '2026-10-02T08:00:00Z', vehicleRelease: null, driverRelease: null }
const reviewState = { status: 'PENDING', reason: null, reviewerId: null, reviewedAt: null, recordVersion: null }
const detail = { trip, driverQualification: null, loadingReview: reviewState, dispatches: [{ dispatchNo: 'DP-1', customerId: longId, packageCount: '12', boundBy: longId, boundAt: trip.updatedAt, review: reviewState }] }
const cargo = { dispatchNo: 'DP-1', customerId: longId, dispatchItemId: longId, warehousePalletId: longId, palletNo: 'PALLET-1', packages: '12', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }
const load = { palletSpaces: '1', weightKg: '100.125', volumeCbm: '1.005' }
const confirmed = { manifestHash: 'a'.repeat(64), cargo: [cargo], pallets: [{ ...load, warehousePalletId: longId, deliveryIndex: '1' }],
segments: [{ fromIndex: '0', toIndex: '1', occupied: load, remaining: { palletSpaces: '29', weightKg: '17899.875', volumeCbm: '88.995' } }], operatorId: longId, confirmedAt: trip.updatedAt }
const loading = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, confirmedLoad: confirmed, blockReason: null }
const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: longId, destination: 'ONT8', status: 'DELIVERED', packages: '12', terminalAt: trip.updatedAt }
const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
describe('fleet trip API contract', () => {
beforeEach(() => vi.clearAllMocks())
it('uses one paged request and keeps long identifiers as exact strings', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
const result = await listTrips(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(result.total).toBe(121)
expect(result.records[0]).toMatchObject({ vehicleId: longId, driverId: longId, warehouseId: longId, recordVersion: 0, maxPalletSpaces: 30, maxLoadKg: 18000.125 })
})
it('encodes the business number and reads details without prefetching history or loading', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope(detail))
const result = await getTrip(trip.tripNo)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST`)
expect(result.dispatches[0]).toMatchObject({ packageCount: 12, customerId: longId })
})
it('forwards explicit versions and stable idempotency keys for every write without extra reads', async () => {
vi.mocked(http.post).mockResolvedValue(envelope(detail)); vi.mocked(http.put).mockResolvedValue(envelope(detail))
const draft = { warehouseId: longId, plannedDepartureAt: trip.plannedDepartureAt, expectedDriverEndAt: trip.expectedDriverEndAt, stops: trip.stops, idempotencyKey: 'same-create-key' }
await createTrip(draft); await createTrip(draft)
expect(http.post).toHaveBeenNthCalledWith(1, base, draft); expect(http.post).toHaveBeenNthCalledWith(2, base, draft)
const plan = { recordVersion: 0, plannedDepartureAt: draft.plannedDepartureAt, expectedDriverEndAt: draft.expectedDriverEndAt, stops: draft.stops, reason: '调整' }
await updateTripPlan(trip.tripNo, plan)
expect(http.put).toHaveBeenCalledWith(`${base}/TR%2FTEST/plan`, plan)
const assignment = { recordVersion: 0, vehicleId: longId, driverId: longId, reason: '分配' }
await assignTrip(trip.tripNo, assignment)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/assignment`, assignment)
const binding = { recordVersion: 1, dispatchNo: 'DP-1', reason: '关联' }
await bindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/bind`, binding)
await unbindTripDispatch(trip.tripNo, binding); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/unbind`, binding)
await cancelTrip(trip.tripNo, { recordVersion: 2, reason: '取消' })
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/cancel`, { recordVersion: 2, reason: '取消' })
const complete = { recordVersion: 4, idempotencyKey: 'complete-key', reason: '核对' }
await completeTrip(trip.tripNo, complete); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/complete`, complete)
const release = { recordVersion: 5, resourceKind: 'DRIVER' as const, actualEndAt: trip.updatedAt, endLocation: '交接点', idempotencyKey: 'release-key', reason: '返程已结束' }
await releaseTripResource(trip.tripNo, release); expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/resources/release`, release)
const review = { recordVersion: 6, decision: 'APPROVED' as const }
await reviewTripDispatch(trip.tripNo, 'DP/1', review)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/dispatches/DP%2F1/review`, review)
await reviewTripLoading(trip.tripNo, review)
expect(http.post).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/loading-review`, review)
expect(http.get).not.toHaveBeenCalled()
})
it('keeps planned capacity separate from actual load and consumes source freshness', async () => {
const plan = { tripNo: trip.tripNo, recordVersion: '3', cargo: [cargo], manifestHash: confirmed.manifestHash, capacityPlan: confirmed, planValid: false, blockReason: null, availableSegments: confirmed.segments }
vi.mocked(http.get).mockResolvedValueOnce(envelope(plan)); vi.mocked(http.post).mockResolvedValueOnce(envelope({ ...plan, planValid: true, recordVersion: '4' }))
const result = await getTripCapacityPlan(trip.tripNo)
expect(result.planValid).toBe(false); expect(result.capacityPlan?.segments[0]?.remaining.palletSpaces).toBe(29)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`)
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }], reason: '计划核对' }
expect((await confirmTripCapacityPlan(trip.tripNo, body)).recordVersion).toBe(4)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/capacity-plan`, body)
})
it('preserves source fingerprints and uses server capacity results without recomputing them', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope(loading)); vi.mocked(http.post).mockResolvedValueOnce(envelope(loading))
const result = await getTripLoading(trip.tripNo)
expect(result.confirmedLoad?.segments[0]?.remaining).toEqual({ palletSpaces: 29, weightKg: 17899.875, volumeCbm: 88.995 })
expect(result.cargo[0]?.warehousePalletId).toBe(longId)
const body = { recordVersion: 3, manifestHash: confirmed.manifestHash, pallets: [{ warehousePalletId: longId, palletSpaces: 1, weightKg: 100.125, volumeCbm: 1.005, deliveryIndex: 1 }], reason: '核对实物' }
await confirmTripLoading(trip.tripNo, body)
expect(http.post).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/loading`, body)
})
it('loads completion and immutable history only when explicitly requested', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ tripNo: trip.tripNo, recordVersion: '5', canComplete: false, blockReason: '另一票未签收', outcomes: [outcome] }))
expect(await getTripCompletion(trip.tripNo)).toMatchObject({ recordVersion: 5, canComplete: false, blockReason: '另一票未签收', outcomes: [{ packages: 12 }] })
vi.mocked(http.get).mockResolvedValueOnce(envelope({ page: '2', size: '10', total: '12', pages: '2', records: [{ recordVersion: '5', action: 'COMPLETE', reason: null, operatorId: longId, occurredAt: trip.updatedAt,
snapshot: { trip, externalBookings: [], capacityPlan: null, loadingReview: reviewState, dispatches: detail.dispatches, confirmedLoad: confirmed, completion: { idempotencyKey: 'key', requestHash: 'hash', completedAt: trip.updatedAt, recordedAt: trip.updatedAt, operatorId: longId, outcomes: [outcome] } } }] }))
const history = await listTripHistory(trip.tripNo, 2, 10)
expect(http.get).toHaveBeenLastCalledWith(`${base}/TR%2FTEST/history`, { params: { page: 2, size: 10 } })
expect(history.records[0]?.snapshot.completion?.outcomes[0]?.packages).toBe(12)
expect(history.records[0]?.snapshot.trip.recordVersion).toBe(0)
})
it('rejects missing or unsafe versions and preserves backend conflict diagnostics', async () => {
for (const version of [null, '', '9007199254740993', '-1']) {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...detail, trip: { ...trip, recordVersion: version } }))
await expect(getTrip(trip.tripNo)).rejects.toThrow()
}
const conflict = new ApiResponseError('车次已修改', '409', 200, null, 'request-1', 'trace-1')
vi.mocked(http.post).mockRejectedValueOnce(conflict)
await expect(cancelTrip(trip.tripNo, { recordVersion: 0, reason: '取消' })).rejects.toBe(conflict)
})
})
@@ -0,0 +1,112 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeBooking, type WireBooking } from './tripBookings'
import type { PageResult } from './overseasMasterData'
import type {
FleetTrip, TripStatus, TripDetail, TripDraft, TripPlan, TripAssignment, TripBindingCommand, TripBinding,
TripCargo, TripLoad, TripMeasurement, TripConfirmedLoad, TripLoading, TripLoadConfirmation,
TripOutcome, TripCompletionInspection, TripComplete, TripResourceRelease, TripHistory, TripReviewState, TripReviewCommand, TripCapacityPlanning,
} from '../tripTypes'
const base = '/platform/v1/last-leg/fleet/trips'
const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
type Numeric = number | string
type WireTrip = Omit<FleetTrip, 'recordVersion' | 'maxPalletSpaces' | 'maxLoadKg' | 'maxVolumeCbm'> & {
recordVersion: Numeric; maxPalletSpaces: Numeric | null; maxLoadKg: Numeric | null; maxVolumeCbm: Numeric | null
}
type WireReview = Omit<TripReviewState, 'recordVersion'> & { recordVersion: Numeric | null }
type WireBinding = Omit<TripBinding, 'packageCount' | 'review'> & { packageCount: Numeric; review: WireReview }
type WireDetail = Omit<TripDetail, 'trip' | 'dispatches' | 'loadingReview'> & { trip: WireTrip; dispatches: WireBinding[]; loadingReview: WireReview }
type WirePage<T> = { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
type WireCargo = Omit<TripCargo, 'packages'> & { packages: Numeric }
type WireLoad = { [K in keyof TripLoad]: Numeric }
type WireMeasurement = Omit<TripMeasurement, 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'deliveryIndex'> & WireLoad & { deliveryIndex: Numeric }
type WireConfirmed = Omit<TripConfirmedLoad, 'cargo' | 'pallets' | 'segments'> & {
cargo: WireCargo[]; pallets: WireMeasurement[]
segments: { fromIndex: Numeric; toIndex: Numeric; occupied: WireLoad; remaining: WireLoad }[]
}
type WireLoading = Omit<TripLoading, 'recordVersion' | 'cargo' | 'confirmedLoad'> & { recordVersion: Numeric; cargo: WireCargo[]; confirmedLoad: WireConfirmed | null }
type WirePlanning = Omit<TripCapacityPlanning, 'recordVersion' | 'cargo' | 'capacityPlan' | 'availableSegments'> & { recordVersion: Numeric; cargo: WireCargo[]; capacityPlan: WireConfirmed | null; availableSegments: WireConfirmed['segments'] }
type WireOutcome = Omit<TripOutcome, 'packages'> & { packages: Numeric }
type WireInspection = Omit<TripCompletionInspection, 'recordVersion' | 'outcomes'> & { recordVersion: Numeric; outcomes: WireOutcome[] }
type WireHistory = Omit<TripHistory, 'recordVersion' | 'snapshot'> & {
recordVersion: Numeric
snapshot: { externalBookings: WireBooking[]; capacityPlan: WireConfirmed | null; trip: WireTrip; dispatches: WireBinding[]; confirmedLoad: WireConfirmed | null; loadingReview: WireReview; completion: (Omit<NonNullable<TripHistory['snapshot']['completion']>, 'outcomes'> & { outcomes: WireOutcome[] }) | null }
}
function number(value: Numeric): number {
if (typeof value !== 'number' && typeof value !== 'string') throw new Error('车次接口缺少有效数值字段')
if (typeof value === 'string' && !value.trim()) throw new Error('车次接口返回了空的数值字段')
const result = Number(value)
if (!Number.isFinite(result) || result < 0) throw new Error('车次接口返回了无效数值')
return result
}
function integer(value: Numeric): number {
const result = number(value)
if (!Number.isSafeInteger(result)) throw new Error('车次接口返回了无效分页、数量或版本')
return result
}
const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer(value.recordVersion),
maxPalletSpaces: value.maxPalletSpaces == null ? null : integer(value.maxPalletSpaces),
maxLoadKg: value.maxLoadKg == null ? null : number(value.maxLoadKg), maxVolumeCbm: value.maxVolumeCbm == null ? null : number(value.maxVolumeCbm) })
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), loadingReview: review(value.loadingReview) })
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages) })
const load = (value: WireLoad): TripLoad => ({ palletSpaces: integer(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) })
function confirmed(value: WireConfirmed | null): TripConfirmedLoad | null {
return value == null ? null : { ...value, cargo: value.cargo.map(cargo),
pallets: value.pallets.map(row => ({ ...row, ...load(row), deliveryIndex: integer(row.deliveryIndex) })),
segments: value.segments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) }
}
const loading = (value: WireLoading): TripLoading => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), confirmedLoad: confirmed(value.confirmedLoad) })
function page<T, W>(value: WirePage<W>, map: (row: W) => T): PageResult<T> {
return { records: value.records.map(map), page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) }
}
export interface TripQuery { page: number; size: number; keyword?: string; status?: TripStatus; warehouseId?: string }
export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>> {
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
}
export async function getTrip(tripNo: string): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
}
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
}
export const createTrip = (body: TripDraft) => postDetail(base, body)
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
export const unbindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/unbind`, body)
export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason: string }) => postDetail(`${path(tripNo)}/cancel`, body)
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
}
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.get<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`)))
}
export async function confirmTripCapacityPlan(tripNo: string, body: TripLoadConfirmation): Promise<TripCapacityPlanning> {
return planning(unwrapApiResponse(await http.post<ApiEnvelope<WirePlanning>, ApiEnvelope<WirePlanning>>(`${path(tripNo)}/capacity-plan`, body)))
}
export async function getTripLoading(tripNo: string): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.get<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`)))
}
export async function confirmTripLoading(tripNo: string, body: TripLoadConfirmation): Promise<TripLoading> {
return loading(unwrapApiResponse(await http.post<ApiEnvelope<WireLoading>, ApiEnvelope<WireLoading>>(`${path(tripNo)}/loading`, body)))
}
export async function getTripCompletion(tripNo: string): Promise<TripCompletionInspection> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WireInspection>, ApiEnvelope<WireInspection>>(`${path(tripNo)}/completion`))
return { ...value, canComplete: value.canComplete === true, recordVersion: integer(value.recordVersion), outcomes: value.outcomes.map(outcome) }
}
export async function listTripHistory(tripNo: string, currentPage = 1, size = 20): Promise<PageResult<TripHistory>> {
const value = unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireHistory>>, ApiEnvelope<WirePage<WireHistory>>>(`${path(tripNo)}/history`, { params: { page: currentPage, size } }))
return page(value, row => ({ ...row, recordVersion: integer(row.recordVersion), snapshot: {
trip: trip(row.snapshot.trip), dispatches: row.snapshot.dispatches.map(binding), confirmedLoad: confirmed(row.snapshot.confirmedLoad),
externalBookings: row.snapshot.externalBookings.map(normalizeBooking), loadingReview: review(row.snapshot.loadingReview), capacityPlan: confirmed(row.snapshot.capacityPlan),
completion: row.snapshot.completion == null ? null : { ...row.snapshot.completion, outcomes: row.snapshot.completion.outcomes.map(outcome) },
} }))
}
@@ -0,0 +1,45 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http, ApiResponseError } from '@/shared/api/http'
import { listVehicleCare, createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto, downloadVehicleCarePhoto } from './vehicleCare'
import type { VehicleCareDraft } from '../vehicleCareTypes'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const id = '9223372036854775701', base = `/platform/v1/last-leg/fleet/vehicles/${id}/records`
const details: VehicleCareDraft = { recordType: 'FUEL', occurredAt: '2026-01-01T00:00:00Z', distanceUnit: 'MI', odometer: 100, lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL', verificationStatus: 'PENDING', remark: null, incident: null }
const wire = { recordNo: 'VR-ONE', vehicleId: id, vehiclePlate: 'TEST', recordVersion: '2', details: { ...details, odometer: '100', fuelQuantity: '28.6' }, files: [{ fileId: '9223372036854775702', fileName: 'meter.png', contentType: 'image/png', sizeBytes: '8' }], updatedBy: 'operator', updatedAt: '2026-01-01T00:00:00Z' }
const ok = (data: unknown) => ({ code: '200', message: '成功', data })
beforeEach(() => vi.clearAllMocks())
it('keeps vehicle and file IDs exact while normalizing values without changing units', async () => {
vi.mocked(http.get).mockResolvedValue(ok({ records: [wire], page: '1', size: '10', total: '1', pages: '1' }))
const result = await listVehicleCare(id, 'FUEL')
expect(result.records[0]).toMatchObject({ vehicleId: id, recordVersion: 2, details, files: [{ fileId: '9223372036854775702', sizeBytes: 8 }] })
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: { recordType: 'FUEL', page: 1, size: 10 } })
})
it('preserves creation and upload keys and sends explicit correction version and reason', async () => {
vi.mocked(http.post).mockResolvedValue(ok(wire)); vi.mocked(http.put).mockResolvedValue(ok(wire))
await createVehicleCare(id, 'same-create-key', details)
await updateVehicleCare(id, 'VR-ONE', 2, '核对里程', details)
const photo = new File(['photo'], 'meter.png', { type: 'image/png' })
await uploadVehicleCarePhoto(id, 'VR-ONE', 3, 'same-photo-key', photo)
expect(http.post).toHaveBeenNthCalledWith(1, base, { idempotencyKey: 'same-create-key', details })
expect(http.put).toHaveBeenCalledWith(`${base}/VR-ONE`, { recordVersion: 2, reason: '核对里程', details })
const [url, form] = vi.mocked(http.post).mock.calls[1]!
expect(url).toBe(`${base}/VR-ONE/files`); expect(form).toBeInstanceOf(FormData)
expect((form as FormData).get('recordVersion')).toBe('3'); expect((form as FormData).get('idempotencyKey')).toBe('same-photo-key')
expect((form as FormData).get('file')).toBe(photo)
})
it('rejects JSON business errors and non-image bodies instead of opening them as photos', async () => {
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['image'], { type: 'image/png' }))
.mockResolvedValueOnce({ code: 'A0404', message: '图片不属于记录', requestId: 'test-request' })
.mockResolvedValueOnce(new Blob(['html'], { type: 'text/html' }))
expect((await downloadVehicleCarePhoto(id, 'VR-ONE', '71')).type).toBe('image/png')
await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '72')).rejects.toMatchObject({ name: 'ApiResponseError', code: 'A0404', requestId: 'test-request' } satisfies Partial<ApiResponseError>)
await expect(downloadVehicleCarePhoto(id, 'VR-ONE', '73')).rejects.toThrow('格式无效')
})
it('keeps unknown and zero incident costs distinct and preserves independent payment state', async () => {
const incident = { description: '救援完成', item: '拖车', place: '现场', handler: '处理人', processingStatus: 'COMPLETED', exceptionType: null, reportedCost: '0.00', currency: 'USD' }
const rescue = { ...wire, paymentStatus: 'UNLINKED', details: { ...details, recordType: 'RESCUE', distanceUnit: null, odometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: null, incident } }
vi.mocked(http.get).mockResolvedValue(ok({ records: [rescue, { ...rescue, recordNo: 'VR-TWO', details: { ...rescue.details, incident: { ...incident, reportedCost: null, currency: null } } }], page: 1, size: 10, total: 2, pages: 1 }))
const result = await listVehicleCare(id, 'RESCUE')
expect(result.records[0]).toMatchObject({ paymentStatus: 'UNLINKED', details: { odometer: null, incident: { reportedCost: 0, currency: 'USD' } } })
expect(result.records[1]?.details.incident?.reportedCost).toBeNull()
})
@@ -0,0 +1,70 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import type { VehicleCareDraft, VehicleCareRecord, VehicleCareType, VehicleIncident } from '../vehicleCareTypes'
import type { PageResult } from './overseasMasterData'
type Numeric = number | string
type Wire = Omit<VehicleCareRecord, 'recordVersion' | 'details' | 'files'> & {
recordVersion: Numeric
details: Omit<VehicleCareDraft, 'odometer' | 'lastServiceOdometer' | 'nextServiceOdometer' | 'fuelQuantity' | 'incident'> & {
odometer: Numeric | null; lastServiceOdometer: Numeric | null; nextServiceOdometer: Numeric | null; fuelQuantity: Numeric | null
incident: (Omit<VehicleIncident, 'reportedCost'> & { reportedCost: Numeric | null }) | null
}
files: Array<Omit<VehicleCareRecord['files'][number], 'sizeBytes'> & { sizeBytes: Numeric }>
}
const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}`
function integer(value: Numeric): number {
const result = Number(value)
if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本')
return result
}
function decimal(value: Numeric): number {
const result = Number(value)
if (value === '' || !Number.isFinite(result) || result < 0) throw new Error('运维接口返回了无效数值')
return result
}
function record(value: Wire): VehicleCareRecord {
const optional = (number: Numeric | null) => number == null ? null : decimal(number)
return { ...value, recordVersion: integer(value.recordVersion), details: { ...value.details, odometer: optional(value.details.odometer),
lastServiceOdometer: optional(value.details.lastServiceOdometer), nextServiceOdometer: optional(value.details.nextServiceOdometer), fuelQuantity: optional(value.details.fuelQuantity),
incident: value.details.incident ? { ...value.details.incident, reportedCost: optional(value.details.incident.reportedCost) } : null },
files: value.files.map(file => ({ ...file, sizeBytes: integer(file.sizeBytes) })) }
}
export async function listVehicleCare(vehicleId: string, recordType: VehicleCareType, page = 1, size = 10): Promise<PageResult<VehicleCareRecord>> {
type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageWire>, ApiEnvelope<PageWire>>(path(vehicleId), { params: { recordType, page, size } }))
return { records: result.records.map(record), page: integer(result.page), size: integer(result.size), total: integer(result.total), pages: integer(result.pages) }
}
export async function createVehicleCare(vehicleId: string, idempotencyKey: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId), { idempotencyKey, details })))
}
export async function getVehicleCare(vehicleId: string, recordNo: string): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.get<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo))))
}
export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise<VehicleCareRecord> {
return record(unwrapApiResponse(await http.put<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(path(vehicleId, recordNo), { recordVersion, reason, details })))
}
export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise<VehicleCareRecord> {
const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file)
return record(unwrapApiResponse(await http.post<ApiEnvelope<Wire>, ApiEnvelope<Wire>>(`${path(vehicleId, recordNo)}/files`, form)))
}
async function downloadError(value: unknown, status: number): Promise<void> {
if (value instanceof Blob && value.type.includes('json')) {
try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') }
}
if (value && typeof value === 'object' && 'code' in value) {
const error = value as ApiEnvelope<unknown>
throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId)
}
}
export async function downloadVehicleCarePhoto(vehicleId: string, recordNo: string, fileId: string): Promise<Blob> {
try {
const blob = await http.get<Blob, Blob>(`${path(vehicleId, recordNo)}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !blob.size || !['image/png', 'image/jpeg'].includes(blob.type)) throw new Error('照片为空或格式无效')
return blob
} catch (cause) {
if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0)
throw cause
}
}