Files
edr-platform/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts
marshal 00c0d86a8a feat: Implement container hazardous-cargo surcharge logic
- 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.
2026-09-06 22:28:57 +00:00

252 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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