feat(fleet): select versioned sales quotes and isolate cost drafts
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listFleetSaleQuoteCandidates, fleetQuoteReference, type FleetSaleQuoteCriteria } from './fleetQuoteCandidates'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
const criteria: FleetSaleQuoteCriteria = { customerId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', effectiveOn: '2026-10-03', priceBasis: 'PALLET', page: 1, size: 20 }
|
||||
const snapshot = { quoteNo: 'FQ-SOURCE', recordVersion: '90071992547409932', priceItem: 'LOOSE_DELIVERY', customerId: criteria.customerId, customerName: '委托客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '10', currency: 'USD', priceBasis: 'PALLET', quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: criteria.effectiveOn }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('keeps source identity/version and explicit zero, without constructing a booking write or total', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [snapshot], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
const result = await listFleetSaleQuoteCandidates(criteria)
|
||||
expect(result.records[0]).toMatchObject({ recordVersion: snapshot.recordVersion, customerId: criteria.customerId, quotedUnitPrice: 0, distanceMiles: 10 })
|
||||
expect(fleetQuoteReference(result.records[0]!)).toEqual({ quoteNo: snapshot.quoteNo, recordVersion: snapshot.recordVersion, priceItem: 'LOOSE_DELIVERY' })
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria })
|
||||
expect(result.records[0]).not.toHaveProperty('agreedAmount')
|
||||
})
|
||||
it('does not turn an unknown price into an eligible zero-price candidate', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...snapshot, quotedUnitPrice: null }], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toThrow('金额格式无效')
|
||||
})
|
||||
it('surfaces permission errors instead of returning an empty successful list', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无报价读取权限', requestId: 'candidate-test-request' })
|
||||
await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toMatchObject({ code: 'A0403', requestId: 'candidate-test-request' })
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { PageResult } from './overseasMasterData'
|
||||
import { fleetMoney, fleetNumber, fleetPage, fleetVersion } from './fleetQuotes'
|
||||
|
||||
export interface FleetSaleQuoteCriteria {
|
||||
customerId: string; originCity: string; destinationCode: string; currency: 'USD' | 'CNY'
|
||||
effectiveOn: string; priceBasis: 'ORDER' | 'PALLET'; page?: number; size?: number
|
||||
}
|
||||
export interface FleetQuoteReference { quoteNo: string; recordVersion: string; priceItem: 'FULL_DELIVERY' | 'LOOSE_DELIVERY' }
|
||||
export interface FleetQuoteSourceSnapshot extends FleetQuoteReference {
|
||||
customerId: string; customerName: string; originCity: string; destinationCode: string; distanceMiles: number
|
||||
currency: 'USD' | 'CNY'; priceBasis: 'ORDER' | 'PALLET'; quotedUnitPrice: number
|
||||
validFrom: string; validTo: string; effectiveOn: string
|
||||
}
|
||||
type Numeric = string | number
|
||||
export type WireSnapshot = Omit<FleetQuoteSourceSnapshot, 'recordVersion' | 'distanceMiles' | 'quotedUnitPrice'> & {
|
||||
recordVersion: Numeric; distanceMiles: Numeric; quotedUnitPrice: Numeric
|
||||
}
|
||||
export type WireQuotePage = Omit<PageResult<WireSnapshot>, 'page' | 'size' | 'total' | 'pages'> & { page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
|
||||
export const normalizeFleetQuoteSource = (row: WireSnapshot): FleetQuoteSourceSnapshot => ({ ...row, recordVersion: fleetVersion(row.recordVersion), distanceMiles: fleetNumber(row.distanceMiles), quotedUnitPrice: fleetMoney(row.quotedUnitPrice) })
|
||||
export const normalizeFleetQuotePage = (value: WireQuotePage): PageResult<FleetQuoteSourceSnapshot> => fleetPage({ ...value, records: value.records.map(normalizeFleetQuoteSource) })
|
||||
|
||||
/** Only a candidate read: the booking writer must revalidate the reference and freeze its own source snapshot. */
|
||||
export async function listFleetSaleQuoteCandidates(criteria: FleetSaleQuoteCriteria): Promise<PageResult<FleetQuoteSourceSnapshot>> {
|
||||
const response = unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria }))
|
||||
return normalizeFleetQuotePage(response)
|
||||
}
|
||||
export function fleetQuoteReference(source: FleetQuoteSourceSnapshot): FleetQuoteReference {
|
||||
return { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listTripBookingQuoteCandidates, normalizeBooking, type WireBooking } from './tripBookings'
|
||||
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
|
||||
const source = { quoteNo: 'FQ-1', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '30', currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('uses trip-scoped candidate criteria without accepting browser-computed city or date', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [source], page: '1', size: '20', total: '1', pages: '1' } })
|
||||
const criteria = { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD' as const, priceBasis: 'PALLET' as const }
|
||||
const value = await listTripBookingQuoteCandidates('TR-1', criteria)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-1/bookings/quote-candidates', { params: criteria })
|
||||
expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 })
|
||||
})
|
||||
it('normalizes quote source without overwriting negotiated price and handles manual absence', () => {
|
||||
const row: WireBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source,
|
||||
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } }
|
||||
const value = normalizeBooking(row)
|
||||
expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion })
|
||||
expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25)
|
||||
expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull()
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeFleetQuoteSource, normalizeFleetQuotePage, type WireSnapshot, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
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 }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource'> & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | 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 }
|
||||
@@ -12,9 +13,13 @@ const number = (value: N) => { const result = Number(value); if (value == null |
|
||||
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,
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), 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 interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number }
|
||||
export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) {
|
||||
return normalizeFleetQuotePage(unwrapApiResponse(await http.get<ApiEnvelope<WireQuotePage>, ApiEnvelope<WireQuotePage>>(`${path(tripNo)}/quote-candidates`, { params: criteria })))
|
||||
}
|
||||
export async function listFleetCustomers(keyword?: string): Promise<FleetCustomerOption[]> {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<FleetCustomerOption[]>, ApiEnvelope<FleetCustomerOption[]>>(`${base}/customer-options`, { params: { keyword } }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user