优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http, ApiResponseError } from '@/shared/api/http'
|
||||
import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes'
|
||||
import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetMoney, fleetVersion, isFleetCommandRejected, 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' }
|
||||
@@ -20,14 +20,6 @@ it('sends stable creation and batch keys without converting business references'
|
||||
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('版本无效')
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import axios from 'axios'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
|
||||
export type FleetQuoteKind = 'PURCHASE' | 'SALE'
|
||||
@@ -54,18 +53,4 @@ 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) }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiResponseError, http } from '@/shared/api/http'
|
||||
|
||||
import {
|
||||
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
|
||||
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||
getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
|
||||
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
|
||||
type QuoteCreate, type QuoteUpdate,
|
||||
} from './overseasMasterData'
|
||||
@@ -124,22 +124,4 @@ describe('overseas master data API', () => {
|
||||
expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' })
|
||||
})
|
||||
|
||||
it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => {
|
||||
const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(csv)
|
||||
.mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' }))
|
||||
|
||||
await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv)
|
||||
await expect(exportExchangeRates({})).resolves.toBe(csv)
|
||||
await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv)
|
||||
const error = await exportVehicles({}).catch((reason: unknown) => reason)
|
||||
expect(error).toBeInstanceOf(ApiResponseError)
|
||||
expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import axios from 'axios'
|
||||
import { http, ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
import type {
|
||||
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
|
||||
@@ -34,58 +33,6 @@ const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDet
|
||||
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), maxPalletSpaces: nullableNumeric(value.maxPalletSpaces), maxLoadKg: nullableNumeric(value.maxLoadKg), maxVolumeCbm: nullableNumeric(value.maxVolumeCbm) })
|
||||
|
||||
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
|
||||
|
||||
async function downloadText(payload: DownloadPayload) {
|
||||
if (typeof payload === 'string') return payload
|
||||
if (payload instanceof Blob) return payload.text()
|
||||
const bytes = payload instanceof ArrayBuffer
|
||||
? new Uint8Array(payload)
|
||||
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||
return new TextDecoder().decode(bytes)
|
||||
}
|
||||
|
||||
async function csvError(payload: DownloadPayload, status: number) {
|
||||
const contentType = payload instanceof Blob ? payload.type.toLowerCase() : ''
|
||||
const text = await downloadText(payload)
|
||||
if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null
|
||||
try {
|
||||
const envelope = JSON.parse(text) as Partial<ApiEnvelope<unknown>>
|
||||
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope')
|
||||
return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status,
|
||||
envelope.data, envelope.requestId, envelope.traceId)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiResponseError) return error
|
||||
return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status)
|
||||
}
|
||||
}
|
||||
function csvBlob(payload: DownloadPayload) {
|
||||
if (payload instanceof Blob) return payload
|
||||
if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' })
|
||||
const bytes = payload instanceof ArrayBuffer
|
||||
? new Uint8Array(payload)
|
||||
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
|
||||
const copy = new Uint8Array(bytes.byteLength)
|
||||
copy.set(bytes)
|
||||
return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' })
|
||||
}
|
||||
async function download(path: string, params: object) {
|
||||
try {
|
||||
const payload = await http.get<DownloadPayload, DownloadPayload>(path, { params, responseType: 'arraybuffer' })
|
||||
const error = await csvError(payload, 200)
|
||||
if (error) throw error
|
||||
return csvBlob(payload)
|
||||
} catch (error) {
|
||||
const axiosError = axios.isAxiosError(error) ? error : null
|
||||
const responsePayload = axiosError?.response?.data as DownloadPayload | undefined
|
||||
if (responsePayload !== undefined) {
|
||||
const responseError = await csvError(responsePayload, axiosError?.response?.status || 0)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
|
||||
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
|
||||
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
|
||||
@@ -98,7 +45,6 @@ export async function listQuoteWarehouseOptions() { return unwrapApiResponse(awa
|
||||
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await http.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
|
||||
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await http.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
|
||||
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await http.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
|
||||
|
||||
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
|
||||
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
|
||||
@@ -110,7 +56,6 @@ export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
|
||||
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates`, payload))) }
|
||||
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await http.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
|
||||
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await http.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
|
||||
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
|
||||
|
||||
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
|
||||
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
|
||||
@@ -121,4 +66,3 @@ export async function getVehicle(id: string) { return normalizeVehicle(unwrapApi
|
||||
export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await http.post<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
|
||||
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await http.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
|
||||
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await http.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
|
||||
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
|
||||
import { 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 }
|
||||
@@ -42,7 +42,6 @@ export async function listTripOperations(query: TripOperationsQuery): Promise<Tr
|
||||
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: TripOperationsMeasuredInput; reason: string | null }
|
||||
export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured }
|
||||
|
||||
Reference in New Issue
Block a user