mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 15:53:27 +00:00
fix issues
This commit is contained in:
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user