This commit is contained in:
Marshal
2026-07-20 03:05:28 +00:00
parent de816ea9d4
commit 4969f62896
10 changed files with 456 additions and 271 deletions

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)