mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
126 lines
4.9 KiB
TypeScript
126 lines
4.9 KiB
TypeScript
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||
|
||
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
|
||
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
|
||
|
||
/**
|
||
* Units billed against a booking's bulk quantity. That quantity is recorded in
|
||
* the commodity's own unit — tonnes for a PER_TON commodity, item count for a
|
||
* PER_ITEM one — so both units scale off the same field and only differ in what
|
||
* they are called.
|
||
*/
|
||
export const isBulkQuantityUnit = (unit: string): boolean =>
|
||
unit === 'PER_TON' || unit === 'PER_ITEM';
|
||
|
||
/**
|
||
* 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 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
|
||
* PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced
|
||
* per unit shipped, wheat per tonne. Per-wagon is offered either way.
|
||
*
|
||
* Returned lists are ordered with the most natural/default unit first.
|
||
*/
|
||
export function allowedRateUnits(input: {
|
||
appliesTo: RateAppliesTo;
|
||
trigger: RateTrigger;
|
||
/** 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;
|
||
}): RateUnit[] {
|
||
const units = unitsForShape(input);
|
||
return input.cargoUnitOfMeasure === 'PER_ITEM'
|
||
? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u))
|
||
: units;
|
||
}
|
||
|
||
function unitsForShape(input: {
|
||
appliesTo: RateAppliesTo;
|
||
trigger: RateTrigger;
|
||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||
}): RateUnit[] {
|
||
const { appliesTo, trigger } = input;
|
||
|
||
// Surcharges (Applies to = Other) are governed by their trigger.
|
||
if (appliesTo === 'OTHER') {
|
||
switch (trigger) {
|
||
case 'OVERWEIGHT':
|
||
// Overweight always bills the excess tonnage — per ton, nothing else.
|
||
return ['PER_TON'];
|
||
case 'REEFER':
|
||
case 'HAZARDOUS':
|
||
// Scale with the freight shape: per container for boxes, per ton for bulk.
|
||
return ['PER_CONTAINER', 'PER_TON'];
|
||
case 'DEMURRAGE':
|
||
return ['PER_CONTAINER', 'PER_TON'];
|
||
case 'WITH_RETURN':
|
||
// 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':
|
||
// 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.
|
||
return input.cargoKind === 'BULK'
|
||
? ['PER_TON', 'PER_WAGON']
|
||
: ['PER_CONTAINER', 'PER_WAGON'];
|
||
case 'LASHING':
|
||
// Bulk-only cargo securing — per ton or per wagon.
|
||
return ['PER_TON', 'PER_WAGON'];
|
||
case 'FUEL':
|
||
// Per wagon (wagons × rate) or per liter (baseLiters × rate, once).
|
||
return ['PER_WAGON', 'PER_LITER'];
|
||
case 'CONSOLIDATION':
|
||
return ['PER_CONTAINER', 'FLAT'];
|
||
case 'SHIPPING_LINE':
|
||
case 'PIL_EXTRA_FEE':
|
||
return ['PER_CONTAINER', 'FLAT'];
|
||
default:
|
||
return ['FLAT', 'PER_TON', 'PER_CONTAINER'];
|
||
}
|
||
}
|
||
|
||
// Base freight + first/last mile scale with the cargo type.
|
||
switch (appliesTo) {
|
||
case 'CONTAINER':
|
||
return ['PER_CONTAINER', 'PER_WAGON'];
|
||
case 'BULK':
|
||
return ['PER_TON', 'PER_WAGON'];
|
||
case 'INTERCITY':
|
||
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||
case 'FIRST_MILE':
|
||
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
||
case 'LAST_MILE':
|
||
// PER_KM = container mode (banded by distance + container size),
|
||
// PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for
|
||
// existing rows.
|
||
return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT'];
|
||
default:
|
||
return ['FLAT'];
|
||
}
|
||
}
|
||
|
||
/** The default (first / most natural) unit for a rate shape. */
|
||
export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit {
|
||
return allowedRateUnits(input)[0];
|
||
}
|
||
|
||
/** True when `unit` is a valid weighting basis for the given rate shape. */
|
||
export function isRateUnitAllowed(input: {
|
||
appliesTo: RateAppliesTo;
|
||
trigger: RateTrigger;
|
||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||
cargoUnitOfMeasure?: CargoUom;
|
||
unit: RateUnit;
|
||
}): boolean {
|
||
return allowedRateUnits(input).includes(input.unit);
|
||
}
|