feat(fleet): connect operations quotes costs and handover workspaces
This commit is contained in:
@@ -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('运维接口返回了无效的分页或版本')
|
||||
|
||||
Reference in New Issue
Block a user