revert back the clerance payment

This commit is contained in:
Marshal
2026-07-23 13:54:09 +00:00
parent 13609f8d59
commit 15a6bab5d0
54 changed files with 1222 additions and 712 deletions

View File

@@ -10,6 +10,7 @@ import {
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
const CURRENCIES = ['USD'] as const;
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
export class CreateRateDto {
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
@@ -47,6 +48,15 @@ export class CreateRateDto {
@IsIn([...INTERCITY_KINDS])
intercityKind?: string;
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance / lashing rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or LASHING. Not stored — container fees carry a containerTypeId, bulk fees none.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])
cargoKind?: string;
@ApiPropertyOptional({
description:
'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.',

View File

@@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE / LASHING only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
}): RateUnit[] {
const { appliesTo, trigger } = input;
@@ -29,16 +31,18 @@ export function allowedRateUnits(input: {
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
case 'WITH_RETURN':
// Container-only empty-return service — bills per returned container.
return ['PER_CONTAINER', 'FLAT'];
// Container-only empty-return service — per returned container, per
// wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
return ['FLAT'];
case 'LASHING':
// Flat cargo-securing fee, billed once per booking.
return ['FLAT'];
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.
return input.cargoKind === 'BULK'
? ['PER_TON', 'PER_WAGON']
: ['PER_CONTAINER', 'PER_WAGON'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':
@@ -74,6 +78,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate
export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
cargoKind?: 'CONTAINER' | 'BULK' | null;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);

View File

@@ -249,6 +249,49 @@ describe('RuleEngineService — empty-container return per route + container typ
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({
@@ -264,3 +307,128 @@ describe('RuleEngineService — empty-container return per route + container typ
expect(ret[0].calculatedAmount).toBe(80);
});
});
describe('RuleEngineService — lashing per cargo kind', () => {
const lashing20: Rate = {
id: 'rate-lash-20',
rateType: 'LASHING',
trigger: 'LASHING',
rateValue: 30,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
tradeDirection: null,
originYardId: null,
destinationYardId: null,
} as Rate;
const lashingBulk: Rate = {
...lashing20,
id: 'rate-lash-bulk',
rateValue: 2,
rateUnit: 'PER_TON',
containerTypeId: null,
} as Rate;
const buildService = (rates: Rate[]): RuleEngineService =>
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(rates) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
const containerInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
hasLashing: true,
totalWagons: 2,
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
wagonsPerUnit: 0.5,
},
],
...overrides,
});
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
hasLashing: true,
totalWagons: 0,
bulkTons: 100,
bulkWagons: 3,
containers: [],
...overrides,
});
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
it('bills each container line at its own container type rate', async () => {
const result = await buildService([lashing20]).evaluate(containerInput());
const mods = lashingMods(result);
expect(mods).toHaveLength(1);
expect(mods[0].triggerValue).toBe(4);
expect(mods[0].calculatedAmount).toBe(120);
expect(mods[0].billingUnit).toBe('PER_CONTAINER');
});
it('PER_WAGON container lashing bills the wagons the line occupies', async () => {
const result = await buildService([
{ ...lashing20, rateUnit: 'PER_WAGON' } as Rate,
]).evaluate(containerInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(2);
expect(mods[0].calculatedAmount).toBe(60);
expect(mods[0].billingUnit).toBe('PER_WAGON');
});
it('an unmatched container type simply bills nothing (lenient)', async () => {
const result = await buildService([
{ ...lashing20, containerTypeId: 'ct-40' } as Rate,
]).evaluate(containerInput());
expect(lashingMods(result)).toHaveLength(0);
});
it('bulk lashing bills per ton on the tonnage', async () => {
const result = await buildService([lashingBulk]).evaluate(bulkInput());
const mods = lashingMods(result);
expect(mods[0].triggerValue).toBe(100);
expect(mods[0].calculatedAmount).toBe(200);
expect(mods[0].billingUnit).toBe('PER_TON');
});
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
const result = await buildService([
{ ...lashingBulk, 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('no lashing charge when the cargo does not need lashing', async () => {
const result = await buildService([lashing20]).evaluate(
containerInput({ hasLashing: false }),
);
expect(lashingMods(result)).toHaveLength(0);
});
});

View File

@@ -48,6 +48,12 @@ export interface BookingContainerEvalInput {
hazardousQuantity?: number;
reeferQuantity?: number;
returnQuantity?: number;
/**
* Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
* Lets a PER_WAGON empty-return rate bill the wagons the returned empties
* ride back on. Missing ⇒ one wagon per container.
*/
wagonsPerUnit?: number;
}
export interface BookingEvaluationInput {
@@ -86,6 +92,12 @@ export interface BookingEvaluationInput {
* container freight, which is scaled by container count instead.
*/
bulkTons?: number;
/**
* Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
* the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
* 0/undefined when unknown — those charges then bill nothing.
*/
bulkWagons?: number;
containers: BookingContainerEvalInput[];
}
@@ -312,6 +324,9 @@ export class RuleEngineService {
// Empty-container return is sold per route + container type — billed by
// the route-matched block below, never by this route-agnostic loop.
if (rate.trigger === 'WITH_RETURN') continue;
// Lashing is sold per cargo kind + container type — billed by the
// kind-aware block below, never by this generic loop.
if (rate.trigger === 'LASHING') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -419,6 +434,10 @@ export class RuleEngineService {
appliedModifiers.push(...withReturn.modifiers);
hardBlocked.push(...withReturn.blocked);
if (hasLashing) {
appliedModifiers.push(...this.lashingCharges(input, liveRates));
}
return {
priorityScore,
appliedModifiers,
@@ -538,12 +557,18 @@ export class RuleEngineService {
}
const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
// PER_WAGON bills the wagons the returned empties occupy (two 20ft share
// one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
const billed =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
if (!(amount > 0)) continue;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: qty,
triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
@@ -555,6 +580,70 @@ export class RuleEngineService {
return { modifiers, blocked: [...new Set(blocked)] };
}
/**
* Cargo securing / lashing — sold per cargo kind, like the customs clearance
* fee. Container bookings bill each container line at its own container
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage,
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply
* bills nothing — same leniency as hazard/reefer.
*/
private lashingCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
const lashingRates = liveRates.filter(
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
);
if (lashingRates.length === 0) return modifiers;
const push = (rate: Rate, billedQty: number): void => {
const rateValue = Number(rate.rateValue);
const amount =
rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
if (!(amount > 0)) return;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
};
if (input.containers.length > 0) {
for (const container of input.containers) {
const qty = Number(container.quantity || 0);
if (!(qty > 0)) continue;
const rate = lashingRates.find(
(r) => r.containerTypeId === container.containerTypeId,
);
if (!rate) continue;
const billedQty =
rate.rateUnit === 'PER_WAGON'
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
: qty;
push(rate, billedQty);
}
return modifiers;
}
// Bulk — the type-less rate covers the whole booking.
const rate = lashingRates.find((r) => !r.containerTypeId);
if (!rate) return modifiers;
const billedQty =
rate.rateUnit === 'PER_TON'
? Math.max(0, Number(input.bulkTons ?? 0))
: rate.rateUnit === 'PER_WAGON'
? Math.max(0, Number(input.bulkWagons ?? 0))
: 1;
push(rate, billedQty);
return modifiers;
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => {
expect(request.payload).toEqual({ rateValue: 200 });
});
it('carries a re-routed leg — a yard-only edit is a real change', async () => {
const { service } = build({
rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
});
const request = await service.submit({
rateId: 'rate-1',
update: {
rateValue: 100,
originYardId: 'yard-a',
destinationYardId: 'yard-c',
},
});
expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
});
it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
const { service } = build();
await expect(

View File

@@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [
'tradeDirection',
'containerTypeId',
'cargoTypeId',
// The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate
// diffed to nothing and the submit was refused as "nothing changed".
'originYardId',
'destinationYardId',
] as const;
/**

View File

@@ -69,18 +69,19 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'] | undefined,
cargoKind?: 'CONTAINER' | 'BULK' | null,
): Rate['rateUnit'] {
// Overweight is per-ton, full stop — the admin form hides the unit field
// for it and omits rateUnit from the payload entirely.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
const allowed = allowedRateUnits({ appliesTo, trigger });
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
if (!requestedUnit) {
throw new BadRequestException(
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
);
}
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
);
@@ -187,17 +188,42 @@ export class RatesService {
trigger: Rate['trigger'];
tradeDirection: string | null;
intercityKind: string | null;
cargoKind: string | null;
containerTypeId: string | null;
cargoTypeId: string | null;
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
// Customs is sold per direction; lashing applies both ways.
if (
trigger === 'CUSTOMS_CLEARANCE' &&
tradeDirection !== 'IMPORT' &&
tradeDirection !== 'EXPORT'
) {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
);
}
// Sold per cargo kind: a container fee names the container type it covers
// (20ft and 40ft price differently); a bulk fee carries no type at all —
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
`A ${label} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
`A container ${label} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
`A bulk ${label} rate cannot be scoped to a container type.`,
);
}
return;
}
if (trigger === 'WITH_RETURN') {
@@ -279,11 +305,17 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container
// return keeps direction + container type — both are sold per lane.
// Exceptions: customs clearance and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN'
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
@@ -304,6 +336,7 @@ export class RatesService {
trigger,
tradeDirection,
intercityKind,
cargoKind,
containerTypeId,
cargoTypeId,
});
@@ -325,6 +358,7 @@ export class RatesService {
appliesTo,
trigger,
dto.rateUnit as Rate['rateUnit'] | undefined,
cargoKind,
);
await this.assertNoDuplicatePattern({
@@ -419,7 +453,19 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
// A patch that leaves the cargo kind unsaid keeps the one the rate already
// has — read back off its container scope (container fees carry the type).
const cargoKind =
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -455,6 +501,7 @@ export class RatesService {
trigger,
tradeDirection: updates.tradeDirection,
intercityKind,
cargoKind,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
});
@@ -486,7 +533,7 @@ export class RatesService {
// Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({