booking cancellation

This commit is contained in:
Marshal
2026-08-15 08:53:24 +00:00
parent 086c4fe8c9
commit c9c2d4dcb3
26 changed files with 751 additions and 163 deletions

View File

@@ -53,7 +53,7 @@ export class CreateRateDto {
@ApiPropertyOptional({
enum: CARGO_KINDS,
description:
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.',
})
@IsOptional()
@IsIn([...CARGO_KINDS])

View File

@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
).toEqual(["PER_TON"]);
});
it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => {
for (const cargoKind of ["CONTAINER", "BULK"] as const) {
expect(
allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }),
).toEqual(["PER_WAGON"]);
}
expect(
allowedRateUnits({
appliesTo: "OTHER",
trigger: "CANCELLATION",
cargoKind: "BULK",
cargoUnitOfMeasure: "PER_ITEM",
}),
).toEqual(["PER_WAGON"]);
});
it("treats per-ton and per-item as the same booking quantity", () => {
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);

View File

@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
cargoKind?: 'CONTAINER' | 'BULK' | null;
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
cargoUnitOfMeasure?: CargoUom;
@@ -64,7 +63,9 @@ function unitsForShape(input: {
// wagon the empties ride back on, or a flat fee.
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
// Wagon cancellation fee — scales with the cancelled wagon count, so
// per wagon is the only unit the wagon-cancel flow can apply.
return ['PER_WAGON'];
case 'CUSTOMS_CLEARANCE':
// Sold per cargo kind: container fees bill per box or per wagon, bulk
// fees per ton or per wagon. Billed on the booking invoice.

View File

@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
*/
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
'CUSTOMS_CLEARANCE',
'CANCELLATION',
'WITH_RETURN',
'LASHING',
'FUEL',
];
/** The yard pair a rate scopes to, already validated against its direction. */
interface YardScope {
@@ -152,7 +165,11 @@ export class RatesService {
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
): boolean {
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
return (
this.isRouteScoped(appliesTo, trigger) ||
trigger === 'LASHING' ||
trigger === 'CANCELLATION'
);
}
/**
@@ -244,10 +261,13 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
// Both fees are sold per direction + cargo kind + type: customs clearance
// per lane, the wagon cancellation fee per direction only.
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
`A ${fee} rate must say whether it covers IMPORT or EXPORT.`,
);
}
// Sold per cargo kind: a container fee names the container type it covers
@@ -255,29 +275,29 @@ export class RatesService {
// that absence is what marks it as the bulk fee.
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers containers or bulk.',
`A ${fee} rate must say whether it covers containers or bulk.`,
);
}
if (cargoKind === 'CONTAINER' && !containerTypeId) {
throw new BadRequestException(
'A container customs clearance rate must name the container type it covers.',
`A container ${fee} rate must name the container type it covers.`,
);
}
if (cargoKind === 'BULK' && containerTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate cannot be scoped to a container type.',
`A bulk ${fee} rate cannot be scoped to a container type.`,
);
}
// The bulk customs fee names the commodity it covers (sugar and
// fertilizer clear differently).
// The bulk fee names the commodity it covers (sugar and fertilizer
// clear — and cancel — differently).
if (cargoKind === 'BULK' && !cargoTypeId) {
throw new BadRequestException(
'A bulk customs clearance rate must name the bulk cargo type it covers.',
`A bulk ${fee} rate must name the bulk cargo type it covers.`,
);
}
if (cargoKind === 'CONTAINER' && cargoTypeId) {
throw new BadRequestException(
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
`A container ${fee} rate cannot be scoped to a bulk cargo type.`,
);
}
return;
@@ -547,22 +567,21 @@ 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 and empty-container return keep direction +
// container type — both are sold per lane (and per container type).
// Exceptions: the directed surcharges (customs clearance, cancellation,
// empty-container return, lashing, fuel) keep direction + cargo scope.
const isSurcharge = trigger !== 'ALWAYS';
const cargoKind =
trigger === 'CUSTOMS_CLEARANCE'
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
: null;
const containerTypeId =
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
? (dto.containerTypeId ?? null)
: isSurcharge
? null
: (dto.containerTypeId ?? null);
const cargoTypeId =
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL'
? (dto.cargoTypeId ?? null)
@@ -574,10 +593,7 @@ export class RatesService {
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
// nothing about the direction.)
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
@@ -758,16 +774,15 @@ export class RatesService {
// 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'
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
? null
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
const keepsContainerType =
!isSurcharge ||
trigger === 'WITH_RETURN' ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
@@ -775,7 +790,7 @@ export class RatesService {
: existing.containerTypeId;
const keepsCargoType =
!isSurcharge ||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
trigger === 'LASHING' ||
trigger === 'FUEL';
const cargoTypeId = !keepsCargoType
@@ -784,10 +799,7 @@ export class RatesService {
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN' ||
trigger === 'LASHING' ||
trigger === 'FUEL'
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection