mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import { RuleEngineService } from './rule-engine.service';
|
||
import type { BookingEvaluationInput } from './rule-engine.service';
|
||
import type { Rate } from './entities/rate.entity';
|
||
|
||
describe('RuleEngineService — requested service without a configured surcharge rate', () => {
|
||
const hazardRate: Rate = {
|
||
id: 'rate-hazard',
|
||
rateType: 'HAZARD_SURCHARGE',
|
||
trigger: 'HAZARDOUS',
|
||
rateValue: 50,
|
||
rateUnit: 'PER_CONTAINER',
|
||
currency: 'USD',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
cargoTypeId: null,
|
||
} as Rate;
|
||
|
||
let ratesRepo: { findLiveRates: jest.Mock };
|
||
let service: RuleEngineService;
|
||
|
||
beforeEach(() => {
|
||
ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||
service = new RuleEngineService(
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
|
||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
|
||
ratesRepo as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
|
||
{} as never, // dataSource (unused by evaluate)
|
||
);
|
||
});
|
||
|
||
const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||
serviceTypeId: 'svc-1',
|
||
paymentCurrency: 'USD',
|
||
tradeDirection: 'IMPORT',
|
||
isHazardous: false,
|
||
totalWagons: 1,
|
||
containers: [],
|
||
...overrides,
|
||
});
|
||
|
||
it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
|
||
const result = await service.evaluate(input({ isHazardous: true }));
|
||
expect(result.hardBlocked).toHaveLength(1);
|
||
expect(result.hardBlocked[0]).toContain('hazardous');
|
||
});
|
||
|
||
it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
|
||
ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
|
||
const result = await service.evaluate(input({ isHazardous: true }));
|
||
expect(result.hardBlocked).toHaveLength(0);
|
||
});
|
||
|
||
it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
|
||
const result = await service.evaluate(input({}));
|
||
expect(result.hardBlocked).toHaveLength(0);
|
||
});
|
||
|
||
it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
|
||
const result = await service.evaluate(
|
||
input({
|
||
containers: [
|
||
{
|
||
containerTypeId: 'ct-20',
|
||
quantity: 2,
|
||
vgmPerUnitTons: 10,
|
||
totalVgmTons: 20,
|
||
reeferQuantity: 1,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
expect(result.hardBlocked).toHaveLength(1);
|
||
expect(result.hardBlocked[0]).toContain('reefer');
|
||
});
|
||
});
|
||
|
||
describe('RuleEngineService — overweight surcharge by trade direction', () => {
|
||
const baseImportRate: Rate = {
|
||
id: 'rate-import-20',
|
||
rateType: 'CONTAINER_IMPORT',
|
||
trigger: 'ALWAYS',
|
||
rateValue: 1000,
|
||
rateUnit: 'PER_CONTAINER',
|
||
currency: 'USD',
|
||
status: 'LIVE',
|
||
containerTypeId: 'ct-20',
|
||
cargoTypeId: null,
|
||
originYardId: 'yard-dj',
|
||
destinationYardId: 'yard-adama',
|
||
} as Rate;
|
||
|
||
const configuredOverweight: Rate = {
|
||
id: 'rate-ow',
|
||
rateType: 'OVERWEIGHT_PER_TON',
|
||
trigger: 'OVERWEIGHT',
|
||
rateValue: 10,
|
||
rateUnit: 'PER_TON',
|
||
currency: 'USD',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
cargoTypeId: null,
|
||
} as Rate;
|
||
|
||
let service: RuleEngineService;
|
||
|
||
beforeEach(() => {
|
||
service = new RuleEngineService(
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{
|
||
findActiveByContainerTypeId: jest
|
||
.fn()
|
||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||
} as never,
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||
{
|
||
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{} as never,
|
||
);
|
||
});
|
||
|
||
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
|
||
serviceTypeId: 'svc-1',
|
||
paymentCurrency: 'USD',
|
||
tradeDirection,
|
||
isHazardous: false,
|
||
totalWagons: 1,
|
||
originYardId: 'yard-dj',
|
||
destinationYardId: 'yard-adama',
|
||
containers: [
|
||
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||
],
|
||
});
|
||
|
||
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
|
||
const result = await service.evaluate(overweightInput('IMPORT'));
|
||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||
expect(ow).toHaveLength(1);
|
||
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
|
||
expect(ow[0].unitPriceUsd).toBe(25);
|
||
expect(ow[0].calculatedAmount).toBe(125);
|
||
expect(ow[0].triggerValue).toBe(5);
|
||
expect(ow[0].rateId).toBe(baseImportRate.id);
|
||
});
|
||
|
||
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
|
||
const result = await service.evaluate(overweightInput('EXPORT'));
|
||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||
expect(ow).toHaveLength(1);
|
||
expect(ow[0].rateId).toBe(configuredOverweight.id);
|
||
// 5 excess tons × the configured 10 USD/t.
|
||
expect(ow[0].calculatedAmount).toBe(50);
|
||
expect(ow[0].unitPriceUsd).toBeUndefined();
|
||
});
|
||
|
||
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
|
||
const result = await service.evaluate({
|
||
...overweightInput('IMPORT'),
|
||
destinationYardId: 'yard-elsewhere',
|
||
});
|
||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||
expect(ow).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('RuleEngineService — empty-container return per route + container type', () => {
|
||
const returnRate20: Rate = {
|
||
id: 'rate-return-20',
|
||
rateType: 'RETURN_SURCHARGE',
|
||
trigger: 'WITH_RETURN',
|
||
rateValue: 20,
|
||
rateUnit: 'PER_CONTAINER',
|
||
currency: 'USD',
|
||
status: 'LIVE',
|
||
containerTypeId: 'ct-20',
|
||
cargoTypeId: null,
|
||
tradeDirection: 'IMPORT',
|
||
originYardId: 'yard-dj',
|
||
destinationYardId: 'yard-adama',
|
||
} as Rate;
|
||
|
||
let service: RuleEngineService;
|
||
|
||
beforeEach(() => {
|
||
service = new RuleEngineService(
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{
|
||
findActiveByContainerTypeId: jest
|
||
.fn()
|
||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||
} as never,
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{} as never,
|
||
);
|
||
});
|
||
|
||
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||
serviceTypeId: 'svc-1',
|
||
paymentCurrency: 'USD',
|
||
tradeDirection: 'IMPORT',
|
||
isHazardous: false,
|
||
totalWagons: 1,
|
||
originYardId: 'yard-dj',
|
||
destinationYardId: 'yard-adama',
|
||
containers: [
|
||
{
|
||
containerTypeId: 'ct-20',
|
||
quantity: 4,
|
||
vgmPerUnitTons: 10,
|
||
totalVgmTons: 40,
|
||
returnQuantity: 2,
|
||
},
|
||
],
|
||
...overrides,
|
||
});
|
||
|
||
it('bills the route + type matched rate on the opted-in count', async () => {
|
||
const result = await service.evaluate(returnInput({}));
|
||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||
expect(result.hardBlocked).toHaveLength(0);
|
||
expect(ret).toHaveLength(1);
|
||
expect(ret[0].rateId).toBe(returnRate20.id);
|
||
expect(ret[0].triggerValue).toBe(2);
|
||
expect(ret[0].calculatedAmount).toBe(40);
|
||
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
|
||
});
|
||
|
||
it('hard-blocks when the booking route has no matching return rate', async () => {
|
||
const result = await service.evaluate(
|
||
returnInput({ destinationYardId: 'yard-elsewhere' }),
|
||
);
|
||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||
expect(
|
||
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
|
||
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
|
||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||
});
|
||
|
||
it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
|
||
// Same service, but the return rate is sold per wagon: 4× 20ft return =
|
||
// 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
|
||
service = new RuleEngineService(
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{
|
||
findActiveByContainerTypeId: jest
|
||
.fn()
|
||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||
} as never,
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||
{
|
||
findLiveRates: jest
|
||
.fn()
|
||
.mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{} as never,
|
||
);
|
||
|
||
const result = await service.evaluate(
|
||
returnInput({
|
||
containers: [
|
||
{
|
||
containerTypeId: 'ct-20',
|
||
quantity: 4,
|
||
vgmPerUnitTons: 10,
|
||
totalVgmTons: 40,
|
||
returnQuantity: 4,
|
||
wagonsPerUnit: 0.5,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
|
||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||
expect(ret).toHaveLength(1);
|
||
expect(ret[0].triggerValue).toBe(2);
|
||
expect(ret[0].calculatedAmount).toBe(40);
|
||
expect(ret[0].billingUnit).toBe('PER_WAGON');
|
||
});
|
||
|
||
it('legacy booking-level flag bills every container at its type rate', async () => {
|
||
const result = await service.evaluate(
|
||
returnInput({
|
||
withReturn: true,
|
||
containers: [
|
||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||
],
|
||
}),
|
||
);
|
||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||
expect(ret).toHaveLength(1);
|
||
expect(ret[0].triggerValue).toBe(4);
|
||
expect(ret[0].calculatedAmount).toBe(80);
|
||
});
|
||
});
|
||
|
||
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
|
||
const lashingBulkImport: Rate = {
|
||
id: 'rate-lash-bulk',
|
||
rateType: 'LASHING',
|
||
trigger: 'LASHING',
|
||
rateValue: 2,
|
||
rateUnit: 'PER_TON',
|
||
currency: 'USD',
|
||
status: 'LIVE',
|
||
containerTypeId: null,
|
||
cargoTypeId: null,
|
||
tradeDirection: 'IMPORT',
|
||
originYardId: null,
|
||
destinationYardId: null,
|
||
} as Rate;
|
||
|
||
const buildService = (rates: Rate[]): RuleEngineService =>
|
||
new RuleEngineService(
|
||
{
|
||
findById: jest
|
||
.fn()
|
||
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{} as never,
|
||
);
|
||
|
||
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||
serviceTypeId: 'svc-1',
|
||
paymentCurrency: 'USD',
|
||
tradeDirection: 'IMPORT',
|
||
isHazardous: false,
|
||
cargoTypeId: 'cargo-sugar',
|
||
totalWagons: 0,
|
||
bulkTons: 100,
|
||
bulkWagons: 3,
|
||
containers: [],
|
||
...overrides,
|
||
});
|
||
|
||
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||
|
||
it('bulk lashing bills per ton on the direction-matched rate', async () => {
|
||
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
|
||
const mods = lashingMods(result);
|
||
expect(mods).toHaveLength(1);
|
||
expect(mods[0].triggerValue).toBe(100);
|
||
expect(mods[0].calculatedAmount).toBe(200);
|
||
expect(mods[0].billingUnit).toBe('PER_TON');
|
||
});
|
||
|
||
it('a rate for the other direction never bills', async () => {
|
||
const result = await buildService([
|
||
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
|
||
]).evaluate(bulkInput());
|
||
expect(lashingMods(result)).toHaveLength(0);
|
||
});
|
||
|
||
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
||
const result = await buildService([
|
||
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||
]).evaluate(bulkInput());
|
||
const mods = lashingMods(result);
|
||
expect(mods[0].triggerValue).toBe(3);
|
||
expect(mods[0].calculatedAmount).toBe(75);
|
||
});
|
||
|
||
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
|
||
const result = await buildService([
|
||
lashingBulkImport,
|
||
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
|
||
]).evaluate(bulkInput());
|
||
const mods = lashingMods(result);
|
||
expect(mods).toHaveLength(1);
|
||
expect(mods[0].unitPriceUsd).toBe(7);
|
||
expect(mods[0].calculatedAmount).toBe(700);
|
||
});
|
||
|
||
it('container bookings never incur lashing (bulk-only service)', async () => {
|
||
const result = await buildService([lashingBulkImport]).evaluate(
|
||
bulkInput({
|
||
cargoTypeId: null,
|
||
hasLashing: true,
|
||
containers: [
|
||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||
],
|
||
}),
|
||
);
|
||
expect(lashingMods(result)).toHaveLength(0);
|
||
});
|
||
|
||
it('no lashing charge when the cargo does not need lashing', async () => {
|
||
const service = new RuleEngineService(
|
||
{
|
||
findById: jest
|
||
.fn()
|
||
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
|
||
} as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
|
||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||
{} as never,
|
||
);
|
||
const result = await service.evaluate(bulkInput());
|
||
expect(lashingMods(result)).toHaveLength(0);
|
||
});
|
||
});
|