mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add wagon allocation snapshot to train schedules
This commit is contained in:
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
|||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.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
|
// Surface the assigned train's operational status so the portal stepper
|
||||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
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 { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
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 })
|
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
||||||
ruleExportBookingLeadHours?: number | null;
|
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)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1191,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
const committed = items.filter(
|
// Every booking still targeting this train holds gross weight — including
|
||||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
// 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
|
const caps = loco
|
||||||
? trainHardCaps({
|
? trainHardCaps({
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
WagonAllocationSnapshot,
|
||||||
WagonMovementKind,
|
WagonMovementKind,
|
||||||
WagonStatus,
|
WagonStatus,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
@@ -132,6 +133,8 @@ import {
|
|||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
earliestSchedulableDeparture,
|
earliestSchedulableDeparture,
|
||||||
eatDay,
|
eatDay,
|
||||||
|
eatDayToUtc,
|
||||||
|
shiftEatDay,
|
||||||
} from './batch-window.util';
|
} from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from './booking-journey.service';
|
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) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
// 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
|
// (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, {
|
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
|
||||||
windowOpensAt: times.windowOpensAt,
|
// WHOLE group — every schedule sharing this origin + destination + EAT
|
||||||
windowClosesAt: times.windowClosesAt,
|
// departure day. They all adopt the SAME rule snapshot and share the SAME
|
||||||
...windowRuleSnapshot(merged),
|
// 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(
|
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);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -634,14 +777,34 @@ export class TrainSchedulingService {
|
|||||||
? computeExportWindowTimes(departure, merged)
|
? computeExportWindowTimes(departure, merged)
|
||||||
: computeImportWindowTimes(departure, merged, now);
|
: 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, {
|
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||||
scheduledDepartureDate: departure,
|
scheduledDepartureDate: departure,
|
||||||
windowOpensAt: times.windowOpensAt,
|
...windowFields,
|
||||||
windowClosesAt: times.windowClosesAt,
|
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
`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);
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
@@ -854,21 +1017,44 @@ export class TrainSchedulingService {
|
|||||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||||
// windows from it rather than the live config.
|
// windows from it rather than the live config.
|
||||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
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'
|
direction === 'EXPORT'
|
||||||
? {
|
? null
|
||||||
bookingWindowStatus: 'CLOSED',
|
: await this.findGroupWindowAnchor(
|
||||||
windowPhase: 'PRE_WINDOW',
|
manager,
|
||||||
...ruleSnapshot,
|
route.originYardId,
|
||||||
...computeExportWindowTimes(departure, windowCfg),
|
route.destinationYardId,
|
||||||
}
|
departure,
|
||||||
|
);
|
||||||
|
const computedTimes =
|
||||||
|
direction === 'EXPORT'
|
||||||
|
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||||
: {
|
: {
|
||||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||||
bookingWindowStatus: 'CLOSED',
|
|
||||||
windowPhase: 'PRE_WINDOW',
|
|
||||||
...ruleSnapshot,
|
...ruleSnapshot,
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
...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))
|
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||||
.maxWagonsPerTrain;
|
.maxWagonsPerTrain;
|
||||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||||
@@ -1522,12 +1708,34 @@ export class TrainSchedulingService {
|
|||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
TrainScheduleStatusEnum.Dispatched,
|
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,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
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 ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
sb.bookingId,
|
sb.bookingId,
|
||||||
@@ -1604,7 +1812,24 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
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) {
|
async getImportDjiboutiOperation(scheduleId: string) {
|
||||||
@@ -2561,7 +2786,15 @@ export class TrainSchedulingService {
|
|||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
TrainScheduleStatusEnum.Arrived,
|
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,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2723,13 +2956,24 @@ export class TrainSchedulingService {
|
|||||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
id,
|
id,
|
||||||
TrainScheduleStatusEnum.Cancelled,
|
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.
|
// Retire the booking window so a canceled schedule never lingers as an
|
||||||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
// "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,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
@@ -2757,6 +3001,9 @@ export class TrainSchedulingService {
|
|||||||
currentTrainScheduleId: null,
|
currentTrainScheduleId: null,
|
||||||
trainSetWagonId: null,
|
trainSetWagonId: null,
|
||||||
status: WagonStatus.Available,
|
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) {
|
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
@@ -4523,6 +4811,20 @@ export class TrainSchedulingService {
|
|||||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
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 {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -4605,52 +4907,84 @@ export class TrainSchedulingService {
|
|||||||
})),
|
})),
|
||||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
.map((wagon) => ({
|
.map((wagon) => {
|
||||||
id: wagon.id,
|
// Frozen schedules read the wagon number + allocations from the
|
||||||
sequenceNo: wagon.sequenceNo,
|
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
// comes live. Falls back to live if a slot is missing from the snap.
|
||||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
const frozenSlot = isWagonAllocationFrozen
|
||||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
: undefined;
|
||||||
// frontend needs it to show the gross train weight.
|
return {
|
||||||
tareWeightTons: wagon.wagonType
|
id: wagon.id,
|
||||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
sequenceNo: wagon.sequenceNo,
|
||||||
: null,
|
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||||
status: wagon.status,
|
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||||||
wagonType: wagon.wagonType
|
// frontend needs it to show the gross train weight.
|
||||||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
tareWeightTons: wagon.wagonType
|
||||||
: null,
|
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||||
allocations:
|
: null,
|
||||||
wagon.allocations?.map((allocation) => ({
|
status: wagon.status,
|
||||||
id: allocation.id,
|
physicalWagonId: frozenSlot
|
||||||
bookingId: allocation.bookingId,
|
? frozenSlot.physicalWagonId
|
||||||
bookingReference: allocation.booking?.reference ?? null,
|
: wagon.physicalWagonId ?? null,
|
||||||
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
physicalWagonNumber: frozenSlot
|
||||||
loadType: allocation.loadType ?? null,
|
? frozenSlot.physicalWagonNumber
|
||||||
status: allocation.status,
|
: wagon.physicalWagon?.wagonNumber ?? null,
|
||||||
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
wagonType: wagon.wagonType
|
||||||
(item) => ({
|
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||||||
id: item.id,
|
: null,
|
||||||
containerNumber: item.containerNumber ?? null,
|
allocations: frozenSlot
|
||||||
containerTypeId: item.containerTypeId,
|
? frozenSlot.allocations.map((allocation) => ({
|
||||||
grossWeightTons: item.grossWeightTons ?? null,
|
id: null,
|
||||||
containerId: item.containerId ?? null,
|
bookingId: allocation.bookingId,
|
||||||
positionOnWagon: item.positionOnWagon ?? null,
|
bookingReference: allocation.bookingReference,
|
||||||
bookingContainerId: item.bookingContainerId ?? null,
|
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
|
||||||
}),
|
loadType: allocation.loadType,
|
||||||
),
|
status: null,
|
||||||
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
|
// Frozen: container detail collapses to the captured numbers;
|
||||||
? {
|
// per-container geometry isn't re-derivable post-release.
|
||||||
id: bulkLoadsByAllocation.get(allocation.id)!.id,
|
containerItems: allocation.containerNumbers.map((containerNumber) => ({
|
||||||
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
|
id: null,
|
||||||
cargoDescription:
|
containerNumber,
|
||||||
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
|
containerTypeId: null,
|
||||||
}
|
grossWeightTons: null,
|
||||||
: 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,
|
: null,
|
||||||
bookings:
|
bookings:
|
||||||
@@ -4668,6 +5002,11 @@ export class TrainSchedulingService {
|
|||||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<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 ? (
|
{isExport ? (
|
||||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
Export schedules use a single first-come-first-served window: it
|
Export schedules use a single first-come-first-served window: it
|
||||||
|
|||||||
@@ -196,11 +196,11 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
href: "/bookings",
|
href: "/bookings",
|
||||||
icon: <Package size={18} />,
|
icon: <Package size={18} />,
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "Tracking",
|
// label: "Tracking",
|
||||||
href: "/tracking",
|
// href: "/tracking",
|
||||||
icon: <MapPin size={18} />,
|
// icon: <MapPin size={18} />,
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
label: "Invoices",
|
label: "Invoices",
|
||||||
href: "/billing",
|
href: "/billing",
|
||||||
|
|||||||
@@ -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 type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -59,6 +60,22 @@ export function KeyFactsStrip({ booking }: { booking: BookingLike }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Fact label="Cargo" value={freight} />
|
<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
|
<Fact
|
||||||
label="Route"
|
label="Route"
|
||||||
value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`}
|
value={`${yardLabel(booking.originYard)} → ${yardLabel(booking.destinationYard)}`}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { PayNowButton } from "./payments/PayNowButton";
|
|||||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||||
import {
|
import {
|
||||||
|
BookingStatusBadge as StatusBadge,
|
||||||
BookingTypeBadge,
|
BookingTypeBadge,
|
||||||
CargoModeCell,
|
CargoModeCell,
|
||||||
PaymentBadge,
|
PaymentBadge,
|
||||||
@@ -46,7 +47,6 @@ import {
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type { BookingListFilter } from "@/services/bookings.service";
|
||||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
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 ───────────────────────────────────────────
|
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||||
|
|
||||||
function PrimaryAction({
|
function PrimaryAction({
|
||||||
|
|||||||
@@ -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 type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
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). */
|
/** Whether a booking is assigned to a train yet (scheduling progress). */
|
||||||
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
|
export function SchedulingCell({ booking }: { booking: BookingLike & { trainScheduleId?: string | null; schedulingStatus?: string } }) {
|
||||||
const assigned = !!booking.trainScheduleId;
|
const assigned = !!booking.trainScheduleId;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
RingProgress,
|
RingProgress,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
@@ -60,6 +61,13 @@ import { fileViewUrl } from "@/constants/apiConfig";
|
|||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
|
import {
|
||||||
|
BookingStatusBadge,
|
||||||
|
BookingTypeBadge,
|
||||||
|
CargoModeCell,
|
||||||
|
PaymentBadge,
|
||||||
|
SchedulingCell,
|
||||||
|
} from "@/pages/bookings/booking-display";
|
||||||
import { ContractClearancePanel } from "./ContractClearancePanel";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
@@ -148,8 +156,15 @@ interface DocGroup {
|
|||||||
* contract PDF, company profile / onboarding documents, and clearance documents
|
* contract PDF, company profile / onboarding documents, and clearance documents
|
||||||
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
||||||
* groups are dropped so the tab only renders sections that have files.
|
* 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 businessLicense: ContractFile[] = [];
|
||||||
const profile: ContractFile[] = [];
|
const profile: ContractFile[] = [];
|
||||||
const clearance: ContractFile[] = [];
|
const clearance: ContractFile[] = [];
|
||||||
@@ -159,7 +174,7 @@ function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
|||||||
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
|
||||||
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
|
||||||
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||||
else clearance.push(f);
|
else if (includeClearance) clearance.push(f);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ key: "clearance", title: "Clearance documents", files: clearance },
|
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||||
@@ -328,7 +343,12 @@ export default function ContractDetailPage() {
|
|||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
const pricing = contract.pricingBreakdown;
|
const pricing = contract.pricingBreakdown;
|
||||||
const files = contract.files ?? [];
|
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
|
// The generated contract PDF — surfaced via a dedicated "View contract" button
|
||||||
// in the header (it's excluded from the Documents tab groups).
|
// in the header (it's excluded from the Documents tab groups).
|
||||||
const contractPdf = files.find((f) => f.code === "contract");
|
const contractPdf = files.find((f) => f.code === "contract");
|
||||||
@@ -621,7 +641,7 @@ export default function ContractDetailPage() {
|
|||||||
active={tab === "documents"}
|
active={tab === "documents"}
|
||||||
icon={<Download size={16} />}
|
icon={<Download size={16} />}
|
||||||
label="Documents"
|
label="Documents"
|
||||||
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
count={docCount + (isPhasedCustomsClearance ? workflowFileCount : 0)}
|
||||||
/>
|
/>
|
||||||
<DetailTab
|
<DetailTab
|
||||||
value="bookings"
|
value="bookings"
|
||||||
@@ -1191,8 +1211,9 @@ export default function ContractDetailPage() {
|
|||||||
No documents yet
|
No documents yet
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
<Text fz={13} c="dimmed" ta="center" maw={420}>
|
||||||
The signed contract and any uploaded clearance documents will
|
{isGeneral
|
||||||
appear here.
|
? "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>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
@@ -1399,49 +1420,113 @@ export default function ContractDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={10}>
|
<Table.ScrollContainer minWidth={980}>
|
||||||
{contractBookings.map((booking) => (
|
<Table
|
||||||
<Group
|
verticalSpacing="sm"
|
||||||
key={booking.id}
|
horizontalSpacing="md"
|
||||||
justify="space-between"
|
highlightOnHover
|
||||||
wrap="nowrap"
|
style={{ fontSize: 13 }}
|
||||||
p="sm"
|
>
|
||||||
style={{
|
<Table.Thead>
|
||||||
borderRadius: 12,
|
<Table.Tr>
|
||||||
border: `1px solid ${BORDER}`,
|
<BookingsTh label="Booking" />
|
||||||
cursor: "pointer",
|
<BookingsTh label="Type" />
|
||||||
}}
|
<BookingsTh label="Cargo" />
|
||||||
onClick={() => navigate(`/bookings/${booking.id}`)}
|
<BookingsTh label="Route" />
|
||||||
>
|
<BookingsTh label="Payment" />
|
||||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
<BookingsTh label="Train" />
|
||||||
<Package
|
<BookingsTh label="Status" />
|
||||||
size={16}
|
<BookingsTh label="Amount" right />
|
||||||
color={MUTED}
|
</Table.Tr>
|
||||||
style={{ flexShrink: 0 }}
|
</Table.Thead>
|
||||||
/>
|
<Table.Tbody>
|
||||||
<Box style={{ minWidth: 0 }}>
|
{contractBookings.map((booking) => {
|
||||||
<Text
|
const origin =
|
||||||
fz={14}
|
booking.originYard?.label ??
|
||||||
fw={700}
|
booking.originYard?.code ??
|
||||||
style={{ color: INK }}
|
"—";
|
||||||
truncate
|
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}
|
<Table.Td>
|
||||||
</Text>
|
<Group gap={10} wrap="nowrap">
|
||||||
{booking.scheduledDate && (
|
<Package
|
||||||
<Text fz={12} c="dimmed" truncate>
|
size={16}
|
||||||
Ship{" "}
|
color={MUTED}
|
||||||
{new Date(
|
style={{ flexShrink: 0 }}
|
||||||
booking.scheduledDate,
|
/>
|
||||||
).toLocaleDateString()}
|
<Box style={{ minWidth: 0 }}>
|
||||||
</Text>
|
<Text
|
||||||
)}
|
fz={13.5}
|
||||||
</Box>
|
fw={700}
|
||||||
</Group>
|
style={{ color: INK }}
|
||||||
<ContractStatusBadge status={booking.status} />
|
truncate
|
||||||
</Group>
|
>
|
||||||
))}
|
{booking.reference}
|
||||||
</Stack>
|
</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>
|
</Card>
|
||||||
</Tabs.Panel>
|
</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
|
* 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).
|
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
||||||
|
|||||||
@@ -237,6 +237,42 @@ export enum WagonReadiness {
|
|||||||
ExportReady = "EXPORT_READY",
|
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";
|
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user