mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
fix issue -> consolidation
This commit is contained in:
@@ -169,6 +169,101 @@ describe('RuleEngineService — overweight surcharge by trade direction', () =>
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — overweight is per container, never pooled', () => {
|
||||
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;
|
||||
|
||||
const makeService = (maxCapacityTons: number | null) =>
|
||||
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 }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([configuredOverweight]) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// The reported case: 3 × 20ft at 22 / 18 / 20 t against a 20 t limit. The
|
||||
// line total (60 t) fits a pooled 3 × 20 t allowance, but the first box is
|
||||
// 2 t over and must be billed for it.
|
||||
const input = (unitVgmTons: number[]): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'EXPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 2,
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: unitVgmTons.length,
|
||||
vgmPerUnitTons: unitVgmTons.reduce((s, v) => s + v, 0) / unitVgmTons.length,
|
||||
totalVgmTons: unitVgmTons.reduce((s, v) => s + v, 0),
|
||||
unitVgmTons,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('bills only the tons the heavy container is over, not the line total', async () => {
|
||||
const result = await makeService(null).evaluate(input([22, 18, 20]));
|
||||
const wr = result.containerWeightResults[0];
|
||||
expect(wr.isOverweight).toBe(true);
|
||||
expect(wr.overweightExcessTons).toBe(2);
|
||||
expect(wr.overweightUnits).toEqual([{ unitIndex: 1, vgmTons: 22, excessTons: 2 }]);
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow[0].calculatedAmount).toBe(20); // 2 t × 10 USD/t
|
||||
});
|
||||
|
||||
it('sums the excess of every over-limit container', async () => {
|
||||
const result = await makeService(null).evaluate(input([22, 18, 23]));
|
||||
const wr = result.containerWeightResults[0];
|
||||
expect(wr.overweightExcessTons).toBe(5);
|
||||
expect(wr.overweightUnits?.map((u) => u.unitIndex)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('is not overweight when no single container is over the limit', async () => {
|
||||
const result = await makeService(null).evaluate(input([20, 18, 20]));
|
||||
expect(result.containerWeightResults[0].isOverweight).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to an even spread when a line carries no per-unit weights', async () => {
|
||||
const result = await makeService(null).evaluate({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'EXPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 2,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 21, totalVgmTons: 63 },
|
||||
],
|
||||
});
|
||||
// 3 boxes at 21 t each → 1 t over on each.
|
||||
expect(result.containerWeightResults[0].overweightExcessTons).toBe(3);
|
||||
});
|
||||
|
||||
it('blocks on capacity per container, not on the pooled line total', async () => {
|
||||
const violations = await makeService(30).capacityViolations(
|
||||
[{ containerTypeId: 'ct-20', quantity: 3, totalVgmTons: 60, unitVgmTons: [35, 5, 20] }],
|
||||
'EXPORT',
|
||||
);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]).toContain('#1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — empty-container return per route + container type', () => {
|
||||
const returnRate20: Rate = {
|
||||
id: 'rate-return-20',
|
||||
|
||||
@@ -33,6 +33,32 @@ import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
|
||||
// from multipart form-data) and a non-empty "false" string is truthy.
|
||||
const truthy = (v: unknown): boolean => v === true || v === 'true';
|
||||
|
||||
/** Tons carry 3 decimals in the schema; keep derived tonnage on that grid. */
|
||||
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
|
||||
|
||||
/**
|
||||
* The VGM of every physical container on a line. Uses the per-unit weights the
|
||||
* booking recorded; when a line has none (or fewer than its quantity — legacy
|
||||
* rows only kept a line total), the remainder is spread evenly, which is the
|
||||
* uniform load those bookings were entered as.
|
||||
*/
|
||||
export const unitWeights = (container: {
|
||||
quantity: number;
|
||||
totalVgmTons: number;
|
||||
unitVgmTons?: number[];
|
||||
}): number[] => {
|
||||
const known = (container.unitVgmTons ?? [])
|
||||
.slice(0, container.quantity)
|
||||
.map((v) => Number(v ?? 0));
|
||||
const missing = Math.max(0, Number(container.quantity || 0) - known.length);
|
||||
if (missing === 0) return known;
|
||||
const rest = Math.max(
|
||||
0,
|
||||
Number(container.totalVgmTons || 0) - known.reduce((s, v) => s + v, 0),
|
||||
);
|
||||
return [...known, ...Array<number>(missing).fill(rest / missing)];
|
||||
};
|
||||
|
||||
export interface BookingContainerEvalInput {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
@@ -41,6 +67,15 @@ export interface BookingContainerEvalInput {
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
/**
|
||||
* VGM of each physical container on this line, when the booking carries
|
||||
* per-unit weights. Weight limits are a per-container ceiling: 3x20ft at
|
||||
* 22/18/20t against a 20t limit is 2t overweight on the first box, not
|
||||
* zero because the line total happens to fit. Missing/short (legacy lines
|
||||
* that only carry a line total) falls back to an even spread across
|
||||
* `quantity`, which is what those bookings actually recorded.
|
||||
*/
|
||||
unitVgmTons?: number[];
|
||||
/**
|
||||
* How many individual containers on this line opted into each handling
|
||||
* service. PER_CONTAINER surcharges bill these counts, not the line
|
||||
@@ -131,11 +166,22 @@ export interface AppliedCargoModifier {
|
||||
billingUnit?: string;
|
||||
}
|
||||
|
||||
/** One physical container that broke the per-container VGM limit. */
|
||||
export interface OverweightUnit {
|
||||
/** 1-based position of the container within its line. */
|
||||
unitIndex: number;
|
||||
vgmTons: number;
|
||||
excessTons: number;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
containerTypeId: string;
|
||||
weightLimitRuleId: string | null;
|
||||
isOverweight: boolean;
|
||||
/** Sum of the per-container excesses on this line. */
|
||||
overweightExcessTons: number | null;
|
||||
/** Which containers of the line are over, and by how much. */
|
||||
overweightUnits?: OverweightUnit[];
|
||||
}
|
||||
|
||||
export interface RuleEvaluationResult {
|
||||
@@ -221,22 +267,37 @@ export class RuleEngineService {
|
||||
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
let overweightUnits: OverweightUnit[] | undefined;
|
||||
|
||||
if (rule) {
|
||||
const maxTotal = Number(rule.maxVgmTons) * container.quantity;
|
||||
const totalVgm = container.totalVgmTons;
|
||||
if (totalVgm > maxTotal) {
|
||||
const perUnitLimit = Number(rule.maxVgmTons);
|
||||
// Per-container, never pooled: an underloaded box does not absorb the
|
||||
// excess of an overloaded one — each container is billed on its own
|
||||
// tons above the limit.
|
||||
overweightUnits = unitWeights(container)
|
||||
.map((vgmTons, i) => ({
|
||||
unitIndex: i + 1,
|
||||
vgmTons,
|
||||
excessTons: round3(Math.max(0, vgmTons - perUnitLimit)),
|
||||
}))
|
||||
.filter((u) => u.excessTons > 0);
|
||||
if (overweightUnits.length > 0) {
|
||||
isOverweight = true;
|
||||
excess = Math.max(0, totalVgm - maxTotal);
|
||||
warnings.push(
|
||||
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
|
||||
excess = round3(
|
||||
overweightUnits.reduce((sum, u) => sum + u.excessTons, 0),
|
||||
);
|
||||
for (const u of overweightUnits) {
|
||||
warnings.push(
|
||||
`Container type ${container.containerTypeId} #${u.unitIndex} VGM ${u.vgmTons}t exceeds the ${perUnitLimit}t limit by ${u.excessTons}t`,
|
||||
);
|
||||
}
|
||||
}
|
||||
containerWeightResults.push({
|
||||
containerTypeId: container.containerTypeId,
|
||||
weightLimitRuleId: rule.id,
|
||||
isOverweight,
|
||||
overweightExcessTons: excess,
|
||||
overweightUnits,
|
||||
});
|
||||
} else {
|
||||
containerWeightResults.push({
|
||||
@@ -719,6 +780,7 @@ export class RuleEngineService {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
totalVgmTons: number;
|
||||
unitVgmTons?: number[];
|
||||
}>,
|
||||
tradeDirection: string,
|
||||
): Promise<string[]> {
|
||||
@@ -731,13 +793,17 @@ export class RuleEngineService {
|
||||
const rule = rules[0];
|
||||
if (!rule || rule.maxCapacityTons == null) continue;
|
||||
const perUnit = Number(rule.maxCapacityTons);
|
||||
const maxTotal = perUnit * container.quantity;
|
||||
if (container.totalVgmTons > maxTotal) {
|
||||
const label = rule.containerType?.code ?? container.containerTypeId;
|
||||
violations.push(
|
||||
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
|
||||
);
|
||||
}
|
||||
const label = rule.containerType?.code ?? container.containerTypeId;
|
||||
// Capacity is a physical ceiling on one box, so it is checked per box for
|
||||
// the same reason the VGM limit is — a light container cannot carry the
|
||||
// overload of a heavy one.
|
||||
unitWeights(container).forEach((vgmTons, i) => {
|
||||
if (vgmTons > perUnit) {
|
||||
violations.push(
|
||||
`${label} #${i + 1} weight ${round3(vgmTons)}t exceeds the maximum capacity of ${perUnit}t per container — the booking cannot be created; reduce the cargo weight`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user