fix issues

This commit is contained in:
Marshal
2026-07-09 20:26:14 +00:00
parent 1d77cc9219
commit e4429592d3
29 changed files with 1143 additions and 147 deletions

View File

@@ -11,7 +11,15 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource, In } from 'typeorm';
import {
Between,
DataSource,
FindOptionsWhere,
ILike,
In,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -27,6 +35,10 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
BatchBoardQueryDto,
} from './dto/batch-board-query.dto';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import { BillingService } from "../billing/billing.service";
@@ -158,6 +170,8 @@ export interface BatchWindowGroup {
export interface BatchBoardScheduleDetail {
scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
@@ -182,11 +196,14 @@ export interface BatchBoardScheduleDetail {
export interface BatchBoardSchedule {
scheduleId: string;
/** Human-facing schedule reference (S-YYYY-NNNNN). */
scheduleReference: string | null;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
createdAt: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
@@ -225,6 +242,16 @@ export interface BatchBoardSchedule {
bookings: BatchBoardBooking[];
}
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
export interface BatchBoardListResponse {
items: BatchBoardSchedule[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
* by priority, greedily fills the train to capacity (skipping bookings that don't fit),
@@ -454,7 +481,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
@@ -612,7 +639,7 @@ export class BookingBatchService implements OnModuleInit {
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(scheduleId, 'FULL');
}
}
@@ -653,12 +680,70 @@ export class BookingBatchService implements OnModuleInit {
// ---- monitoring board -----------------------------------------------------
/**
* Read model for the batch monitoring page: every still-relevant schedule (not arrived/
* cancelled) with its locomotive, capacity usage and its bookings grouped by lifecycle
* state (allocated / awaiting payment / paid-waiting / pending contract / expired).
* Read model for the batch monitoring page: every import schedule — including
* dispatched, arrived and cancelled history — with its locomotive, capacity
* usage and its bookings grouped by lifecycle state (allocated / awaiting
* payment / paid-waiting / pending contract / expired). Paginated and
* filterable; per-schedule booking summaries are only computed for the
* requested page.
*/
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
const schedules = await this.trainSchedulesRepository.findAll({
async getBatchBoard(
query: BatchBoardQueryDto = {},
): Promise<BatchBoardListResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 12;
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
// arrived / cancelled / dispatched schedules stay visible as history.
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
const statuses = (query.statuses ?? "")
.split(",")
.map((v) => v.trim().toUpperCase())
.filter((v) => allowedStatuses.has(v));
const dateRange = (from?: string, to?: string) => {
const f = from ? new Date(from) : null;
const t = to ? new Date(to) : null;
if (f && t) return Between(f, t);
if (f) return MoreThanOrEqual(f);
if (t) return LessThanOrEqual(t);
return undefined;
};
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
if (statuses.length) base.status = In(statuses) as never;
if (query.bookingWindowStatus) {
base.bookingWindowStatus = query.bookingWindowStatus;
}
const departure = dateRange(query.departureFrom, query.departureTo);
if (departure) base.scheduledDepartureDate = departure as never;
const created = dateRange(query.createdFrom, query.createdTo);
if (created) base.createdAt = created as never;
// Search fans out across every human-recognizable label. Each OR variant
// repeats the base filters so the search never widens them.
const term = query.search?.trim();
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
base;
if (term) {
const like = ILike(`%${term}%`);
where = [
{ ...base, trainNumber: like as never },
{ ...base, originStation: { label: like } },
{ ...base, destinationStation: { label: like } },
{ ...base, route: { originYard: { label: like } } },
{ ...base, route: { destinationYard: { label: like } } },
{ ...base, trainSet: { locomotive: { code: like } } },
] as FindOptionsWhere<TrainSchedule>[];
}
const sortBy = query.sortBy ?? "createdAt";
const sortOrder = query.sortOrder ?? "DESC";
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
where,
relations: {
trainSet: { locomotive: true },
originStation: true,
@@ -666,7 +751,9 @@ export class BookingBatchService implements OnModuleInit {
// Yards supply the route's display name for `routeName` below.
route: { originYard: true, destinationYard: true },
},
order: { scheduledDepartureDate: "ASC" },
order: { [sortBy]: sortOrder } as never,
skip: (page - 1) * pageSize,
take: pageSize,
});
const wagonDims = await this.loadWagonDims();
@@ -675,11 +762,6 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
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);
@@ -707,7 +789,14 @@ export class BookingBatchService implements OnModuleInit {
board.push(this.buildScheduleSummary(s, items, rules));
}
return board;
return {
items: board,
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
@@ -718,9 +807,8 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!s)
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Arrived / cancelled schedules stay viewable — the board is also the
// historical record of what each train carried.
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
@@ -895,6 +983,7 @@ export class BookingBatchService implements OnModuleInit {
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1016,6 +1105,7 @@ export class BookingBatchService implements OnModuleInit {
return {
scheduleId: s.id,
scheduleReference: s.reference ?? null,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
@@ -1024,6 +1114,7 @@ export class BookingBatchService implements OnModuleInit {
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
createdAt: s.createdAt ? s.createdAt.toISOString() : null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
@@ -1117,7 +1208,8 @@ export class BookingBatchService implements OnModuleInit {
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
if (budget.maxRemaining().wagons <= 0) {
const minPerWagon = this.minPerWagonNeed(wagonDims);
if (budget.isExhausted(minPerWagon)) {
await this.setWindow(scheduleId, "FULL");
return 0;
}
@@ -1213,7 +1305,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
@@ -1461,8 +1553,9 @@ export class BookingBatchService implements OnModuleInit {
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
const minPerWagon = this.minPerWagonNeed(wagonDims);
for (const t of trains) {
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
@@ -1783,7 +1876,7 @@ export class BookingBatchService implements OnModuleInit {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
if (schedule && (await this.isTrainFull(schedule))) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
@@ -2616,7 +2709,9 @@ export class BookingBatchService implements OnModuleInit {
/**
* Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
* FULL signal is {@link isTrainFull}, which also closes weight/length-bound
* trains that still show free slots.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
@@ -2681,12 +2776,52 @@ export class BookingBatchService implements OnModuleInit {
);
}
/** No wagon slots left for allocated + reserved bookings. */
/**
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
* train length for even one more loaded wagon. The old slot-only check let
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
*/
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return false;
return (await this.remainingWagons(schedule)) <= 0;
return this.isTrainFull(schedule);
}
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
if ((await this.remainingWagons(schedule)) <= 0) return true;
const locomotive = schedule.trainSet?.locomotive;
if (!locomotive) return false; // no weight/length limits to bind against
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
}
/**
* Smallest gross weight / shortest length one more wagon could add: the
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
* so FULL is only declared when not even this wagon fits anywhere.
*/
private minPerWagonNeed(wagonDims: WagonDims): {
grossWeightTons: number;
lengthMeters: number;
} {
const all = [
wagonDims.container,
wagonDims.bulk,
...wagonDims.byWagonTypeId.values(),
];
return {
grossWeightTons: Math.min(
...all.map((d) => d.tareWeightTons + d.capacityTons),
),
lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
};
}
/**
@@ -2707,7 +2842,10 @@ export class BookingBatchService implements OnModuleInit {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return;
// Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
// just because slots remain — clearing it here would reopen a train
// nothing can board.
if (await this.isTrainFull(schedule)) return;
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";