mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
- Added support for container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) in the rule engine. - Introduced new method in to calculate and apply container hazard charges based on booking details. - Updated to handle per-container hazard rates, ensuring they are scoped by trade direction and lane. - Enhanced tests to cover scenarios for container hazard rates, including validation for required fields and conflict checks. - Created a migration to update existing rates and enforce new constraints for container hazard rates in the database.
252 lines
9.5 KiB
TypeScript
252 lines
9.5 KiB
TypeScript
import { BaseEntity } from '@edr/api-common';
|
||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
|
||
import { CargoType } from './cargo-type.entity';
|
||
import { ContainerType } from './container-type.entity';
|
||
import { Yard } from './yard.entity';
|
||
|
||
export const RATE_TYPES = [
|
||
'CONTAINER_IMPORT',
|
||
'CONTAINER_EXPORT',
|
||
// Empty equipment moved as freight in its own right — no cargo, priced per
|
||
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
|
||
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
|
||
// would collide with the laden 40ft rate for the same lane.
|
||
'EMPTY_CONTAINER_IMPORT',
|
||
'EMPTY_CONTAINER_EXPORT',
|
||
'BULK_IMPORT',
|
||
'BULK_EXPORT',
|
||
'INTERCITY_BULK',
|
||
'INTERCITY_CONTAINER',
|
||
'FIRST_MILE',
|
||
'LAST_MILE',
|
||
'DEMURRAGE',
|
||
'LASHING',
|
||
'DOUBLE_HANDLING',
|
||
'CONTAINER_WITH_RETURN',
|
||
'CANCELLATION_FEE',
|
||
'OVERWEIGHT_PER_TON',
|
||
'HAZARD_SURCHARGE',
|
||
'REEFER_SURCHARGE',
|
||
'RETURN_SURCHARGE',
|
||
'PIL_EXTRA_FEE',
|
||
'CUSTOMS_CLEARANCE',
|
||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||
'FUEL_SURCHARGE',
|
||
] as const;
|
||
|
||
export type RateType = typeof RATE_TYPES[number];
|
||
|
||
export const RATE_STATUSES = ['DRAFT', 'PENDING_APPROVAL', 'LIVE', 'SUPERSEDED'] as const;
|
||
export type RateStatus = typeof RATE_STATUSES[number];
|
||
|
||
export const RATE_UNITS = [
|
||
'PER_WAGON',
|
||
'PER_TON',
|
||
// Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure
|
||
// = PER_ITEM) — their rates bill per item off the same booking quantity field.
|
||
'PER_ITEM',
|
||
'PER_CONTAINER',
|
||
'PER_KM',
|
||
// Last-mile bulk: price = tons × km × rateValue.
|
||
'PER_TON_KM',
|
||
// Fuel surcharge only: price = baseLiters × rateValue, once per booking.
|
||
'PER_LITER',
|
||
'PER_INVOICE',
|
||
'FLAT',
|
||
] as const;
|
||
export type RateUnit = typeof RATE_UNITS[number];
|
||
|
||
/**
|
||
* Friendly, admin-facing category that determines how the rate is used in
|
||
* pricing and which fields the rate form shows. Replaces the cryptic
|
||
* `rateType` matrix for the configuration UI (rateType is still persisted and
|
||
* derived from `appliesTo` + `tradeDirection` + `trigger` for base-freight
|
||
* lookup and snapshots).
|
||
*
|
||
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
|
||
* - EMPTY_CONTAINER : base rail freight for empty equipment
|
||
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
|
||
* - OTHER : trigger-based surcharges (hazard, reefer …)
|
||
*/
|
||
export const RATE_APPLIES_TO = [
|
||
'BULK',
|
||
'CONTAINER',
|
||
'EMPTY_CONTAINER',
|
||
'INTERCITY',
|
||
'FIRST_MILE',
|
||
'LAST_MILE',
|
||
'OTHER',
|
||
] as const;
|
||
export type RateAppliesTo = typeof RATE_APPLIES_TO[number];
|
||
|
||
/**
|
||
* What makes a rate apply to a booking. `ALWAYS` is base freight (matched by
|
||
* direction + container/bulk scope). Everything else is a surcharge that the
|
||
* rule engine adds on top, additively, when the booking matches the trigger —
|
||
* so hazard stacks on container/bulk with each line's own unit.
|
||
*/
|
||
export const RATE_TRIGGERS = [
|
||
'ALWAYS',
|
||
// Hazardous cargo. Two shapes under one trigger, told apart by the unit:
|
||
// PER_CONTAINER is the container surcharge, sold per direction + lane and
|
||
// optionally per box size (20ft / 40ft) like the empty-return service;
|
||
// PER_TON is the bulk surcharge, direction-agnostic and unscoped.
|
||
'HAZARDOUS',
|
||
'OVERWEIGHT',
|
||
'REEFER',
|
||
// Empty-container return service (container freight only) — fires when the
|
||
// booking ships WITH_RETURN, billed like hazard/reefer (usually PER_CONTAINER).
|
||
'WITH_RETURN',
|
||
'SHIPPING_LINE',
|
||
'CONSOLIDATION',
|
||
// Cargo securing / lashing. Fires when the booking's cargo type has
|
||
// hasLashing = true. Flat fee, billed once per booking.
|
||
'LASHING',
|
||
'CANCELLATION',
|
||
'DEMURRAGE',
|
||
'PIL_EXTRA_FEE',
|
||
// Customs clearance service fee — billed up front via a clearance invoice,
|
||
// never auto-applied to booking pricing (matchesTrigger returns false).
|
||
'CUSTOMS_CLEARANCE',
|
||
// Same shape as CUSTOMS_CLEARANCE; priced instead of it when the booking's
|
||
// service type has includesEthiopianCustomsOnly (Ethiopian-side clearance).
|
||
'ETHIOPIAN_CUSTOMS_CLEARANCE',
|
||
// Fuel surcharge — fires when the booking's cargo type has hasFuel = true,
|
||
// billed off the lane-scoped rate (direction + route + cargo type).
|
||
'FUEL',
|
||
] as const;
|
||
export type RateTrigger = typeof RATE_TRIGGERS[number];
|
||
|
||
/**
|
||
* The two customs clearance service fees share one rate shape (per direction +
|
||
* route + cargo kind); only which one a booking prices off differs.
|
||
*/
|
||
export const isCustomsClearanceTrigger = (trigger: string): boolean =>
|
||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE';
|
||
|
||
/**
|
||
* The container hazardous-cargo surcharge: HAZARDOUS billed per container.
|
||
* It is sold per trade direction + origin → destination lane, optionally
|
||
* narrowed to one container type (20ft / 40ft), and priced by the
|
||
* route-matched block in RuleEngineService — never by the additive loop.
|
||
* The per-ton (bulk) hazard rate keeps the old global, unscoped shape.
|
||
*/
|
||
export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean =>
|
||
trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER';
|
||
|
||
@Entity({ schema: 'freight', name: 'rates' })
|
||
@Index(['rateType'])
|
||
@Index(['status'])
|
||
@Index(['containerTypeId'])
|
||
@Index(['trigger'])
|
||
@Index(['originYardId'])
|
||
@Index(['destinationYardId'])
|
||
@Index(['shippingLineCompanyId'])
|
||
export class Rate extends BaseEntity {
|
||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||
rateType!: RateType;
|
||
|
||
@Column({ name: 'applies_to', type: 'varchar', length: 20, default: 'OTHER' })
|
||
appliesTo!: RateAppliesTo;
|
||
|
||
@Column({ name: 'trigger', type: 'varchar', length: 30, default: 'ALWAYS' })
|
||
trigger!: RateTrigger;
|
||
|
||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||
containerTypeId?: string | null;
|
||
|
||
@ManyToOne(() => ContainerType, { nullable: true, eager: false })
|
||
@JoinColumn({ name: 'container_type_id' })
|
||
containerType?: ContainerType | null;
|
||
|
||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||
cargoTypeId?: string | null;
|
||
|
||
@ManyToOne(() => CargoType, { nullable: true, eager: false })
|
||
@JoinColumn({ name: 'cargo_type_id' })
|
||
cargoType?: CargoType | null;
|
||
|
||
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||
tradeDirection?: string | null;
|
||
|
||
/**
|
||
* The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per
|
||
* route — "container import, Djibouti → Dire Dawa" — so both yards are
|
||
* required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold
|
||
* surcharges (customs clearance, empty return, fuel, container hazard) and
|
||
* NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces
|
||
* both halves of that.
|
||
*/
|
||
@Column({ name: 'origin_yard_id', type: 'uuid', nullable: true })
|
||
originYardId?: string | null;
|
||
|
||
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||
@JoinColumn({ name: 'origin_yard_id' })
|
||
originYard?: Yard | null;
|
||
|
||
@Column({ name: 'destination_yard_id', type: 'uuid', nullable: true })
|
||
destinationYardId?: string | null;
|
||
|
||
@ManyToOne(() => Yard, { nullable: true, eager: false })
|
||
@JoinColumn({ name: 'destination_yard_id' })
|
||
destinationYard?: Yard | null;
|
||
|
||
/**
|
||
* The shipping line this rate belongs to, or NULL for the standard rate every
|
||
* customer pays. A booking owned by a shipping line prices exclusively off
|
||
* that line's rates — the standard rate is NOT a fallback, so a missing line
|
||
* rate hard-blocks the booking rather than quietly billing the customer price.
|
||
*
|
||
* Points at `shipping_line_companies` (the portal account that books capacity),
|
||
* not `shipping_lines` (carrier reference data behind the SHIPPING_LINE
|
||
* trigger). The two are unrelated despite the similar names.
|
||
*/
|
||
@Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true })
|
||
shippingLineCompanyId?: string | null;
|
||
|
||
@ManyToOne(() => ShippingLineCompany, { nullable: true, eager: false })
|
||
@JoinColumn({ name: 'shipping_line_company_id' })
|
||
shippingLineCompany?: ShippingLineCompany | null;
|
||
|
||
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||
currency!: string;
|
||
|
||
@Column({ name: 'rate_value', type: 'numeric', precision: 14, scale: 4 })
|
||
rateValue!: number;
|
||
|
||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||
rateUnit!: RateUnit;
|
||
|
||
/**
|
||
* Distance band for container last-mile rates (rateUnit = PER_KM, scoped by
|
||
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||
* open-ended). NULL on every other rate shape.
|
||
*/
|
||
/**
|
||
* FUEL rates billed PER_LITER only: the liters the surcharge covers —
|
||
* price = baseLiters × rateValue, once per booking. NULL on every other
|
||
* rate shape (a PER_WAGON fuel rate bills wagons × rateValue instead).
|
||
*/
|
||
@Column({ name: 'base_liters', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||
baseLiters?: number | null;
|
||
|
||
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||
minKm?: number | null;
|
||
|
||
@Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||
maxKm?: number | null;
|
||
|
||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||
status!: RateStatus;
|
||
|
||
@Column({ name: 'proposed_by_staff_id', type: 'uuid' })
|
||
proposedByStaffId!: string;
|
||
|
||
@Column({ name: 'approved_by_ceo_id', type: 'uuid', nullable: true })
|
||
approvedByCeoId?: string | null;
|
||
|
||
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
|
||
approvedAt?: Date | null;
|
||
}
|