Merge pull request #826 from Tria-plc/freight_feature/usermanagement

changes
This commit is contained in:
marshal
2026-07-20 06:06:06 +03:00
committed by GitHub
10 changed files with 456 additions and 271 deletions

View File

@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
.getMany();
}
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
* one query instead of one per schedule (batch monitoring board). */
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
if (!scheduleIds.length) return Promise.resolve([]);
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({
where: { id: In(bookingIds) },
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
// multiply rows badly in a single join (hot path for every allocation
// preview / assignment validation).
relationLoadStrategy: 'query',
relations: {
company: true,
originYard: true,

View File

@@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
return this.repo(manager).findOne({
where: { id },
// One SELECT per relation instead of a single monster join — the nested
// wagon×allocation×booking×container branches multiply rows catastrophically
// when joined (measured ~925ms vs ~84ms on a 21-wagon schedule).
relationLoadStrategy: 'query',
relations: {
// Yards carry the route's display name; without them formatRouteLabel
// degrades to the literal "Origin → Destination". Milestones (with

View File

@@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { eatDay } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
@@ -172,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
export interface BatchBoardCounts {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
}
/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
counts: BatchBoardCounts;
bookings: BatchBoardBookingDetail[];
}
@@ -215,8 +210,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
/** Bookings inside the schedule's booking window (fully-executed contracts). */
bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[];
}
@@ -1193,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
// One links query + one bookings query for the whole page (was 2 per card).
const scheduleIds = schedules.map((s) => s.id);
const [allLinks, allBookings] = await Promise.all([
scheduleIds.length
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
: Promise.resolve([]),
this.bookingsRepository.findAllBySchedules(scheduleIds),
]);
const linkedIdsBySchedule = new Map<string, Set<string>>();
for (const l of allLinks) {
let set = linkedIdsBySchedule.get(l.trainScheduleId);
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
set.add(l.bookingId);
}
const bookingsBySchedule = new Map<string, Booking[]>();
for (const b of allBookings) {
if (!b.trainScheduleId) continue;
let list = bookingsBySchedule.get(b.trainScheduleId);
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
list.push(b);
}
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
const bookings = bookingsBySchedule.get(s.id) ?? [];
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
@@ -1226,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
/** Schedule-level batch board: the schedule's own booking window plus its
* bookings split into in-window (contract executed) vs pending-contract. */
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
@@ -1244,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
}
const wagonDims = await this.loadWagonDims();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
// The full graph already carries the schedule↔booking links — no separate
// link query needed.
const linkedIds = new Set(
(s.scheduleBookings ?? []).map((l) => l.bookingId),
);
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
// Reuse the graph loaded above — the preview otherwise re-loads the same
// heavy schedule graph a second time per request.
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
await this.trainSchedulingService.previewAllocationForSchedule(
s.id,
s,
);
} catch {
allocationPreview = {
assignedBookingIds: [],
@@ -1324,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
const loco = s.trainSet?.locomotive ?? null;
// Display windows are the REAL booking-window cycles this schedule was FROZEN
// with at creation (import: opens at its stored window time, lasts its rule's
// duration, reopens per its rule's delay; export: single FCFS lead window) —
// NOT the live global config. A later global-rules edit only re-derives
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
// must keep drawing from its own snapshot, anchored on its stored open time.
// Legacy rows with no snapshot fall back to the live config.
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,
),
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
reopenGapMinutes: num(
s.ruleReopenDelayMinutes,
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
),
exportBookingLeadHours: num(
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
// Frozen close offsets: a snapshot null means "no offset for this train"
// and stays null (not the live offset); only legacy rows lacking the
// column (undefined) fall back to live config.
importCloseOffsetMinutes:
s.ruleImportCloseOffsetMinutes !== undefined
? s.ruleImportCloseOffsetMinutes
: liveCfg.importCloseOffsetMinutes,
exportCloseOffsetMinutes:
s.ruleExportCloseOffsetMinutes !== undefined
? s.ruleExportCloseOffsetMinutes
: liveCfg.exportCloseOffsetMinutes,
};
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
s.direction ?? null,
departureDate,
windowCfg,
undefined,
s.windowOpensAt ?? null,
);
const emptyCounts = () => ({
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
});
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
const counts = emptyCounts();
for (const b of bookingsInWindow) {
// The board renders ONE booking window the schedule's own frozen window
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
// split into two buckets: contract executed (in the window) vs pending
// contract. The old per-cycle window projection was dropped — the UI never
// showed it, and reconstructing every cycle cost a config load + grouping
// pass per request.
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
const counts: BatchBoardCounts = {
allocated: 0,
selectedForBatch: 0,
ready: 0,
waiting: 0,
expired: 0,
pendingContract: 0,
};
for (const b of bucket) {
if (b.state === "ALLOCATED") counts.allocated += 1;
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
else if (b.state === "READY") counts.ready += 1;
@@ -1401,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
return counts;
};
const windows: BatchWindowGroup[] = [];
for (const [key, bucket] of windowBuckets) {
if (key === "pending-contract" || !bucket.window) continue;
const w = bucket.window;
windows.push({
key: w.key,
label: w.label,
date: w.date,
dateLabel: w.dateLabel,
start: w.start.toISOString(),
end: w.end.toISOString(),
counts: countFor(bucket.items),
bookings: bucket.items,
});
}
windows.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
const windowBookings = items.filter((i) => i.fullyExecutedAt);
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
return {
scheduleId: s.id,
@@ -1470,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
bookings: windowBookings,
pendingContract: {
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
@@ -2511,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) return null;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
// Built trains: collapse to a single train-wide pool so the freed capacity of
// a booking that alights mid-corridor is NOT re-offered on the pass-through
// leg (see remainingBudget). Keeps intercity accept consistent with the
// train-wide isTrainFull / committedWagons finalize signal.
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
collapseForBuiltTrain: true,
});
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
@@ -3305,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit {
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
* no wagon type configured yet.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per board/detail request without letting edits go stale long. */
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
private async loadWagonDims(): Promise<WagonDims> {
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
return this.wagonDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
@@ -3319,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit {
// must fall back rather than yield an infinite wagon count.
const payload = (value: number | undefined, fallback: number): number =>
value && value > 0 ? value : fallback;
return {
const value: WagonDims = {
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
@@ -3332,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
},
byWagonTypeId,
};
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
@@ -3433,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
schedule: TrainSchedule,
limits: TrainLimits,
wagonDims: WagonDims,
opts?: { collapseForBuiltTrain?: boolean },
): Promise<CorridorBudget> {
const physicalWagons = await this.builtTrainWagonCount(schedule);
if (physicalWagons != null) {
@@ -3445,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
tolerance: { weightTons: 0, lengthMeters: 0 },
};
}
const stops = await this.stopsForSchedule(schedule);
// A built train's wagons are coupled for the WHOLE trip, and the allocator
// commits each booking to a wagon for the entire route — it never reloads a
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
// its capacity is one train-wide pool, exactly as isTrainFull /
// committedWagons already count it. When a caller opts in, collapse the
// corridor to a single whole-route edge so every booking (full-route OR
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
// leg it merely passes through, instead of over-promising the freed slots.
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
// abstract slot/weight/length budget genuinely frees past an alight yard.
const stops =
physicalWagons != null && opts?.collapseForBuiltTrain
? [schedule.originStationId, schedule.destinationStationId]
: await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)

View File

@@ -104,7 +104,8 @@ export class IntercityService {
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const milestones = await this.routeMilestones(schedule);
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
@@ -112,16 +113,32 @@ export class IntercityService {
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
// Mid-corridor intercity matching needs a real stop list (>= 2 route
// milestones). Without one the fallback is a 2-stop origin->destination
// pseudo-route that only matches bookings on the train's exact corridor —
// surface that so an empty candidate list isn't misread as "nobody waiting".
const warning =
milestoneSeq == null
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
: schedule.routeId && milestones.length < 2
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
: null;
return {
scheduleId,
routeId: schedule.routeId ?? null,
warning,
// Segment-based: "remaining" is the most-open edge; each candidate's
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
// even when the train is full elsewhere.
remaining: capacity?.budget.maxRemaining() ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
const leg = capacity?.budget.legOf(
// legForYards, not legOf: on a built train the budget is a single
// whole-route edge (see intercityCapacity), so a mid-corridor booking
// must draw from that one pool via the whole-route fallback. On a
// locomotive-derived schedule it still resolves to the booking's own leg.
const leg = capacity?.budget.legForYards(
booking.originYardId,
booking.destinationYardId,
);
@@ -186,14 +203,17 @@ export class IntercityService {
continue;
}
const need = capacity.needFor(booking);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
// Segment-based: only the booking's own leg must have room, so an
// intercity booking still boards a train that is full on other legs.
if (!leg || !budget.fits(need, leg)) {
// legForYards, not legOf: a built train's budget is a single whole-route
// pool (mid-corridor wagons are committed for the whole trip and never
// reloaded), so the booking draws from that pool via the whole-route
// fallback; a locomotive-derived schedule still gets the booking's own
// leg, so it can still board a train that is full only on other legs.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!budget.fits(need, leg)) {
rejected.push({
bookingId,
reason:
'Does not fit the remaining wagon/weight/length capacity on its leg',
'Does not fit the remaining wagon/weight/length capacity for this train',
});
continue;
}
@@ -244,16 +264,21 @@ export class IntercityService {
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
private async routeMilestones(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
): Promise<RouteMilestone[]> {
if (!schedule.routeId) return [];
return this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
}
private milestoneSequenceOf(
schedule: TrainSchedule,
milestones: RouteMilestone[],
): Map<string, number> | null {
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
@@ -264,6 +289,15 @@ export class IntercityService {
return null;
}
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
return this.milestoneSequenceOf(
schedule,
await this.routeMilestones(schedule),
);
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,

View File

@@ -139,7 +139,8 @@ export class TrainSchedulingController {
@Get("batch-board/:scheduleId")
@TrainSchedulingView()
@ApiOperation({
summary: "Batch board detail for one schedule with EAT 3h windows",
summary:
"Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket",
})
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
return this.bookingBatchService.getBatchBoardDetail(scheduleId);

View File

@@ -479,6 +479,38 @@ export class TrainSchedulingService {
return qb.getMany();
}
/**
* A built train makes at most ONE departure per route per EAT day. Returns
* the non-cancelled schedule already holding this train on this route for
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
* legal — siblings must be different trains.
*/
private async findTrainRouteDayConflict(
trainId: string,
routeId: string,
departure: Date,
excludeScheduleId?: string,
): Promise<TrainSchedule | null> {
const day = eatDay(departure);
const dayStart = eatDayToUtc(day, 0);
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
const qb = this.dataSource
.getRepository(TrainSchedule)
.createQueryBuilder('s')
.innerJoin('s.trainSet', 'ts')
.where('ts.trainId = :trainId', { trainId })
.andWhere('s.routeId = :routeId', { routeId })
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
.andWhere('s.status != :cancelledStatus', {
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
});
if (excludeScheduleId) {
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
}
return qb.getOne();
}
/**
* 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
@@ -882,6 +914,28 @@ export class TrainSchedulingService {
);
}
// Moving onto a day where this same built train already runs this route
// would double-book the physical train — blocked for planning moves.
if (schedule.trainSetId && schedule.routeId) {
const trainSet = await this.dataSource
.getRepository(TrainSet)
.findOne({ where: { id: schedule.trainSetId } });
if (trainSet?.trainId) {
const conflict = await this.findTrainRouteDayConflict(
trainSet.trainId,
schedule.routeId,
departure,
id,
);
if (conflict) {
throw new ConflictException(
`This train is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
}
}
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg);
@@ -915,10 +969,37 @@ export class TrainSchedulingService {
scheduledDepartureDate: departure,
...windowFields,
});
// Only customers whose bookings already HOLD wagons on this train are told
// about the move (SMS + email + portal inbox). Linked-but-unallocated
// bookings are skipped — nothing of theirs is riding this departure yet.
let notifiedCount = 0;
if (schedule.trainSetId) {
const allocations = await this.dataSource
.getRepository(WagonBookingAllocation)
.createQueryBuilder('a')
.innerJoin('a.trainSetWagon', 'slot')
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
.getMany();
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
if (allocatedBookingIds.length) {
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
where: { id: In(allocatedBookingIds) },
relations: { company: true },
});
for (const booking of allocatedBookings) {
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
this.bookingNotifier.rescheduled(booking, departure);
notifiedCount += 1;
}
}
}
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
`${notifiedCount} allocated customer booking(s) notified`,
);
void this.emitWindowState(id);
@@ -1222,6 +1303,17 @@ export class TrainSchedulingService {
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
);
}
const conflict = await this.findTrainRouteDayConflict(
builtTrain.id,
route.id,
new Date(dto.scheduleDate),
);
if (conflict) {
throw new ConflictException(
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
);
}
} else {
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
if (locomotiveIds.length < 2) {
@@ -1681,6 +1773,7 @@ export class TrainSchedulingService {
scheduleId,
schedule.originStationId,
savedWagons,
schedule.reverseWagonOrder ?? false,
);
});
@@ -4283,6 +4376,7 @@ export class TrainSchedulingService {
scheduleId: string,
originYardId: string,
slots: TrainSetWagon[],
reverseWagonOrder = false,
) {
const wagons = await manager.getRepository(Wagon).find();
const wagonTypes = await manager.getRepository(WagonType).find();
@@ -4328,6 +4422,7 @@ export class TrainSchedulingService {
assignedPhysicalIds,
builtTrainId,
pinnedToScheduleIds,
reverseWagonOrder,
);
if (!physical) continue;
@@ -4421,6 +4516,7 @@ export class TrainSchedulingService {
assignedPhysicalIds: Set<string>,
builtTrainId: string | null = null,
pinnedToScheduleIds: Set<string> = new Set(),
reverseWagonOrder = false,
): Wagon | undefined {
const usable = (wagon: Wagon): boolean => {
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
@@ -4441,12 +4537,26 @@ export class TrainSchedulingService {
// wherever they currently sit (they travel with the train), never a loose
// yard wagon.
if (builtTrainId) {
return wagons.find(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
);
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
// consist views draw the schedule exactly like the train builder; a schedule
// created with reverseWagonOrder pins back-to-front (physically-last wagon
// takes slot #1). Unsequenced wagons sort after every sequenced one.
const candidates = wagons
.filter(
(w) =>
w.trainId === builtTrainId &&
w.wagonTypeId === slot.wagonTypeId &&
!assignedPhysicalIds.has(w.id),
)
.sort((a, b) => {
if (a.sequenceNumber == null || b.sequenceNumber == null) {
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
}
return reverseWagonOrder
? b.sequenceNumber - a.sequenceNumber
: a.sequenceNumber - b.sequenceNumber;
});
return candidates[0];
}
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
// fall back to one riding from the train's origin.
@@ -6220,11 +6330,21 @@ export class TrainSchedulingService {
* engine's representative fallbacks for bookings whose cargo/container type
* has no wagon type configured. Loaded once per request before mapping.
*/
/** Wagon types are near-static reference data — a short TTL cache spares one
* table scan per detail/board request without letting edits go stale long. */
private wagonTareDimsCache: {
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
expiresAt: number;
} | null = null;
private async loadWagonTareDims(): Promise<{
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
bulk: { tareWeightTons: number; capacityTons: number };
container: { tareWeightTons: number; capacityTons: number };
}> {
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
return this.wagonTareDimsCache.value;
}
const types = await this.dataSource.getRepository(WagonType).find();
const byWagonTypeId = new Map(
types.map((t) => [
@@ -6235,7 +6355,7 @@ export class TrainSchedulingService {
},
]),
);
return {
const value = {
byWagonTypeId,
bulk: {
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
@@ -6246,6 +6366,8 @@ export class TrainSchedulingService {
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
},
};
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
return value;
}
/**
@@ -6302,49 +6424,14 @@ export class TrainSchedulingService {
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
// locomotive actually hauls and the axis its pull limit is compared against.
const tareDims = await this.loadWagonTareDims();
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
let loadingConfirmed = !requiresLoadingConfirmation;
if (requiresLoadingConfirmation) {
const op = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } });
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
}
const windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
]);
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// Snapshot state decides below whether the live consist may be drawn at
// all, so it is derived before the consist wagons are fetched.
// 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,
@@ -6359,6 +6446,55 @@ export class TrainSchedulingService {
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
);
// All independent lookups fired at once — they used to run one after
// another, stacking round-trips onto every detail request.
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
// number the locomotive actually hauls against its pull limit.
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
await Promise.all([
this.loadWagonTareDims(),
requiresLoadingConfirmation
? this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } })
: null,
this.getWindowConfig(),
allocationIds.length
? this.wagonAllocationContainerItemsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { containerType: true, bookingContainer: true },
})
: [],
allocationIds.length
? this.wagonAllocationBulkLoadsRepository.findAll({
where: { wagonBookingAllocationId: In(allocationIds) },
relations: { cargoType: true },
})
: [],
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
// Mirror the pinning direction: a reverse-order schedule draws the
// whole consist back-to-front, empties included.
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
})
: [],
]);
const loadingConfirmed = requiresLoadingConfirmation
? Boolean(importOp?.loadedOnTrainAt)
: true;
const containerItemsByAllocation = new Map<string, typeof containerItems>();
for (const item of containerItems) {
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
list.push(item);
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
}
const bulkLoadsByAllocation = new Map(
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
);
// The trainSet slots below are the PLANNED wagons (one per allocation). A
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
// included (the pull-limit check already counts their tare) — so append the
@@ -6381,41 +6517,32 @@ export class TrainSchedulingService {
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons =
schedule.trainSet?.trainId && !isWagonAllocationFrozen
? (
await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
)
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}))
: [];
const emptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
// row, so remove/edit affordances must stay disabled (consistOnly).
id: wagon.id,
sequenceNo: maxSlotSequenceNo + index + 1,
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
assignedWeightTons: 0,
tareWeightTons: wagon.wagonType
? roundTons(Number(wagon.wagonType.tareWeightTons))
: null,
status: 'EMPTY',
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations: [],
consistOnly: true,
}));
return {
id: schedule.id,
@@ -6425,6 +6552,7 @@ export class TrainSchedulingService {
trainNumber: schedule.trainNumber ?? null,
maxWagons: schedule.maxWagons ?? null,
direction: schedule.direction ?? null,
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
@@ -6725,8 +6853,13 @@ export class TrainSchedulingService {
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
async previewAllocationForSchedule(
scheduleId: string,
// Callers that already hold the full schedule graph (batch board detail)
// pass it in so the preview doesn't re-load the same heavy graph.
preloadedSchedule?: TrainSchedule,
): Promise<WagonAllocationAttemptResult> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
@@ -6773,7 +6906,10 @@ export class TrainSchedulingService {
);
if (!eligible.length) return empty;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
schedule.id,
schedule,
);
const previewDto = {
bookingIds: eligible.map((b) => b.id),
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
@@ -7304,8 +7440,15 @@ export class TrainSchedulingService {
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
}
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
private async getWagonAssignedBookingIds(
scheduleId: string,
// Pass when the caller already holds the schedule with trainSet.wagons —
// only wagon ids are read here, the old full-graph reload was pure waste.
preloadedSchedule?: TrainSchedule,
): Promise<Set<string>> {
const schedule =
preloadedSchedule ??
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
if (!wagonIds.length) return new Set();

View File

@@ -1926,7 +1926,8 @@ export class WarehouseInventoryService {
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
const [schedule] = await this.dataSource.query(
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
// Only bookings whose destination IS this train's final yard unload into
// this (final-destination) warehouse. A mid-corridor import that alighted
// at an intermediate yard was already unloaded there by the checkpoint
// auto-unload; without this filter it would be mis-located into the final
// yard's inventory too.
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
[scheduleId],
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
AND b.destination_yard_id = $2`,
[scheduleId, schedule.destinationStationId],
);
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;

View File

@@ -104,6 +104,11 @@ export function ApiErrorModal() {
onClose={close}
centered
radius="md"
// Mounted at the app root, so its portal is FIRST in <body> — at the
// default z-index (200) any page modal opened later (create schedule,
// allocation wizard, …) paints over it and the error hides underneath.
// Hoist above every Mantine overlay and the react-hot-toast layer (9999).
zIndex={10000}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />

View File

@@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardCounts,
BatchBoardScheduleDetail,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
@@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({
);
});
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
function WindowCountChips({ counts }: { counts: BatchBoardCounts }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "edr-green", label: "allocated" },
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
@@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() {
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") ||
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
@@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() {
[data],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const scheduleDetailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
// The heavy composition graph is only rendered by the composition tab and
// the overview diagram (which needs assigned wagons) — don't fetch it
// until one of them can actually show something.
enabled:
Boolean(scheduleId) &&
(hasAssignedWagons || activeTab === "composition"),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}),
);
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
// Every booking on this schedule: in-window + pending-contract (the two
// buckets are disjoint). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
return [...data.bookings, ...data.pendingContract.bookings];
}, [data]);
// All bookings that fall inside the schedule's booking window (every window
// cycle, flattened) — the window is one booking day, so these belong to the
// single window panel above.
const windowBookings = useMemo(
() => (data?.windows ?? []).flatMap((w) => w.bookings),
[data?.windows],
);
// Bookings inside the schedule's booking window — they belong to the single
// window panel above.
const windowBookings = data?.bookings ?? [];
const windowCounts = useMemo(() => {
const counts = {
@@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() {
[data?.status],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,

View File

@@ -400,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
export interface BatchBoardCounts {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
}
/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
counts: BatchBoardCounts;
bookings: BatchBoardBookingDetail[];
}
@@ -443,8 +438,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
/** Bookings inside the schedule's booking window (fully-executed contracts). */
bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[];
}