mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
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<string, string> | 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<string, string> | 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<string, string> | 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[];
|
|
}
|