import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; 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', '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', ] 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', 'PER_CONTAINER', 'PER_KM', '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) * - FIRST_MILE / LAST_MILE : pickup / delivery legs * - OTHER : trigger-based surcharges (hazard, reefer …) */ export const RATE_APPLIES_TO = [ 'BULK', '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', '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', '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', ] as const; export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) @Index(['containerTypeId']) @Index(['trigger']) @Index(['originYardId']) @Index(['destinationYardId']) 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: 20, 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/INTERCITY 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; @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; @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; }