feat(fleet): add internal sales capacity pool workflow
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes'
|
||||
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 offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer
|
||||
const inquiry = { bookingNo: 'FB/1', offerNo: 'CO/1', recordVersion: '1', tripRecordVersion: '3', offerRecordVersion: '2', expectedPalletCount: null, agreedAmount: null, quoteSource: null, cargo: { packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: '0' } } as unknown as CapacityInquiry
|
||||
const envelope = <T,>(data: T) => ({ code: '200', data })
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
it('keeps unknown quantities distinct from a known zero price and normalizes segment facts', () => {
|
||||
expect(normalizeInquiry(inquiry)).toMatchObject({ recordVersion: 1, expectedPalletCount: null, agreedAmount: null, cargo: { packageCount: null, unitPrice: 0 } })
|
||||
expect(normalizeOffer(offer)).toMatchObject({ capacityKnown: false, destinations: [{ remaining: null }], segments: [{ planned: null, reserved: { palletSpaces: 0 }, remaining: null }] })
|
||||
})
|
||||
it('uses only the dedicated pool endpoint for sales reads and forwards the requested page once', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [offer], total: '1', page: '2', size: '20', pages: '2' })).mockResolvedValueOnce(envelope({ records: [inquiry], total: '1', page: '1', size: '20', pages: '1' })).mockResolvedValueOnce(envelope([{ id: '9007199254740993', code: 'C-1', name: '测试客户' }]))
|
||||
await listCapacityOffers({ page: 2, size: 20 }); await listCapacityInquiries({ scope: 'MINE' }); const customers = await listOfferCustomers('测试')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers', { params: { page: 2, size: 20 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/inquiries', { params: { scope: 'MINE' } })
|
||||
expect(customers[0]?.id).toBe('9007199254740993'); expect(http.get).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
it('submits one command with encoded business numbers and an unchanged retry key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue(envelope({ offer, inquiry, replayed: false }))
|
||||
const body = { customerId: '19', idempotencyKey: 'stable-test-key', cargo: { packageCount: null } } as InquiryWrite
|
||||
await saveCapacityInquiry('CO/1', null, body)
|
||||
expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/inquiries', body)
|
||||
const command = { recordVersion: 1, offerRecordVersion: 2, tripRecordVersion: 3, idempotencyKey: 'confirm-stable-key' }
|
||||
await commandCapacityInquiry('FB/1', 'confirm', command)
|
||||
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CapacityOffer, CapacityInquiry, CapacityOfferDetail, CapacityLoad, OfferPage, OfferQuery, InquiryQuery, OfferWrite, OfferCommand, InquiryWrite, InquiryCommand, OfferResult, InquiryResult, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import { normalizeFleetQuotePage, normalizeFleetQuoteSource, type WireQuotePage } from './fleetQuoteCandidates'
|
||||
import type { TripBookingQuoteCriteria } from './tripBookings'
|
||||
const base = '/platform/v1/last-leg/fleet/capacity-offers'
|
||||
const path = (value: string) => `${base}/${encodeURIComponent(value)}`
|
||||
const inquiryPath = (value: string) => `${base}/inquiries/${encodeURIComponent(value)}`
|
||||
const number = (value: number) => { const n = Number(value); if (value == null || !Number.isFinite(n)) throw new Error('运力数据数值无效'); return n }
|
||||
const nullable = (value: number | null) => value == null ? null : number(value)
|
||||
const load = (value: CapacityLoad | null) => value == null ? null : { palletSpaces: number(value.palletSpaces), weightKg: number(value.weightKg), volumeCbm: number(value.volumeCbm) }
|
||||
export function normalizeOffer(value: CapacityOffer): CapacityOffer { return { ...value, recordVersion: number(value.recordVersion), tripRecordVersion: number(value.tripRecordVersion), destinations: value.destinations.map(d => ({ ...d, deliveryIndex: number(d.deliveryIndex), remaining: load(d.remaining) })), segments: value.segments.map(s => ({ ...s, fromIndex: number(s.fromIndex), toIndex: number(s.toIndex), planned: load(s.planned), reserved: load(s.reserved), remaining: load(s.remaining) })) } }
|
||||
export function normalizeInquiry(value: CapacityInquiry): CapacityInquiry { return { ...value, recordVersion: number(value.recordVersion), offerRecordVersion: number(value.offerRecordVersion), tripRecordVersion: number(value.tripRecordVersion), expectedPalletCount: nullable(value.expectedPalletCount), agreedAmount: nullable(value.agreedAmount), quoteSource: value.quoteSource ? normalizeFleetQuoteSource(value.quoteSource) : null, cargo: { ...value.cargo, packageCount: nullable(value.cargo.packageCount), palletSpaces: nullable(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } } }
|
||||
function page<T>(value: OfferPage<T>, normalize: (row: T) => T): OfferPage<T> { return { records: value.records.map(normalize), page: number(value.page), size: number(value.size), total: number(value.total), pages: number(value.pages) } }
|
||||
const offerResult = (value: OfferResult): OfferResult => ({ ...value, offer: normalizeOffer(value.offer) })
|
||||
const inquiryResult = (value: InquiryResult): InquiryResult => ({ ...offerResult(value), inquiry: normalizeInquiry(value.inquiry) })
|
||||
async function get<T>(url: string, params?: object) { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
async function post<T>(url: string, body: object) { return unwrapApiResponse(await http.post<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
async function put<T>(url: string, body: object) { return unwrapApiResponse(await http.put<ApiEnvelope<T>, ApiEnvelope<T>>(url, body)) }
|
||||
export async function listCapacityOffers(query: OfferQuery) { return page(await get<OfferPage<CapacityOffer>>(base, query), normalizeOffer) }
|
||||
export async function getCapacityOffer(offerNo: string): Promise<CapacityOfferDetail> { const value = await get<CapacityOfferDetail>(path(offerNo)); return { offer: normalizeOffer(value.offer), inquiries: page(value.inquiries, normalizeInquiry) } }
|
||||
export async function listCapacityInquiries(query: InquiryQuery) { return page(await get<OfferPage<CapacityInquiry>>(`${base}/inquiries`, query), normalizeInquiry) }
|
||||
export async function getCapacityInquiry(bookingNo: string) { return normalizeInquiry(await get<CapacityInquiry>(inquiryPath(bookingNo))) }
|
||||
export async function saveCapacityOffer(offerNo: string | null, body: OfferWrite) { return offerResult(await (offerNo ? put<OfferResult>(path(offerNo), body) : post<OfferResult>(base, body))) }
|
||||
export async function commandCapacityOffer(offerNo: string, action: 'publish' | 'pause' | 'resume' | 'close', body: OfferCommand) { return offerResult(await post<OfferResult>(`${path(offerNo)}/${action}`, body)) }
|
||||
export async function saveCapacityInquiry(offerNo: string, bookingNo: string | null, body: InquiryWrite) { return inquiryResult(await (bookingNo ? put<InquiryResult>(inquiryPath(bookingNo), body) : post<InquiryResult>(`${path(offerNo)}/inquiries`, body))) }
|
||||
export async function commandCapacityInquiry(bookingNo: string, action: 'confirm' | 'cancel', body: InquiryCommand) { return inquiryResult(await post<InquiryResult>(`${inquiryPath(bookingNo)}/${action}`, body)) }
|
||||
export const listOfferCustomers = (keyword?: string) => get<OfferCustomerOption[]>(`${base}/customer-options`, { keyword })
|
||||
export async function listOfferQuotes(offerNo: string, query: TripBookingQuoteCriteria) { return normalizeFleetQuotePage(await get<WireQuotePage>(`${path(offerNo)}/quote-candidates`, query)) }
|
||||
@@ -12,10 +12,11 @@ it('uses trip-scoped candidate criteria without accepting browser-computed city
|
||||
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,
|
||||
const row: WireBooking = { offerNo: null, expectedPalletCount: null, 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()
|
||||
expect(normalizeBooking({ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } })).toMatchObject({ offerNo: 'CO-1', expectedPalletCount: null, cargo: { packageCount: null } })
|
||||
})
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCre
|
||||
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' | 'quoteSource'> & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null }
|
||||
type WireCargo = Omit<TripBookingCargo, 'packageCount' | 'palletSpaces' | 'weightKg' | 'volumeCbm' | 'unitPrice'> & { packageCount: N | null; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null }
|
||||
export type WireBooking = Omit<TripBooking, 'recordVersion' | 'agreedAmount' | 'cargo' | 'receipt' | 'quoteSource' | 'expectedPalletCount'> & { expectedPalletCount: N | null; 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 }
|
||||
@@ -13,8 +13,8 @@ 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, 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) } })
|
||||
export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, expectedPalletCount: value.expectedPalletCount == null ? null : integer(value.expectedPalletCount), 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: value.cargo.packageCount == null ? null : 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) {
|
||||
|
||||
Reference in New Issue
Block a user