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