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