fix issue -> consolidation

This commit is contained in:
Marshal
2026-08-24 12:24:23 +00:00
parent 979f024869
commit 2a107e8ba3
20 changed files with 475 additions and 39 deletions

View File

@@ -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',

View File

@@ -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;
}