mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
changes
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user