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,
};
}

View File

@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
</Alert>
) : (
<Stack gap="lg">
{!isExport ? (
<Alert variant="light" color="orange" icon={<Info size={16} />}>
These settings apply to every train on this route (same origin and
destination) departing the same day they all share one booking
window, so it opens, moves to document review, opens for payment,
and closes at the same time for all of them.
</Alert>
) : null}
{isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single first-come-first-served window: it

View File

@@ -196,11 +196,11 @@ const sidebarItems: SidebarItem[] = [
href: "/bookings",
icon: <Package size={18} />,
},
{
label: "Tracking",
href: "/tracking",
icon: <MapPin size={18} />,
},
// {
// label: "Tracking",
// href: "/tracking",
// icon: <MapPin size={18} />,
// },
{
label: "Invoices",
href: "/billing",

View File

@@ -1,5 +1,6 @@
import { Box, SimpleGrid, Text } from "@mantine/core";
import { Anchor, Box, SimpleGrid, Text } from "@mantine/core";
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import type { Freight } from "@edr/types";
@@ -59,6 +60,22 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
}
/>
<Fact label="Cargo" value={freight} />
{booking.contractId ? (
<Fact
label="Contract"
value={
<Anchor
component={Link}
to={`/contracts/${booking.contractId}`}
fz="15px"
fw={700}
underline="hover"
>
{booking.contractReference ?? "View contract"}
</Anchor>
}
/>
) : null}
<Fact
label="Route"
value={`${yardLabel(booking.originYard)}${yardLabel(booking.destinationYard)}`}

View File

@@ -38,6 +38,7 @@ import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
BookingStatusBadge as StatusBadge,
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
@@ -46,7 +47,6 @@ import {
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
import type { Freight } from "@edr/types";
import {
DataTable,
@@ -157,46 +157,6 @@ const STAT_CARDS: Array<{
},
];
// ── Status badge (reuses the shared portal status config) ─────────────────────
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
// ── Context-sensitive action button ───────────────────────────────────────────
function PrimaryAction({

View File

@@ -1,4 +1,4 @@
import { Badge, Group, Text } from "@mantine/core";
import { Badge, Box, Group, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
@@ -124,6 +124,45 @@ export function PaymentBadge({ status }: { status?: string | null }) {
);
}
/** Booking status pill (dot + label) driven by the shared STATUS_CONFIG. */
export function BookingStatusBadge({ status }: { status: string }) {
const cfg = STATUS_CONFIG[status];
const label = cfg?.badgeLabel ?? titleCaseStatus(status);
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
const text = cfg
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
: "#475569";
const dot = cfg
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
: "#94A3B8";
return (
<Group
gap={6}
align="center"
wrap="nowrap"
style={{
display: "inline-flex",
borderRadius: 999,
backgroundColor: bg,
padding: "5px 11px",
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: "50%",
backgroundColor: dot,
flexShrink: 0,
}}
/>
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
{label}
</Text>
</Group>
);
}
/** Whether a booking is assigned to a train yet (scheduling progress). */
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
const assigned = !!booking.trainScheduleId;

View File

@@ -21,6 +21,7 @@ import {
RingProgress,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
Title,
@@ -60,6 +61,13 @@ import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit";
import {
BookingStatusBadge,
BookingTypeBadge,
CargoModeCell,
PaymentBadge,
SchedulingCell,
} from "@/pages/bookings/booking-display";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
@@ -148,8 +156,15 @@ interface DocGroup {
* contract PDF, company profile / onboarding documents, and clearance documents
* (everything else — the clearance set uses dynamic per-contract codes). Empty
* groups are dropped so the tab only renders sections that have files.
*
* GENERAL contracts clear per booking, so their contract-level "clearance"
* leftovers are not shown — pass includeClearance: false to keep only the
* profile / business-licence sections.
*/
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
function groupContractDocuments(
files: ContractFile[],
{ includeClearance = true }: { includeClearance?: boolean } = {},
): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
@@ -159,7 +174,7 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] {
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
else if (includeClearance) clearance.push(f);
}
return [
{ key: "clearance", title: "Clearance documents", files: clearance },
@@ -328,7 +343,12 @@ export default function ContractDetailPage() {
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
const docGroups = groupContractDocuments(files);
// GENERAL contracts clear per booking — clearance documents live on each
// booking's detail page, so this tab keeps only profile/licence documents.
const docGroups = groupContractDocuments(files, {
includeClearance: contract.contractKind !== "GENERAL",
});
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
// The generated contract PDF — surfaced via a dedicated "View contract" button
// in the header (it's excluded from the Documents tab groups).
const contractPdf = files.find((f) => f.code === "contract");
@@ -621,7 +641,7 @@ export default function ContractDetailPage() {
active={tab === "documents"}
icon={<Download size={16} />}
label="Documents"
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
/>
<DetailTab
value="bookings"
@@ -1191,8 +1211,9 @@ export default function ContractDetailPage() {
No documents yet
</Text>
<Text fz={13} c="dimmed" ta="center" maw={420}>
The signed contract and any uploaded clearance documents will
appear here.
{isGeneral
? "Your company profile documents (TIN certificate, business licence, ID) appear here. Clearance documents are managed on each booking."
: "The signed contract and any uploaded clearance documents will appear here."}
</Text>
</Stack>
) : (
@@ -1399,49 +1420,113 @@ export default function ContractDetailPage() {
</Text>
</Stack>
) : (
<Stack gap={10}>
{contractBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={700}
style={{ color: INK }}
truncate
<Table.ScrollContainer minWidth={980}>
<Table
verticalSpacing="sm"
horizontalSpacing="md"
highlightOnHover
style={{ fontSize: 13 }}
>
<Table.Thead>
<Table.Tr>
<BookingsTh label="Booking" />
<BookingsTh label="Type" />
<BookingsTh label="Cargo" />
<BookingsTh label="Route" />
<BookingsTh label="Payment" />
<BookingsTh label="Train" />
<BookingsTh label="Status" />
<BookingsTh label="Amount" right />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contractBookings.map((booking) => {
const origin =
booking.originYard?.label ??
booking.originYard?.code ??
"—";
const dest =
booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—";
const amount = Number(booking.totalAmount ?? 0);
return (
<Table.Tr
key={booking.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
{booking.reference}
</Text>
{booking.scheduledDate && (
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Box>
</Group>
<ContractStatusBadge status={booking.status} />
</Group>
))}
</Stack>
<Table.Td>
<Group gap={10} wrap="nowrap">
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={13.5}
fw={700}
style={{ color: INK }}
truncate
>
{booking.reference}
</Text>
<Text fz={11.5} c="dimmed">
{booking.freightType === "BULK"
? "Bulk cargo"
: "Container"}
</Text>
</Box>
</Group>
</Table.Td>
<Table.Td>
<BookingTypeBadge booking={booking} />
</Table.Td>
<Table.Td>
<CargoModeCell booking={booking} />
</Table.Td>
<Table.Td>
<Text fz={13} fw={600} style={{ color: INK }}>
{origin} {dest}
</Text>
{booking.scheduledDate && (
<Text fz={11.5} c="dimmed">
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Table.Td>
<Table.Td>
<PaymentBadge status={booking.paymentStatus} />
</Table.Td>
<Table.Td>
<SchedulingCell booking={booking} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={booking.status} />
</Table.Td>
<Table.Td style={{ textAlign: "right" }}>
<Text
fz={13.5}
fw={700}
style={{
color: amount > 0 ? INK : "#94A3B8",
whiteSpace: "nowrap",
}}
>
{amount > 0
? `ETB ${amount.toLocaleString()}`
: "—"}
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
</Card>
</Tabs.Panel>
@@ -1572,6 +1657,23 @@ function SectionLabel({
);
}
/** Column header for the bookings table — mirrors the /bookings list styling. */
function BookingsTh({ label, right }: { label: string; right?: boolean }) {
return (
<Table.Th style={{ textAlign: right ? "right" : "left" }}>
<Text
fz={11}
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: "0.06em", whiteSpace: "nowrap" }}
>
{label}
</Text>
</Table.Th>
);
}
/**
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).

View File

@@ -237,6 +237,42 @@ export enum WagonReadiness {
ExportReady = "EXPORT_READY",
}
/**
* Frozen record of one wagon slot's allocation at the moment a schedule leaves
* DRAFT/SCHEDULED (dispatch / arrive / cancel). Captured once into
* `train_schedules.wagon_allocation_snapshot` so the historical wagon plan
* survives later re-pinning of the same physical wagons onto other trains.
*/
export interface WagonAllocationSnapshotSlot {
sequenceNo: number;
trainSetWagonId: string;
physicalWagonId: string | null;
physicalWagonNumber: string | null;
wagonTypeId: string | null;
wagonTypeCode: string | null;
/** Wagon slot status (RESERVED/LOADED/…) at capture time. */
slotStatus: string | null;
boardYardId: string | null;
alightYardId: string | null;
allocations: WagonAllocationSnapshotAllocation[];
}
export interface WagonAllocationSnapshotAllocation {
bookingId: string;
bookingReference: string | null;
allocatedWeightTons: number;
loadType: string | null;
containerNumbers: string[];
}
/** Whole-schedule frozen wagon plan written at a terminal/transit transition. */
export interface WagonAllocationSnapshot {
/** Schedule status the snapshot was captured at (DISPATCHED/ARRIVED/CANCELLED). */
capturedStatus: string;
capturedAt: string;
slots: WagonAllocationSnapshotSlot[];
}
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
/**