import { BaseEntity } from '@edr/api-common'; import { TrainScheduleStatus as TrainScheduleStatusEnum, WagonAllocationSnapshot, } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Route } from '../../routes/entities/route.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity'; export const TRAIN_SCHEDULE_STATUSES = [ TrainScheduleStatusEnum.Draft, TrainScheduleStatusEnum.Scheduled, TrainScheduleStatusEnum.Dispatched, TrainScheduleStatusEnum.Arrived, TrainScheduleStatusEnum.Cancelled, ] as const; export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; @Entity({ schema: 'freight', name: 'train_schedules' }) @Index(['scheduledDepartureDate']) @Index(['status']) export class TrainSchedule extends BaseEntity { @Column({ name: 'train_set_id', type: 'uuid', unique: true }) trainSetId!: string; @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule) @JoinColumn({ name: 'train_set_id' }) trainSet?: TrainSet; @Column({ name: 'route_id', type: 'uuid', nullable: true }) routeId?: string | null; @ManyToOne(() => Route) @JoinColumn({ name: 'route_id' }) route?: Route | null; @Column({ name: 'origin_station_id', type: 'uuid' }) originStationId!: string; @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_station_id' }) originStation?: Yard; @Column({ name: 'destination_station_id', type: 'uuid' }) destinationStationId!: string; @ManyToOne(() => Yard) @JoinColumn({ name: 'destination_station_id' }) destinationStation?: Yard; @Column({ name: 'scheduled_departure_date', type: 'timestamptz' }) scheduledDepartureDate!: Date; @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true }) scheduledArrivalDate?: Date | null; @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) status!: TrainScheduleStatus; @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; /** * Voyage (sailing) number for this departure — the identifier yards and * customs quote alongside the train number. Per-departure, so it lives here * rather than on the built train. */ @Column({ name: 'voyage_number', type: 'varchar', length: 20, nullable: true }) voyageNumber?: string | null; // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule // list, booking windows, and load lists. Assigned at creation from the highest // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). @Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true }) reference?: string | null; @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) direction?: string | null; /** * Dedicates this departure to one shipping line. NULL = a normal train, * visible to customers as today. Set = the train is HIDDEN from every * customer-facing read (windows, day pools, home cards) and shown only to * this shipping line in its portal. */ @Column({ name: 'shipping_line_company_id', type: 'uuid', nullable: true }) shippingLineCompanyId?: string | null; @ManyToOne(() => ShippingLineCompany) @JoinColumn({ name: 'shipping_line_company_id' }) shippingLineCompany?: ShippingLineCompany | null; /** * Reverse the wagon ORDER on this train: when true, the built wagon plan is * flipped at build so the physically-last wagon sits at position 1. Only the * order (sequenceNo) changes — composition and allocations travel with their * slot. Frozen at create; every (re)assignment rebuilds under this flag so the * stored train order and the schedule order always match. Default false. */ @Column({ name: 'reverse_wagon_order', type: 'boolean', default: false }) reverseWagonOrder!: boolean; @Column({ name: 'actual_departure_at', type: 'timestamptz', nullable: true }) actualDepartureAt?: Date | null; @Column({ name: 'actual_arrival_at', type: 'timestamptz', nullable: true }) actualArrivalAt?: Date | null; @Column({ name: 'prepared_by_user_id', type: 'uuid', nullable: true }) preparedByUserId?: string | null; @Column({ name: 'checked_by_user_id', type: 'uuid', nullable: true }) checkedByUserId?: string | null; @Column({ name: 'max_wagons', type: 'int', default: 53 }) maxWagons!: number; /** * Where THIS departure plans to board each consist wagon: `{ wagonId: yardId }`. * Sparse — a wagon absent from the map boards from its physical * `wagons.current_yard_id`. Independent of the built train's physical spread * so a departure can be sold from Dire while the steel still stands in Mojo; * dispatch requires plan and physical yards to agree. */ @Column({ name: 'planned_wagon_yards', type: 'jsonb', nullable: true }) plannedWagonYards?: Record | null; /** * Where THIS departure plans to CUT (detach and leave) each consist wagon: * `{ wagonId: yardId }`. Sparse — a wagon absent from the map rides to the * schedule destination. A cap, not a promise: cargo may alight earlier, but * validation forbids cargo allocated past the cut. */ @Column({ name: 'planned_wagon_cut_yards', type: 'jsonb', nullable: true }) plannedWagonCutYards?: Record | null; /** * LOOSE wagons this departure plans to COUPLE onto the train at a route * stop: `{ wagonId: pickupYardId }`. They join the built train permanently * when the trip reaches that stop (dispatch for the origin, checkpoint log * for mid-route stops). */ @Column({ name: 'planned_wagon_couples', type: 'jsonb', nullable: true }) plannedWagonCouples?: Record | null; /** * Cut wagons (see plannedWagonCutYards) flagged as REAL cuts: the built * train permanently loses the wagon at its cut yard. Absent from this list, * a cut is soft — the wagon sits out the rest of this trip but stays in * the build. */ @Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true }) plannedWagonRealCuts?: string[] | null; /** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */ @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; /** * Booking-window lifecycle for the one-booking-day cycle * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). * NULL on legacy and DOMESTIC schedules — the window engine ignores those. */ @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) windowPhase?: string | null; @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) windowOpensAt?: Date | null; @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) windowClosesAt?: Date | null; @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) docReviewEndsAt?: Date | null; /** Staff finished document review early — starts the batch/payment phase immediately. */ @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) docReviewCompletedAt?: Date | null; @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) paymentPhaseEndsAt?: Date | null; /** 1-based count of open→settle cycles run on the booking day. */ @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) bookingCycleNo!: number; // ── Booking-window rule snapshot ────────────────────────────────────────── // The scheduling rule this train was created with, frozen at creation. A later // global-rules edit applies only to FUTURE schedules — an already-open schedule // keeps its base rule. The batch board derives its display windows (open time + // reopen cycles) from THIS snapshot, never from the live global config. NULL on // legacy rows created before the snapshot existed (board falls back to live cfg). @Column({ name: 'rule_window_open_hour', type: 'int', nullable: true }) ruleWindowOpenHour?: number | null; /** EAT hour the daily booking desk shuts (equals open hour for a 24h desk). */ @Column({ name: 'rule_window_close_hour', type: 'int', nullable: true }) ruleWindowCloseHour?: number | null; @Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true }) ruleWindowDurationHours?: number | null; /** * Frozen reopen gap = doc-review + payment minutes at creation. The board * projects each next cycle at close + this delay, then snaps it into office hours. */ @Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true }) ruleReopenDelayMinutes?: number | null; /** * Per-schedule pay-window override (minutes). NULL = use the live global * value for the schedule's direction. Unlike the other rule_* snapshots this * is only written by an explicit staff override, never stamped at creation. */ @Column({ name: 'rule_payment_window_minutes', type: 'int', nullable: true }) rulePaymentWindowMinutes?: number | null; /** * Staff configured this schedule's booking window by hand (at creation or via * the per-schedule override) instead of inheriting the live global rules. * `restampPendingWindows` skips these, so a later global-rules edit cannot * silently overwrite the hand-picked settings. */ @Column({ name: 'window_rule_custom', type: 'boolean', default: false }) windowRuleCustom!: boolean; @Column({ name: 'rule_import_window_lead_days', type: 'int', nullable: true }) ruleImportWindowLeadDays?: number | null; @Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true }) ruleExportBookingLeadHours?: number | null; /** Frozen import booking-close offset (minutes before departure). NULL = none. */ @Column({ name: 'rule_import_close_offset_minutes', type: 'int', nullable: true }) ruleImportCloseOffsetMinutes?: number | null; /** Frozen export booking-close offset (minutes before departure). NULL = none. */ @Column({ name: 'rule_export_close_offset_minutes', type: 'int', nullable: true }) ruleExportCloseOffsetMinutes?: number | null; // Frozen wagon plan captured once when the schedule leaves the editable // DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a // non-editable schedule read THIS instead of the live wagon↔slot joins, so the // historical allocation survives the same physical wagons being re-pinned onto // later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows. @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) wagonAllocationSnapshot?: WagonAllocationSnapshot | null; @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; }