mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 17:50:54 +00:00
add wagon allocation snapshot to train schedules
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user