mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
775 lines
26 KiB
TypeScript
775 lines
26 KiB
TypeScript
import { BookingPricingService } from './booking-pricing.service';
|
||
import type { Booking } from './entities/booking.entity';
|
||
import type { Rate } from '../rule-engine/entities/rate.entity';
|
||
|
||
const MOCK_CBE_RATE = 130;
|
||
|
||
// Base freight is configured per leg, so every rate and every booking names the
|
||
// route it runs. MOJO → DIRE is the corridor these rates are priced for.
|
||
const MOJO = 'yard-mojo';
|
||
const DIRE = 'yard-dire-dawa';
|
||
const LEBU = 'yard-lebu';
|
||
|
||
describe('BookingPricingService — domestic corridor', () => {
|
||
const intercityBulkUsd: Rate = {
|
||
id: 'rate-intercity-bulk-usd',
|
||
rateType: 'INTERCITY_BULK',
|
||
currency: 'USD',
|
||
rateValue: 35,
|
||
rateUnit: 'PER_TON',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
const intercityContainerUsd: Rate = {
|
||
id: 'rate-intercity-container-usd',
|
||
rateType: 'INTERCITY_CONTAINER',
|
||
currency: 'USD',
|
||
rateValue: 400,
|
||
rateUnit: 'PER_CONTAINER',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
let service: BookingPricingService;
|
||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||
let ratesService: { findLiveRates: jest.Mock };
|
||
let exchangeService: { getRate: jest.Mock };
|
||
|
||
beforeEach(() => {
|
||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||
ratesService = {
|
||
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
|
||
};
|
||
exchangeService = {
|
||
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||
};
|
||
|
||
service = new BookingPricingService(
|
||
bookingsRepository as never,
|
||
{} as never,
|
||
{} as never,
|
||
ratesService as never,
|
||
exchangeService as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{} as never,
|
||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||
);
|
||
});
|
||
|
||
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
|
||
const booking = {
|
||
id: 'b-1',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'ETB',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||
expect(result.lineItems[0].currency).toBe('ETB');
|
||
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
|
||
});
|
||
|
||
it('keeps the exchange rate decimals — ETB amounts round to cents, not whole birr', async () => {
|
||
exchangeService.getRate.mockResolvedValue(162.2132);
|
||
const booking = {
|
||
id: 'b-1-frac',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'ETB',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
// 35 × 120 × 162.2132 = 681,295.44 — the .44 must survive (whole-birr
|
||
// rounding here billed with the integer part of the rate, in effect).
|
||
expect(result.lineItems[0].amount).toBe(681295.44);
|
||
});
|
||
|
||
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
|
||
const booking = {
|
||
id: 'b-1-usd',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
|
||
expect(result.lineItems[0].currency).toBe('USD');
|
||
expect(result.lineItems[0].amount).toBe(35 * 120);
|
||
});
|
||
|
||
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
|
||
const booking = {
|
||
id: 'b-2',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'ETB',
|
||
cargoTotalWeightVgm: 50,
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||
});
|
||
|
||
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
|
||
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
|
||
expect(line.currency).toBe('ETB');
|
||
});
|
||
|
||
// Rates are quoted per leg, so one configured for MOJO → DIRE must not price a
|
||
// shipment that runs LEBU → DIRE. Charging the wrong corridor's price because
|
||
// nobody configured this one yet is worse than billing no base freight.
|
||
it('does not price bulk off a rate configured for a different leg', async () => {
|
||
const booking = {
|
||
id: 'b-3',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: LEBU,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: { containers: [] },
|
||
) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, { containers: [] });
|
||
|
||
expect(result.lineItems).toHaveLength(0);
|
||
expect(result.blocked).toHaveLength(1);
|
||
});
|
||
|
||
it('does not price containers off a rate configured for a different leg', async () => {
|
||
const booking = {
|
||
id: 'b-4',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
cargoTotalWeightVgm: 50,
|
||
originYardId: LEBU,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ amount: number }> }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
|
||
});
|
||
|
||
expect(result.lineItems).toHaveLength(0);
|
||
});
|
||
|
||
// A mixed booking where only one container size has a configured rate must
|
||
// hard-block, not silently carry the unconfigured size for free.
|
||
it('blocks the unconfigured container size and prices the configured one', async () => {
|
||
const fortyOnly: Rate = {
|
||
...intercityContainerUsd,
|
||
id: 'rate-ct-40-only',
|
||
containerTypeId: 'ct-40',
|
||
} as Rate;
|
||
ratesService.findLiveRates.mockResolvedValue([fortyOnly]);
|
||
|
||
const booking = {
|
||
id: 'b-5',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'DOMESTIC',
|
||
paymentCurrency: 'USD',
|
||
originYardId: MOJO,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
} as unknown as Booking;
|
||
|
||
const result = await (
|
||
service as unknown as {
|
||
computeBaseRailLinesWithRates: (
|
||
b: Booking,
|
||
input: {
|
||
containers: Array<{ containerTypeId: string; quantity: number }>;
|
||
},
|
||
) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>;
|
||
}
|
||
).computeBaseRailLinesWithRates(booking, {
|
||
containers: [
|
||
{ containerTypeId: 'ct-40', quantity: 2 },
|
||
{ containerTypeId: 'ct-20', quantity: 3 },
|
||
],
|
||
});
|
||
|
||
expect(result.lineItems).toHaveLength(1);
|
||
expect(result.blocked).toHaveLength(1);
|
||
expect(result.blocked[0]).toContain('rate is configured');
|
||
});
|
||
});
|
||
|
||
describe('BookingPricingService — customs clearance fee billed on the booking price', () => {
|
||
const DJ = 'yard-dj';
|
||
|
||
const containerFee20: Rate = {
|
||
id: 'rate-cc-20',
|
||
rateType: 'CUSTOMS_CLEARANCE',
|
||
trigger: 'CUSTOMS_CLEARANCE',
|
||
currency: 'USD',
|
||
rateValue: 100,
|
||
rateUnit: 'PER_CONTAINER',
|
||
status: 'LIVE',
|
||
containerTypeId: 'ct-20',
|
||
tradeDirection: 'IMPORT',
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
} as Rate;
|
||
|
||
const bulkFeePerTon: Rate = {
|
||
...containerFee20,
|
||
id: 'rate-cc-bulk',
|
||
rateValue: 5,
|
||
rateUnit: 'PER_TON',
|
||
containerTypeId: null,
|
||
} as Rate;
|
||
|
||
const emptyEval = {
|
||
priorityScore: 0,
|
||
appliedModifiers: [],
|
||
containerWeightResults: [],
|
||
warnings: [],
|
||
hardBlocked: [],
|
||
requiresDirectorApproval: false,
|
||
};
|
||
|
||
const makeService = (opts: {
|
||
snapshots?: unknown[];
|
||
liveRates?: Rate[];
|
||
wagonCapacity?: number;
|
||
}) =>
|
||
new BookingPricingService(
|
||
{
|
||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||
findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []),
|
||
} as never,
|
||
{ evaluate: jest.fn().mockResolvedValue(emptyEval) } as never,
|
||
{
|
||
findById: jest.fn(async (id: string) => ({
|
||
id,
|
||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||
isReefer: false,
|
||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||
})),
|
||
} as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{
|
||
findById: jest.fn().mockResolvedValue({
|
||
wagonTypes:
|
||
opts.wagonCapacity !== undefined
|
||
? [{ capacityTons: opts.wagonCapacity }]
|
||
: [],
|
||
}),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||
);
|
||
|
||
const containerBooking = (overrides: Record<string, unknown> = {}) =>
|
||
({
|
||
id: 'b-cc',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
customsClearingEnabled: true,
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [
|
||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 },
|
||
],
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
const bulkBooking = (overrides: Record<string, unknown> = {}) =>
|
||
({
|
||
id: 'b-cc-bulk',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
customsClearingEnabled: true,
|
||
cargoTypeId: 'cargo-1',
|
||
cargoTotalWeightVgm: 120,
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE,
|
||
bookingContainers: [],
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
it('bills a container booking per box at its own container type fee', async () => {
|
||
const service = makeService({ liveRates: [containerFee20] });
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line).toBeDefined();
|
||
expect(line!.unit).toBe('PER_CONTAINER');
|
||
expect(line!.quantity).toBe(4);
|
||
expect(line!.amount).toBe(400);
|
||
});
|
||
|
||
it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate],
|
||
});
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line!.unit).toBe('PER_WAGON');
|
||
expect(line!.quantity).toBe(2);
|
||
expect(line!.amount).toBe(200);
|
||
});
|
||
|
||
it('prices an Ethiopian-customs-only service off ETHIOPIAN_CUSTOMS_CLEARANCE, not the full fee', async () => {
|
||
const ethiopianFee = {
|
||
...containerFee20,
|
||
id: 'rate-et-20',
|
||
rateType: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||
trigger: 'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||
rateValue: 40,
|
||
} as Rate;
|
||
// No serviceType relation on the booking (like the GL/portal shipment
|
||
// preview) — the flag must be resolved from serviceTypeId.
|
||
const service = makeService({ liveRates: [containerFee20, ethiopianFee] });
|
||
(service as unknown as { serviceTypesService: { findById: jest.Mock } }).serviceTypesService = {
|
||
findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: true }),
|
||
};
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ serviceTypeId: 'st-et', serviceType: undefined }),
|
||
);
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'ETHIOPIAN_CUSTOMS_CLEARANCE_20FT');
|
||
expect(line).toBeDefined();
|
||
expect(line!.amount).toBe(160);
|
||
expect(result.lineItems.some((l) => l.code === 'CUSTOMS_CLEARANCE_20FT')).toBe(false);
|
||
});
|
||
|
||
it('hard-blocks a container type with no fee configured (never free clearance)', async () => {
|
||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||
const result = await service.computePriceForBooking(containerBooking());
|
||
|
||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true);
|
||
});
|
||
|
||
it('bills a bulk booking per ton at the route bulk fee', async () => {
|
||
const service = makeService({ liveRates: [bulkFeePerTon] });
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('PER_TON');
|
||
expect(line!.quantity).toBe(120);
|
||
expect(line!.amount).toBe(600);
|
||
});
|
||
|
||
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
|
||
const service = makeService({
|
||
liveRates: [
|
||
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
|
||
{
|
||
...bulkFeePerTon,
|
||
id: 'rate-cc-sugar',
|
||
rateValue: 9,
|
||
cargoTypeId: 'cargo-1',
|
||
} as Rate,
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
|
||
expect(line!.amount).toBe(1080);
|
||
});
|
||
|
||
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||
wagonCapacity: 60,
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('PER_WAGON');
|
||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||
expect(line!.amount).toBe(100);
|
||
});
|
||
|
||
it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => {
|
||
const service = makeService({
|
||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate],
|
||
});
|
||
const result = await service.computePriceForBooking(bulkBooking());
|
||
|
||
expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true);
|
||
});
|
||
|
||
it('prefers the contract frozen per-size snapshot over the live rate', async () => {
|
||
const service = makeService({
|
||
liveRates: [containerFee20],
|
||
snapshots: [
|
||
{
|
||
rateCode: 'CUSTOMS_CLEARANCE_20FT',
|
||
unitPrice: 80,
|
||
currency: 'USD',
|
||
unitOfMeasure: 'per_container',
|
||
isClearance: true,
|
||
},
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ contractId: 'c-1' }),
|
||
);
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT');
|
||
expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100
|
||
});
|
||
|
||
it('honours a legacy FLAT snapshot once for the whole container booking', async () => {
|
||
const service = makeService({
|
||
liveRates: [],
|
||
snapshots: [
|
||
{
|
||
rateCode: 'CUSTOMS_CLEARANCE',
|
||
unitPrice: 500,
|
||
currency: 'USD',
|
||
unitOfMeasure: 'flat',
|
||
isClearance: true,
|
||
},
|
||
],
|
||
});
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ contractId: 'c-legacy' }),
|
||
);
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||
expect(line!.unit).toBe('FLAT');
|
||
expect(line!.amount).toBe(500);
|
||
expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false);
|
||
});
|
||
|
||
it('adds no fee line when customs clearing is disabled', async () => {
|
||
const service = makeService({ liveRates: [containerFee20] });
|
||
const result = await service.computePriceForBooking(
|
||
containerBooking({ customsClearingEnabled: false }),
|
||
);
|
||
|
||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
|
||
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
|
||
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
|
||
*/
|
||
describe('BookingPricingService — bulk base freight units', () => {
|
||
const DJ = 'yard-dj-bulk';
|
||
const DIRE_B = 'yard-dire-bulk';
|
||
|
||
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
|
||
({
|
||
id: 'rate-bulk',
|
||
rateType: 'BULK_IMPORT',
|
||
appliesTo: 'BULK',
|
||
trigger: 'ALWAYS',
|
||
currency: 'USD',
|
||
rateValue: 200,
|
||
rateUnit: 'PER_ITEM',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
cargoTypeId: null,
|
||
tradeDirection: 'IMPORT',
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE_B,
|
||
...overrides,
|
||
}) as Rate;
|
||
|
||
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
|
||
new BookingPricingService(
|
||
{
|
||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||
} as never,
|
||
{
|
||
evaluate: jest.fn().mockResolvedValue({
|
||
priorityScore: 0,
|
||
appliedModifiers: [],
|
||
containerWeightResults: [],
|
||
warnings: [],
|
||
hardBlocked: [],
|
||
requiresDirectorApproval: false,
|
||
}),
|
||
} as never,
|
||
{ findById: jest.fn() } as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{
|
||
findById: jest.fn().mockResolvedValue({
|
||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||
}),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||
);
|
||
|
||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||
const booking = (overrides: Record<string, unknown> = {}) =>
|
||
({
|
||
id: 'b-bulk',
|
||
freightType: 'BULK',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
cargoTypeId: 'cargo-machinery',
|
||
cargoTotalWeightVgm: 12,
|
||
originYardId: DJ,
|
||
destinationYardId: DIRE_B,
|
||
bookingContainers: [],
|
||
...overrides,
|
||
}) as unknown as Booking;
|
||
|
||
it('bills a PER_ITEM rate on the item count', async () => {
|
||
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||
expect(line!.unit).toBe('PER_ITEM');
|
||
expect(line!.quantity).toBe(12);
|
||
expect(line!.amount).toBe(2400);
|
||
});
|
||
|
||
it('bills a PER_TON rate on the tonnage', async () => {
|
||
const result = await makeService([
|
||
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
|
||
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||
expect(line!.unit).toBe('PER_TON');
|
||
expect(line!.amount).toBe(35 * 120);
|
||
});
|
||
|
||
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
|
||
const result = await makeService(
|
||
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
|
||
60,
|
||
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||
expect(line!.unit).toBe('PER_WAGON');
|
||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||
expect(line!.amount).toBe(1000);
|
||
});
|
||
|
||
it('prices off the rate scoped to the booking commodity, not another one', async () => {
|
||
const result = await makeService([
|
||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
|
||
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
|
||
]).computePriceForBooking(booking());
|
||
|
||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||
expect(line!.unit).toBe('PER_ITEM');
|
||
expect(line!.amount).toBe(2400);
|
||
});
|
||
|
||
it('hard-blocks when the leg only carries another commodity’s rate', async () => {
|
||
const result = await makeService([
|
||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
|
||
]).computePriceForBooking(booking());
|
||
|
||
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
|
||
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A PER_WAGON container rate bills the wagons the LINE occupies — two 20ft share
|
||
* one wagon, a 40ft takes a whole one. Regression cases taken from real
|
||
* bookings on Doraleh → Gelan, where the 20ft line was being charged for the
|
||
* 40ft line's wagons as well.
|
||
*/
|
||
describe('BookingPricingService — PER_WAGON container freight', () => {
|
||
const DJ = 'yard-dj-w';
|
||
const ET = 'yard-et-w';
|
||
|
||
const perWagon20: Rate = {
|
||
id: 'rate-20-wagon',
|
||
rateType: 'CONTAINER_IMPORT',
|
||
currency: 'USD',
|
||
rateValue: 1690,
|
||
rateUnit: 'PER_WAGON',
|
||
status: 'LIVE',
|
||
containerTypeId: 'ct-20',
|
||
originYardId: DJ,
|
||
destinationYardId: ET,
|
||
} as Rate;
|
||
|
||
const perContainer40: Rate = {
|
||
...perWagon20,
|
||
id: 'rate-40-container',
|
||
rateValue: 1676,
|
||
rateUnit: 'PER_CONTAINER',
|
||
containerTypeId: 'ct-40',
|
||
} as Rate;
|
||
|
||
const makeService = () =>
|
||
new BookingPricingService(
|
||
{
|
||
// Booking-wide aggregate — deliberately larger than any single line, so
|
||
// a regression that reads it instead of the line's own wagons shows up.
|
||
calculateWagonCount: jest.fn().mockResolvedValue(5),
|
||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||
} as never,
|
||
{
|
||
evaluate: jest.fn().mockResolvedValue({
|
||
priorityScore: 0,
|
||
appliedModifiers: [],
|
||
containerWeightResults: [],
|
||
warnings: [],
|
||
hardBlocked: [],
|
||
requiresDirectorApproval: false,
|
||
}),
|
||
} as never,
|
||
{
|
||
findById: jest.fn(async (id: string) => ({
|
||
id,
|
||
sizeFt: id === 'ct-40' ? 40 : 20,
|
||
isReefer: false,
|
||
code: id === 'ct-40' ? 'C40' : 'C20',
|
||
label: id === 'ct-40' ? 'C40' : 'C20',
|
||
})),
|
||
} as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
|
||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findById: jest.fn() } as never,
|
||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||
);
|
||
|
||
const booking = (
|
||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||
) =>
|
||
({
|
||
id: 'b-wagon',
|
||
freightType: 'CONTAINER',
|
||
tradeDirection: 'IMPORT',
|
||
paymentCurrency: 'USD',
|
||
originYardId: DJ,
|
||
destinationYardId: ET,
|
||
bookingContainers: lines.map((l) => ({
|
||
containerTypeId: l.containerTypeId,
|
||
quantity: l.quantity,
|
||
vgmPerUnitTons: 10,
|
||
})),
|
||
}) as unknown as Booking;
|
||
|
||
const price = async (
|
||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||
) => {
|
||
const service = makeService();
|
||
const result = await service.computePriceForBooking(booking(lines));
|
||
return result.lineItems.filter((l) => l.code === 'CONTAINER_IMPORT');
|
||
};
|
||
|
||
it('bills 2× 20ft as one wagon', async () => {
|
||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 2 }]);
|
||
expect(line.unit).toBe('PER_WAGON');
|
||
expect(line.quantity).toBe(1);
|
||
expect(line.amount).toBe(1690);
|
||
});
|
||
|
||
it('bills 10× 20ft as five wagons', async () => {
|
||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 10 }]);
|
||
expect(line.quantity).toBe(5);
|
||
expect(line.amount).toBe(5 * 1690);
|
||
});
|
||
|
||
it('does not charge the 20ft line for the 40ft line’s wagons', async () => {
|
||
const lines = await price([
|
||
{ containerTypeId: 'ct-20', quantity: 4 },
|
||
{ containerTypeId: 'ct-40', quantity: 1 },
|
||
]);
|
||
const twenty = lines.find((l) => l.description.startsWith('C20'))!;
|
||
const forty = lines.find((l) => l.description.startsWith('C40'))!;
|
||
// 4× 20ft = 2 wagons, NOT the booking-wide 3.
|
||
expect(twenty.quantity).toBe(2);
|
||
expect(twenty.amount).toBe(2 * 1690);
|
||
// The 40ft line keeps billing per container.
|
||
expect(forty.quantity).toBe(1);
|
||
expect(forty.amount).toBe(1676);
|
||
});
|
||
|
||
it('rounds an odd 20ft count up to a whole wagon', async () => {
|
||
const [line] = await price([{ containerTypeId: 'ct-20', quantity: 5 }]);
|
||
expect(line.quantity).toBe(3);
|
||
expect(line.amount).toBe(3 * 1690);
|
||
});
|
||
});
|