add wagon allocation snapshot to train schedules

This commit is contained in:
Marshal
2026-07-13 08:56:06 +00:00
parent 95b17b2729
commit a4ccdb1173
12 changed files with 733 additions and 171 deletions

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a frozen wagon-allocation snapshot to each train schedule.
*
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
* cancel), the same physical wagons get released and re-pinned onto later trains.
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
*
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
* physical wagon + booking allocations) captured at the transition. Non-editable
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
* legacy rows and while editable — the read path falls back to the live joins.
*/
export class AddScheduleWagonAllocationSnapshot2120000000000
implements MigrationInterface
{
name = "AddScheduleWagonAllocationSnapshot2120000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
`);
}
}

View File

@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
@@ -1482,6 +1483,18 @@ export class BookingsService {
);
}
// Surface the parent contract's reference for drawdown bookings — the
// portal detail header shows it (the entity has no contract relation, so
// the list attaches it via a raw join and the detail attaches it here).
if (booking.contractId) {
const contract = await this.dataSource.getRepository(Contract).findOne({
where: { id: booking.contractId },
select: { reference: true },
});
(booking as Booking & { contractReference?: string | null }).contractReference =
contract?.reference ?? null;
}
// Surface the assigned train's operational status so the portal stepper
// can show the Arrival stage: the booking status stays IN_TRANSIT from
// dispatch until delivery, so arrival is only knowable from the schedule.

View File

@@ -1,5 +1,8 @@
import { BaseEntity } from '@edr/api-common';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
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';
@@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
ruleExportBookingLeadHours?: 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[];
}

View File

@@ -1191,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit {
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
// Every booking still targeting this train holds gross weight — including
// PAID ones waiting for wagon allocation (WAITING) and post-dispatch
// catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
// board's weight the moment customers paid. Only EXPIRED released its hold.
const committed = items.filter((i) => i.state !== "EXPIRED");
const caps = loco
? trainHardCaps({
maxPullWeightTons: Number(loco.maxPullWeightTons),

View File

@@ -4,6 +4,7 @@
SchedulingStatus,
TrainCheckpointKind,
TrainScheduleStatus as TrainScheduleStatusEnum,
WagonAllocationSnapshot,
WagonMovementKind,
WagonStatus,
} from '@edr/types';
@@ -132,6 +133,8 @@ import {
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
@@ -395,6 +398,108 @@ export class TrainSchedulingService {
}
}
/**
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
* group together, since the route entity keys only origin + destination). All
* schedules in a group must run ONE shared booking-window timeline so a
* customer booking on a later-created train is never expired by a sibling
* train's payment window closing on a different clock.
*
* Grouping keys off the columns the schedule already carries — no new schema.
* Callers pass a live `manager` so both the create (inside its transaction) and
* the update paths see uncommitted siblings.
*/
private async findGroupSiblings(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule[]> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = manager
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.where('s.originStationId = :originStationId', { originStationId })
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getMany();
}
/**
* The window timeline a brand-new schedule must adopt to join its route+day
* group. Returns the canonical open/close times + rule snapshot copied from an
* existing sibling, or null when this is the first schedule in the group (the
* caller then computes its own times as before — nothing changes for the
* single-schedule case).
*
* The anchor is preferably a still-PRE_WINDOW sibling (its times are the live
* group clock). If every sibling has already opened, we still copy the earliest
* sibling's frozen times so the new train lines up with the group the customer
* already sees rather than drifting onto its own `now`-based clock.
*/
private async findGroupWindowAnchor(
manager: EntityManager,
originStationId: string,
destinationStationId: string,
departure: Date,
): Promise<TrainSchedule | null> {
const siblings = await this.findGroupSiblings(
manager,
originStationId,
destinationStationId,
departure,
);
if (siblings.length === 0) return null;
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
if (withWindow.length === 0) return null;
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
const pool = pending.length > 0 ? pending : withWindow;
// Earliest-opening sibling defines the group clock — deterministic and the
// one a customer would have seen first.
return pool.reduce((earliest, s) =>
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
);
}
/**
* The window fields (open/close times + frozen rule snapshot) an anchor sibling
* hands down to the rest of its group. The shared open/close instants make every
* schedule in the group advance through the SAME open, doc-review, payment, and
* close instants on the shared 10s tick — doc-review/payment ends are derived
* live from these shared times during phase advance, so they fall in sync.
*
* `targetDeparture` is the JOINING schedule's own departure: the shared times
* are clamped to it so a group whose trains depart at different times of the
* same day never hands an earlier-departing train a window that outlives its
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
* preserves that invariant when the anchor departed later). A window that would
* be entirely after this train's departure collapses to a zero-length window at
* departure — truthful, not a window that never closes.
*/
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
const cap = targetDeparture.getTime();
const clamp = (d: Date | null | undefined): Date | null =>
d == null ? null : d.getTime() > cap ? targetDeparture : d;
return {
windowOpensAt: clamp(anchor.windowOpensAt),
windowClosesAt: clamp(anchor.windowClosesAt),
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
};
}
async getEligibleBookings(query: GetEligibleBookingsDto) {
// Day-level pooling: when the wizard targets a schedule, surface the whole
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
@@ -561,15 +666,53 @@ export class TrainSchedulingService {
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(merged),
});
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
// WHOLE group — every schedule sharing this origin + destination + EAT
// departure day. They all adopt the SAME rule snapshot and share the SAME
// window open/close timeline (the whole point of grouping). The shared times
// are clamped to each train's OWN departure so a group whose trains depart at
// different times of the same day never hands an earlier-departing sibling a
// window that outlives its departure. Only still-PRE_WINDOW siblings are
// touched — a sibling that has already opened, finalized, or dispatched stays
// frozen on the times its customers were shown and simply drops out of the
// group; the remaining pending trains stay in sync. EXPORT is excluded
// (departure-anchored FCFS window, no cross-expiry), so an export override
// only touches its own schedule.
const ruleFields = windowRuleSnapshot(merged);
const repo = this.dataSource.getRepository(TrainSchedule);
const cap = (d: Date, departure: Date): Date =>
d.getTime() > departure.getTime() ? departure : d;
const targets: Array<{ id: string; departure: Date }> = [
{ id, departure: schedule.scheduledDepartureDate },
];
if (schedule.direction !== 'EXPORT') {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
}
}
}
for (const t of targets) {
await repo.update(t.id, {
windowOpensAt: cap(times.windowOpensAt, t.departure),
windowClosesAt: cap(times.windowClosesAt, t.departure),
...ruleFields,
});
}
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
);
void this.emitWindowState(id);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
@@ -634,14 +777,34 @@ export class TrainSchedulingService {
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, now);
// Moving the departure moves this train between route+day GROUPS. If the
// destination day already has a group (a sibling on the same origin +
// destination + new EAT day), adopt that group's shared timeline instead of
// the times just derived, so the rescheduled train lines up with the group
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
// own re-derived times and becomes the anchor for that day. EXPORT is
// excluded — its window is anchored to its own departure, not shared.
const anchor =
schedule.direction === 'EXPORT'
? null
: await this.findGroupWindowAnchor(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
departure,
);
const windowFields = anchor
? this.groupWindowFieldsFrom(anchor, departure)
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowFields,
});
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${times.windowOpensAt.toISOString()})`,
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
);
void this.emitWindowState(id);
@@ -854,21 +1017,44 @@ export class TrainSchedulingService {
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const windowFields =
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
// on this origin + destination + EAT departure day, this new train JOINS
// its group and adopts the group's shared window timeline (open/close +
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
// That keeps every train on the day advancing through the same open/
// doc-review/payment/close instants, so a booking on one train is never
// expired by a sibling train's payment window closing on a different clock.
// First train in the group falls through to the normal computation.
//
// EXPORT is excluded: an export window is a single FCFS window anchored to
// each train's OWN departure (windowClosesAt = departure) with no
// doc-review/payment phase — so there is no cross-expiry to fix, and two
// export trains departing the same day at different times must keep their
// own departure-anchored windows.
const groupAnchor =
direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeExportWindowTimes(departure, windowCfg),
}
? null
: await this.findGroupWindowAnchor(
manager,
route.originYardId,
route.destinationYardId,
departure,
);
const computedTimes =
direction === 'EXPORT'
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
: {
// IMPORT and DOMESTIC share the import booking-day window cycle.
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
const windowFields = {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...(groupAnchor
? this.groupWindowFieldsFrom(groupAnchor, departure)
: computedTimes),
};
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
.maxWagonsPerTrain;
// Retry past a concurrent insert that grabbed the same S-<year> sequence
@@ -1522,12 +1708,34 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Dispatched,
{ actualDepartureAt: now, trainNumber },
{
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
),
},
manager,
);
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
}
// The train is out — every pinned wagon is ASSIGNED to this schedule and
// stays pinned so no other schedule can pick it while it's rolling.
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
.map((slot) => slot.physicalWagonId)
.filter((id): id is string => Boolean(id));
if (dispatchedPhysicalIds.length) {
await manager
.getRepository(Wagon)
.update(
{ id: In(dispatchedPhysicalIds) },
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
);
}
for (const sb of schedule.scheduleBookings ?? []) {
await this.bookingsRepository.updateSchedulingFields(
sb.bookingId,
@@ -1604,7 +1812,24 @@ export class TrainSchedulingService {
);
}
void this.notifyScheduleBookings(schedule, 'dispatched');
return this.getTrainScheduleById(scheduleId);
const detail = await this.getTrainScheduleById(scheduleId);
// Surface a compact dispatch confirmation so the caller can toast the "train
// is out" info (train number, departure, wagons committed) without re-deriving it.
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
(slot) => slot.physicalWagonId,
).length;
return Object.assign(detail, {
dispatchInfo: {
// The real train number was assigned inside the txn — read it back off
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
departedAt: now.toISOString(),
wagonsDispatched: dispatchedWagonCount,
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
},
});
}
async getImportDjiboutiOperation(scheduleId: string) {
@@ -2561,7 +2786,15 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
scheduleId,
TrainScheduleStatusEnum.Arrived,
{ actualArrivalAt: now },
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
),
},
manager,
);
@@ -2723,13 +2956,24 @@ export class TrainSchedulingService {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
{
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
),
},
manager,
);
if (schedule.trainSetId) {
@@ -2757,6 +3001,9 @@ export class TrainSchedulingService {
currentTrainScheduleId: null,
trainSetWagonId: null,
status: WagonStatus.Available,
// A cancelled train never left — its wagons stay/return at the origin
// yard, free to be re-pinned onto another schedule from there.
currentYardId: schedule.originStationId,
});
}
}
@@ -3296,6 +3543,47 @@ export class TrainSchedulingService {
}));
}
/**
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
sequenceNo: wagon.sequenceNo,
trainSetWagonId: wagon.id,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
slotStatus: wagon.status ?? null,
boardYardId: wagon.boardYardId ?? null,
alightYardId: wagon.alightYardId ?? null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
loadType: allocation.loadType ?? null,
containerNumbers: (allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter((n): n is string => Boolean(n)),
})),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
};
}
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
for (const slot of slots) {
@@ -4523,6 +4811,20 @@ export class TrainSchedulingService {
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
// and re-pinned onto later trains — the live wagon↔slot joins no longer
// describe THIS train. If a frozen snapshot was captured at the transition,
// the per-slot wagon number + booking allocations are read from it instead.
const snapshot = schedule.wagonAllocationSnapshot ?? null;
const isWagonAllocationFrozen = Boolean(
snapshot &&
schedule.status !== TrainScheduleStatusEnum.Draft &&
schedule.status !== TrainScheduleStatusEnum.Scheduled,
);
const snapshotSlotByTrainSetWagonId = new Map(
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -4605,52 +4907,84 @@ export class TrainSchedulingService {
})),
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: wagon.physicalWagonId ?? null,
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
})),
.map((wagon) => {
// Frozen schedules read the wagon number + allocations from the
// snapshot slot; the immutable slot geometry (capacity/type) still
// comes live. Falls back to live if a slot is missing from the snap.
const frozenSlot = isWagonAllocationFrozen
? snapshotSlotByTrainSetWagonId.get(wagon.id)
: undefined;
return {
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(Number(wagon.capacityTons)),
lengthMeters: roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
// frontend needs it to show the gross train weight.
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: wagon.status,
physicalWagonId: frozenSlot
? frozenSlot.physicalWagonId
: wagon.physicalWagonId ?? null,
physicalWagonNumber: frozenSlot
? frozenSlot.physicalWagonNumber
: wagon.physicalWagon?.wagonNumber ?? null,
wagonType: wagon.wagonType
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
: null,
allocations: frozenSlot
? frozenSlot.allocations.map((allocation) => ({
id: null,
bookingId: allocation.bookingId,
bookingReference: allocation.bookingReference,
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
loadType: allocation.loadType,
status: null,
// Frozen: container detail collapses to the captured numbers;
// per-container geometry isn't re-derivable post-release.
containerItems: allocation.containerNumbers.map((containerNumber) => ({
id: null,
containerNumber,
containerTypeId: null,
grossWeightTons: null,
containerId: null,
positionOnWagon: null,
bookingContainerId: null,
})),
bulkLoad: null,
}))
: wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
loadType: allocation.loadType ?? null,
status: allocation.status,
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
(item) => ({
id: item.id,
containerNumber: item.containerNumber ?? null,
containerTypeId: item.containerTypeId,
grossWeightTons: item.grossWeightTons ?? null,
containerId: item.containerId ?? null,
positionOnWagon: item.positionOnWagon ?? null,
bookingContainerId: item.bookingContainerId ?? null,
}),
),
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
? {
id: bulkLoadsByAllocation.get(allocation.id)!.id,
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
cargoDescription:
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
}
: null,
})) ?? [],
};
}),
}
: null,
bookings:
@@ -4668,6 +5002,11 @@ export class TrainSchedulingService {
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
// True when the wagon plan above is served from the frozen snapshot (schedule
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
// it "historical" and skip re-pin affordances.
isWagonAllocationFrozen,
wagonAllocationSnapshot: snapshot,
};
}