Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts

2780 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource, In } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
WagonTypeDimensions,
bookingGrossWeightTons,
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
trainHardCaps,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
} from './wagon-plan.util';
import {
Capacity,
CorridorBudget,
CorridorLeg,
stopYardsFor,
} from './corridor-capacity.util';
export type { Capacity } from './corridor-capacity.util';
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
destinationYardId: string;
/** EAT calendar day, `yyyy-MM-dd`. */
day: string;
}
/**
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
* its length on the train and the tare it adds to the locomotive's gross load.
*/
type WagonDims = {
container: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
bulk: { lengthMeters: number; tareWeightTons: number; capacityTons: number };
};
export type BatchBoardBookingState =
| "ALLOCATED"
| "SELECTED_FOR_BATCH"
| "READY"
| "WAITING"
| "PENDING_CONTRACT"
| "EXPIRED";
export interface BatchBoardBooking {
id: string;
reference: string;
company: string;
isGovernment: boolean;
wagons: number;
weightTons: number;
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
/** Rule-engine priority score used to rank the batch (higher = boards first). */
priorityScore: number;
/** CONTAINER | BULK — for the priority-tracking visuals. */
freightType: string | null;
}
export type BookingAllocationStatus =
| "NOT_ATTEMPTED"
| "ASSIGNED"
| "DEFERRED"
| "FAILED";
export interface BatchBoardBookingDetail extends BatchBoardBooking {
fullyExecutedAt: string | null;
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
/** Set when this booking shares a wagon with a consolidation partner. */
consolidationPartnerId: string | null;
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;
};
bookings: BatchBoardBookingDetail[];
}
export interface BatchBoardScheduleDetail {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
allocationViolations: string[];
}
export interface BatchBoardSchedule {
scheduleId: string;
trainNumber: string | null;
routeName: string | null;
origin: string | null;
destination: string | null;
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
locomotive: {
code: string;
name: string | null;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
} | null;
capacity: {
/** Wagons on bookings already linked to the train (ALLOCATED only). */
allocatedWagons: number;
/** Train length used by allocated bookings (from wagon-type dimensions). */
allocatedLengthMeters: number;
maxLengthMeters: number | null;
/** Weight committed on the train (allocated + selected-for-batch). */
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
};
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
pendingContract: number;
expired: number;
};
bookings: BatchBoardBooking[];
}
/**
* 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),
* reserves a 1h pay window for commercial customers (government allocated unpaid,
* preempting lower-priority commercial if needed), then settles each batch 1h later —
* allocating those who paid and expiring those who didn't, topping up from the waiting list.
* Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the
* locomotive's max pull weight, and its max train length (also capped by global rules).
*/
@Injectable()
export class BookingBatchService implements OnModuleInit {
private readonly logger = new Logger(BookingBatchService.name);
/**
* Serialises settle/top-up per schedule. The PAYMENT phase transition and the
* tick's overdue backstop both call settleDueReservations for the same schedule
* in the same second; without this they interleave and the top-up runs against a
* schedule whose phase has already been concluded.
*/
private readonly scheduleLocks = new Map<string, Promise<void>>();
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
private readonly notifier: BookingNotifierService,
private readonly scheduler: SchedulerRegistry,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly billing: BillingService,
private readonly bookingWindowGateway: BookingWindowGateway,
@Inject(forwardRef(() => BookingPricingService))
private readonly pricingService: BookingPricingService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
) {}
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const groups = await this.openRouteDayGroups();
for (const group of groups) {
try {
await this.processRouteDay(group);
} catch (err) {
this.logger.warn(
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
const reserved = await this.dataSource
.getRepository(Booking)
.createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
}
/**
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
* (contract sign, payment). Day-level pooling distributes across all of that
* day's trains, so a single schedule id maps to its whole route-day group.
*/
enqueueScheduleProcessing(scheduleId: string): void {
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
this.logger.error(
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
),
);
}
/**
* Fire-and-forget batch pipeline for a (route, day) directly — used when a
* booking enters the pool without a target train yet (e.g. after the
* operations team accepts an operation request). The booking is already
* FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick
* it up; this just runs that fill immediately instead of waiting for the cron.
*/
enqueueRouteDayProcessing(
originYardId: string,
destinationYardId: string,
day: string,
): void {
void this.processRouteDay({ originYardId, destinationYardId, day }).catch(
(err) =>
this.logger.error(
`processRouteDay for ${originYardId}${destinationYardId} on ${day} failed: ${(err as Error).message}`,
),
);
}
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return;
await this.processRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
});
}
/**
* Day-level pipeline: distribute the (route, day) pool across all its trains,
* then settle / reconcile / assign wagons per schedule (those steps stay
* schedule-scoped — only the fill is day-level).
*/
async processRouteDay(group: RouteDayGroup): Promise<void> {
this.logger.log(
`[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`,
);
const scheduleIds = await this.fillRouteDay(
group.originYardId,
group.destinationYardId,
group.day,
);
for (const scheduleId of scheduleIds) {
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
}
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
async processSchedule(scheduleId: string): Promise<void> {
await this.fillSchedule(scheduleId);
await this.settleDueReservations(scheduleId);
await this.reconcilePaidUnlinked(scheduleId);
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
* schedules with a `windowPhase` are driven exclusively by the window engine
* (BookingWindowService), never by the periodic legacy fill.
*/
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = (
await this.trainSchedulesRepository.findAll({
where: [
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
],
})
).filter((s) => s.windowPhase == null);
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
if (!s.scheduledDepartureDate) continue;
const day = eatDay(s.scheduledDepartureDate);
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
if (!groups.has(key)) {
groups.set(key, {
originYardId: s.originStationId,
destinationYardId: s.destinationStationId,
day,
});
}
}
return [...groups.values()];
}
private groupLabel(group: RouteDayGroup): string {
return `${group.originYardId}${group.destinationYardId} on ${group.day}`;
}
/**
* Idempotent: link a paid batch booking to its schedule and assign wagons.
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
*/
async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { company: true },
});
if (!booking?.trainScheduleId) return;
const isBatchPaid =
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT" ||
booking.status === "PAID" ||
booking.paymentStatus === "PAID";
if (!isBatchPaid) return;
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID", status: "PAID" });
} else if (booking.paymentStatus !== "PAID") {
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" });
}
// Paying inside the window accepts an open partial offer — reduce the booking
// to the offered part before it boards (remainder returns to the contract cap).
if (this.splitService) {
await this.splitService.applySplit(bookingId);
}
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
await this.allocate(booking.trainScheduleId, booking, "paid");
this.logger.log(
`Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
);
} else {
// Already linked at booking time (export FCFS: the customer books a
// specific train, so allocate() ran up front). allocate() is where the
// payment-settled tracking milestones are written, so on this branch we
// record them here — otherwise a paid, already-linked booking leaves
// FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
void this.completeTrackingMilestones(bookingId, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
]);
void this.markWagonAllocatedMilestone(bookingId);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
const result = await this.trainSchedulingService.tryAutoWagonAllocation(
booking.trainScheduleId,
);
if (result.assignedBookingIds.length) {
this.logger.log(
`Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
);
}
if (
result.issues.some(
(i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
)
) {
const issue = result.issues.find((i) => i.bookingId === bookingId);
this.logger.warn(
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
);
}
}
/** Customer paid — delegate to ensurePaidBookingAllocated. */
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
await this.ensurePaidBookingAllocated(bookingId);
}
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
async getOpenOfferSummary(bookingId: string): Promise<{
offeredWagons: number;
totalWagons: number;
offeredAmount: number;
paymentDeadline: Date;
} | null> {
if (!this.splitService) return null;
const offer = await this.splitService.findOpenOffer(bookingId);
if (!offer) return null;
return {
offeredWagons: offer.offeredWagons,
totalWagons: offer.totalWagons,
offeredAmount: Number(offer.offeredAmount),
paymentDeadline: offer.paymentDeadline,
};
}
// ---- export FCFS -----------------------------------------------------------
/**
* Export is first-come-first-serve: no window cycle, no priority, no batch.
* Pick the earliest open export train on the booking's corridor/day that still
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date');
}
const day = eatDay(new Date(booking.scheduledDate));
// Corridor-aware: any train whose route carries the booking's origin
// strictly before its destination qualifies — a Dire→Djibouti booking may
// ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
// stop order, so we fetch the day's open trains without endpoint filters.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
)
.sort(
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
);
if (!candidates.length) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false;
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true;
if (budget.fits(required, leg)) return schedule.id;
}
if (!corridorMatched) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
throw new ConflictException('Train is full — no export capacity left for this day');
}
/**
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
* against the combined shared-wagon need; the first partner's accept just waits.
* Throws ConflictException (before this booking is persisted-ready) when there is
* no export capacity for the day, so staff accept fails.
*/
async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId);
return;
}
const partner = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
// waits; the partner's later accept will reserve the pair.
if (!partner || partner.status !== 'FULLY_EXECUTED') {
return;
}
const wagonDims = await this.loadWagonDims();
const need = this.combinedNeed(booking, partner, wagonDims);
const scheduleId = await this.pickExportSchedule(booking, need);
await this.reserveOnExport([booking, partner], scheduleId);
}
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
private async reserveOnExport(
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(scheduleId, 'FULL');
}
}
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked =
await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
for (const booking of unlinked) {
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
);
}
}
// ---- legacy fill entry point ----------------------------------------------
/**
* Legacy periodic fill for schedules without a window phase (DOMESTIC and
* pre-migration trains). Invoked by BookingWindowService's tick — the old
* standalone cron was replaced by the window engine.
*/
async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
for (const group of groups) {
try {
await this.processRouteDay(group);
} catch (err) {
this.logger.error(
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
);
}
}
}
// ---- 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).
*/
async getBatchBoard(): Promise<BatchBoardSchedule[]> {
const schedules = await this.trainSchedulesRepository.findAll({
relations: {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
// Yards supply the route's display name for `routeName` below.
route: { originYard: true, destinationYard: true },
},
order: { scheduledDepartureDate: "ASC" },
});
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
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);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
priorityScore: Number(b.priorityScore ?? 0),
freightType: b.freightType ?? null,
};
});
board.push(this.buildScheduleSummary(s, items, rules));
}
return board;
}
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
async getBatchBoardDetail(
scheduleId: string,
): Promise<BatchBoardScheduleDetail> {
const s =
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");
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
"The batch board only covers import schedules",
);
}
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
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);
let allocationPreview: Awaited<
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
>;
try {
allocationPreview =
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
} catch {
allocationPreview = {
assignedBookingIds: [],
deferred: [],
issues: [],
violations: [],
};
}
const allocationByBooking = new Map(
allocationPreview.issues.map((i) => [i.bookingId, i]),
);
// Resolve consolidation-partner references for the shared-wagon badge. Most
// partners are on this same schedule; look up any that aren't in one query.
const refById = new Map(
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
);
const missingPartnerIds = [
...new Set(
bookings
.map((b) => b.consolidationPartnerId)
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
),
];
if (missingPartnerIds.length) {
const partners = await this.dataSource
.getRepository(Booking)
.find({ where: { id: In(missingPartnerIds) } });
for (const p of partners) {
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
}
}
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
company: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—"),
isGovernment: Boolean(b.isGovernment),
wagons: need.wagons,
weightTons: need.weightTons,
lengthMeters: need.lengthMeters,
paymentDeadline: b.paymentDeadline
? b.paymentDeadline.toISOString()
: null,
state: this.boardState(b, linkedIds.has(b.id)),
priorityScore: Number(b.priorityScore ?? 0),
freightType: b.freightType ?? null,
fullyExecutedAt: b.fullyExecutedAt
? b.fullyExecutedAt.toISOString()
: null,
selectedForBatchAt: b.selectedForBatchAt
? b.selectedForBatchAt.toISOString()
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
consolidationPartnerId: b.consolidationPartnerId ?? null,
consolidationPartnerRef: b.consolidationPartnerId
? (refById.get(b.consolidationPartnerId) ?? null)
: null,
};
});
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,
),
reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes),
importWindowLeadDays: num(
s.ruleImportWindowLeadDays,
liveCfg.importWindowLeadDays,
),
exportBookingLeadHours: num(
s.ruleExportBookingLeadHours,
liveCfg.exportBookingLeadHours,
),
};
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) {
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;
else if (b.state === "WAITING") counts.waiting += 1;
else if (b.state === "EXPIRED") counts.expired += 1;
else counts.pendingContract += 1;
}
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 ?? [];
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
windowPhase: s.windowPhase ?? null,
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
paymentPhaseEndsAt: s.paymentPhaseEndsAt
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
windows,
pendingContract: {
key: "pending-contract",
label: "Pending contract",
date: "",
dateLabel: "",
start: "",
end: "",
counts: countFor(pendingBookings),
bookings: pendingBookings,
},
allocationViolations: allocationPreview.violations,
};
}
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
async runWagonAllocation(scheduleId: string) {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
* includes the tare of the wagons it occupies), so the ceiling it is measured
* against must be the same one the fill loop spends from: the locomotive floored
* by the global rule caps and widened by its overage tolerance. Reading the raw
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
*/
private computeBoardCapacity(
items: Array<{
state: BatchBoardBookingState;
wagons: number;
weightTons: number;
lengthMeters: number;
}>,
loco: Locomotive | null,
maxWagons: number | null,
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
const caps = loco
? trainHardCaps(
{
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
},
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
)
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters: round2(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
),
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
};
}
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
return {
scheduleId: s.id,
trainNumber: s.trainNumber ?? null,
routeName: s.route ? formatRouteLabel(s.route) : null,
origin: s.originStation?.label ?? s.originStation?.code ?? null,
destination:
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
scheduleDate: s.scheduledDepartureDate
? s.scheduledDepartureDate.toISOString()
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
windowPhase: s.windowPhase ?? null,
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
paymentPhaseEndsAt: s.paymentPhaseEndsAt
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
locomotive: loco
? {
code: loco.code,
name: loco.name ?? null,
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
.length,
ready: items.filter((i) => i.state === "READY").length,
waiting: items.filter((i) => i.state === "WAITING").length,
pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
.length,
expired: items.filter((i) => i.state === "EXPIRED").length,
},
bookings: items.slice(0, 3),
};
}
private boardState(
booking: Booking,
linked: boolean,
): BatchBoardBookingState {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
) {
return "SELECTED_FOR_BATCH";
}
if (booking.status === "EXPIRED") return "EXPIRED";
if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
return "READY";
if (booking.status === "PAID") return "WAITING";
return "PENDING_CONTRACT";
}
// ---- core fill ------------------------------------------------------------
/**
* Whether the batch engine may reserve/allocate onto this schedule right now.
* Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
* Import window cycle: the engine fills while the customer window is CLOSED —
* during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
* Export: FCFS while the booking window is open.
*/
isFillable(schedule: TrainSchedule): boolean {
if (schedule.bookingWindowStatus === "FULL") return false;
if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
if (schedule.direction === "EXPORT") {
return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
}
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
}
/**
* Fill one schedule from its priority-ordered pool until full. Returns the
* number of commercial units it RESERVED this pass (0 for government-only or
* no-fit passes) so a top-up caller can extend the payment phase only when a
* fresh pay window actually opened.
*/
async fillSchedule(scheduleId: string): Promise<number> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || !this.isFillable(schedule)) return 0;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
);
return 0;
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
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) {
await this.setWindow(scheduleId, "FULL");
return 0;
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
// Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
// must rank bulk bookings by their wagon-derived priority too.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let reservedThisPass = 0;
let commercialReserved = 0;
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
// reservations trickle instead of landing in one pass (a reserve() throwing
// mid-loop, e.g. schema drift, or a mis-synced capacity cap).
this.logger.debug(
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
`poolSize=${pool.length} units=${units.length}`,
);
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
// Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
this.logger.debug(
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
);
if (!budget.fits(need, leg)) {
if (isGov) {
const freed = await this.preemptForGovernment(
scheduleId,
need,
leg,
budget,
wagonDims,
);
if (!freed) continue; // still doesn't fit even after preempt
} else {
// Doesn't fit whole. A split-eligible import booking is offered the part
// that fits in the remaining room (top-up path splits the boundary
// booking, mirroring fillRouteDay); otherwise skip and try the next.
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
id: scheduleId,
budget,
armed,
};
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
armed = cand.armed;
continue;
}
continue; // skip a unit that exceeds weight/length/wagons, try the next
}
}
// Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup)
// can't abort the whole top-up pass and leave the rest to trickle in one
// per tick. Log + skip the failing unit, keep going.
try {
if (isGov) {
await this.allocate(scheduleId, booking, "gov");
if (partner) await this.allocate(scheduleId, partner, "gov");
} else {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
commercialReserved += 1;
}
budget.subtract(need, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
`[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` +
`— skipping this unit, continuing: ${(err as Error).message}`,
);
continue;
}
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
}
this.logger.log(
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
return commercialReserved;
}
/**
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
* priority, filling each train (earliest departure first) until it's full and
* spilling overflow to the next. Government bookings that fit no train preempt
* lower-priority commercial; bookings that fit no train at all stay pending and
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
* (or that had remaining pool work) so the caller can settle them per-schedule.
*/
async fillRouteDay(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<string[]> {
const { scheduleIds } = await this.fillRouteDayInternal(
originYardId,
destinationYardId,
day,
);
return scheduleIds;
}
/**
* Route-day top-up for a single schedule: re-run the DAY pool over the whole
* corridor the schedule belongs to, and report how many commercial units got a
* fresh pay window.
*
* `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
* `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
* booking that has not been reserved yet has a NULL `train_schedule_id` — it is
* only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
* and the waiting list never boarded after an expiry freed capacity; bookings
* trickled in one per window cycle instead.
*/
private async topUpFill(scheduleId: string): Promise<number> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return 0;
const { commercialReserved } = await this.fillRouteDayInternal(
schedule.originStationId,
schedule.destinationStationId,
eatDay(schedule.scheduledDepartureDate),
);
return commercialReserved;
}
private async fillRouteDayInternal(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
// The day's fillable schedules on this exact corridor, earliest first. Fillable
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
// the batch must run while the customer window is closed.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const onDay = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day,
)
.sort(
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
);
// A schedule flagged FULL is rejected by isFillable() before its budget is
// ever consulted. Re-derive that flag from live capacity first, so a train
// whose bookings all expired is not skipped forever with an empty consist.
for (const s of onDay) {
if (s.bookingWindowStatus === "FULL") {
await this.refreshWindowStatus(s.id);
const fresh = await this.trainSchedulesRepository.findById(s.id);
if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
}
}
const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);
if (scheduleIds.length === 0) {
return { scheduleIds: [], commercialReserved: 0 };
}
const rules = await this.loadGlobalRules();
const wagonDims = await this.loadWagonDims();
// Live per-schedule corridor budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
for (const id of scheduleIds) {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !schedule.trainSetId || !locomotive) {
this.logger.warn(
`Schedule ${id} has no locomotive/train set — skipped.`,
);
continue;
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
// The day pool covers every booking whose leg lies somewhere on one of the
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
// Addis→Djibouti train). Which train actually takes a booking is decided
// by the per-train legOf check below.
const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
corridorYards,
day,
);
// BULK bookings only get their real (wagon-derived) priority score now, at
// batch time — stamp it and re-rank before the fill consumes the pool.
await this.recomputeBulkPriorities(pool, wagonDims);
this.resortPoolByPriority(pool);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
// Batch fill trace: each train's caps + the day pool size at entry.
this.logger.debug(
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
`poolSize=${pool.length} units=${units.length}`,
);
let reservedThisPass = 0;
let commercialReserved = 0;
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
t.budget.legOf(booking.originYardId, booking.destinationYardId);
// First train (earliest departure) whose corridor carries this booking's
// leg and still fits it as-is.
let target = trains.find((t) => {
const leg = legOn(t);
return leg != null && t.budget.fits(need, leg);
});
// Per-unit trace: chosen train + each train's remaining room on this leg.
this.logger.debug(
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
`targetTrain=${target?.id ?? "none"} ` +
`rooms=${trains
.map((t) => {
const leg = legOn(t);
return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
})
.join(",")}`,
);
if (!target && isGov) {
// Government fits nowhere on its own — try to preempt commercial
// on each corridor-matching train (earliest first) until one frees room.
for (const t of trains) {
const leg = legOn(t);
if (!leg) continue;
const freed = await this.preemptForGovernment(
t.id,
need,
leg,
t.budget,
wagonDims,
);
if (freed) {
target = t;
break;
}
}
}
if (!target) {
// Fits no train whole. A split-eligible booking is offered the largest
// part that fits on the train with the most free wagons on its leg (this
// covers both "fits nowhere" and the boundary case where earlier bookings
// already consumed most of the room). Consolidated pairs / government /
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
reservedThisPass += 1;
continue;
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
if (partner) this.notifier.unplaced(partner, day);
continue;
}
// A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort
// the whole pass — otherwise only the bookings before the failure get a pay
// window and the rest trickle in one-per-tick on later retries (the
// "selected one at a time / staggered" symptom). Isolate each unit: log +
// skip a failing one, keep reserving the others. The skipped unit stays in
// the pool and is retried next cycle.
try {
if (isGov) {
await this.allocate(target.id, booking, "gov");
if (partner) await this.allocate(target.id, partner, "gov");
} else {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
`[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` +
`— skipping this unit, continuing the batch: ${(err as Error).message}`,
);
}
}
this.logger.log(
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
for (const t of trains) {
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
void this.triggerWagonAllocation(t.id);
}
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
}
/**
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
* neither shared wagon) and government bookings never split (they preempt).
*/
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
return (
!isPair &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
this.splitService != null
);
}
/**
* Offer the largest fitting part of a booking that does not fit any candidate
* train whole, on the train with the most free wagons on the booking's leg.
* Mutates the chosen candidate's budget + armed flag in place. Returns true when
* an offer was opened (caller should `continue` past this unit), false otherwise.
* Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
* is computed per candidate from the booking's yards, so callers pass their live
* train entries and only leg-carrying trains are considered.
*/
private async maybeOfferPartial(
booking: Booking,
isPair: boolean,
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
need: Capacity,
): Promise<boolean> {
if (!this.isSplitEligible(booking, isPair)) return false;
const target = candidates
.map((c) => {
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
})
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
if (!target) return false;
const offered = await this.tryPartialOffer(
booking,
target.c.id,
target.room,
need,
);
if (!offered) return false;
target.c.budget.subtract(offered, target.leg);
target.c.armed = true;
return true;
}
/**
* Offer the largest fitting part of an over-capacity booking as a partial
* (split-on-payment). Returns the capacity the offer consumes, or null when no
* meaningful partial fits / an offer is already open.
*/
private async tryPartialOffer(
booking: Booking,
scheduleId: string,
budget: Capacity,
need: Capacity,
): Promise<Capacity | null> {
if (!this.splitService) return null;
// A consolidated booking is already half of a shared wagon — never split it.
if (booking.consolidationPartnerId) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonDims = await this.loadWagonDims();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
// The wagon-slot axis alone under-constrains the offer. On a weight- or
// length-limited train (slots to spare, but e.g. only 798T of pull weight
// left) sizing by slots either produced an offer the fits() check below
// rejected, or — when the free slots exceeded the booking's own wagon
// count — sizeOffer refused outright, so a bulk booking on a weight-bound
// train was never offered a split at all. Size across all three axes.
const perWagon =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon);
if (!partial) return null;
const sized = await this.splitService.sizeOffer(
booking,
partial.wagons,
need.wagons,
bulkCapacityTons,
partial.maxCargoTons,
);
if (!sized) return null;
const offeredNeed: Capacity = {
wagons: sized.offeredWagons,
weightTons: bookingGrossWeightTons(
sized.offeredWeightTons,
sized.offeredWagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
sized.offeredWagons,
this.lengthsOf(wagonDims),
),
};
if (!this.fits(offeredNeed, budget)) return null;
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
// Reserve like a normal batch selection, but the partial invoice + partial
// pay-now notification were already produced by createOffer.
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: new Date(),
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
return offeredNeed;
}
private async loadBulkWagonCapacityTons(): Promise<number> {
const cw3 = await this.dataSource
.getRepository(WagonType)
.findOne({ where: { code: "CW3" } });
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
return capacity > 0 ? capacity : 60;
}
/**
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout
* path: expire it). Consolidated pairs settle atomically: both allocate only
* when both paid; if either partner expires, both expire (a half-paid shared
* wagon must not ship). Returns whether anything changed.
*/
private async settleReserved(
scheduleId: string,
expireUnpaidUnknownDeadline: boolean,
): Promise<boolean> {
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
const byId = new Map(reserved.map((b) => [b.id, b]));
const done = new Set<string>();
let anySettled = false;
this.logger.debug(
`[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`,
);
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
if (done.has(booking.id)) continue;
const partner = booking.consolidationPartnerId
? (byId.get(booking.consolidationPartnerId) ?? null)
: null;
if (partner) {
done.add(booking.id);
done.add(partner.id);
// Both-or-neither: allocate the shared wagon only when both partners paid;
// if either lapsed, expire both so no half-paid wagon rides.
if (isPaid(booking) && isPaid(partner)) {
await this.allocate(scheduleId, booking, "paid");
await this.allocate(scheduleId, partner, "paid");
anySettled = true;
} else if (isExpired(booking) || isExpired(partner)) {
await this.expire(booking);
await this.expire(partner);
anySettled = true;
}
continue;
}
done.add(booking.id);
if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (isExpired(booking)) {
await this.expire(booking);
anySettled = true;
}
}
return anySettled;
}
/**
* Durable settle: allocate paid / expire overdue reservations, then top up the
* freed capacity from the waiting list.
*
* Serialised per schedule. Two callers race here every time a payment phase
* ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations`
* backstop. Both read the same reserved rows in the same second, so without the
* lock the second caller re-settles rows the first is mid-way through expiring,
* and `concludeCycle` observes capacity that is neither pre- nor post-expiry.
*/
async settleDueReservations(scheduleId: string): Promise<void> {
await this.withScheduleLock(scheduleId, () =>
this.settleAndTopUp(scheduleId, false),
);
}
/**
* Settle, then keep promoting the waiting list until the train can take no more.
* Returns whether anything settled.
*
* One top-up pass is not enough: expiring an N-wagon booking can free room for
* several smaller ones, and reserving those can in turn leave room for the next
* size down. Loop until a pass reserves nothing, so the batch ends with the train
* as full as the pool allows — rather than leaving a booking stranded until the
* next window cycle.
*
* Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so
* `concludeCycle` cannot fire before the promoted customers' deadlines.
*/
private async settleAndTopUp(
scheduleId: string,
expireUnpaidUnknownDeadline: boolean,
): Promise<boolean> {
const anySettled = await this.settleReserved(
scheduleId,
expireUnpaidUnknownDeadline,
);
if (!anySettled) return false;
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
// Bounded: every round either reserves at least one unit (shrinking the pool)
// or breaks. The cap is a backstop against a pathological reserve/expire cycle.
let promoted = 0;
for (let round = 0; round < 10; round += 1) {
const reservedThisRound = await this.topUpFill(scheduleId);
if (reservedThisRound <= 0) break;
promoted += reservedThisRound;
await this.extendPaymentPhaseForTopUp(scheduleId);
}
if (promoted > 0) {
this.logger.log(
`[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` +
`— payment phase extended for them`,
);
}
return true;
}
/**
* Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the
* in-flight run rather than interleaving with it. Single-process only — a second
* API replica would need a row lock on the schedule instead.
*/
private async withScheduleLock<T>(
scheduleId: string,
fn: () => Promise<T>,
): Promise<T> {
const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve();
// Chain onto the previous holder; swallow its rejection so one failure does
// not poison every later caller's lock.
const run = inFlight.catch(() => undefined).then(fn);
const gate = run.then(
() => undefined,
() => undefined,
);
this.scheduleLocks.set(scheduleId, gate);
try {
return await run;
} finally {
// Last one out clears the slot so the map does not grow without bound.
if (this.scheduleLocks.get(scheduleId) === gate) {
this.scheduleLocks.delete(scheduleId);
}
}
}
// ---- settle (1h after a batch) -------------------------------------------
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
await this.withScheduleLock(scheduleId, () =>
this.settleAndTopUp(scheduleId, true),
);
void this.triggerWagonAllocation(scheduleId);
}
private triggerWagonAllocation(scheduleId: string): void {
void this.trainSchedulingService
.tryAutoWagonAllocation(scheduleId)
.catch((err) =>
this.logger.warn(
`Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
),
);
}
// ---- staff override actions ----------------------------------------------
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
async markPaid(bookingId: string): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (!booking.trainScheduleId) {
throw new BadRequestException(
"Booking has no target schedule to allocate to",
);
}
await this.dataSource
.getRepository(Booking)
.update(bookingId, { paymentStatus: "PAID" });
await this.allocate(booking.trainScheduleId, booking, "paid");
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
booking.trainScheduleId,
);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(booking.trainScheduleId, "FULL");
}
void this.triggerWagonAllocation(booking.trainScheduleId!);
}
/**
* Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
* Used for EXPIRED or full-schedule bookings — no re-approval.
*/
async moveToSchedule(
bookingId: string,
newScheduleId: string,
): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: newScheduleId } });
if (!schedule)
throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
if (schedule.bookingWindowStatus !== "OPEN") {
throw new BadRequestException(
"Target schedule is not accepting bookings",
);
}
const stops = await this.stopsForSchedule(schedule);
const fromIdx = stops.indexOf(booking.originYardId);
const toIdx = stops.indexOf(booking.destinationYardId);
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
throw new BadRequestException(
"Target schedule is not on the booking route",
);
}
await this.dataSource.transaction(async (manager) => {
if (booking.trainScheduleId) {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
booking.trainScheduleId,
bookingId,
manager,
);
}
const restoredStatus =
booking.status === "EXPIRED"
? booking.isGovernment
? "APPROVED"
: "FULLY_EXECUTED"
: booking.status;
await manager.getRepository(Booking).update(bookingId, {
trainScheduleId: newScheduleId,
status: restoredStatus,
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
}
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
async expireReservation(bookingId: string): Promise<void> {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Capture the train before expire() detaches the booking from it — the
// top-up has to run against the schedule whose wagons were just freed.
const freedScheduleId = booking.trainScheduleId;
await this.expire(booking);
if (freedScheduleId) {
const topUpReserved = await this.topUpFill(freedScheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(freedScheduleId);
}
}
}
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
* a schedule, and the per-booking need calculator — exposed for the intercity
* accept flow, which reserves ride-along bookings onto import/export trains
* outside the batch engine. Segment-based: an intercity booking fits whenever
* ITS leg has room, even if the train is full on other legs.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: CorridorBudget;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
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, needFor: (booking) => this.needFor(booking, wagonDims) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------
/**
* Reserve capacity for a commercial booking on a specific train and open its
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
* which is all keyed off `booking.trainScheduleId`, can find the train — with
* day-level pooling the booking arrives here with `trainScheduleId` still null,
* so the engine sets it as it picks the train.
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
// Idempotency guard: a booking already reserved (pay window open) or already
// paid on THIS schedule must never be re-reserved — that would fire a second
// `payNow` and reset its deadline, the "asked to pay again after paying"
// symptom. Read fresh state (the in-memory `booking` may be stale from the
// pooled query). Only bookings not yet committed to this train pass through.
const fresh = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: booking.id } });
if (
fresh &&
fresh.trainScheduleId === scheduleId &&
(fresh.status === "SELECTED_FOR_BATCH" ||
fresh.status === "AWAITING_PAYMENT" ||
fresh.status === "PAID" ||
fresh.paymentStatus === "PAID")
) {
this.logger.debug(
`[BATCH] reserve skipped for ${booking.reference} — already ` +
`${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
);
return;
}
const now = new Date();
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: now,
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
// The invoice was generated at booking creation/approval, before this pay
// window opened — refresh its printed due date to the real deadline.
await this.billing.syncPayableDueDate(
Freight.InvoiceSource.Booking,
booking.id,
deadline,
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims());
this.logger.log(
`[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` +
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId}` +
`pay by ${deadline.toISOString()}`,
);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
]);
}
/** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
private async allocate(
scheduleId: string,
booking: Booking,
reason: "paid" | "gov",
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const exists =
await this.trainScheduleBookingsRepository.existsForBooking(
booking.id,
manager,
);
if (!exists) {
await this.trainScheduleBookingsRepository.createMany(
[{ trainScheduleId: scheduleId, bookingId: booking.id }],
manager,
);
}
await manager.getRepository(Booking).update(booking.id, {
status: reason === "paid" ? "PAID" : booking.status,
schedulingStatus: "SCHEDULED",
scheduledAt: new Date(),
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
});
this.logger.log(
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).
// Government allocations don't pay upfront — theirs stay pending.
if (reason === 'paid') {
void this.completeTrackingMilestones(booking.id, [
'WAGON_REQUESTED',
'FREIGHT_PAYMENT_PENDING',
'FREIGHT_PAYMENT_SETTLED',
]);
}
}
private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
if (!this.milestoneService) return;
try {
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
} catch {
// Booking may have no milestone rows (non-contract path).
}
}
/**
* Complete customer-tracking milestones on lifecycle events via the
* doc-trigger path — a silent no-op for bookings without milestone rows
* (non-customs bookings). Never blocks the batch action.
*/
private async completeTrackingMilestones(
bookingId: string,
codes: string[],
): Promise<void> {
if (!this.milestoneService) return;
for (const code of codes) {
try {
await this.milestoneService.completeByDocTrigger({ bookingId }, code);
} catch (err) {
this.logger.warn(
`Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
);
}
}
}
/**
* Expire an unpaid reservation and free its capacity. With day-level pooling we
* also clear `trainScheduleId` so the booking is no longer pinned to the train
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// The wagons this reservation held are back — a schedule parked at FULL
// because of it must reopen, or it can never be filled again.
if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
// An unpaid partial offer dies with the reservation — the booking stays whole.
if (this.splitService) {
await this.splitService.expireOpenOffer(booking.id);
}
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
this.logger.log(
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
`wagons back to the pool for top-up`,
);
}
/**
* Union of stop yards across the day's fillable schedules on this corridor —
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
* are covered. Empty when no fillable schedule exists for the group.
*/
private async corridorYardsForRouteDay(
group: RouteDayGroup,
): Promise<string[]> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: group.originYardId,
destinationStationId: group.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: group.originYardId,
destinationStationId: group.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const yards = new Set<string>();
for (const schedule of corridor) {
if (
schedule.scheduledDepartureDate == null ||
eatDay(schedule.scheduledDepartureDate) !== group.day
) {
continue;
}
for (const yardId of await this.stopsForSchedule(schedule)) {
yards.add(yardId);
}
}
return [...yards];
}
/**
* Sweep bookings on a route-day whose operation request staff did NOT accept by
* the time the window's document-review phase ends. They never reached
* FULLY_EXECUTED, so they never enter the batch — expire them (customer must
* rebook a new window). No reservation and no invoice exists yet at this stage,
* so this is a lighter expiry than `expire()`: just flip status + notify, and
* best-effort close any payable if one was issued early. Government/export are
* excluded by the query.
*/
async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
const corridorYards = await this.corridorYardsForRouteDay(group);
if (corridorYards.length === 0) return;
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
corridorYards,
group.day,
);
if (unaccepted.length > 0) {
this.logger.log(
`[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` +
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
);
}
for (const booking of unaccepted) {
await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
// Free the shipment day so the customer can rebook a fresh window.
scheduledDate: null,
} as never);
// Close any payable issued before doc-review end (normally none — the invoice
// is created at ops-accept, which by definition has not happened here).
await this.billing
.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
.catch(() => undefined);
this.notifier.expired(booking);
this.logger.log(
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
);
}
}
/**
* Free capacity for a government booking by displacing the lowest-priority commercial
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
* Only victims whose legs overlap the government booking's leg actually free useful
* room, so others are skipped. Mutates `budget`; returns whether the need now fits.
*/
private async preemptForGovernment(
scheduleId: string,
need: Capacity,
leg: CorridorLeg,
budget: CorridorBudget,
wagonDims: WagonDims,
): Promise<boolean> {
if (budget.fits(need, leg)) return true;
const reservedCommercial = (
await this.bookingsRepository.findReservedForSchedule(scheduleId)
).filter((b) => !b.isGovernment);
const allocatedCommercial =
await this.bookingsRepository.findAllocatedCommercialForSchedule(
scheduleId,
);
// lowest priority first; reserved are cheaper to free than allocated
const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
);
for (const victim of candidates) {
if (budget.fits(need, leg)) break;
const victimLeg = budget.legForYards(
victim.originYardId,
victim.destinationYardId,
);
// Displacing a booking on a disjoint leg frees nothing the government
// booking can use — don't kill it for nothing.
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
if (!overlaps) continue;
await this.dataSource.transaction(async (manager) => {
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
scheduleId,
victim.id,
manager,
);
await manager.getRepository(Booking).update(victim.id, {
status: "EXPIRED",
schedulingStatus: "ELIGIBLE",
paymentDeadline: null,
selectedForBatchAt: null,
} as never);
// Displaced → EXPIRED: close its open invoice too, so a dead booking
// can't still be paid (mirrors `expire()`; enlisted in this txn).
await this.billing.expirePayable(
Freight.InvoiceSource.Booking,
victim.id,
"PREPAID",
manager,
);
});
this.notifier.displaced(victim);
budget.add(this.needFor(victim, wagonDims), victimLeg);
// Displacing frees wagons the same way an expiry does — don't leave the
// schedule stuck at FULL.
await this.refreshWindowStatus(scheduleId);
}
return budget.fits(need, leg);
}
// ---- capacity helpers -----------------------------------------------------
/**
* Collapse consolidated partners into single pool entries so the fill treats a
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
* - no `consolidationPartnerId` → passes through as a lone booking.
* - consolidated + partner also in this pool → emitted ONCE (at the position of
* whichever partner ranks first) as a pair; the partner is not emitted again.
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
* it waits for the partner to become ready in a later cycle).
* The pool is already priority-ordered, so emitting the pair at the first-seen
* partner's slot ranks it by the stronger (max-priority) partner automatically.
*/
private groupConsolidatedPool(
pool: Booking[],
): Array<{ primary: Booking; partner: Booking | null }> {
const byId = new Map(pool.map((b) => [b.id, b]));
const emitted = new Set<string>();
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
for (const booking of pool) {
if (emitted.has(booking.id)) continue;
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
emitted.add(booking.id);
units.push({ primary: booking, partner: null });
continue;
}
const partner = byId.get(partnerId) ?? null;
if (!partner) {
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
emitted.add(booking.id);
continue;
}
emitted.add(booking.id);
emitted.add(partner.id);
units.push({ primary: booking, partner });
}
return units;
}
/**
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
* independently-rounded-up needs (that is the capacity consolidation saves).
*/
private combinedNeed(
primary: Booking,
partner: Booking,
wagonDims: WagonDims,
): Capacity {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
// Consolidation shares TEU slots, never rated payload: the pair still needs
// enough wagons to carry its combined cargo, so the weight axis bounds the
// shared count exactly as it bounds an individual booking's.
const capacityTons = this.capacityFor(primary.freightType, wagonDims);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
const byLength =
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims);
const sharedWagons = Math.max(byLength, byWeight);
return {
wagons: sharedWagons,
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
// wagons, so it is charged `sharedWagons` tares, not one per booking.
weightTons: bookingGrossWeightTons(
cargoTons,
sharedWagons,
this.tareFor(primary.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
primary.freightType,
sharedWagons,
this.lengthsOf(wagonDims),
),
};
}
/** Per-wagon tare for the wagon type this freight rides on. */
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
return freightType === 'BULK'
? wagonDims.bulk.tareWeightTons
: wagonDims.container.tareWeightTons;
}
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
return {
container: wagonDims.container.lengthMeters,
bulk: wagonDims.bulk.lengthMeters,
};
}
/**
* Stamp real priority scores on the pool's BULK bookings before the batch
* ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk
* booking has no container lines to carry a wagon count), so every
* wagon-range priority config missed and bulk import bookings entered the
* batch at score 0 — they were never prioritized. Their wagon footprint is
* derivable from tonnage vs. live wagon capacity (wagonsFor), so the score
* is computed here — when doc review closes and the batch runs — and
* persisted so the priority board shows the same ranking. The pool arrives
* SQL-ordered by the old scores; the caller must re-sort after this.
*/
private async recomputeBulkPriorities(
pool: Booking[],
wagonDims: WagonDims,
): Promise<void> {
for (const booking of pool) {
if (booking.freightType !== 'BULK') continue;
try {
const wagons = this.wagonsFor(booking, wagonDims);
const score = await this.pricingService.computeSubmitPriorityScore(
booking,
wagons,
);
if (Number(booking.priorityScore ?? 0) === score) continue;
await this.dataSource
.getRepository(Booking)
.update(booking.id, { priorityScore: score });
booking.priorityScore = score;
} catch (err) {
// A failed recompute keeps the stored score — never blocks the batch.
this.logger.warn(
`Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` +
`${(err as Error).message}`,
);
}
}
}
/** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
private resortPoolByPriority(pool: Booking[]): void {
pool.sort(
(a, b) =>
Number(b.isGovernment) - Number(a.isGovernment) ||
Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
(a.fullyExecutedAt?.getTime() ?? Infinity) -
(b.fullyExecutedAt?.getTime() ?? Infinity) ||
a.createdAt.getTime() - b.createdAt.getTime(),
);
}
/**
* Wagons a booking occupies. Two axes bind independently and the booking needs
* enough wagons to satisfy BOTH, so the count is the larger of:
*
* weight — ceil(cargoTons / wagonType.capacityTons), the rated payload
* length — TEU geometry, two 20ft to a wagon (container bookings only)
*
* The weight axis was missing entirely. A BULK booking carries no container
* lines, so `containerWagonsForLines` returned 0 and every bulk booking
* collapsed to a single wagon no matter its tonnage — a 2590T fertilizer
* booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37.
* That under-reported the board and let the fill loop overbook the train.
*/
private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
// Stored wagonsRequired is a candidate, never an early return: rows written
// while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and
// trusting them charged one tare for a whole bulk consist (a 700T booking on
// 70T wagons read 700 + 1 tare instead of 700 + 10 tares).
const stored =
booking.wagonsRequired && booking.wagonsRequired > 0
? Math.ceil(booking.wagonsRequired)
: 0;
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
const capacityTons = this.capacityFor(booking.freightType, wagonDims);
const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0);
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
}
private capacityFor(
freightType: string | null | undefined,
wagonDims: WagonDims,
): number {
return freightType === "BULK"
? wagonDims.bulk.capacityTons
: wagonDims.container.capacityTons;
}
/**
* What one booking consumes along all three capacity axes.
*
* The weight axis is GROSS — cargo plus the tare of every wagon the booking
* occupies — because it is spent against the locomotive's pull limit, which
* governs the whole train and not just its payload. Charging cargo alone let a
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
*/
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
const wagons = this.wagonsFor(booking, wagonDims);
return {
wagons,
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
wagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
wagons,
this.lengthsOf(wagonDims),
),
};
}
private fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
/**
* Hard caps for a schedule's train: gross pull weight, train length, and the
* length-derived wagon slot count (never a fixed 53). Bookings spend against
* these via {@link needFor}, whose weight axis is gross.
*/
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<Capacity> {
const wagonTypes = await this.loadWagonTypeDimensions();
const derived = deriveTrainCapacityFromLocomotive(
{
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
},
wagonTypes,
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
);
return {
wagons: derived.maxWagonSlots,
weightTons: derived.maxWeightTons,
lengthMeters: derived.maxLengthMeters,
};
}
/** Keep schedule.max_wagons aligned with locomotive physical limits. */
private async syncScheduleMaxWagons(
schedule: TrainSchedule,
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
): Promise<void> {
const limits = await this.capacityLimits(locomotive, rules);
if ((schedule.maxWagons ?? 0) !== limits.wagons) {
await this.dataSource
.getRepository(TrainSchedule)
.update(schedule.id, { maxWagons: limits.wagons });
schedule.maxWagons = limits.wagons;
}
}
/**
* Every active wagon type, so the slot count is derived from the shortest wagon
* the fleet can actually marshal rather than from an arbitrary two-code sample.
*/
private async loadWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
const types = await this.dataSource
.getRepository(WagonType)
.find({ where: { isActive: true } });
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
capacityTons: 70,
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
{
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
capacityTons: 60,
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
},
];
}
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
private async loadWagonDims(): Promise<WagonDims> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
const nw5 = byCode.get("NW5");
const cw3 = byCode.get("CW3");
// capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload
// must fall back rather than yield an infinite wagon count.
const payload = (value: number | undefined, fallback: number): number =>
value && value > 0 ? value : fallback;
return {
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS),
},
bulk: {
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS),
},
};
}
private async loadGlobalRules(): Promise<TrainSchedulingGlobalRules | null> {
return this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.findOne({ where: {} });
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.
*/
private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
let milestoneYards: string[] | null = null;
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
}
return stopYardsFor(
milestoneYards,
schedule.originStationId,
schedule.destinationStationId,
);
}
/**
* Remaining capacity per corridor edge = hard caps minus what allocated +
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
* Dire→Djibouti leaves the Addis→Dire edges untouched.
*/
private async remainingBudget(
schedule: TrainSchedule,
limits: Capacity,
wagonDims: WagonDims,
): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits);
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/**
* Wagon slots still boardable somewhere on the corridor (most-open edge).
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonDims = await this.loadWagonDims();
const budget = await this.remainingBudget(
schedule,
{
wagons: schedule.maxWagons ?? 0,
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
wagonDims,
);
return budget.maxRemaining().wagons;
}
async setWindow(
scheduleId: string,
status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> {
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
// Push the change (open / train full / closed) so portal home and GL cards
// flip in real time — FULL in particular happens outside the window tick
// (batch fill, staff mark-paid) and had no live signal before.
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
if (fresh) this.bookingWindowGateway.emitPhase(fresh);
} catch (err) {
this.logger.warn(
`Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/**
* A reservation on this schedule still has time left to pay.
*
* The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
* is stamped when the phase starts, then `reserve()` gives each booking
* `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So
* the first settle after the phase deadline finds every reservation still in date,
* expires nothing, reports `anySettled = false`, runs no top-up — and the caller
* concludes the cycle out from under customers who still had time to pay. The next
* tick then expires them with no cycle left to promote the waiting list into.
*
* Callers must not conclude the cycle while this returns true.
*/
async hasLiveReservations(scheduleId: string): Promise<boolean> {
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
return reserved.some(
(b) =>
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() > now,
);
}
/** No wagon slots left for allocated + reserved bookings. */
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return false;
return (await this.remainingWagons(schedule)) <= 0;
}
/**
* Re-derive `bookingWindowStatus` from live capacity after wagons were freed
* (a reservation expired, a booking was displaced, a link was removed).
*
* FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
* it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
* a train that filled once and then lost every booking to expiry stayed FULL
* with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
* forever while `concludeCycle` (which reads real capacity, not the flag) kept
* reopening it. Clearing FULL here is what lets the next batch actually run.
*
* Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
* DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
*/
async refreshWindowStatus(scheduleId: string): Promise<void> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return;
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
this.logger.log(
`[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
`${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
);
}
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */
private async paymentWindowMs(): Promise<number> {
const cfg = await this.trainSchedulingService.getWindowConfig();
return cfg.paymentWindowMinutes * 60_000;
}
private timeoutName(scheduleId: string): string {
return `settle:${scheduleId}`;
}
/**
* In-process accelerator only — the durable settle enforcement is the window
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
*/
private armSettle(scheduleId: string): void {
void this.paymentWindowMs()
.then((delayMs) => {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
);
}, delayMs);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
})
.catch((err) =>
this.logger.warn(
`armSettle ${scheduleId} skipped: ${(err as Error).message}`,
),
);
}
/**
* A top-up reservation (settle freed capacity mid-cycle, so the next waiting
* booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
* `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
* concludeCycle — was frozen when the phase started. Without this, concludeCycle
* fires before the top-up customer's deadline and expires a booking that still
* had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
* window from now, but never past departure. Only while the schedule is still
* in the PAYMENT phase (a reopened cycle manages its own phase).
*/
async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
const windowMs = await this.paymentWindowMs();
let target = new Date(Date.now() + windowMs);
if (
schedule.scheduledDepartureDate &&
target > schedule.scheduledDepartureDate
) {
target = schedule.scheduledDepartureDate;
}
// Only ever push the deadline OUT, never pull it in.
if (
schedule.paymentPhaseEndsAt &&
schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
) {
return;
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { paymentPhaseEndsAt: target });
this.logger.log(
`[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
`(top-up reservation opened a fresh pay window)`,
);
}
private removeTimeout(scheduleId: string): void {
const name = this.timeoutName(scheduleId);
try {
if (this.scheduler.doesExist("timeout", name)) {
this.scheduler.deleteTimeout(name);
}
} catch {
// ignore — not armed
}
}
}