mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- planned couples: loose wagons join the train at a route stop, added from the schedule yards tab; capacity credits them per corridor edge and coupling validates locomotive weight/length caps per leg - real-cut toggle: a cut wagon permanently leaves the train build at its cut yard (soft cut still sits out one trip only) - fix heaviest-leg display counting a shared slot's full cargo on every spanned edge (phantom pull-weight overload on S-2026-00045) - confirmation dialogs for workspace add/load/unload/remove actions - train-builder History and Detached-wagons tabs, backed by paginated endpoints; builder detaches now always write adjustment-log rows Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and 3670 (adjustment log train_schedule_id nullable) — both applied to the dev DB by hand; watch mode does not run migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5826 lines
234 KiB
TypeScript
5826 lines
234 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
forwardRef,
|
||
Inject,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
OnModuleInit,
|
||
Optional,
|
||
} from '@nestjs/common';
|
||
import { InjectDataSource } from '@nestjs/typeorm';
|
||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||
import {
|
||
Between,
|
||
DataSource,
|
||
FindOptionsWhere,
|
||
ILike,
|
||
In,
|
||
IsNull,
|
||
LessThanOrEqual,
|
||
MoreThanOrEqual,
|
||
} from 'typeorm';
|
||
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||
import { isRoadService } from '../bookings/road.util';
|
||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||
import { ContainerType } from '../rule-engine/entities/container-type.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 { BookingNotifierService } from './booking-notifier.service';
|
||
import {
|
||
TrainSchedulingService,
|
||
effectiveWindowConfig,
|
||
} from './services/train-scheduling.service';
|
||
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||
import {
|
||
BATCH_BOARD_STATUSES,
|
||
BatchBoardQueryDto,
|
||
} from './dto/batch-board-query.dto';
|
||
import {
|
||
Freight,
|
||
PaginatedResponse,
|
||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||
} from "@edr/types";
|
||
import { BillingService } from "../billing/billing.service";
|
||
import {
|
||
buildPaginationMeta,
|
||
normalizePagination,
|
||
} from '../../common/utils/pagination.util';
|
||
|
||
|
||
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,
|
||
PAYMENT_REMINDER_LEAD_MS,
|
||
payWindowLapsed,
|
||
paymentDrainMs,
|
||
} from "./booking-batch.constants";
|
||
import {
|
||
LocomotiveLimits,
|
||
WagonTypeDimensions,
|
||
bookingCargoTons,
|
||
bulkItemsFitFor,
|
||
bulkItemWagonsRequired,
|
||
bulkTonsPerWagon,
|
||
bookingGrossWeightTons,
|
||
deriveTrainCapacityFromLocomotive,
|
||
sizePartialOfferWagons,
|
||
trainHardCaps,
|
||
trainSetLocomotiveLimits,
|
||
wagonTypeDimensionsFromEntity,
|
||
} from './train-capacity.util';
|
||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||
import { BookingSplitService } from './booking-split.service';
|
||
import { RemainderPlacementService } from './remainder-placement.service';
|
||
import { BookingWindowGateway } from './booking-window.gateway';
|
||
import {
|
||
MAX_TEU_SLOTS_PER_WAGON,
|
||
containerWagonsForLines,
|
||
roundTons,
|
||
} from './utils/wagon-plan.util';
|
||
import {
|
||
Capacity,
|
||
CorridorBudget,
|
||
CorridorLeg,
|
||
OverageTolerance,
|
||
addCoupledWagons,
|
||
stopYardsFor,
|
||
subtractCutWagons,
|
||
} from './corridor-capacity.util';
|
||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||
|
||
export type { Capacity } from './corridor-capacity.util';
|
||
|
||
/**
|
||
* A train's fill limits: the base caps the corridor budget spends from, plus
|
||
* the locomotive overage tolerance spendable only on whole-booking admission.
|
||
*/
|
||
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
|
||
|
||
/**
|
||
* Result of the export whole-booking single-train space check. `scheduleId`
|
||
* is the earliest fillable train that carries the whole booking, or null when
|
||
* none can — then `bestAvailable` reports the largest single-train leftover
|
||
* in the booking's own units and `fullMessage` is the customer-facing copy.
|
||
*/
|
||
export interface ExportSpaceReport {
|
||
scheduleId: string | null;
|
||
trainsForDay: boolean;
|
||
corridorMatched: boolean;
|
||
need: Capacity;
|
||
bestAvailable: { wagons: number; cargoTons: number } | null;
|
||
fullMessage: string | null;
|
||
}
|
||
|
||
/**
|
||
* One export train the customer can pick for a shipment day: live free-wagon
|
||
* space measured against THE BOOKING'S allowed wagon types (so the per-type
|
||
* list doubles as "what cargo this train can take for you"). Unpaid holds
|
||
* count as taken; lapsed holds free up via the lazy-expiry capacity filter.
|
||
*/
|
||
export interface ExportTrainOption {
|
||
scheduleId: string;
|
||
/** Schedule's train number (falls back to the built train's number). */
|
||
trainNumber: string | null;
|
||
/** Built train's name/code, when the schedule runs a Train Builder train. */
|
||
trainName: string | null;
|
||
departure: Date;
|
||
/** Booking cutoff for this train (windowClosesAt), null on legacy rows. */
|
||
bookingClosesAt: Date | null;
|
||
/** Whether the export FCFS window is open for booking right now. */
|
||
isOpen: boolean;
|
||
/** Best bookable wagons across the booking's allowed types. */
|
||
freeWagons: number;
|
||
/** Wagons this booking needs — `fits` = freeWagons >= neededWagons. */
|
||
neededWagons: number;
|
||
fits: boolean;
|
||
byWagonType: Array<{
|
||
wagonTypeId: string | null;
|
||
code: string | null;
|
||
name: string | null;
|
||
freeWagons: number;
|
||
}>;
|
||
}
|
||
|
||
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
|
||
export interface TrainOptionCargoOverrides {
|
||
/** Container types drive the per-type space. */
|
||
containerTypeIds?: string[];
|
||
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
||
containerSizes?: string[];
|
||
/** Bulk counterparts of the container inputs. */
|
||
cargoTypeId?: string;
|
||
cargoTypeCode?: string;
|
||
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
||
wagons?: number;
|
||
}
|
||
|
||
/** A train a paid-unallocated booking can board (route + capacity verified). */
|
||
export interface AllocationCandidate {
|
||
id: string;
|
||
reference: string | null;
|
||
direction: string | null;
|
||
scheduledDepartureDate: Date;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** One wagon type's footprint: its length on the train, the tare it adds to the
|
||
* locomotive's gross load, and the payload it carries. */
|
||
type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number };
|
||
|
||
/**
|
||
* Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds
|
||
* every wagon type so a booking is measured on the type its cargo/container type
|
||
* actually rides (the same FK resolution allocation uses); `container`/`bulk` are
|
||
* representative fallbacks for bookings whose type has no wagon type configured.
|
||
*/
|
||
type WagonDims = {
|
||
container: PerWagonDims;
|
||
bulk: PerWagonDims;
|
||
byWagonTypeId: Map<string, PerWagonDims>;
|
||
};
|
||
|
||
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 {
|
||
/**
|
||
* 0-based booking-window cycle this booking entered the pool in (derived from
|
||
* `fullyExecutedAt` against the schedule's window cycles). Ranking compares
|
||
* bookings within a cycle only — an earlier cycle always boards before a later
|
||
* one regardless of score. Null while the contract is still pending.
|
||
*/
|
||
windowCycleNo: number | null;
|
||
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 BatchBoardCounts {
|
||
allocated: number;
|
||
selectedForBatch: number;
|
||
ready: number;
|
||
waiting: number;
|
||
expired: number;
|
||
pendingContract: number;
|
||
}
|
||
|
||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||
export interface BatchBoardBucket {
|
||
counts: BatchBoardCounts;
|
||
bookings: BatchBoardBookingDetail[];
|
||
}
|
||
|
||
export interface BatchBoardScheduleDetail {
|
||
scheduleId: string;
|
||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||
scheduleReference: string | null;
|
||
trainNumber: string | null;
|
||
routeName: string | null;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
scheduleDate: string | null;
|
||
status: string;
|
||
bookingWindowStatus: string;
|
||
direction: string | null;
|
||
windowPhase: string | null;
|
||
windowOpensAt: string | null;
|
||
windowClosesAt: string | null;
|
||
docReviewEndsAt: string | null;
|
||
paymentPhaseEndsAt: string | null;
|
||
bookingCycleNo: number;
|
||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||
train: BatchBoardSchedule["train"];
|
||
locomotive: BatchBoardSchedule["locomotive"];
|
||
capacity: BatchBoardSchedule["capacity"];
|
||
counts: BatchBoardSchedule["counts"];
|
||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||
bookings: BatchBoardBookingDetail[];
|
||
pendingContract: BatchBoardBucket;
|
||
allocationViolations: string[];
|
||
}
|
||
|
||
export interface BatchBoardSchedule {
|
||
scheduleId: string;
|
||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||
scheduleReference: string | null;
|
||
trainNumber: string | null;
|
||
routeName: string | null;
|
||
origin: string | null;
|
||
destination: string | null;
|
||
scheduleDate: string | null;
|
||
createdAt: string | null;
|
||
status: string;
|
||
bookingWindowStatus: string;
|
||
direction: string | null;
|
||
windowPhase: string | null;
|
||
windowOpensAt: string | null;
|
||
windowClosesAt: string | null;
|
||
docReviewEndsAt: string | null;
|
||
paymentPhaseEndsAt: string | null;
|
||
bookingCycleNo: number;
|
||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||
train: {
|
||
id: string;
|
||
code: string;
|
||
trainName: string | null;
|
||
} | null;
|
||
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). On a
|
||
* multi-stop corridor this is the HEAVIEST single edge, not the sum —
|
||
* disjoint legs (intercity + export) never ride together, so summing
|
||
* them over-reports the train against the pull limit.
|
||
*/
|
||
usedWeightTons: number;
|
||
maxWeightTons: number | null;
|
||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||
maxWagons: number | null;
|
||
/** Physical consist length of the built train (Train Builder), null without one. */
|
||
trainLengthMeters: number | null;
|
||
/** Committed gross weight per corridor edge, in stop order; null on 2-stop routes. */
|
||
legUsage: Array<{ from: string; to: string; usedWeightTons: number }> | null;
|
||
};
|
||
counts: {
|
||
allocated: number;
|
||
selectedForBatch: number;
|
||
ready: number;
|
||
waiting: number;
|
||
pendingContract: number;
|
||
expired: number;
|
||
};
|
||
bookings: BatchBoardBooking[];
|
||
}
|
||
|
||
/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
|
||
* response wrapper already uses `data`, and the frontend's unwrap() strips one
|
||
* `data` level. */
|
||
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
|
||
|
||
/**
|
||
* 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,
|
||
// forwardRef: TrainSchedulingService injects this service back (window
|
||
// refresh after adjust-consist), so the classes load in a cycle.
|
||
@Inject(forwardRef(() => TrainSchedulingService))
|
||
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,
|
||
// Optional so hand-constructed spec instances keep compiling.
|
||
@Optional() private readonly eventEmitter?: EventEmitter2,
|
||
@Optional()
|
||
@Inject(forwardRef(() => RemainderPlacementService))
|
||
private readonly remainderPlacement?: RemainderPlacementService,
|
||
|
||
) {}
|
||
|
||
/**
|
||
* Auto-place a paid booking's split remainder onto the next fitting train.
|
||
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
|
||
*/
|
||
private get autoRemainderEnabled(): boolean {
|
||
return process.env.FREIGHT_AUTO_REMAINDER === "true";
|
||
}
|
||
|
||
/**
|
||
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
|
||
* on the next train). Separate flag from auto-remainder: export touches the
|
||
* FCFS money path, so partial-offer can be enabled independently.
|
||
*/
|
||
private get exportSplitEnabled(): boolean {
|
||
return process.env.FREIGHT_EXPORT_SPLIT === "true";
|
||
}
|
||
|
||
/** 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', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
|
||
)
|
||
.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,
|
||
);
|
||
// Backstop: PAID bookings stranded without a schedule (hold expired before
|
||
// the payment landed) get re-placed onto whatever fits today.
|
||
await this.rescueStrandedPaidForDay(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) return;
|
||
if (!booking.trainScheduleId) {
|
||
// A paid booking with no train is money taken and nothing boarding. The
|
||
// hold was expired before the payment landed (webhook lag beat the
|
||
// reconcile, or the stranding predates it) — try to re-place it on a
|
||
// fitting same-day train before falling back to a manual-assign scream.
|
||
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
||
const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking);
|
||
if (!rescuedScheduleId) {
|
||
this.logger.error(
|
||
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
||
`its reservation was likely expired before the payment landed and no ` +
|
||
`same-day train fits it. Assign it to a schedule manually from the batch board.`,
|
||
);
|
||
return;
|
||
}
|
||
booking.trainScheduleId = rescuedScheduleId;
|
||
} else {
|
||
return;
|
||
}
|
||
}
|
||
if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS
|
||
|
||
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);
|
||
|
||
// The split only happens on payment (here) — so auto-placing the remainder
|
||
// also only happens once the customer has accepted+paid. Re-read to see if
|
||
// applySplit actually reduced this booking (an open offer existed); if so,
|
||
// auto-create + place the remainder booking on the next fitting train.
|
||
// applySplit committed its own transaction before returning, so this reads
|
||
// the reduced lines. Best-effort: a placement failure never blocks the
|
||
// paid booking from boarding — the remainder falls back to manual rebook.
|
||
if (this.autoRemainderEnabled && this.remainderPlacement) {
|
||
const split = await this.dataSource
|
||
.getRepository(Booking)
|
||
.findOne({ where: { id: bookingId } });
|
||
// Export remainders only auto-place when export split is on — otherwise
|
||
// an export booking never splits in the first place.
|
||
const directionOn =
|
||
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
|
||
if (split?.isSplit && directionOn) {
|
||
await this.remainderPlacement
|
||
.placeRemainder(split)
|
||
.catch((err) =>
|
||
this.logger.error(
|
||
`Auto-place remainder failed for ${split.reference}: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const linked =
|
||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||
// Intercity is allocated MANUALLY: payment secures the ride, staff then
|
||
// place it on whichever same-route train suits (intercity panel). Unpin
|
||
// from the train it reserved against — that train may be the wrong one by
|
||
// the time it departs — and return it to the waiting pool as PAID.
|
||
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
|
||
await this.dataSource.getRepository(Booking).update(bookingId, {
|
||
trainScheduleId: null,
|
||
schedulingStatus: "ELIGIBLE",
|
||
paymentDeadline: null,
|
||
} as never);
|
||
this.logger.log(
|
||
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
|
||
);
|
||
void this.completeTrackingMilestones(bookingId, [
|
||
"FREIGHT_PAYMENT_PENDING",
|
||
"FREIGHT_PAYMENT_SETTLED",
|
||
]);
|
||
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
|
||
return;
|
||
}
|
||
if (!linked) {
|
||
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
||
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.isTrainFull(schedule))) {
|
||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||
// This payment may have been the last live pay window on a now-full
|
||
// export day — the settle that normally re-runs the sweep finds nothing
|
||
// left to settle, so trigger it here.
|
||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||
}
|
||
|
||
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}`,
|
||
);
|
||
}
|
||
this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
|
||
}
|
||
|
||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||
async confirmPaidAndAllocate(bookingId: string): Promise<void> {
|
||
await this.ensurePaidBookingAllocated(bookingId);
|
||
}
|
||
|
||
/**
|
||
* A late settlement paid a partial offer whose window had lapsed — bring the
|
||
* offer back so `ensurePaidBookingAllocated`'s applySplit still reduces the
|
||
* booking to what was actually bought. No-op without the split feature.
|
||
*/
|
||
async reviveOfferForInvoice(invoiceId: string): Promise<void> {
|
||
await this.splitService?.reviveOfferForInvoice(invoiceId);
|
||
}
|
||
|
||
/**
|
||
* Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
|
||
* keyed on train_schedule_id, so a booking whose hold was expired (schedule
|
||
* cleared) before its payment landed never re-enters it. Sweep the day's
|
||
* PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which
|
||
* re-places them on a fitting train.
|
||
*/
|
||
private async rescueStrandedPaidForDay(day: string): Promise<void> {
|
||
const stranded: Array<{ id: string }> = await this.dataSource.query(
|
||
`SELECT id FROM freight.bookings
|
||
WHERE deleted_at IS NULL
|
||
AND train_schedule_id IS NULL
|
||
AND (payment_status = 'PAID' OR status = 'PAID')
|
||
AND scheduled_date IS NOT NULL
|
||
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
|
||
[day],
|
||
);
|
||
for (const { id } of stranded) {
|
||
await this.ensurePaidBookingAllocated(id).catch((err) =>
|
||
this.logger.error(
|
||
`Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-place a PAID booking whose hold was expired before the payment landed
|
||
* (trainScheduleId already cleared). Picks the earliest same-day train that
|
||
* still fits the booking's whole need on ITS OWN leg and pins the booking to
|
||
* it. Returns the schedule id, or null when no train fits (manual assign).
|
||
*/
|
||
private async replaceStrandedPaidBooking(
|
||
booking: Booking,
|
||
): Promise<string | null> {
|
||
if (!booking.scheduledDate) return null;
|
||
// The booking loaded by ensurePaidBookingAllocated carries no cargo
|
||
// relations; needFor/fittingTrainsForDay derive the wagon need from them.
|
||
const full = await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: booking.id },
|
||
relations: {
|
||
bookingContainers: { containerType: true },
|
||
cargoType: true,
|
||
},
|
||
});
|
||
if (!full) return null;
|
||
const day = eatDay(new Date(booking.scheduledDate));
|
||
const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
|
||
const wagonDims = await this.loadWagonDims();
|
||
const need = this.needFor(full, wagonDims);
|
||
const fitting = await this.fittingTrainsForDay(full, day, direction);
|
||
const target = fitting.find((t) => t.freeWagons >= need.wagons);
|
||
if (!target) return null;
|
||
await this.dataSource
|
||
.getRepository(Booking)
|
||
.update(booking.id, { trainScheduleId: target.scheduleId });
|
||
this.logger.warn(
|
||
`[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` +
|
||
`onto schedule ${target.scheduleId} — its hold expired before the payment landed`,
|
||
);
|
||
return target.scheduleId;
|
||
}
|
||
|
||
/** 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 -----------------------------------------------------------
|
||
|
||
/**
|
||
* Whole-booking single-train space report for an EXPORT booking. Export
|
||
* bookings never split — the entire booking must ride ONE train, so the
|
||
* report scans every fillable export train on the booking's corridor/day
|
||
* (earliest first) for one whose remaining budget fits the whole need. When
|
||
* none fits, `bestAvailable` carries the largest single-train leftover
|
||
* converted into the booking's own units (base caps, no overage tolerance)
|
||
* so the customer can be told exactly how much he COULD book on that day.
|
||
*/
|
||
async exportSpaceReport(
|
||
booking: Booking,
|
||
need?: Capacity,
|
||
): Promise<ExportSpaceReport> {
|
||
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: [
|
||
// Dedicated shipping-line trains are never customer-booking targets.
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||
],
|
||
});
|
||
// A customer-picked train narrows the scan to that ONE schedule: export
|
||
// FCFS honors the pick or fails loudly (exportFullMessage names it).
|
||
const requestedId = booking.requestedTrainScheduleId ?? null;
|
||
const candidates = corridor
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day &&
|
||
this.isFillable(s) &&
|
||
(!requestedId || s.id === requestedId),
|
||
)
|
||
.sort(
|
||
(a, b) =>
|
||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||
);
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
const required = need ?? this.needFor(booking, wagonDims);
|
||
const dims = this.dimsFor(booking, wagonDims);
|
||
const report: ExportSpaceReport = {
|
||
scheduleId: null,
|
||
trainsForDay: candidates.length > 0,
|
||
corridorMatched: false,
|
||
need: required,
|
||
bestAvailable: null,
|
||
fullMessage: null,
|
||
};
|
||
|
||
for (const candidate of candidates) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
candidate.id,
|
||
);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) continue;
|
||
const limits = await this.capacityLimits(locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims, [
|
||
booking.id,
|
||
]);
|
||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||
report.corridorMatched = true;
|
||
if (budget.fits(required, leg)) {
|
||
// Earliest fitting train wins — no need to keep sizing leftovers.
|
||
report.scheduleId = schedule.id;
|
||
return report;
|
||
}
|
||
const available = this.bookableWithin(budget.remainingFor(leg), dims);
|
||
if (
|
||
!report.bestAvailable ||
|
||
available.cargoTons > report.bestAvailable.cargoTons ||
|
||
(available.cargoTons === report.bestAvailable.cargoTons &&
|
||
available.wagons > report.bestAvailable.wagons)
|
||
) {
|
||
report.bestAvailable = available;
|
||
}
|
||
}
|
||
|
||
report.fullMessage = this.exportFullMessage(booking, report);
|
||
return report;
|
||
}
|
||
|
||
/**
|
||
* Largest booking (in the requester's own wagon-type units) that a single
|
||
* train's leftover base capacity could still admit: bounded by free wagon
|
||
* slots, free train length, and the locomotive's remaining pull weight
|
||
* (gross — each wagon's tare eats into it before any cargo does).
|
||
*/
|
||
private bookableWithin(
|
||
remaining: Capacity,
|
||
dims: PerWagonDims,
|
||
): { wagons: number; cargoTons: number } {
|
||
const byLength =
|
||
dims.lengthMeters > 0
|
||
? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
|
||
: Math.floor(Math.max(0, remaining.wagons));
|
||
const maxWagons = Math.max(
|
||
0,
|
||
Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
|
||
);
|
||
let bestTons = 0;
|
||
let usableWagons = 0;
|
||
for (let w = 1; w <= maxWagons; w++) {
|
||
if (w * dims.tareWeightTons > remaining.weightTons) break;
|
||
usableWagons = w;
|
||
const tons = Math.min(
|
||
w * dims.capacityTons,
|
||
remaining.weightTons - w * dims.tareWeightTons,
|
||
);
|
||
if (tons > bestTons) bestTons = tons;
|
||
}
|
||
return {
|
||
wagons: usableWagons,
|
||
cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
|
||
};
|
||
}
|
||
|
||
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
||
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
||
const picked = Boolean(booking.requestedTrainScheduleId);
|
||
if (!report.trainsForDay || !report.corridorMatched) {
|
||
return picked
|
||
? 'The selected train is no longer accepting bookings — pick another train or day.'
|
||
: 'No export train is accepting bookings for this day';
|
||
}
|
||
const best = report.bestAvailable;
|
||
const base = picked
|
||
? 'Not enough space left on the selected train — an export booking must ' +
|
||
'ride one train whole. '
|
||
: 'Not enough train space — an export booking must ride a single train whole, ' +
|
||
'and no open train on this day can carry it. ';
|
||
if (!best || best.wagons <= 0) {
|
||
return base + 'No capacity is left on this day — pick another shipment day.';
|
||
}
|
||
if (booking.freightType === 'BULK') {
|
||
return (
|
||
base +
|
||
`The largest remaining space is about ${best.cargoTons} tons ` +
|
||
`(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
|
||
);
|
||
}
|
||
return (
|
||
base +
|
||
`The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
|
||
`(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
|
||
'reduce the booking or pick another day.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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> {
|
||
const report = await this.exportSpaceReport(booking, need);
|
||
if (report.scheduleId) return report.scheduleId;
|
||
throw new ConflictException(
|
||
report.fullMessage ?? 'Train is full — no export capacity left for this day',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Trains that can carry a booking's leg on a given day, earliest departure
|
||
* first, each with the largest number of wagons it could still admit for the
|
||
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
|
||
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
|
||
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
|
||
* non-primary allowed type still counts. The remainder placer uses this to
|
||
* pick the next fitting train; the `free` wagon count is the best across the
|
||
* allowed types (a train fits under whichever allowed type gives most room).
|
||
*/
|
||
async fittingTrainsForDay(
|
||
booking: Booking,
|
||
day: string,
|
||
direction: "IMPORT" | "EXPORT",
|
||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||
const corridor = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
// Dedicated shipping-line trains are never customer-booking targets.
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||
],
|
||
});
|
||
const candidates = corridor
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day &&
|
||
s.bookingWindowStatus !== "FULL" &&
|
||
(direction === "EXPORT"
|
||
? s.direction === "EXPORT"
|
||
: s.direction !== "EXPORT"),
|
||
)
|
||
.sort(
|
||
(a, b) =>
|
||
a.scheduledDepartureDate!.getTime() -
|
||
b.scheduledDepartureDate!.getTime(),
|
||
);
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
|
||
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
|
||
|
||
for (const candidate of candidates) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
candidate.id,
|
||
);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) continue;
|
||
const limits = await this.capacityLimits(locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims, [
|
||
booking.id,
|
||
]);
|
||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||
const room = budget.remainingFor(leg);
|
||
// Best usable wagons across the allowed types — a train fits under
|
||
// whichever configured wagon type gives it the most room.
|
||
let freeWagons = 0;
|
||
for (const dims of dimsOptions) {
|
||
const w = this.bookableWithin(room, dims).wagons;
|
||
if (w > freeWagons) freeWagons = w;
|
||
}
|
||
if (freeWagons > 0) {
|
||
out.push({
|
||
scheduleId: schedule.id,
|
||
departure: schedule.scheduledDepartureDate!,
|
||
freeWagons,
|
||
});
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The export train picker: every export train on the booking's corridor/day
|
||
* with its live space, measured per allowed wagon type so the customer sees
|
||
* what each train can still take for THEIR cargo. Includes full/not-yet-open
|
||
* trains (freeWagons 0 / isOpen false) so the UI can show them disabled —
|
||
* the request-time gate (exportSpaceReport) stays the enforcement point.
|
||
*/
|
||
async exportTrainOptionsForDay(
|
||
booking: Booking,
|
||
day: string,
|
||
overrides?: TrainOptionCargoOverrides,
|
||
): Promise<ExportTrainOption[]> {
|
||
booking = await this.withCargoOverrides(booking, overrides);
|
||
const corridor = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
// Dedicated shipping-line trains are never customer-booking targets.
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||
],
|
||
});
|
||
const candidates = corridor
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day &&
|
||
s.direction === 'EXPORT',
|
||
)
|
||
.sort(
|
||
(a, b) =>
|
||
a.scheduledDepartureDate!.getTime() -
|
||
b.scheduledDepartureDate!.getTime(),
|
||
);
|
||
return this.buildTrainOptions(booking, candidates);
|
||
}
|
||
|
||
/**
|
||
* The same per-train wagon-availability cards, but for the trains DEDICATED
|
||
* to a shipping line on the booking's lane + day. Same option shape as the
|
||
* export picker so the portal reuses the same component; `isOpen`
|
||
* additionally respects the dedicated close offset (windowClosesAt), since
|
||
* these trains run no window cycle.
|
||
*/
|
||
async dedicatedTrainOptionsForDay(
|
||
booking: Booking,
|
||
day: string | null,
|
||
shippingLineCompanyId: string,
|
||
overrides?: TrainOptionCargoOverrides,
|
||
): Promise<ExportTrainOption[]> {
|
||
booking = await this.withCargoOverrides(booking, overrides);
|
||
const dedicated = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
|
||
],
|
||
});
|
||
const candidates = dedicated
|
||
.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
// A day narrows to that departure day; without one, every upcoming
|
||
// departure on the lane is listed (the picker's full card list).
|
||
(day
|
||
? eatDay(s.scheduledDepartureDate) === day
|
||
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
|
||
(!booking.originYardId || s.originStationId === booking.originYardId) &&
|
||
(!booking.destinationYardId ||
|
||
s.destinationStationId === booking.destinationYardId),
|
||
)
|
||
.sort(
|
||
(a, b) =>
|
||
a.scheduledDepartureDate!.getTime() -
|
||
b.scheduledDepartureDate!.getTime(),
|
||
);
|
||
const options = await this.buildTrainOptions(booking, candidates);
|
||
const now = Date.now();
|
||
return options.map((o) => ({
|
||
...o,
|
||
isOpen:
|
||
o.isOpen &&
|
||
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
|
||
}));
|
||
}
|
||
|
||
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
|
||
private async withCargoOverrides(
|
||
booking: Booking,
|
||
overrides?: TrainOptionCargoOverrides,
|
||
): Promise<Booking> {
|
||
const sizeFts = (overrides?.containerSizes ?? [])
|
||
.map((s) => parseInt(s, 10))
|
||
.filter((n) => Number.isFinite(n) && n > 0);
|
||
if (overrides?.containerTypeIds?.length || sizeFts.length) {
|
||
const types = await this.dataSource.getRepository(ContainerType).find({
|
||
where: overrides?.containerTypeIds?.length
|
||
? { id: In(overrides.containerTypeIds) }
|
||
: { sizeFt: In(sizeFts) },
|
||
relations: { wagonTypes: true },
|
||
});
|
||
booking = {
|
||
...booking,
|
||
freightType: "CONTAINER",
|
||
bookingContainers: types.map((ct) => ({ containerType: ct })),
|
||
} as Booking;
|
||
} else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) {
|
||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||
where: overrides.cargoTypeId
|
||
? { id: overrides.cargoTypeId }
|
||
: { code: overrides.cargoTypeCode },
|
||
relations: { wagonTypes: true },
|
||
});
|
||
booking = {
|
||
...booking,
|
||
freightType: "BULK",
|
||
cargoType: cargoType ?? undefined,
|
||
} as Booking;
|
||
}
|
||
if (overrides?.wagons && overrides.wagons > 0) {
|
||
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
||
}
|
||
return booking;
|
||
}
|
||
|
||
/** One availability card per candidate schedule — the export picker's math. */
|
||
private async buildTrainOptions(
|
||
booking: Booking,
|
||
candidates: TrainSchedule[],
|
||
): Promise<ExportTrainOption[]> {
|
||
const wagonDims = await this.loadWagonDims();
|
||
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
|
||
const neededWagons = this.wagonsFor(booking, wagonDims);
|
||
const typeIds = allowed
|
||
.map((a) => a.wagonTypeId)
|
||
.filter((id): id is string => Boolean(id));
|
||
const types = typeIds.length
|
||
? await this.dataSource
|
||
.getRepository(WagonType)
|
||
.find({ where: { id: In(typeIds) } })
|
||
: [];
|
||
const typeById = new Map(types.map((t) => [t.id, t]));
|
||
|
||
const out: ExportTrainOption[] = [];
|
||
for (const candidate of candidates) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
candidate.id,
|
||
);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) continue;
|
||
const limits = await this.capacityLimits(locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims, [
|
||
booking.id,
|
||
]);
|
||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||
const room = budget.remainingFor(leg);
|
||
// The abstract budget can't tell wagon types apart — cap each type's free
|
||
// count with the PHYSICAL wagons of that type the train (or yard pool)
|
||
// actually holds on this leg, and on a built train hide types the consist
|
||
// doesn't carry at all. Otherwise a 47×NW5 train advertised "PW2: 47 free".
|
||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||
schedule.id,
|
||
schedule.originStationId,
|
||
budget.stops,
|
||
);
|
||
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
|
||
// On a multi-yard consist the pool that matters is the one standing at
|
||
// the booking's own boarding yard — a type carried only in Mojo must not
|
||
// be advertised to a customer boarding at Dire.
|
||
const carriedAtBoardYard = (wagonTypeId: string): number => {
|
||
const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null;
|
||
if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0;
|
||
return stock.remainingByTypeId.get(wagonTypeId) ?? 0;
|
||
};
|
||
const byWagonType = allowed
|
||
.filter(
|
||
({ wagonTypeId }) =>
|
||
stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0,
|
||
)
|
||
.map(({ wagonTypeId, dims }) => {
|
||
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
|
||
const roomWagons = this.bookableWithin(room, dims).wagons;
|
||
const physical = wagonTypeId
|
||
? ledger.availableFor([wagonTypeId], leg)
|
||
: roomWagons;
|
||
return {
|
||
wagonTypeId,
|
||
code: type?.code ?? null,
|
||
name: type?.name ?? null,
|
||
freeWagons: Math.min(roomWagons, physical),
|
||
};
|
||
});
|
||
const freeWagons = byWagonType.reduce(
|
||
(best, t) => Math.max(best, t.freeWagons),
|
||
0,
|
||
);
|
||
const builtTrain = schedule.trainSet?.train;
|
||
out.push({
|
||
scheduleId: schedule.id,
|
||
trainNumber:
|
||
schedule.trainNumber ??
|
||
builtTrain?.exportTrainNumber ??
|
||
builtTrain?.trainNumber ??
|
||
null,
|
||
trainName: builtTrain?.trainName ?? builtTrain?.code ?? null,
|
||
departure: schedule.scheduledDepartureDate!,
|
||
bookingClosesAt: schedule.windowClosesAt ?? null,
|
||
isOpen: this.isFillable(schedule),
|
||
freeWagons,
|
||
neededWagons,
|
||
fits: freeWagons >= neededWagons,
|
||
byWagonType,
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||
* summed across every train on the booking's corridor that day. Unlike the
|
||
* export gate this does NOT block and does NOT first-fit a single train:
|
||
* import is batched and splittable, so the honest number a customer can plan
|
||
* against is the TOTAL room across the day's trains for the booking's wagon
|
||
* type, in that type's own wagon units.
|
||
*
|
||
* It deliberately skips the `isFillable` window-phase gate. A customer picks a
|
||
* shipment day while its window is still OPEN (or pre-window) — the batch fill
|
||
* only makes those trains fillable after the window closes — so gating on the
|
||
* fill phase here would report 0 for exactly the days customers are choosing.
|
||
* We therefore count any non-FULL train that carries the leg, netting out the
|
||
* capacity already consumed by allocated + live-reserved bookings
|
||
* (`remainingBudget`). The count is an upper bound: the batch engine may still
|
||
* split the booking across trains or defer a remainder to a later window.
|
||
*/
|
||
async dayImportAvailability(
|
||
booking: Booking,
|
||
day: string,
|
||
): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
|
||
const corridor = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
// Dedicated shipping-line trains are never customer-booking targets.
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||
],
|
||
});
|
||
const candidates = corridor.filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day &&
|
||
s.bookingWindowStatus !== 'FULL' &&
|
||
s.direction !== 'EXPORT',
|
||
);
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
const dims = this.dimsFor(booking, wagonDims);
|
||
const need = this.wagonsFor(booking, wagonDims);
|
||
let freeWagons = 0;
|
||
let trainsForDay = false;
|
||
|
||
for (const candidate of candidates) {
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
candidate.id,
|
||
);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) continue;
|
||
const limits = await this.capacityLimits(locomotive);
|
||
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
|
||
trainsForDay = true;
|
||
freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons;
|
||
}
|
||
|
||
return { freeWagons, need, trainsForDay };
|
||
}
|
||
|
||
/**
|
||
* Export split: no single train carries the whole booking, so offer the
|
||
* largest fitting part on the export train with the most room for its leg.
|
||
* Returns true when an offer was opened (the caller must NOT then reserve —
|
||
* the offer already opened its own pay window), false when the booking fits
|
||
* whole somewhere (normal FCFS path) or no meaningful partial exists.
|
||
*
|
||
* Only the offer is written here: the booking is reduced to the offered part
|
||
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
|
||
* unpaid export booking stays whole and the customer may still cancel it.
|
||
*/
|
||
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
|
||
if (!this.splitService) return false;
|
||
const report = await this.exportSpaceReport(booking);
|
||
// A train fits it whole — nothing to split, take the normal path.
|
||
if (report.scheduleId) return false;
|
||
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
|
||
|
||
if (!booking.scheduledDate) return false;
|
||
const day = eatDay(new Date(booking.scheduledDate));
|
||
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
|
||
if (!fitting.length) return false;
|
||
// Most room first — the largest single part ships now, the smallest leftover
|
||
// is what has to find another train.
|
||
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
|
||
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
target.scheduleId,
|
||
);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) return false;
|
||
const wagonDims = await this.loadWagonDims();
|
||
const limits = await this.capacityLimits(locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims, [
|
||
booking.id,
|
||
]);
|
||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||
if (!leg) return false;
|
||
|
||
const offered = await this.tryPartialOffer(
|
||
booking,
|
||
schedule.id,
|
||
budget.remainingFor(leg),
|
||
report.need,
|
||
);
|
||
if (!offered) return false;
|
||
this.logger.log(
|
||
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
|
||
`${schedule.id} — leftover rebooks on the next train once paid.`,
|
||
);
|
||
this.notifyBoardChanged(schedule.id, "batch_fill");
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
// Export split: when no single train carries the whole booking, offer the
|
||
// largest fitting part instead of failing the accept. The customer pays
|
||
// that part; on payment applySplit reduces this booking to it and the
|
||
// leftover is auto-placed as its own booking on the next train. Pairs are
|
||
// excluded (handled below) — a shared wagon is never split.
|
||
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
|
||
const offered = await this.tryExportPartialOffer(booking);
|
||
if (offered) return;
|
||
}
|
||
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: { containerType: true },
|
||
cargoType: 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> {
|
||
// H8: the capacity check (pickExportSchedule → budget.fits) and the
|
||
// reservation writes below are not atomic on their own — two concurrent
|
||
// export accepts can each see the same train as fitting and both reserve,
|
||
// overshooting the train's capacity. Serialize reservations against this
|
||
// schedule: take a pessimistic_write lock on the TrainSchedule row
|
||
// (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
|
||
// combined need from freshly-committed state INSIDE the lock before the
|
||
// reserve writes run. A loser (another accept took the space first) gets a
|
||
// ConflictException — the staff accept fails and reverts, exactly as an
|
||
// up-front full train does. Covered: the fits-vs-reserve overshoot on the
|
||
// export FCFS path; the lock is held for the duration of the reserve writes.
|
||
await this.dataSource.transaction(async (manager) => {
|
||
const locked = await manager.findOne(TrainSchedule, {
|
||
where: { id: scheduleId },
|
||
lock: { mode: "pessimistic_write" },
|
||
});
|
||
if (!locked) {
|
||
throw new ConflictException(
|
||
"Export train is no longer available for reservation",
|
||
);
|
||
}
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) {
|
||
throw new ConflictException(
|
||
"Export train is no longer available for reservation",
|
||
);
|
||
}
|
||
const wagonDims = await this.loadWagonDims();
|
||
const limits = await this.capacityLimits(locomotive);
|
||
const budget = await this.remainingBudget(
|
||
schedule,
|
||
limits,
|
||
wagonDims,
|
||
bookings.map((b) => b.id),
|
||
);
|
||
const leg = budget.legOf(
|
||
bookings[0].originYardId,
|
||
bookings[0].destinationYardId,
|
||
);
|
||
const need =
|
||
bookings.length >= 2
|
||
? this.combinedNeed(bookings[0], bookings[1], wagonDims)
|
||
: this.needFor(bookings[0], wagonDims);
|
||
if (!leg || !budget.fits(need, leg)) {
|
||
throw new ConflictException(
|
||
"Train is full — no export capacity left for this day",
|
||
);
|
||
}
|
||
// Physical wagon gate — a pay window must never open for wagons that do
|
||
// not exist in a type this cargo can ride. PER_TON bulk is seated
|
||
// type-by-type at its per-wagon caps (the count allocation will really
|
||
// need); everything else checks the summed free stock of its types.
|
||
const stock = await this.stockLedgerFor(
|
||
schedule,
|
||
budget,
|
||
bookings.map((b) => b.id),
|
||
);
|
||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||
const primary = bookings[0];
|
||
const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes);
|
||
const perItemBulk =
|
||
Number(primary.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(primary.cargoTotalWeightVgm ?? 0) > 0;
|
||
const useSmart =
|
||
bookings.length === 1 &&
|
||
primary.freightType === "BULK" &&
|
||
!perItemBulk &&
|
||
wagonTypeIds.length > 0;
|
||
const smart = useSmart
|
||
? this.smartBulkNeed(
|
||
primary,
|
||
wagonDims,
|
||
stock,
|
||
leg,
|
||
this.scarcityRankForPool([primary], allowedWagonTypes),
|
||
wagonTypeIds,
|
||
)
|
||
: null;
|
||
const seated = useSmart
|
||
? smart != null && budget.fits(smart.need, leg)
|
||
: this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||
if (!seated) {
|
||
throw new ConflictException(
|
||
"Train has no free wagons of a type this cargo can ride — payment was not opened",
|
||
);
|
||
}
|
||
|
||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||
});
|
||
this.armSettle(scheduleId);
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (schedule && (await this.isTrainFull(schedule))) {
|
||
await this.setWindow(scheduleId, 'FULL');
|
||
}
|
||
this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
|
||
}
|
||
|
||
/** 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) {
|
||
// Held on purpose (paid, no wagon free) — the cron must not undo it.
|
||
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
|
||
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
|
||
await this.allocate(scheduleId, booking, "paid");
|
||
this.logger.log(
|
||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||
);
|
||
}
|
||
if (unlinked.length > 0) {
|
||
this.notifyBoardChanged(scheduleId, "paid_reconciled");
|
||
}
|
||
}
|
||
|
||
// ---- 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 import schedule — including
|
||
* dispatched, arrived and cancelled history — with its locomotive, capacity
|
||
* usage and its bookings grouped by lifecycle state (allocated / awaiting
|
||
* payment / paid-waiting / pending contract / expired). Paginated and
|
||
* filterable; per-schedule booking summaries are only computed for the
|
||
* requested page.
|
||
*/
|
||
async getBatchBoard(
|
||
query: BatchBoardQueryDto = {},
|
||
allowedDirections?: string[],
|
||
): Promise<BatchBoardListResponse> {
|
||
// Board cards are heavy (per-schedule booking summaries), so the default
|
||
// page is smaller than the toolkit-wide 20.
|
||
const { page, pageSize, skip, take } = normalizePagination(query, {
|
||
defaultPageSize: 12,
|
||
});
|
||
|
||
// The board is IMPORT-only — a user scoped away from IMPORT sees nothing.
|
||
if (allowedDirections && !allowedDirections.includes("IMPORT")) {
|
||
return { items: [], meta: buildPaginationMeta(0, page, pageSize) };
|
||
}
|
||
|
||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||
// arrived / cancelled / dispatched schedules stay visible as history.
|
||
const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
|
||
const statuses = (query.statuses ?? "")
|
||
.split(",")
|
||
.map((v) => v.trim().toUpperCase())
|
||
.filter((v) => allowedStatuses.has(v));
|
||
|
||
const dateRange = (from?: string, to?: string) => {
|
||
const f = from ? new Date(from) : null;
|
||
const t = to ? new Date(to) : null;
|
||
if (f && t) return Between(f, t);
|
||
if (f) return MoreThanOrEqual(f);
|
||
if (t) return LessThanOrEqual(t);
|
||
return undefined;
|
||
};
|
||
|
||
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
|
||
// and domestic/legacy schedules run the legacy fill, not the window batch.
|
||
const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
|
||
if (statuses.length) base.status = In(statuses) as never;
|
||
if (query.bookingWindowStatus) {
|
||
base.bookingWindowStatus = query.bookingWindowStatus;
|
||
}
|
||
const departure = dateRange(query.departureFrom, query.departureTo);
|
||
if (departure) base.scheduledDepartureDate = departure as never;
|
||
const created = dateRange(query.createdFrom, query.createdTo);
|
||
if (created) base.createdAt = created as never;
|
||
|
||
// Search fans out across every human-recognizable label. Each OR variant
|
||
// repeats the base filters so the search never widens them.
|
||
const term = query.search?.trim();
|
||
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
|
||
base;
|
||
if (term) {
|
||
const like = ILike(`%${term}%`);
|
||
where = [
|
||
{ ...base, trainNumber: like as never },
|
||
{ ...base, originStation: { label: like } },
|
||
{ ...base, destinationStation: { label: like } },
|
||
{ ...base, route: { originYard: { label: like } } },
|
||
{ ...base, route: { destinationYard: { label: like } } },
|
||
{ ...base, trainSet: { locomotive: { code: like } } },
|
||
] as FindOptionsWhere<TrainSchedule>[];
|
||
}
|
||
|
||
const sortBy = query.sortBy ?? "createdAt";
|
||
const sortOrder = query.sortOrder ?? "DESC";
|
||
|
||
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||
where,
|
||
relations: {
|
||
// locomotives (plural) too — the caps SUM the whole set's pull; the
|
||
// single legacy column alone under-reports a two-loco train by half.
|
||
trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
|
||
originStation: true,
|
||
destinationStation: true,
|
||
// Yards supply the route's display name for `routeName` below;
|
||
// milestones (with yards) give it the full corridor path.
|
||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||
},
|
||
order: { [sortBy]: sortOrder } as never,
|
||
skip,
|
||
take,
|
||
});
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||
|
||
// One links query + one bookings query for the whole page (was 2 per card).
|
||
const scheduleIds = schedules.map((s) => s.id);
|
||
const [allLinks, allBookings] = await Promise.all([
|
||
scheduleIds.length
|
||
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
|
||
: Promise.resolve([]),
|
||
this.bookingsRepository.findAllBySchedules(scheduleIds),
|
||
]);
|
||
const linkedIdsBySchedule = new Map<string, Set<string>>();
|
||
for (const l of allLinks) {
|
||
let set = linkedIdsBySchedule.get(l.trainScheduleId);
|
||
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
|
||
set.add(l.bookingId);
|
||
}
|
||
const bookingsBySchedule = new Map<string, Booking[]>();
|
||
for (const b of allBookings) {
|
||
if (!b.trainScheduleId) continue;
|
||
let list = bookingsBySchedule.get(b.trainScheduleId);
|
||
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
|
||
list.push(b);
|
||
}
|
||
|
||
const board: BatchBoardSchedule[] = [];
|
||
for (const s of schedules) {
|
||
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
|
||
const bookings = bookingsBySchedule.get(s.id) ?? [];
|
||
|
||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||
const need = this.needFor(b, wagonDims);
|
||
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,
|
||
new Map(
|
||
bookings.map((b) => [
|
||
b.id,
|
||
{
|
||
originYardId: b.originYardId ?? null,
|
||
destinationYardId: b.destinationYardId ?? null,
|
||
},
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||
}
|
||
|
||
/** Schedule-level batch board: the schedule's own booking window plus its
|
||
* bookings split into in-window (contract executed) vs pending-contract. */
|
||
async getBatchBoardDetail(
|
||
scheduleId: string,
|
||
): Promise<BatchBoardScheduleDetail> {
|
||
const s =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!s)
|
||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||
// Arrived / cancelled schedules stay viewable — the board is also the
|
||
// historical record of what each train carried.
|
||
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
|
||
if (s.direction !== "IMPORT") {
|
||
throw new BadRequestException(
|
||
"The batch board only covers import schedules",
|
||
);
|
||
}
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
// The full graph already carries the schedule↔booking links — no separate
|
||
// link query needed.
|
||
const linkedIds = new Set(
|
||
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
||
);
|
||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||
// Under day-level pooling a booking is only pinned to a schedule by
|
||
// reserve() — until then its train_schedule_id is NULL and the query above
|
||
// misses it. Merge in the corridor-day candidates so staff see the whole
|
||
// waiting pool (the 7 that lost the batch), not just the winners. These are
|
||
// display-only candidates: they are excluded from the capacity meters below.
|
||
const pinnedIds = new Set(bookings.map((b) => b.id));
|
||
// Corridor stops drive both the day-pool candidate merge and the per-leg
|
||
// capacity meters below; a failed lookup degrades to whole-route math.
|
||
let stops: string[] = [];
|
||
try {
|
||
stops = await this.stopsForSchedule(s);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Stop lookup failed for schedule ${s.id}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
if (s.scheduledDepartureDate && stops.length) {
|
||
try {
|
||
const candidates =
|
||
await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||
stops,
|
||
eatDay(s.scheduledDepartureDate),
|
||
);
|
||
for (const b of candidates) {
|
||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||
}
|
||
// Expiry frees the schedule pin (expire() nulls train_schedule_id), so
|
||
// expired bookings match neither query above — merge them back so the
|
||
// board keeps its expired lane. Display-only: boardState maps them to
|
||
// EXPIRED, which every capacity meter already excludes.
|
||
const expiredPool =
|
||
await this.bookingsRepository.findExpiredByCorridorDay(
|
||
stops,
|
||
eatDay(s.scheduledDepartureDate),
|
||
);
|
||
for (const b of expiredPool) {
|
||
if (!pinnedIds.has(b.id)) bookings.push(b);
|
||
}
|
||
} catch (err) {
|
||
// The board must still render the pinned bookings.
|
||
this.logger.warn(
|
||
`Corridor-day candidate merge failed for schedule ${s.id}: ` +
|
||
`${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
let allocationPreview: Awaited<
|
||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||
>;
|
||
try {
|
||
// Reuse the graph loaded above — the preview otherwise re-loads the same
|
||
// heavy schedule graph a second time per request.
|
||
allocationPreview =
|
||
await this.trainSchedulingService.previewAllocationForSchedule(
|
||
s.id,
|
||
s,
|
||
);
|
||
} 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 cycleOf = await this.windowCycleIndexer(s);
|
||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||
const need = this.needFor(b, wagonDims);
|
||
const alloc = allocationByBooking.get(b.id);
|
||
return {
|
||
windowCycleNo: b.fullyExecutedAt ? cycleOf(b.fullyExecutedAt) : null,
|
||
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 = trainSetLocomotiveLimits(s.trainSet);
|
||
|
||
// The board renders ONE booking window — the schedule's own frozen window
|
||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||
// split into two buckets: contract executed (in the window) vs pending
|
||
// contract. The old per-cycle window projection was dropped — the UI never
|
||
// showed it, and reconstructing every cycle cost a config load + grouping
|
||
// pass per request.
|
||
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
|
||
const counts: BatchBoardCounts = {
|
||
allocated: 0,
|
||
selectedForBatch: 0,
|
||
ready: 0,
|
||
waiting: 0,
|
||
expired: 0,
|
||
pendingContract: 0,
|
||
};
|
||
for (const b of bucket) {
|
||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||
else if (b.state === "READY") counts.ready += 1;
|
||
else if (b.state === "WAITING") counts.waiting += 1;
|
||
else if (b.state === "EXPIRED") counts.expired += 1;
|
||
else counts.pendingContract += 1;
|
||
}
|
||
return counts;
|
||
};
|
||
|
||
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
||
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
||
|
||
const stopLabels =
|
||
stops.length > 2 ? await this.yardLabels(stops) : new Map<string, string>();
|
||
const yardsByBookingId = new Map(
|
||
bookings.map((b) => [
|
||
b.id,
|
||
{
|
||
originYardId: b.originYardId ?? null,
|
||
destinationYardId: b.destinationYardId ?? null,
|
||
},
|
||
]),
|
||
);
|
||
|
||
return {
|
||
scheduleId: s.id,
|
||
scheduleReference: s.reference ?? null,
|
||
trainNumber: s.trainNumber ?? null,
|
||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||
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,
|
||
train: s.trainSet?.train
|
||
? {
|
||
id: s.trainSet.train.id,
|
||
code: s.trainSet.train.code,
|
||
trainName: s.trainSet.train.trainName ?? null,
|
||
}
|
||
: null,
|
||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||
// whole set's effective minimum — what the fill engine actually spends.
|
||
locomotive: loco
|
||
? {
|
||
code: s.trainSet?.locomotive?.code ?? '',
|
||
name: s.trainSet?.locomotive?.name ?? null,
|
||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||
}
|
||
: null,
|
||
// Capacity holds come from bookings actually pinned to this train —
|
||
// unpinned day-pool candidates are shown in the lists but hold nothing.
|
||
capacity: this.computeBoardCapacity(
|
||
items.filter((i) => pinnedIds.has(i.id)),
|
||
loco,
|
||
s.maxWagons ?? null,
|
||
{
|
||
stops,
|
||
labelByYardId: stopLabels,
|
||
yardsByBookingId,
|
||
trainLengthMeters: this.builtTrainLengthOf(s),
|
||
},
|
||
),
|
||
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: windowBookings,
|
||
pendingContract: {
|
||
counts: countFor(pendingBookings),
|
||
bookings: pendingBookings,
|
||
},
|
||
allocationViolations: allocationPreview.violations,
|
||
};
|
||
}
|
||
|
||
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
||
async runWagonAllocation(scheduleId: string) {
|
||
const result =
|
||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||
if (result.assignedBookingIds.length > 0) {
|
||
this.notifyBoardChanged(scheduleId, "wagon_allocation_run");
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* 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's own
|
||
* limits widened by its overage tolerance (global rule caps do not apply, same
|
||
* as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here
|
||
* showed staff a ceiling the batch engine did not use.
|
||
*/
|
||
private computeBoardCapacity(
|
||
items: Array<{
|
||
id: string;
|
||
state: BatchBoardBookingState;
|
||
wagons: number;
|
||
weightTons: number;
|
||
lengthMeters: number;
|
||
}>,
|
||
loco: LocomotiveLimits | null,
|
||
maxWagons: number | null,
|
||
legCtx?: {
|
||
/** Ordered corridor stop yard ids; per-leg math needs 3+ stops. */
|
||
stops: string[];
|
||
labelByYardId: Map<string, string>;
|
||
yardsByBookingId: Map<
|
||
string,
|
||
{ originYardId: string | null; destinationYardId: string | null }
|
||
>;
|
||
trainLengthMeters: number | null;
|
||
},
|
||
): BatchBoardSchedule["capacity"] {
|
||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||
// Every booking still targeting this train holds gross weight — including
|
||
// PAID ones waiting for wagon allocation (WAITING) and post-dispatch
|
||
// catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
|
||
// board's weight the moment customers paid. Only EXPIRED released its hold.
|
||
const committed = items.filter((i) => i.state !== "EXPIRED");
|
||
const caps = loco
|
||
? trainHardCaps({
|
||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
|
||
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
|
||
})
|
||
: null;
|
||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||
|
||
// Per-leg committed usage: a booking holds capacity only on the edges it
|
||
// rides, so every meter compares the HEAVIEST single edge against its cap
|
||
// — weight, wagons and length alike. Whole-route bookings (or yards
|
||
// missing from the stop list) load every edge — never under-reported.
|
||
const stops = legCtx?.stops ?? [];
|
||
let usedWeightTons = round2(
|
||
committed.reduce((sum, i) => sum + i.weightTons, 0),
|
||
);
|
||
let allocatedWagons = allocated.reduce((sum, i) => sum + i.wagons, 0);
|
||
let allocatedLengthMeters = round2(
|
||
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
|
||
);
|
||
let legUsage: BatchBoardSchedule["capacity"]["legUsage"] = null;
|
||
if (legCtx && stops.length > 2) {
|
||
const stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||
const edgeCount = stops.length - 1;
|
||
const legOf = (bookingId: string): { from: number; to: number } => {
|
||
const yards = legCtx.yardsByBookingId.get(bookingId);
|
||
const from = yards?.originYardId
|
||
? stopIndex.get(yards.originYardId)
|
||
: undefined;
|
||
const to = yards?.destinationYardId
|
||
? stopIndex.get(yards.destinationYardId)
|
||
: undefined;
|
||
return from != null && to != null && from < to
|
||
? { from, to }
|
||
: { from: 0, to: edgeCount };
|
||
};
|
||
const weightEdges = new Array<number>(edgeCount).fill(0);
|
||
for (const item of committed) {
|
||
const leg = legOf(item.id);
|
||
for (let e = leg.from; e < leg.to; e += 1) weightEdges[e] += item.weightTons;
|
||
}
|
||
const wagonEdges = new Array<number>(edgeCount).fill(0);
|
||
const lengthEdges = new Array<number>(edgeCount).fill(0);
|
||
for (const item of allocated) {
|
||
const leg = legOf(item.id);
|
||
for (let e = leg.from; e < leg.to; e += 1) {
|
||
wagonEdges[e] += item.wagons;
|
||
lengthEdges[e] += item.lengthMeters;
|
||
}
|
||
}
|
||
const label = (yardId: string) =>
|
||
legCtx.labelByYardId.get(yardId) ?? yardId;
|
||
legUsage = weightEdges.map((weight, i) => ({
|
||
from: label(stops[i]),
|
||
to: label(stops[i + 1]),
|
||
usedWeightTons: round2(weight),
|
||
}));
|
||
usedWeightTons = round2(Math.max(0, ...weightEdges));
|
||
allocatedWagons = Math.max(0, ...wagonEdges);
|
||
allocatedLengthMeters = round2(Math.max(0, ...lengthEdges));
|
||
}
|
||
|
||
return {
|
||
allocatedWagons,
|
||
allocatedLengthMeters,
|
||
maxLengthMeters: caps ? caps.maxLengthMeters : null,
|
||
usedWeightTons,
|
||
maxWeightTons: caps ? caps.maxWeightTons : null,
|
||
maxWagons: maxWagons ?? null,
|
||
trainLengthMeters: legCtx?.trainLengthMeters ?? null,
|
||
legUsage,
|
||
};
|
||
}
|
||
|
||
/** Built consist's physical length (what Train Builder shows), null without a built train. */
|
||
private builtTrainLengthOf(s: TrainSchedule): number | null {
|
||
const raw = s.trainSet?.totalLengthMeters;
|
||
const value = raw != null ? Number(raw) : NaN;
|
||
return Number.isFinite(value) && value > 0 ? value : null;
|
||
}
|
||
|
||
/** Yard display labels for corridor stops (falls back to the yard id). */
|
||
private async yardLabels(yardIds: string[]): Promise<Map<string, string>> {
|
||
if (!yardIds.length) return new Map();
|
||
const yards = await this.dataSource
|
||
.getRepository(Yard)
|
||
.find({ where: { id: In(yardIds) } });
|
||
return new Map(yards.map((y) => [y.id, y.label ?? y.code]));
|
||
}
|
||
|
||
/**
|
||
* Corridor stops + labels from the already-loaded route graph (milestones
|
||
* with yards) — the list flow must not fire a query per schedule row.
|
||
*/
|
||
private stopsFromGraph(s: TrainSchedule): {
|
||
stops: string[];
|
||
labelByYardId: Map<string, string>;
|
||
} {
|
||
const milestones = [...(s.route?.milestones ?? [])].sort(
|
||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||
);
|
||
const stops: string[] = [];
|
||
const labelByYardId = new Map<string, string>();
|
||
const push = (yardId?: string | null, label?: string | null) => {
|
||
if (!yardId || labelByYardId.has(yardId)) return;
|
||
stops.push(yardId);
|
||
labelByYardId.set(yardId, label ?? yardId);
|
||
};
|
||
if (milestones.length >= 2) {
|
||
for (const m of milestones) push(m.yardId, m.yard?.label ?? m.yard?.code);
|
||
} else {
|
||
push(s.originStationId, s.originStation?.label ?? s.originStation?.code);
|
||
push(
|
||
s.destinationStationId,
|
||
s.destinationStation?.label ?? s.destinationStation?.code,
|
||
);
|
||
}
|
||
return { stops, labelByYardId };
|
||
}
|
||
|
||
private buildScheduleSummary(
|
||
s: TrainSchedule,
|
||
items: BatchBoardBooking[],
|
||
yardsByBookingId: Map<
|
||
string,
|
||
{ originYardId: string | null; destinationYardId: string | null }
|
||
>,
|
||
): BatchBoardSchedule {
|
||
const loco = trainSetLocomotiveLimits(s.trainSet);
|
||
const { stops, labelByYardId } = this.stopsFromGraph(s);
|
||
|
||
return {
|
||
scheduleId: s.id,
|
||
scheduleReference: s.reference ?? null,
|
||
trainNumber: s.trainNumber ?? null,
|
||
routeName: s.route ? formatRouteLabel(s.route) : null,
|
||
origin: s.originStation?.label ?? s.originStation?.code ?? null,
|
||
destination:
|
||
s.destinationStation?.label ?? s.destinationStation?.code ?? null,
|
||
scheduleDate: s.scheduledDepartureDate
|
||
? s.scheduledDepartureDate.toISOString()
|
||
: null,
|
||
createdAt: s.createdAt ? s.createdAt.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,
|
||
train: s.trainSet?.train
|
||
? {
|
||
id: s.trainSet.train.id,
|
||
code: s.trainSet.train.code,
|
||
trainName: s.trainSet.train.trainName ?? null,
|
||
}
|
||
: null,
|
||
// Identity from the primary (legacy) locomotive; limit figures from the
|
||
// whole set's effective minimum — what the fill engine actually spends.
|
||
locomotive: loco
|
||
? {
|
||
code: s.trainSet?.locomotive?.code ?? '',
|
||
name: s.trainSet?.locomotive?.name ?? null,
|
||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
|
||
}
|
||
: null,
|
||
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, {
|
||
stops,
|
||
labelByYardId,
|
||
yardsByBookingId,
|
||
trainLengthMeters: this.builtTrainLengthOf(s),
|
||
}),
|
||
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" ||
|
||
// Redirect-acked, webhook pending — still a reserved (unpaid) hold.
|
||
booking.status === "PAYMENT_VERIFICATION_IN_PROGRESS"
|
||
) {
|
||
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 = trainSetLocomotiveLimits(schedule.trainSet);
|
||
if (!schedule.trainSetId || !locomotive) {
|
||
this.logger.warn(
|
||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||
);
|
||
return 0;
|
||
}
|
||
|
||
const wagonDims = await this.loadWagonDims();
|
||
const limits = await this.capacityLimits(locomotive);
|
||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||
const stock = await this.stockLedgerFor(schedule, budget);
|
||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||
if (budget.isExhausted(minPerWagon)) {
|
||
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, await this.windowCycleIndexer(schedule));
|
||
const units = this.groupConsolidatedPool(pool);
|
||
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
|
||
let armed = false;
|
||
let preempted = 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);
|
||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||
// Abstract room AND real wagons of a type this booking can ride — see
|
||
// fillRouteDayInternal for why both gates are needed. PER_TON bulk
|
||
// singles get the smart gate (exact per-type seating at the cargo's
|
||
// caps); a booking is only reserved — and only ever invoiced — when
|
||
// that seating is proven against the train's actual free wagons.
|
||
const perItemBulk =
|
||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||
const useSmart =
|
||
!isPair &&
|
||
booking.freightType === "BULK" &&
|
||
!perItemBulk &&
|
||
wagonTypeIds.length > 0;
|
||
const smart = useSmart
|
||
? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank, wagonTypeIds)
|
||
: null;
|
||
const admitted = useSmart
|
||
? smart != null && budget.fits(smart.need, leg)
|
||
: budget.fits(need, leg) &&
|
||
this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||
|
||
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
|
||
this.logger.debug(
|
||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(
|
||
smart?.need ?? need,
|
||
)} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`,
|
||
);
|
||
|
||
if (!admitted) {
|
||
if (isGov) {
|
||
const freed = await this.preemptForGovernment(
|
||
scheduleId,
|
||
need,
|
||
leg,
|
||
budget,
|
||
wagonDims,
|
||
);
|
||
preempted = true;
|
||
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;
|
||
stock: WagonStockLedger;
|
||
} = { id: scheduleId, budget, armed, stock };
|
||
if (
|
||
await this.maybeOfferPartial(booking, isPair, [cand], need, wagonTypeIds)
|
||
) {
|
||
armed = cand.armed;
|
||
continue;
|
||
}
|
||
continue; // skip a unit that exceeds weight/length/wagons/stock, 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(smart?.need ?? need, leg);
|
||
// Hold the physical wagons too — the next unit must not re-count them.
|
||
// The smart gate holds the exact per-type counts it seated.
|
||
if (smart) {
|
||
for (const part of smart.perType) {
|
||
stock.consume([part.wagonTypeId], part.wagons, leg);
|
||
}
|
||
} else {
|
||
stock.consume(wagonTypeIds, need.wagons, 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.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
|
||
if (armed) this.armSettle(scheduleId);
|
||
// One push per fill pass (never per booking) — only when rows changed.
|
||
if (reservedThisPass > 0 || armed || preempted) {
|
||
this.notifyBoardChanged(scheduleId, "batch_fill");
|
||
}
|
||
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,
|
||
// Dedicated shipping-line trains never join the customer day pool.
|
||
shippingLineCompanyId: IsNull(),
|
||
},
|
||
{
|
||
originStationId: originYardId,
|
||
destinationStationId: destinationYardId,
|
||
status: TrainScheduleStatusEnum.Scheduled,
|
||
shippingLineCompanyId: IsNull(),
|
||
},
|
||
],
|
||
});
|
||
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 wagonDims = await this.loadWagonDims();
|
||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||
|
||
// Live per-schedule corridor budget + physical wagon-type stock + arm/changed
|
||
// flags, in departure order.
|
||
const trains: Array<{
|
||
id: string;
|
||
budget: CorridorBudget;
|
||
stock: WagonStockLedger;
|
||
armed: boolean;
|
||
changed: boolean;
|
||
}> = [];
|
||
// The day group shares one booking window (route+day grouping), so any
|
||
// member's window grid stands for the pool's cycle derivation.
|
||
let cycleSchedule: TrainSchedule | null = null;
|
||
for (const id of scheduleIds) {
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||
this.logger.warn(
|
||
`Schedule ${id} has no locomotive/train set — skipped.`,
|
||
);
|
||
continue;
|
||
}
|
||
cycleSchedule ??= schedule;
|
||
const limits = await this.capacityLimits(locomotive);
|
||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||
const stock = await this.stockLedgerFor(schedule, budget);
|
||
trains.push({ id, budget, stock, armed: false, changed: 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,
|
||
cycleSchedule ? await this.windowCycleIndexer(cycleSchedule) : undefined,
|
||
);
|
||
// 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);
|
||
// Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked
|
||
// once against the whole pool, so what containers will need is known
|
||
// before any bulk booking picks its wagons.
|
||
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
|
||
|
||
// 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);
|
||
// Consolidated pairs share one wagon set; the primary's types stand for both.
|
||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||
|
||
// PER_TON bulk singles get the smart gate: seated type-by-type at the
|
||
// cargo's per-wagon caps, scarcest type first — the count the allocator
|
||
// will actually need, not a one-type estimate. Pairs, PER_ITEM and
|
||
// unconfigured cargo keep the generic gate (gov preemption and partial
|
||
// offers below also still size on the generic `need`).
|
||
const perItemBulk =
|
||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||
const useSmart =
|
||
!isPair &&
|
||
booking.freightType === "BULK" &&
|
||
!perItemBulk &&
|
||
wagonTypeIds.length > 0;
|
||
let smart: {
|
||
need: Capacity;
|
||
perType: Array<{ wagonTypeId: string; wagons: number }>;
|
||
} | null = null;
|
||
|
||
// First train (earliest departure) whose corridor carries this booking's
|
||
// leg, still fits it as-is AND physically holds enough wagons of a type the
|
||
// booking can ride. Both gates matter: abstract room without the right
|
||
// wagon type is space the allocator can never turn into a loaded consist.
|
||
let target: (typeof trains)[number] | undefined;
|
||
for (const t of trains) {
|
||
const leg = legOn(t);
|
||
if (leg == null) continue;
|
||
if (useSmart) {
|
||
const probe = this.smartBulkNeed(
|
||
booking,
|
||
wagonDims,
|
||
t.stock,
|
||
leg,
|
||
scarcityRank,
|
||
wagonTypeIds,
|
||
);
|
||
if (probe != null && t.budget.fits(probe.need, leg)) {
|
||
smart = probe;
|
||
target = t;
|
||
break;
|
||
}
|
||
} else if (
|
||
t.budget.fits(need, leg) &&
|
||
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
|
||
) {
|
||
target = t;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
);
|
||
// Preempt may have displaced (expired) victims even when the need
|
||
// still doesn't fit — the board must refresh either way.
|
||
t.changed = true;
|
||
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,
|
||
wagonTypeIds,
|
||
);
|
||
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(smart?.need ?? need, legOn(target)!);
|
||
// Hold the physical wagons too, so the next unit in this pass sees them
|
||
// gone — otherwise two bookings both "fit" the same 16 NW5. The smart
|
||
// gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5),
|
||
// not a type-blind total drained deepest-first.
|
||
if (smart) {
|
||
for (const part of smart.perType) {
|
||
target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!);
|
||
}
|
||
} else {
|
||
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
|
||
}
|
||
target.changed = true;
|
||
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`,
|
||
);
|
||
|
||
const minPerWagon = this.minPerWagonNeed(wagonDims);
|
||
for (const t of trains) {
|
||
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
||
if (t.armed) this.armSettle(t.id);
|
||
// One push per touched train per pass (never per booking). `armed` covers
|
||
// commercial reserves + partial offers; `changed` covers gov allocations
|
||
// and preemption.
|
||
if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill");
|
||
void this.triggerWagonAllocation(t.id);
|
||
}
|
||
|
||
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
|
||
}
|
||
|
||
/**
|
||
* A lone commercial 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).
|
||
*
|
||
* IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is
|
||
* eligible only when export split is enabled: export historically rides one
|
||
* train whole, so splitting it changes the FCFS money path — each split part
|
||
* still rides ONE train whole, and the leftover becomes its own booking on
|
||
* the next train.
|
||
*/
|
||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||
const directionOk =
|
||
booking.tradeDirection === "IMPORT" ||
|
||
booking.tradeDirection === "DOMESTIC" ||
|
||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||
return (
|
||
!isPair &&
|
||
!booking.isGovernment &&
|
||
directionOk &&
|
||
(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;
|
||
stock?: WagonStockLedger;
|
||
}>,
|
||
need: Capacity,
|
||
wagonTypeIds: string[] = [],
|
||
): Promise<boolean> {
|
||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||
// PER_TON bulk partials are sized on ONE concrete wagon type at the
|
||
// cargo's per-wagon cap — sizing on the first type's raw 70T rating
|
||
// offered tonnage the wagons could never carry (Perishable caps at
|
||
// 20/30T), taking payment for cargo that stalls at allocation.
|
||
// ponytail: single-type bulk partials; a multi-type partial (PW2+NW5
|
||
// mixed) is the upgrade path if offers come out too small.
|
||
const perItemBulk =
|
||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||
const cappedBulk =
|
||
!isPair &&
|
||
booking.freightType === "BULK" &&
|
||
!perItemBulk &&
|
||
wagonTypeIds.length > 0;
|
||
const wagonDims = cappedBulk ? await this.loadWagonDims() : null;
|
||
const target = candidates
|
||
.map((c) => {
|
||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||
if (!leg) return null;
|
||
const room = c.budget.remainingFor(leg);
|
||
// The offer may never exceed the wagons that physically exist in a type
|
||
// this booking can ride. This is what turns "20 free wagons, only 16 of
|
||
// them NW5" into an offer for 16 — the customer pays for 16 and the
|
||
// other 4 leave as the usual remainder booking, instead of paying for
|
||
// 20 and stalling at allocation on wagon 17.
|
||
if (cappedBulk && wagonDims) {
|
||
// Types resolved from the id list (join tables), never the pool
|
||
// entity's unloaded cargoType.wagonTypes relation — see smartBulkNeed.
|
||
const best = [...new Set(wagonTypeIds)]
|
||
.map((wagonTypeId) => ({
|
||
wagonTypeId,
|
||
dims: wagonDims.byWagonTypeId.get(wagonTypeId),
|
||
}))
|
||
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
|
||
.map((o) => ({
|
||
...o,
|
||
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
|
||
takePerWagon: bulkTonsPerWagon(
|
||
booking.cargoType,
|
||
o.wagonTypeId,
|
||
o.dims.capacityTons,
|
||
),
|
||
}))
|
||
.filter((o) => o.free > 0 && o.takePerWagon > 0)
|
||
.sort((a, b) => b.takePerWagon - a.takePerWagon)[0];
|
||
if (!best) return null;
|
||
return {
|
||
c,
|
||
leg,
|
||
room: { ...room, wagons: Math.min(room.wagons, best.free) },
|
||
seat: {
|
||
wagonTypeId: best.wagonTypeId,
|
||
perWagon: { ...best.dims, capacityTons: best.takePerWagon },
|
||
},
|
||
};
|
||
}
|
||
const physical = wagonTypeIds.length
|
||
? c.stock?.availableFor(wagonTypeIds, leg)
|
||
: undefined;
|
||
const wagons =
|
||
physical == null ? room.wagons : Math.min(room.wagons, physical);
|
||
return { c, leg, room: { ...room, wagons }, seat: undefined };
|
||
})
|
||
.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,
|
||
target.seat,
|
||
);
|
||
if (!offered) return false;
|
||
target.c.budget.subtract(offered, target.leg);
|
||
target.c.stock?.consume(
|
||
target.seat ? [target.seat.wagonTypeId] : wagonTypeIds,
|
||
offered.wagons,
|
||
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,
|
||
/**
|
||
* Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this
|
||
* offer rides, with capacityTons already reduced to the cargo's per-wagon
|
||
* cap — so the offered tonnage is what those wagons can really carry.
|
||
*/
|
||
seat?: { wagonTypeId: string; perWagon: PerWagonDims },
|
||
): 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();
|
||
|
||
// 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,
|
||
// measured on the booking's REAL wagon type — the same one allocation
|
||
// validates against. Bulk splits ride FULL wagons only: the offer never
|
||
// part-loads its last wagon.
|
||
const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims);
|
||
// With a capped seat, the whole booking's wagon count follows the cap too
|
||
// (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a
|
||
// strict subset of THAT count.
|
||
const wholeWagons = seat
|
||
? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons))
|
||
: need.wagons;
|
||
const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, {
|
||
fullWagonsOnly: booking.freightType === "BULK",
|
||
});
|
||
if (!partial) return null;
|
||
|
||
const sized = await this.splitService.sizeOffer(
|
||
booking,
|
||
partial.wagons,
|
||
wholeWagons,
|
||
perWagon.capacityTons,
|
||
partial.maxCargoTons,
|
||
);
|
||
if (!sized) return null;
|
||
|
||
const offeredNeed: Capacity = {
|
||
wagons: sized.offeredWagons,
|
||
weightTons: bookingGrossWeightTons(
|
||
sized.offeredWeightTons,
|
||
sized.offeredWagons,
|
||
perWagon.tareWeightTons,
|
||
),
|
||
lengthMeters: sized.offeredWagons * perWagon.lengthMeters,
|
||
};
|
||
if (!this.fits(offeredNeed, budget)) return null;
|
||
|
||
const deadline = new Date(
|
||
Date.now() +
|
||
(await this.paymentWindowMsFor(await this.scheduleById(scheduleId))),
|
||
);
|
||
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,
|
||
paymentReminderSentAt: null,
|
||
} as never);
|
||
booking.trainScheduleId = scheduleId;
|
||
return offeredNeed;
|
||
}
|
||
|
||
/**
|
||
* 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; when neither paid, both expire. A half-paid pair splits:
|
||
* the paid half keeps the whole wagon, the lapsed half expires and owes the
|
||
* cancellation fee (expire()'s pair cascade). 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";
|
||
// The deadline carries a drain tail (payWindowLapsed): settlement is async,
|
||
// so a payment made in the window's last seconds lands after it. Nothing is
|
||
// expired until the tail passes. expire()'s gateway reconcile is the second
|
||
// line of defence, not the first.
|
||
const isExpired = (b: Booking) =>
|
||
b.paymentDeadline
|
||
? payWindowLapsed(b.paymentDeadline, 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)) {
|
||
// One call is enough: expire()'s pair cascade settles both sides —
|
||
// both expire when neither paid; a paid half is rescued (keeps the
|
||
// whole wagon) while the lapsed half expires with its fee.
|
||
await this.expire(booking);
|
||
anySettled = true;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
done.add(booking.id);
|
||
if (isPaid(booking)) {
|
||
if (!(await this.holdIfWagonShort(scheduleId, 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),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Conclude-time retry: promote whatever still fits from the route-day waiting
|
||
* list, opening fresh pay windows. Returns how many commercial units got
|
||
* reserved — corridor-wide, since the fill is day-level and may reserve onto a
|
||
* sibling train; the caller must check `hasLiveReservations` for its OWN
|
||
* schedule before deciding to stay in PAYMENT.
|
||
*/
|
||
async fillFromWaitingList(scheduleId: string): Promise<number> {
|
||
return this.withScheduleLock(scheduleId, async () => {
|
||
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.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
|
||
}
|
||
return promoted;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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`,
|
||
);
|
||
}
|
||
// The settle may have resolved the last pay window on a full export day
|
||
// (paid → allocated, and the top-up found nothing else that fits) — sweep
|
||
// the date's leftover bookings. Self-guarded: no-op for import/domestic
|
||
// and while any train on the day can still take bookings.
|
||
await this.expireLeftoverExportDay(scheduleId);
|
||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||
// this) so one settle produces one push, after every allocation/expiry/
|
||
// top-up extension for this schedule has been persisted.
|
||
this.notifyBoardChanged(scheduleId, "reservations_settled");
|
||
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}`,
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Announce that a schedule's batch-board data changed so open boards refetch.
|
||
* Called AFTER the state change is persisted; a push failure only logs — it
|
||
* must never break the business transaction that triggered it.
|
||
*/
|
||
private notifyBoardChanged(scheduleId: string, reason: string): void {
|
||
try {
|
||
this.bookingWindowGateway.emitBatchChanged(scheduleId, reason);
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Batch-board push (${reason}) 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" });
|
||
if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
|
||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||
}
|
||
|
||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||
booking.trainScheduleId,
|
||
);
|
||
if (schedule && (await this.isTrainFull(schedule))) {
|
||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||
// Same as the webhook path: a staff mark-paid can settle the last live
|
||
// pay window on a now-full export day — sweep the date's leftovers.
|
||
void this.expireLeftoverExportDay(booking.trainScheduleId);
|
||
}
|
||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||
}
|
||
|
||
/**
|
||
* 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",
|
||
);
|
||
}
|
||
|
||
const sourceScheduleId = booking.trainScheduleId ?? null;
|
||
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,
|
||
scheduledDate: schedule.scheduledDepartureDate,
|
||
status: restoredStatus,
|
||
// A paid booking still hunting for a wagon keeps its flag through the
|
||
// move — it only clears when wagons are actually assigned.
|
||
schedulingStatus:
|
||
booking.schedulingStatus === "WAITING_FOR_WAGON"
|
||
? "WAITING_FOR_WAGON"
|
||
: "ELIGIBLE",
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
} as never);
|
||
});
|
||
// Both boards changed: the booking left the source train and joined the target.
|
||
if (sourceScheduleId && sourceScheduleId !== newScheduleId) {
|
||
this.notifyBoardChanged(sourceScheduleId, "booking_moved");
|
||
}
|
||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||
}
|
||
|
||
/**
|
||
* Trains a paid-but-unallocated booking can board right now: OPEN window,
|
||
* future departure, route covers the booking's leg, and remaining corridor
|
||
* capacity fits it. Split by the booking's own scheduled day so the UI can
|
||
* offer one-click same-day allocation vs an explicit "another date" choice.
|
||
*/
|
||
async allocationCandidates(bookingId: string): Promise<{
|
||
sameDay: AllocationCandidate[];
|
||
otherDays: AllocationCandidate[];
|
||
}> {
|
||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: bookingId },
|
||
relations: {
|
||
bookingContainers: { containerType: true },
|
||
// wagonTypes drives the break-bulk items-per-wagon fit — size the
|
||
// booking exactly as the intercity accept check does.
|
||
cargoType: { wagonTypes: true },
|
||
},
|
||
});
|
||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
const schedules = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
// Dedicated shipping-line trains are never customer-booking targets.
|
||
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
|
||
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
|
||
],
|
||
});
|
||
const today = eatDay(new Date());
|
||
const bookingDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
|
||
const sameDay: AllocationCandidate[] = [];
|
||
const otherDays: AllocationCandidate[] = [];
|
||
for (const s of schedules) {
|
||
if (!s.scheduledDepartureDate || eatDay(s.scheduledDepartureDate) < today) continue;
|
||
if (s.bookingWindowStatus !== "OPEN") continue;
|
||
if (s.id === booking.trainScheduleId) continue;
|
||
const stops = await this.stopsForSchedule(s);
|
||
const fromIdx = stops.indexOf(booking.originYardId);
|
||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) continue;
|
||
// ponytail: full capacity build per candidate is heavy; the set is small
|
||
// (future OPEN trains on the booking's route) — precompute if it grows.
|
||
const cap = await this.intercityCapacity(s.id);
|
||
if (!cap) continue;
|
||
const leg = cap.budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||
if (!cap.budget.fits(cap.needFor(booking), leg)) continue;
|
||
const candidate: AllocationCandidate = {
|
||
id: s.id,
|
||
reference: s.reference ?? s.trainNumber ?? null,
|
||
direction: s.direction ?? null,
|
||
scheduledDepartureDate: s.scheduledDepartureDate,
|
||
};
|
||
(eatDay(s.scheduledDepartureDate) === bookingDay ? sameDay : otherDays).push(candidate);
|
||
}
|
||
const byDate = (a: AllocationCandidate, b: AllocationCandidate) =>
|
||
new Date(a.scheduledDepartureDate).getTime() - new Date(b.scheduledDepartureDate).getTime();
|
||
sameDay.sort(byDate);
|
||
otherDays.sort(byDate);
|
||
return { sameDay, otherDays };
|
||
}
|
||
|
||
/**
|
||
* Place a PAID booking that lost (or never got) its train: re-point via
|
||
* moveToSchedule (window/route validation + day sync), then allocate it
|
||
* immediately — payment already landed, so no new pay window opens. The
|
||
* customer gets an in-app notice when the new train departs on a different
|
||
* day than their original choice.
|
||
*/
|
||
async allocatePaid(bookingId: string, scheduleId: string): Promise<void> {
|
||
const before = await this.dataSource
|
||
.getRepository(Booking)
|
||
.findOne({ where: { id: bookingId } });
|
||
if (!before) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
if (before.paymentStatus !== "PAID" && before.status !== "PAID") {
|
||
throw new BadRequestException(
|
||
"Booking is not paid — use the regular scheduling flow",
|
||
);
|
||
}
|
||
const previousDay = before.scheduledDate ? eatDay(before.scheduledDate) : null;
|
||
await this.moveToSchedule(bookingId, scheduleId);
|
||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: bookingId },
|
||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||
});
|
||
if (!fresh) return;
|
||
if (!(await this.holdIfWagonShort(scheduleId, fresh))) {
|
||
await this.allocate(scheduleId, fresh, "paid");
|
||
}
|
||
const schedule = await this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.findOne({ where: { id: scheduleId } });
|
||
if (
|
||
previousDay &&
|
||
schedule?.scheduledDepartureDate &&
|
||
eatDay(schedule.scheduledDepartureDate) !== previousDay
|
||
) {
|
||
this.notifier.allocatedOtherDay(fresh, schedule.scheduledDepartureDate);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
|
||
* dedicated train for the booking's lane and shipment day.
|
||
*
|
||
* Runs at operation-accept: shipping lines pay later on the credit ledger,
|
||
* so there is no pay window between accept and wagon placement — the
|
||
* booking boards its train immediately. Customer bookings never come here;
|
||
* they keep the batch pool → reserve → pay → allocate pipeline.
|
||
*
|
||
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
|
||
* (without the PAID stamps the customer hold writes — nothing was paid).
|
||
* No dedicated train on the day is not an error: the booking simply stays
|
||
* in the ordinary day pool for the batch engine.
|
||
*/
|
||
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
|
||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: bookingId },
|
||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||
});
|
||
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
|
||
if (isRoadService(booking.serviceType)) return;
|
||
|
||
const day = eatDay(booking.scheduledDate);
|
||
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
|
||
where: [
|
||
{
|
||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||
originStationId: booking.originYardId,
|
||
destinationStationId: booking.destinationYardId,
|
||
status: TrainScheduleStatusEnum.Draft,
|
||
},
|
||
{
|
||
shippingLineCompanyId: booking.shippingLineCompanyId,
|
||
originStationId: booking.originYardId,
|
||
destinationStationId: booking.destinationYardId,
|
||
status: TrainScheduleStatusEnum.Scheduled,
|
||
},
|
||
],
|
||
});
|
||
const target = dedicated.find(
|
||
(s) =>
|
||
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
|
||
);
|
||
if (!target) {
|
||
this.logger.log(
|
||
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
|
||
`train on ${day} — left in the day pool for the batch engine`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Point the booking at its train BEFORE the shortage probe — the probe
|
||
// reads the link to size the need against that schedule's wagons.
|
||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||
trainScheduleId: target.id,
|
||
} as never);
|
||
booking.trainScheduleId = target.id;
|
||
|
||
// One dedicated train carries ONE booking: the accept claims the train by
|
||
// closing its booking window on the spot. Both gates a later booking
|
||
// passes — the day picker (isStillOpen on windowClosesAt) and the
|
||
// completion's dedicated-day check — read these fields, so a second
|
||
// booking can never pick this train.
|
||
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
|
||
bookingWindowStatus: "CLOSED",
|
||
windowClosesAt: new Date(),
|
||
} as never);
|
||
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
|
||
|
||
const shortage =
|
||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||
target.id,
|
||
booking.id,
|
||
);
|
||
if (shortage) {
|
||
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
|
||
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
|
||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||
schedulingStatus: "WAITING_FOR_WAGON",
|
||
} as never);
|
||
this.logger.warn(
|
||
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
|
||
`dedicated train ${target.reference ?? target.id}: needs ` +
|
||
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
|
||
);
|
||
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
|
||
return;
|
||
}
|
||
|
||
await this.allocate(target.id, booking, "shipping_line");
|
||
}
|
||
|
||
/**
|
||
* One reminder per hold, shortly before its pay deadline (the window tick
|
||
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
|
||
* bookings — a landed payment the settle hasn't processed yet needs no nag.
|
||
*/
|
||
async sendPaymentReminders(): Promise<void> {
|
||
const now = new Date();
|
||
const due = await this.dataSource
|
||
.getRepository(Booking)
|
||
.createQueryBuilder("b")
|
||
.leftJoinAndSelect("b.company", "company")
|
||
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
|
||
.andWhere(`b.payment_status != 'PAID'`)
|
||
.andWhere("b.payment_reminder_sent_at IS NULL")
|
||
.andWhere("b.payment_deadline > :now", { now })
|
||
.andWhere("b.payment_deadline <= :soon", {
|
||
soon: new Date(now.getTime() + PAYMENT_REMINDER_LEAD_MS),
|
||
})
|
||
.getMany();
|
||
for (const booking of due) {
|
||
// Stamp BEFORE sending so a slow notifier can't double-send next tick.
|
||
await this.bookingsRepository.update(booking.id, {
|
||
paymentReminderSentAt: new Date(),
|
||
} as never);
|
||
if (booking.paymentDeadline) {
|
||
await this.notifier.payDeadlineApproaching(
|
||
booking,
|
||
booking.paymentDeadline,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
// After the top-up + phase extension so one push carries the final state.
|
||
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Customer cancel of an unpaid hold: the same immediate release as
|
||
* expireReservation, but the booking ends CANCELLED (the customer chose to
|
||
* walk away — "payment window missed" copy would be wrong). Consolidated
|
||
* pairs are rejected by the caller: the shared wagon is both-or-neither.
|
||
*/
|
||
async cancelReservation(bookingId: string): Promise<void> {
|
||
const booking = await this.dataSource
|
||
.getRepository(Booking)
|
||
.findOne({ where: { id: bookingId } });
|
||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
const freedScheduleId = booking.trainScheduleId;
|
||
await this.bookingsRepository.update(booking.id, {
|
||
trainScheduleId: null,
|
||
requestedTrainScheduleId: null,
|
||
status: "CANCELLED",
|
||
schedulingStatus: "ELIGIBLE",
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
paymentReminderSentAt: null,
|
||
} as never);
|
||
// An unpaid partial offer dies with the hold — same as expire().
|
||
if (this.splitService) {
|
||
await this.splitService.expireOpenOffer(booking.id);
|
||
}
|
||
await this.billing.expirePayable(
|
||
Freight.InvoiceSource.Booking,
|
||
booking.id,
|
||
"PREPAID",
|
||
);
|
||
if (freedScheduleId) {
|
||
// Same release choreography as expireReservation: reopen a FULL window,
|
||
// top up from the waiting list, push one board update with final state.
|
||
await this.refreshWindowStatus(freedScheduleId);
|
||
const topUpReserved = await this.topUpFill(freedScheduleId);
|
||
if (topUpReserved > 0) {
|
||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||
}
|
||
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
|
||
}
|
||
this.logger.log(
|
||
`[BATCH] CANCELLED hold ${booking.reference} — customer released the ` +
|
||
`reservation before paying; wagons freed`,
|
||
);
|
||
}
|
||
|
||
// ---- 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;
|
||
/**
|
||
* Per-wagon-type split of `needFor(booking).wagons`, against THIS
|
||
* schedule's own wagon stock — so the same booking reads differently on a
|
||
* different train. Empty when the stock can't be resolved.
|
||
*/
|
||
breakdownFor: (
|
||
booking: Booking,
|
||
) => Array<{ wagonTypeId: string; code: string; wagons: number }>;
|
||
} | null> {
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
|
||
if (!schedule || !locomotive) return null;
|
||
const wagonDims = await this.loadWagonDims();
|
||
const limits = await this.capacityLimits(locomotive);
|
||
// Built trains use the leg-aware corridor budget too: the wagon planner
|
||
// consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
|
||
// that runs empty Gelan→Adama genuinely can carry an intercity booking
|
||
// there before its export cargo boards at Adama. A train full on one leg
|
||
// still accepts ride-alongs on its empty legs — that is the whole point
|
||
// of the ride-along flow.
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||
// Physical stock of THIS schedule's train (built consist, or the yard fleet
|
||
// it will draw from) — what makes the breakdown train-specific.
|
||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||
schedule.id,
|
||
schedule.originStationId,
|
||
budget.stops,
|
||
);
|
||
return {
|
||
budget,
|
||
needFor: (booking) => this.needFor(booking, wagonDims),
|
||
breakdownFor: (booking) =>
|
||
this.wagonBreakdownFor(
|
||
booking,
|
||
wagonDims,
|
||
stock.remainingByTypeId,
|
||
stock.codesByTypeId,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||
return;
|
||
}
|
||
// Manual placement of an ALREADY-PAID intercity booking: payment landed
|
||
// earlier (and unpinned it back to the pool) — staff are now choosing its
|
||
// train, so link directly. No new pay window; wagon assignment stays with
|
||
// staff in the workspace.
|
||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||
await this.dataSource
|
||
.getRepository(Booking)
|
||
.update(booking.id, { trainScheduleId: scheduleId });
|
||
booking.trainScheduleId = scheduleId;
|
||
await this.allocate(scheduleId, booking, 'paid');
|
||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||
return;
|
||
}
|
||
await this.reserve(booking, scheduleId);
|
||
this.armSettle(scheduleId);
|
||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||
}
|
||
|
||
/**
|
||
* Intercity booking that does not fit its leg whole: offer the largest part
|
||
* that does (split-on-payment, customer notified with a pay window), sized
|
||
* against the leg's remaining room AND the train's physical wagon stock.
|
||
* Returns true when an offer was opened. The caller's budget is mutated so
|
||
* later bookings in the same accept pass see the offer's consumption.
|
||
*/
|
||
async offerIntercityPartial(
|
||
booking: Booking,
|
||
scheduleId: string,
|
||
budget: CorridorBudget,
|
||
): Promise<boolean> {
|
||
const wagonDims = await this.loadWagonDims();
|
||
const need = this.needFor(booking, wagonDims);
|
||
const allowed = await this.loadAllowedWagonTypeIds();
|
||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed);
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) return false;
|
||
const stock = await this.stockLedgerFor(schedule, budget);
|
||
const cand = { id: scheduleId, budget, armed: false, stock };
|
||
const offered = await this.maybeOfferPartial(
|
||
booking,
|
||
false,
|
||
[cand],
|
||
need,
|
||
wagonTypeIds,
|
||
);
|
||
if (offered && cand.armed) {
|
||
this.armSettle(scheduleId);
|
||
this.notifyBoardChanged(scheduleId, 'intercity_partial_offered');
|
||
}
|
||
return offered;
|
||
}
|
||
|
||
// ---- 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 targetSchedule = await this.scheduleById(scheduleId);
|
||
let deadline = new Date(
|
||
now.getTime() + (await this.paymentWindowMsFor(targetSchedule)),
|
||
);
|
||
// EXPORT parity: pay windows on an export train never outlive its booking
|
||
// window — export bookings expire at close, so anything reserved onto the
|
||
// same train (FCFS export or an intercity ride-along) must too. Import
|
||
// keeps the plain payment window; its cycles re-fill after settle.
|
||
if (targetSchedule?.direction === "EXPORT") {
|
||
const cutoff =
|
||
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
|
||
if (cutoff && cutoff.getTime() <= now.getTime()) {
|
||
throw new BadRequestException(
|
||
"Export booking window has closed — cannot open a pay window on this train",
|
||
);
|
||
}
|
||
if (cutoff && cutoff.getTime() < deadline.getTime()) {
|
||
deadline = new Date(cutoff);
|
||
}
|
||
}
|
||
await this.bookingsRepository.update(booking.id, {
|
||
trainScheduleId: scheduleId,
|
||
status: "SELECTED_FOR_BATCH",
|
||
selectedForBatchAt: now,
|
||
paymentDeadline: deadline,
|
||
paymentReminderSentAt: null,
|
||
} as never);
|
||
booking.trainScheduleId = scheduleId;
|
||
// The invoice was generated DRAFT at booking creation / operation-accept,
|
||
// before this pay window existed. Reserving is the moment the booking becomes
|
||
// payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and
|
||
// print the deadline as its due date — never earlier, or the customer could
|
||
// settle an invoice for a slot they have not been offered yet. Idempotent: a
|
||
// re-reserve only refreshes `dueAt`.
|
||
await this.billing.issuePayable(
|
||
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). */
|
||
/**
|
||
* Fleet preflight shared by every single-booking paid-allocation path: when
|
||
* no wagon of the booking's required type is free, hold it OUT of the train
|
||
* instead of linking — it stays PAID + unlinked in the (route, day) pool,
|
||
* flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
|
||
* the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
|
||
* when the booking was held. Consolidated pairs are exempt (the shared wagon
|
||
* is both-or-neither and settles atomically in settleReserved).
|
||
*/
|
||
private async holdIfWagonShort(
|
||
scheduleId: string,
|
||
booking: Booking,
|
||
): Promise<boolean> {
|
||
if (booking.consolidationPartnerId) return false;
|
||
const shortage =
|
||
await this.trainSchedulingService.previewPaidBookingWagonShortage(
|
||
scheduleId,
|
||
booking.id,
|
||
);
|
||
if (!shortage) return false;
|
||
|
||
await this.dataSource.getRepository(Booking).update(booking.id, {
|
||
status: "PAID",
|
||
paymentStatus: "PAID",
|
||
schedulingStatus: "WAITING_FOR_WAGON",
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
} as never);
|
||
// Payment landed — record it even though nothing boards yet. The wagon
|
||
// milestone stays pending until staff assign one.
|
||
void this.completeTrackingMilestones(booking.id, [
|
||
"FREIGHT_PAYMENT_PENDING",
|
||
"FREIGHT_PAYMENT_SETTLED",
|
||
]);
|
||
this.logger.warn(
|
||
`PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
|
||
`needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
|
||
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
|
||
`Held in the day pool for manual placement.`,
|
||
);
|
||
this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
|
||
return true;
|
||
}
|
||
|
||
private async allocate(
|
||
scheduleId: string,
|
||
booking: Booking,
|
||
reason: "paid" | "gov" | "shipping_line",
|
||
): Promise<void> {
|
||
// Stamp the computed wagon need on the link. Several callers pass a booking
|
||
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
|
||
// wagonsRequired makes every capacity/occupancy reader miscount this
|
||
// booking as 1 wagon — reload with the relations wagonsFor sizes from.
|
||
const wagonDims = await this.loadWagonDims();
|
||
const full =
|
||
booking.bookingContainers || booking.cargoType
|
||
? booking
|
||
: await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: booking.id },
|
||
relations: {
|
||
bookingContainers: { containerType: true },
|
||
cargoType: true,
|
||
},
|
||
});
|
||
const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims);
|
||
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(),
|
||
wagonsRequired,
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
} as never);
|
||
});
|
||
this.logger.log(
|
||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||
);
|
||
this.notifier.secured(booking, reason, scheduleId);
|
||
// Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
|
||
// wagon assignment is for the import/export batch flow only.
|
||
if (booking.tradeDirection !== 'DOMESTIC') {
|
||
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.
|
||
* `reason` picks the customer message: 'payment' (pay window lapsed) or
|
||
* 'no-capacity' (no train on the chosen day could take the booking).
|
||
*
|
||
* PAID GUARD: a booking whose payment has landed is never expired — money was
|
||
* taken, so it boards, even when the webhook arrived after the deadline or the
|
||
* settle read a stale row. It allocates onto the train it was selected for; if
|
||
* the wagon planner then finds no physical wagon, the booking stays linked and
|
||
* staff assign wagons manually. Consolidated bookings are exempt from the
|
||
* rescue: the shared wagon is both-or-neither, and settleReserved owns that
|
||
* pair decision.
|
||
*/
|
||
private async expire(
|
||
booking: Booking,
|
||
reason: "payment" | "no-capacity" = "payment",
|
||
): Promise<void> {
|
||
// Consolidated pair: break the link FIRST, then settle each side singly.
|
||
// - neither paid → both expire, no fee.
|
||
// - one side paid → the paid half keeps the whole wagon (rescued by the
|
||
// paid guard below at no extra cost); the lapsed half expires and owes
|
||
// the cancellation fee (the 'partnerLapsed' event opens the fee invoice
|
||
// in BookingWagonCancellationService).
|
||
// - both paid → nothing to expire; the paid guard rescues.
|
||
if (booking.consolidationPartnerId) {
|
||
const partnerId = booking.consolidationPartnerId;
|
||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||
const partnerRow = await bookingRepo.findOne({
|
||
where: { id: partnerId },
|
||
relations: { company: true },
|
||
});
|
||
const freshSelf = await bookingRepo.findOne({
|
||
where: { id: booking.id },
|
||
});
|
||
const paidOf = (b: Booking | null) =>
|
||
b != null && (b.paymentStatus === "PAID" || b.status === "PAID");
|
||
const selfPaid = paidOf(freshSelf);
|
||
const partnerPaid = paidOf(partnerRow);
|
||
|
||
await this.bookingsRepository.clearConsolidationPair(
|
||
booking.id,
|
||
partnerId,
|
||
);
|
||
booking.consolidationPartnerId = null;
|
||
if (partnerRow) partnerRow.consolidationPartnerId = null;
|
||
|
||
if (selfPaid && !partnerPaid) {
|
||
// Wrong side called first: the lapsed partner is the one that expires
|
||
// (with its fee); this paid booking falls through to the rescue below.
|
||
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
|
||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||
expiredBookingId: partnerRow.id,
|
||
});
|
||
await this.expire(partnerRow, reason);
|
||
}
|
||
} else if (!selfPaid && partnerPaid) {
|
||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||
expiredBookingId: booking.id,
|
||
});
|
||
// fall through: this side expires below; the paid partner is untouched.
|
||
} else if (!selfPaid && !partnerPaid) {
|
||
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
|
||
await this.expire(partnerRow, reason);
|
||
}
|
||
}
|
||
}
|
||
if (!booking.consolidationPartnerId) {
|
||
const fresh = await this.dataSource
|
||
.getRepository(Booking)
|
||
.findOne({ where: { id: booking.id }, relations: { company: true } });
|
||
const paid =
|
||
fresh != null &&
|
||
(fresh.paymentStatus === "PAID" || fresh.status === "PAID");
|
||
const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId;
|
||
if (paid && paidScheduleId) {
|
||
this.logger.log(
|
||
`[BATCH] expire skipped for ${booking.reference} — payment already ` +
|
||
`landed; allocating on schedule ${paidScheduleId} instead`,
|
||
);
|
||
if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
|
||
await this.allocate(paidScheduleId, fresh, "paid");
|
||
}
|
||
return;
|
||
}
|
||
// Paid but detached from any train (staff removed it from an allocation,
|
||
// or a sweep caught it unpinned): money was taken, so it must board — it
|
||
// stays paid-unallocated for staff to place via the allocate action.
|
||
if (paid) {
|
||
this.logger.log(
|
||
`[BATCH] expire skipped for ${booking.reference} — payment landed ` +
|
||
`but no train attached; left paid-unallocated for manual placement`,
|
||
);
|
||
return;
|
||
}
|
||
// Reconcile-before-expire (only when a pay window was actually open):
|
||
// no webhook arrived, so ask the gateway DIRECTLY whether the money
|
||
// landed. A late capture found there is registered as SUCCEEDED and
|
||
// emits payment.succeeded — that event marks the booking PAID and
|
||
// allocates it, so we just leave the hold alone here. `unverifiable`
|
||
// (provider query errored / payment still in flight) means we could not
|
||
// confirm "not paid" — never expire on unknown; the next settle tick
|
||
// asks again.
|
||
// TODO: CBE has no reconcile endpoint yet — re-enable once available.
|
||
// if (reason === "payment" && (fresh?.paymentDeadline ?? booking.paymentDeadline)) {
|
||
// const reconcile = await this.billing.reconcilePayable(booking.id);
|
||
// if (reconcile.paid) {
|
||
// this.logger.log(
|
||
// `[BATCH] expire skipped for ${booking.reference} — gateway ` +
|
||
// `reconcile found a settled payment; payment.succeeded will allocate it`,
|
||
// );
|
||
// return;
|
||
// }
|
||
// if (reconcile.unverifiable) {
|
||
// this.logger.warn(
|
||
// `[BATCH] expire deferred for ${booking.reference} — settlement ` +
|
||
// `unverifiable at the gateway; retrying next settle tick`,
|
||
// );
|
||
// return;
|
||
// }
|
||
// }
|
||
}
|
||
const freedScheduleId = booking.trainScheduleId;
|
||
await this.bookingsRepository.update(booking.id, {
|
||
trainScheduleId: null,
|
||
// The customer's train pick died with the hold — a rebook re-picks.
|
||
requestedTrainScheduleId: null,
|
||
status: "EXPIRED",
|
||
schedulingStatus: "ELIGIBLE",
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
paymentReminderSentAt: 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");
|
||
if (reason === "no-capacity") {
|
||
this.notifier.expiredNoCapacity(booking);
|
||
} else {
|
||
this.notifier.expired(booking);
|
||
}
|
||
this.logger.log(
|
||
`[BATCH] EXPIRED ${booking.reference} — ` +
|
||
(reason === "no-capacity"
|
||
? "no train on its day had capacity left"
|
||
: "payment window passed; freed its wagons back to the pool for top-up"),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* End-of-day sweep: once a schedule's window cycle concludes and NO other
|
||
* train on the same route-day can still run a cycle, the waiting pool for
|
||
* that day is dead — a FULLY_EXECUTED booking left in it would wait forever.
|
||
* Expire every leftover commercial booking and tell the customers to rebook
|
||
* another day. Government bookings are never auto-expired (they preempt).
|
||
* Returns how many bookings were expired.
|
||
*/
|
||
async expireLeftoverDayPool(scheduleId: string): Promise<number> {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (!schedule?.scheduledDepartureDate) return 0;
|
||
const day = eatDay(schedule.scheduledDepartureDate);
|
||
const group: RouteDayGroup = {
|
||
originYardId: schedule.originStationId,
|
||
destinationYardId: schedule.destinationStationId,
|
||
day,
|
||
};
|
||
|
||
// Another train on this route-day that can still take bookings keeps the
|
||
// pool alive — when IT concludes, its own sweep runs this check again.
|
||
const siblings = await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
{
|
||
originStationId: group.originYardId,
|
||
destinationStationId: group.destinationYardId,
|
||
status: TrainScheduleStatusEnum.Draft,
|
||
},
|
||
{
|
||
originStationId: group.originYardId,
|
||
destinationStationId: group.destinationYardId,
|
||
status: TrainScheduleStatusEnum.Scheduled,
|
||
},
|
||
],
|
||
});
|
||
const anotherTrainStillOpen = siblings.some(
|
||
(s) =>
|
||
s.id !== schedule.id &&
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day &&
|
||
s.windowPhase !== "DONE" &&
|
||
s.bookingWindowStatus !== "FULL",
|
||
);
|
||
if (anotherTrainStillOpen) return 0;
|
||
|
||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||
const pool = corridorYards.length
|
||
? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day)
|
||
: await this.bookingsRepository.findBatchPoolByRouteDay(
|
||
group.originYardId,
|
||
group.destinationYardId,
|
||
day,
|
||
);
|
||
const leftovers = pool.filter((b) => !b.isGovernment);
|
||
// Capture pinned schedules BEFORE expire() clears trainScheduleId, so each
|
||
// touched board gets exactly one push at the end of the sweep.
|
||
const touchedScheduleIds = new Set<string>();
|
||
if (leftovers.length) touchedScheduleIds.add(scheduleId);
|
||
for (const booking of leftovers) {
|
||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||
await this.expire(booking, "no-capacity");
|
||
}
|
||
if (leftovers.length) {
|
||
this.logger.log(
|
||
`[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` +
|
||
`expired ${leftovers.length} waiting booking(s)`,
|
||
);
|
||
}
|
||
for (const id of touchedScheduleIds) {
|
||
this.notifyBoardChanged(id, "day_pool_expired");
|
||
}
|
||
return leftovers.length;
|
||
}
|
||
|
||
/**
|
||
* EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
|
||
* so nothing ever concluded its day: bookings still waiting when the trains
|
||
* filled up or the window closed stayed pending forever. Once every export
|
||
* train on this route-day is shut — window DONE, or FULL with no pay window
|
||
* still live that could lapse and free space — the date is dead: expire the
|
||
* un-accepted bookings staff can no longer accept AND the ready
|
||
* (FULLY_EXECUTED) bookings that never got a reservation (consolidation
|
||
* waiters). Runs at export window close and whenever an export train's
|
||
* fullness settles.
|
||
*/
|
||
async expireLeftoverExportDay(scheduleId: string): Promise<void> {
|
||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||
if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
|
||
return;
|
||
}
|
||
const day = eatDay(schedule.scheduledDepartureDate);
|
||
const trains = (
|
||
await this.trainSchedulesRepository.findAll({
|
||
where: [
|
||
{
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
status: TrainScheduleStatusEnum.Draft,
|
||
},
|
||
{
|
||
originStationId: schedule.originStationId,
|
||
destinationStationId: schedule.destinationStationId,
|
||
status: TrainScheduleStatusEnum.Scheduled,
|
||
},
|
||
],
|
||
})
|
||
).filter(
|
||
(s) =>
|
||
s.scheduledDepartureDate != null &&
|
||
eatDay(s.scheduledDepartureDate) === day,
|
||
);
|
||
for (const s of trains) {
|
||
// Any train still taking bookings keeps the date alive.
|
||
if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
|
||
// A FULL train whose reservations are still inside their pay windows can
|
||
// reopen when one lapses unpaid — defer; the settle re-runs this sweep.
|
||
if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
|
||
return;
|
||
}
|
||
}
|
||
await this.expireUnacceptedForRouteDay({
|
||
originYardId: schedule.originStationId,
|
||
destinationYardId: schedule.destinationStationId,
|
||
day,
|
||
});
|
||
await this.expireLeftoverDayPool(scheduleId);
|
||
}
|
||
|
||
/**
|
||
* 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}`,
|
||
);
|
||
}
|
||
// Only bookings pinned to a train show on a board — collect their schedules
|
||
// and push once per schedule after the sweep (most unaccepted rows are
|
||
// unpinned under day-level pooling, so this usually emits nothing).
|
||
const touchedScheduleIds = new Set<string>();
|
||
const swept = new Set<string>();
|
||
for (const booking of unaccepted) {
|
||
if (swept.has(booking.id)) continue;
|
||
swept.add(booking.id);
|
||
// Consolidated pair: the partner may sit outside this route-day's result
|
||
// set (different yards/day/status), so cascade explicitly — an unpaid
|
||
// partner expires with this booking; a PAID partner keeps the whole
|
||
// wagon and this booking owes the cancellation fee (partnerLapsed).
|
||
if (booking.consolidationPartnerId) {
|
||
const partner = await this.dataSource.getRepository(Booking).findOne({
|
||
where: { id: booking.consolidationPartnerId },
|
||
relations: { company: true },
|
||
});
|
||
await this.bookingsRepository.clearConsolidationPair(
|
||
booking.id,
|
||
booking.consolidationPartnerId,
|
||
);
|
||
booking.consolidationPartnerId = null;
|
||
if (partner) {
|
||
const partnerPaid =
|
||
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
||
if (partnerPaid) {
|
||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||
expiredBookingId: booking.id,
|
||
});
|
||
} else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) {
|
||
swept.add(partner.id);
|
||
partner.consolidationPartnerId = null;
|
||
if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId);
|
||
await this.bookingsRepository.update(partner.id, {
|
||
status: "EXPIRED",
|
||
schedulingStatus: "ELIGIBLE",
|
||
scheduledDate: null,
|
||
} as never);
|
||
await this.billing
|
||
.expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID")
|
||
.catch(() => undefined);
|
||
this.notifier.expired(partner);
|
||
this.logger.log(
|
||
`[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||
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`,
|
||
);
|
||
}
|
||
for (const id of touchedScheduleIds) {
|
||
this.notifyBoardChanged(id, "unaccepted_expired");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How many bookings on this route-day would be expired if document review
|
||
* ended right now — i.e. requests staff have neither accepted nor rejected.
|
||
* Same query the doc-review-end sweep runs, so the number staff see is
|
||
* exactly what is at risk.
|
||
*/
|
||
async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
|
||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||
if (corridorYards.length === 0) return 0;
|
||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||
corridorYards,
|
||
group.day,
|
||
);
|
||
return unaccepted.length;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
const victimPaid =
|
||
victim.paymentStatus === "PAID" || victim.status === "PAID";
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||
scheduleId,
|
||
victim.id,
|
||
manager,
|
||
);
|
||
if (victimPaid) {
|
||
// Paid bookings are never expired — money was taken, so it boards.
|
||
// Detach it so it surfaces in the paid-unallocated queue for staff
|
||
// to re-place; the settled invoice stays untouched.
|
||
await manager.getRepository(Booking).update(victim.id, {
|
||
trainScheduleId: null,
|
||
schedulingStatus: "ELIGIBLE",
|
||
paymentDeadline: null,
|
||
selectedForBatchAt: null,
|
||
} as never);
|
||
return;
|
||
}
|
||
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 = bookingCargoTons(primary) + bookingCargoTons(partner);
|
||
|
||
// 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. A pair shares
|
||
// wagons, so the primary's wagon type stands for both partners.
|
||
const dims = this.dimsFor(primary, wagonDims);
|
||
const capacityTons = dims.capacityTons;
|
||
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,
|
||
dims.tareWeightTons,
|
||
),
|
||
lengthMeters: sharedWagons * dims.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}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Maps a booking's pool-entry time (`fullyExecutedAt`) to the 0-based
|
||
* booking-window cycle it arrived in: the last window whose open is at/before
|
||
* the timestamp (a timestamp in the doc-review/payment gap belongs to the
|
||
* cycle that just closed). The cycle grid comes from the schedule's frozen
|
||
* window-rule snapshot — the exact windows the cycle engine runs.
|
||
*/
|
||
private async windowCycleIndexer(
|
||
schedule: TrainSchedule,
|
||
): Promise<(ts: Date | null | undefined) => number> {
|
||
if (!schedule.scheduledDepartureDate) return () => 0;
|
||
let starts: number[];
|
||
try {
|
||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||
const cfg = effectiveWindowConfig(schedule, liveCfg);
|
||
const windows = listConfigBookingWindows(
|
||
schedule.direction,
|
||
schedule.scheduledDepartureDate,
|
||
{
|
||
...cfg,
|
||
reopenGapMinutes:
|
||
schedule.ruleReopenDelayMinutes ??
|
||
cfg.docReviewMinutes + cfg.paymentWindowMinutes,
|
||
},
|
||
);
|
||
starts = windows.map((w) => w.start.getTime());
|
||
} catch (err) {
|
||
// A failed cycle derivation must never block the batch — fall back to one
|
||
// flat cycle (pure priority order, the old behaviour).
|
||
this.logger.warn(
|
||
`Window-cycle derivation failed for schedule ${schedule.id}: ` +
|
||
`${(err as Error).message}`,
|
||
);
|
||
return () => 0;
|
||
}
|
||
return (ts) => {
|
||
if (!ts) return 0;
|
||
const ms = ts.getTime();
|
||
let idx = 0;
|
||
for (let i = 0; i < starts.length; i += 1) {
|
||
if (ms >= starts[i]) idx = i;
|
||
}
|
||
return idx;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Rank the batch pool: government first, then WINDOW CYCLE (bookings compete
|
||
* only within the cycle they arrived in — an earlier cycle's booking always
|
||
* outranks a later cycle's, whatever the scores), then priority score, then
|
||
* oldest. `cycleOf` comes from {@link windowCycleIndexer}.
|
||
*/
|
||
private resortPoolByPriority(
|
||
pool: Booking[],
|
||
cycleOf: (ts: Date | null | undefined) => number = () => 0,
|
||
): void {
|
||
pool.sort(
|
||
(a, b) =>
|
||
Number(b.isGovernment) - Number(a.isGovernment) ||
|
||
cycleOf(a.fullyExecutedAt) - cycleOf(b.fullyExecutedAt) ||
|
||
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 (half a wagon each). The old fallback
|
||
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||
|
||
const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
|
||
const cargoTons = bookingCargoTons(booking);
|
||
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
|
||
// wagon), so divide by the cap where one is configured for this type.
|
||
const tonsPerWagon = bulkTonsPerWagon(
|
||
booking.cargoType,
|
||
booking.cargoType?.wagonTypes?.[0]?.id,
|
||
capacityTons,
|
||
);
|
||
const byWeight =
|
||
cargoTons > 0 && tonsPerWagon > 0 ? Math.ceil(cargoTons / tonsPerWagon) : 0;
|
||
|
||
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
|
||
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
|
||
// `dimsFor` resolved dims from the first allowed wagon type, so charge that
|
||
// same type's configured items-fit alongside its capacity.
|
||
const byItems = bulkItemWagonsRequired(
|
||
booking,
|
||
capacityTons,
|
||
bulkItemsFitFor(booking.cargoType, booking.cargoType?.wagonTypes?.[0]?.id),
|
||
);
|
||
|
||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
const dims = this.dimsFor(booking, wagonDims);
|
||
return {
|
||
wagons,
|
||
weightTons: bookingGrossWeightTons(
|
||
bookingCargoTons(booking),
|
||
wagons,
|
||
dims.tareWeightTons,
|
||
),
|
||
lengthMeters: wagons * dims.lengthMeters,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The wagon count of {@link wagonsFor}, split across the wagon TYPES this
|
||
* particular train stocks — "3 × N35 + 1 × PW2" rather than a bare 4.
|
||
*
|
||
* `wagonsFor` sizes the booking on ONE representative type (the first the
|
||
* cargo type allows), which is all the abstract budget needs. Staff placing a
|
||
* ride-along need the physical picture: how many of each type this schedule
|
||
* must actually give up. So each allowed type is sized on its OWN capacity and
|
||
* items-fit, then filled greedily from the type with the largest per-wagon
|
||
* take, bounded by what the schedule has left of it.
|
||
*
|
||
* Because the stock is per-schedule, the same booking breaks down differently
|
||
* on a train stocking 60T N35s than on one stocking 40T PW2s. Returns [] when
|
||
* the booking's types are unconfigured or the train stocks none of them — the
|
||
* caller then shows the plain total.
|
||
*/
|
||
private wagonBreakdownFor(
|
||
booking: Booking,
|
||
wagonDims: WagonDims,
|
||
stockByTypeId: Map<string, number>,
|
||
codesByTypeId: Map<string, string>,
|
||
): Array<{ wagonTypeId: string; code: string; wagons: number }> {
|
||
const total = this.wagonsFor(booking, wagonDims);
|
||
if (total <= 0) return [];
|
||
|
||
// Per-wagon take of each allowed type ON THIS TRAIN, largest first: a type
|
||
// that swallows more of the booking per wagon needs fewer wagons.
|
||
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||
.filter((o) => o.wagonTypeId && (stockByTypeId.get(o.wagonTypeId) ?? 0) > 0)
|
||
.map((o) => {
|
||
const wagonTypeId = o.wagonTypeId as string;
|
||
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
|
||
// — a type capped lower swallows less per wagon.
|
||
const tonsPerWagon = bulkTonsPerWagon(
|
||
booking.cargoType,
|
||
wagonTypeId,
|
||
o.dims.capacityTons,
|
||
);
|
||
const wagonsIfAlone = Math.max(
|
||
1,
|
||
bulkItemWagonsRequired(
|
||
booking,
|
||
o.dims.capacityTons,
|
||
bulkItemsFitFor(booking.cargoType, wagonTypeId),
|
||
) ||
|
||
(tonsPerWagon > 0
|
||
? Math.ceil(bookingCargoTons(booking) / tonsPerWagon)
|
||
: total),
|
||
);
|
||
return {
|
||
wagonTypeId,
|
||
code: codesByTypeId.get(wagonTypeId) ?? '—',
|
||
available: stockByTypeId.get(wagonTypeId) ?? 0,
|
||
// Share of the whole booking one wagon of this type carries.
|
||
takePerWagon: 1 / wagonsIfAlone,
|
||
};
|
||
})
|
||
.sort((a, b) => b.takePerWagon - a.takePerWagon);
|
||
if (!options.length) return [];
|
||
|
||
// Fill greedily by take, capped by stock; `remaining` is the fraction of the
|
||
// booking still unplaced, so a wagon of any type covers `takePerWagon` of it.
|
||
const out: Array<{ wagonTypeId: string; code: string; wagons: number }> = [];
|
||
let remaining = 1;
|
||
for (const option of options) {
|
||
if (remaining <= 1e-9) break;
|
||
const wagons = Math.min(
|
||
option.available,
|
||
Math.ceil(remaining / option.takePerWagon),
|
||
);
|
||
if (wagons <= 0) continue;
|
||
out.push({ wagonTypeId: option.wagonTypeId, code: option.code, wagons });
|
||
remaining -= wagons * option.takePerWagon;
|
||
}
|
||
// The train cannot hold the whole booking in the types it stocks — the
|
||
// `fits` check already fails it; report only what it CAN take.
|
||
return out;
|
||
}
|
||
|
||
private fits(need: Capacity, budget: Capacity): boolean {
|
||
return (
|
||
need.wagons <= budget.wagons &&
|
||
need.weightTons <= budget.weightTons &&
|
||
need.lengthMeters <= budget.lengthMeters
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* `base` via {@link needFor}, whose weight axis is gross. The locomotive's
|
||
* overage tolerance is returned separately — the corridor budget spends it
|
||
* only to admit a booking whole, never to size a split.
|
||
*
|
||
* Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length
|
||
* caps deliberately do not apply here (a mis-set global row once capped
|
||
* every train at 14m and no export booking could board).
|
||
*/
|
||
private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
|
||
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,
|
||
);
|
||
return {
|
||
base: {
|
||
wagons: derived.maxWagonSlots,
|
||
weightTons: derived.baseWeightTons,
|
||
lengthMeters: derived.baseLengthMeters,
|
||
},
|
||
tolerance: {
|
||
weightTons: derived.toleranceTons,
|
||
lengthMeters: derived.toleranceMeters,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Keep schedule.max_wagons aligned with the train's boarding limit. A built
|
||
* train's limit is its physical consist — the wagon count staff marshalled
|
||
* (and may change via adjust-consist). Only schedules WITHOUT a built train
|
||
* fall back to the locomotive's length-derived slot count, where bookings
|
||
* are admitted on length/weight alone and yard staff attach the wagons
|
||
* manually before departure.
|
||
*/
|
||
private async syncScheduleMaxWagons(
|
||
schedule: TrainSchedule,
|
||
locomotive: LocomotiveLimits,
|
||
): Promise<void> {
|
||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||
const maxWagons =
|
||
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
|
||
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||
await this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.update(schedule.id, { maxWagons });
|
||
schedule.maxWagons = maxWagons;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Every wagon type keyed by id (drives per-booking dims via the cargo/container
|
||
* type's wagon_type_id FK), plus representative fallbacks per freight type
|
||
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
|
||
* no wagon type configured yet.
|
||
*/
|
||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||
* table scan per board/detail request without letting edits go stale long. */
|
||
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
|
||
|
||
private async loadWagonDims(): Promise<WagonDims> {
|
||
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
|
||
return this.wagonDimsCache.value;
|
||
}
|
||
const types = await this.dataSource.getRepository(WagonType).find();
|
||
const byCode = new Map(
|
||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||
);
|
||
const byWagonTypeId = new Map(
|
||
types.map((t) => [t.id, 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;
|
||
const value: WagonDims = {
|
||
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),
|
||
},
|
||
byWagonTypeId,
|
||
};
|
||
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Dimensions of the wagon type THIS booking rides: bulk resolves through its
|
||
* cargo type's allowed wagon-type list, container through the first container
|
||
* line's — the same list resolution the scheduling planner applies when the
|
||
* paid booking is allocated. Board/fill math measured on a representative
|
||
* wagon while allocation validated the real one let a selected batch flunk
|
||
* the post-payment gross-weight check; sharing the resolution closes that
|
||
* gap. Uses the first configured type (the fill engine has no train context);
|
||
* falls back to the representative dims when the list or relation is absent.
|
||
*/
|
||
private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
|
||
const fallback =
|
||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||
const wagonTypeId =
|
||
booking.freightType === "BULK"
|
||
? booking.cargoType?.wagonTypes?.[0]?.id
|
||
: (booking.bookingContainers ?? [])
|
||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||
.map((wagonType) => wagonType.id)
|
||
.find((id): id is string => Boolean(id));
|
||
const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
|
||
if (!dims) return fallback;
|
||
return {
|
||
...dims,
|
||
capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
|
||
* full allowed (many-to-many) wagon-type list, not just the first like
|
||
* {@link dimsFor}. The remainder placer needs the whole set so a train that
|
||
* stocks a non-primary allowed type still counts as fitting: a container type
|
||
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
|
||
* train actually has free. Deduped by wagon-type id; falls back to the single
|
||
* representative dims when no allowed type is configured.
|
||
*/
|
||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||
return this.allowedDimsWithTypes(booking, wagonDims).map((p) => p.dims);
|
||
}
|
||
|
||
/**
|
||
* Same allowed set as {@link dimsForAllowed} but keeping each wagon-type id,
|
||
* so callers (the export train picker) can label per-type availability.
|
||
* `wagonTypeId` is null only on the unconfigured fallback entry.
|
||
*/
|
||
private allowedDimsWithTypes(
|
||
booking: Booking,
|
||
wagonDims: WagonDims,
|
||
): Array<{ wagonTypeId: string | null; dims: PerWagonDims }> {
|
||
const fallback =
|
||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||
const ids =
|
||
booking.freightType === "BULK"
|
||
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
|
||
: (booking.bookingContainers ?? [])
|
||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||
.map((wt) => wt.id);
|
||
const seen = new Set<string>();
|
||
const out: Array<{ wagonTypeId: string | null; dims: PerWagonDims }> = [];
|
||
for (const id of ids) {
|
||
if (!id || seen.has(id)) continue;
|
||
seen.add(id);
|
||
const d = wagonDims.byWagonTypeId.get(id);
|
||
if (d) {
|
||
out.push({
|
||
wagonTypeId: id,
|
||
dims: {
|
||
...d,
|
||
capacityTons:
|
||
d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
return out.length ? out : [{ wagonTypeId: null, dims: fallback }];
|
||
}
|
||
|
||
/**
|
||
* Physical wagon-type stock for one schedule, on the same corridor edges its
|
||
* {@link CorridorBudget} uses. Sourced from the scheduling service so the
|
||
* batch counts exactly the wagons the allocator will later plan against.
|
||
*/
|
||
private async stockLedgerFor(
|
||
schedule: TrainSchedule,
|
||
budget: CorridorBudget,
|
||
excludeBookingIds?: string[],
|
||
): Promise<WagonStockLedger> {
|
||
const stock = await this.trainSchedulingService.wagonStockForSchedule(
|
||
schedule.id,
|
||
schedule.originStationId,
|
||
budget.stops,
|
||
);
|
||
const ledger = new WagonStockLedger(
|
||
stock.remainingByTypeId,
|
||
Math.max(1, budget.stops.length - 1),
|
||
stock.byYardId,
|
||
budget.stops,
|
||
);
|
||
// Wagons staff cut mid-route are not stock past their cut stop.
|
||
ledger.debitCutWagons(stock.cutWagons ?? []);
|
||
// Debit what is already committed, per boarding yard and wagon type — the
|
||
// same bookings the corridor budget subtracted. A booking with no resolvable
|
||
// wagon type still occupies steel, so it drains any type at its yard.
|
||
const [wagonDims, allowed] = await Promise.all([
|
||
this.loadWagonDims(),
|
||
this.loadAllowedWagonTypeIds(),
|
||
]);
|
||
const anyType = [...stock.remainingByTypeId.keys()];
|
||
const committed = await this.committedBookings(schedule, excludeBookingIds);
|
||
// Debit committed PER_TON bulk the way it was SEATED — per type at the
|
||
// cargo's caps, scarcest type first — not a one-type wagon count drained
|
||
// deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds
|
||
// 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that
|
||
// were already spoken for).
|
||
const rank = this.scarcityRankForPool(committed, allowed);
|
||
for (const b of committed) {
|
||
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
|
||
const leg = budget.legForYards(b.originYardId, b.destinationYardId);
|
||
const perItemBulk =
|
||
Number(b.bulkTotalWeightTons ?? 0) > 0 &&
|
||
Number(b.cargoTotalWeightVgm ?? 0) > 0;
|
||
if (b.freightType === "BULK" && !perItemBulk && typeIds.length) {
|
||
const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank, typeIds);
|
||
if (smart) {
|
||
for (const part of smart.perType) {
|
||
ledger.consume([part.wagonTypeId], part.wagons, leg);
|
||
}
|
||
continue;
|
||
}
|
||
// Over-committed (stock cannot seat it any more) — drain what exists,
|
||
// same as before, so the shortage stays visible to the gates.
|
||
}
|
||
ledger.consume(
|
||
typeIds.length ? typeIds : anyType,
|
||
this.wagonsFor(b, wagonDims),
|
||
leg,
|
||
);
|
||
}
|
||
return ledger;
|
||
}
|
||
|
||
/**
|
||
* Whether the train holds enough PHYSICAL wagons of the types this booking may
|
||
* ride. Unresolvable configuration (no allowed wagon type) returns true: the
|
||
* abstract budget still governs, and a mis-configured cargo type must not
|
||
* silently strand every booking that uses it.
|
||
*/
|
||
private hasWagonStock(
|
||
stock: WagonStockLedger,
|
||
wagonTypeIds: string[],
|
||
wagonsNeeded: number,
|
||
leg: CorridorLeg,
|
||
): boolean {
|
||
if (!wagonTypeIds.length) return true;
|
||
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
|
||
}
|
||
|
||
/**
|
||
* Scarcity rank over the day pool: how many distinct demand groups (bulk
|
||
* cargo types / container types among these bookings) may ride each wagon
|
||
* type. The batch seats least-shareable types first, so bulk with a
|
||
* bulk-only alternative (PW2) never eats the container-capable stock (NW5)
|
||
* that containers cannot substitute.
|
||
*/
|
||
private scarcityRankForPool(
|
||
pool: Booking[],
|
||
allowed: {
|
||
byCargoTypeId: Map<string, string[]>;
|
||
byContainerTypeId: Map<string, string[]>;
|
||
},
|
||
): Map<string, number> {
|
||
const groups = new Map<string, string[]>();
|
||
for (const b of pool) {
|
||
if (b.freightType === "BULK") {
|
||
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
|
||
if (cargoTypeId) {
|
||
groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
|
||
}
|
||
} else {
|
||
for (const line of b.bookingContainers ?? []) {
|
||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||
if (containerTypeId) {
|
||
groups.set(
|
||
`C:${containerTypeId}`,
|
||
allowed.byContainerTypeId.get(containerTypeId) ?? [],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
const rank = new Map<string, number>();
|
||
for (const ids of groups.values()) {
|
||
for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1);
|
||
}
|
||
return rank;
|
||
}
|
||
|
||
/**
|
||
* Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the
|
||
* wagon types this train actually has free on its leg — the same policy the
|
||
* wagon planner applies at allocation time (least-shareable type first, each
|
||
* wagon filled to the cargo type's per-wagon cap, one booking per wagon).
|
||
*
|
||
* This is the payment gate's real fit check for bulk: the generic
|
||
* `hasWagonStock` sums free wagons across allowed types against a count
|
||
* sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free"
|
||
* when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons.
|
||
* Returns the exact per-type counts and the three-axis capacity they
|
||
* consume, or null when the free stock cannot seat the whole booking.
|
||
*/
|
||
private smartBulkNeed(
|
||
booking: Booking,
|
||
wagonDims: WagonDims,
|
||
stock: WagonStockLedger,
|
||
leg: CorridorLeg,
|
||
scarcityRank: Map<string, number>,
|
||
/**
|
||
* Wagon-type ids this booking may ride, from {@link loadAllowedWagonTypeIds}
|
||
* — NEVER from `booking.cargoType.wagonTypes`. The batch pool finders
|
||
* deliberately do not join that relation (hot path), so on a pool entity
|
||
* it is always empty; resolving through it made every PER_TON bulk booking
|
||
* unseatable — no whole fit and no partial offer, silently READY forever
|
||
* (the S-2026-00020 / BK-2026-000036 incident).
|
||
*/
|
||
wagonTypeIds: readonly string[],
|
||
): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null {
|
||
const options = [...new Set(wagonTypeIds)]
|
||
.map((wagonTypeId) => ({ wagonTypeId, dims: wagonDims.byWagonTypeId.get(wagonTypeId) }))
|
||
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.dims != null)
|
||
.map((o) => ({
|
||
...o,
|
||
free: stock.availableFor([o.wagonTypeId], leg),
|
||
takePerWagon: bulkTonsPerWagon(
|
||
booking.cargoType,
|
||
o.wagonTypeId,
|
||
o.dims.capacityTons,
|
||
),
|
||
}))
|
||
.filter((o) => o.free > 0 && o.takePerWagon > 0)
|
||
.sort(
|
||
(a, b) =>
|
||
(scarcityRank.get(a.wagonTypeId) ?? 1) -
|
||
(scarcityRank.get(b.wagonTypeId) ?? 1) ||
|
||
b.takePerWagon - a.takePerWagon,
|
||
);
|
||
|
||
let remaining = bookingCargoTons(booking);
|
||
if (remaining <= 0) return null;
|
||
const perType: Array<{ wagonTypeId: string; wagons: number }> = [];
|
||
let weightTons = remaining; // gross: cargo plus each seated wagon's tare
|
||
let lengthMeters = 0;
|
||
let wagons = 0;
|
||
for (const option of options) {
|
||
if (remaining <= 1e-9) break;
|
||
const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon));
|
||
if (take <= 0) continue;
|
||
remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon));
|
||
wagons += take;
|
||
weightTons += take * option.dims.tareWeightTons;
|
||
lengthMeters += take * option.dims.lengthMeters;
|
||
perType.push({ wagonTypeId: option.wagonTypeId, wagons: take });
|
||
}
|
||
if (remaining > 1e-9) return null;
|
||
return {
|
||
need: {
|
||
wagons,
|
||
weightTons: roundTons(weightTons),
|
||
lengthMeters: roundTons(lengthMeters),
|
||
},
|
||
perType,
|
||
};
|
||
}
|
||
|
||
private allowedWagonTypeCache: {
|
||
byCargoTypeId: Map<string, string[]>;
|
||
byContainerTypeId: Map<string, string[]>;
|
||
expiresAt: number;
|
||
} | null = null;
|
||
|
||
/**
|
||
* Wagon-type ids each cargo / container type may ride, read straight from the
|
||
* join tables.
|
||
*
|
||
* The batch pool finders deliberately do NOT join `cargoType.wagonTypes` /
|
||
* `containerType.wagonTypes` — those many-to-many joins multiply rows badly on
|
||
* a hot path. So the pool's booking entities carry the type FK but not the
|
||
* allowed list, and resolving it per booking through the relation would come
|
||
* back empty. Two small lookups, cached for a minute like {@link loadWagonDims},
|
||
* give the same answer without touching the pool query.
|
||
*/
|
||
private async loadAllowedWagonTypeIds(): Promise<{
|
||
byCargoTypeId: Map<string, string[]>;
|
||
byContainerTypeId: Map<string, string[]>;
|
||
}> {
|
||
if (this.allowedWagonTypeCache && this.allowedWagonTypeCache.expiresAt > Date.now()) {
|
||
return this.allowedWagonTypeCache;
|
||
}
|
||
// Inactive wagon types are excluded, matching loadAllowedWagonTypes() in the
|
||
// scheduling service — the allocator will not plan against them either.
|
||
const [cargoRows, containerRows]: [
|
||
Array<{ typeId: string; wagonTypeId: string }>,
|
||
Array<{ typeId: string; wagonTypeId: string }>,
|
||
] = await Promise.all([
|
||
this.dataSource.query(
|
||
`SELECT ct.cargo_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||
FROM freight.cargo_type_wagon_types ct
|
||
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||
WHERE wt.is_active IS NOT FALSE`,
|
||
),
|
||
this.dataSource.query(
|
||
`SELECT ct.container_type_id AS "typeId", ct.wagon_type_id AS "wagonTypeId"
|
||
FROM freight.container_type_wagon_types ct
|
||
JOIN freight.wagon_types wt ON wt.id = ct.wagon_type_id
|
||
WHERE wt.is_active IS NOT FALSE`,
|
||
),
|
||
]);
|
||
|
||
const collect = (rows: Array<{ typeId: string; wagonTypeId: string }>) => {
|
||
const map = new Map<string, string[]>();
|
||
for (const row of rows) {
|
||
const list = map.get(row.typeId) ?? [];
|
||
list.push(row.wagonTypeId);
|
||
map.set(row.typeId, list);
|
||
}
|
||
return map;
|
||
};
|
||
|
||
const value = {
|
||
byCargoTypeId: collect(cargoRows),
|
||
byContainerTypeId: collect(containerRows),
|
||
};
|
||
this.allowedWagonTypeCache = { ...value, expiresAt: Date.now() + 60_000 };
|
||
return value;
|
||
}
|
||
|
||
/**
|
||
* Every wagon-type id this booking may ride. Empty means "unresolvable" — the
|
||
* caller must then skip the physical-stock gate rather than block the booking
|
||
* on missing configuration.
|
||
*/
|
||
private allowedWagonTypeIdsFor(
|
||
booking: Booking,
|
||
allowed: {
|
||
byCargoTypeId: Map<string, string[]>;
|
||
byContainerTypeId: Map<string, string[]>;
|
||
},
|
||
): string[] {
|
||
if (booking.freightType === "BULK") {
|
||
const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id;
|
||
return cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : [];
|
||
}
|
||
const ids = new Set<string>();
|
||
for (const line of booking.bookingContainers ?? []) {
|
||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||
if (!containerTypeId) continue;
|
||
for (const id of allowed.byContainerTypeId.get(containerTypeId) ?? []) {
|
||
ids.add(id);
|
||
}
|
||
}
|
||
return [...ids];
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* Two capacity regimes, decided by the schedule's train:
|
||
* - Built train (Train Builder consist with physical wagons): the consist IS
|
||
* the capacity. Wagon slots = physical wagon count; weight and length are
|
||
* NOT re-checked here — the builder and adjust-consist already enforced the
|
||
* locomotive's pull/length limits when the consist was assembled.
|
||
* - No built train (legacy schedules): the locomotive's length-derived slot
|
||
* count plus its weight/length budgets, as before — yard staff attach the
|
||
* missing wagons manually before wagon assignment.
|
||
*/
|
||
private async remainingBudget(
|
||
schedule: TrainSchedule,
|
||
limits: TrainLimits,
|
||
wagonDims: WagonDims,
|
||
excludeBookingIds?: string[],
|
||
): Promise<CorridorBudget> {
|
||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||
if (physicalWagons != null) {
|
||
limits = {
|
||
base: {
|
||
wagons: physicalWagons,
|
||
weightTons: Number.POSITIVE_INFINITY,
|
||
lengthMeters: Number.POSITIVE_INFINITY,
|
||
},
|
||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||
};
|
||
}
|
||
// Built trains keep the leg-aware multi-edge corridor too: the wagon
|
||
// planner consumes stock per edge (planWagonsWithStock legs), so a consist
|
||
// wagon serves disjoint legs — capacity freed past an alight yard is real.
|
||
const stops = await this.stopsForSchedule(schedule);
|
||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||
// Wagons staff plan to cut mid-route are gone from every edge past the cut.
|
||
// ponytail: the wagon-type stock ledger stays cut-blind; bucket
|
||
// builtTrainStock by (yard, reach) if mixed-type cut trains appear.
|
||
subtractCutWagons(budget, schedule.plannedWagonCutYards);
|
||
// Planned couples add a slot from their couple stop onward.
|
||
addCoupledWagons(budget, schedule.plannedWagonCouples);
|
||
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||
budget.subtract(
|
||
this.needFor(b, wagonDims),
|
||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||
);
|
||
}
|
||
return budget;
|
||
}
|
||
|
||
/**
|
||
* Every booking already holding capacity on the schedule: allocated (linked),
|
||
* live-reserved (unexpired pay window or paid), and pending export requests
|
||
* that named this train. The ONE list both the abstract corridor budget and
|
||
* the per-yard wagon-type ledger must debit — when only the budget saw them,
|
||
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
|
||
* still advertised "15 free" there, because the whole-train budget had room
|
||
* left on that edge (from the other yard's wagons) and the ledger was born
|
||
* full.
|
||
*/
|
||
private async committedBookings(
|
||
schedule: TrainSchedule,
|
||
excludeBookingIds?: string[],
|
||
): Promise<Booking[]> {
|
||
const allocated = (schedule.scheduleBookings ?? [])
|
||
.map((sb) => sb.booking)
|
||
.filter((b): b is Booking => Boolean(b));
|
||
// Lazy-expiry guard: a hold whose deadline AND drain tail lapsed no longer
|
||
// blocks capacity, even before the 10s sweep flips it to EXPIRED —
|
||
// availability shown to the next customer is honest between ticks. The drain
|
||
// has to be honoured here too: releasing the wagons at the raw deadline
|
||
// would resell them to someone else while the paying customer's settlement
|
||
// is still in flight, stranding it into WAITING_FOR_WAGON.
|
||
const deadlineCutoff = Date.now();
|
||
const reserved = (
|
||
await this.bookingsRepository.findReservedForSchedule(schedule.id)
|
||
).filter(
|
||
(b) =>
|
||
b.paymentStatus === "PAID" ||
|
||
b.status === "PAID" ||
|
||
!payWindowLapsed(b.paymentDeadline, deadlineCutoff),
|
||
);
|
||
// Export FCFS: a customer's pending operation request HOLDS its wagons from
|
||
// the moment it is submitted — the request named this exact train
|
||
// (requestedTrainScheduleId), so its capacity must not be shown to or
|
||
// booked by anyone else while staff review it. The booking(s) being
|
||
// evaluated are excluded so a request never blocks its own accept.
|
||
const pendingHolds = (
|
||
await this.dataSource.getRepository(Booking).find({
|
||
where: {
|
||
requestedTrainScheduleId: schedule.id,
|
||
status: 'OPERATION_REQUEST_PENDING',
|
||
} as never,
|
||
relations: ['bookingContainers'],
|
||
})
|
||
).filter((b) => !excludeBookingIds?.includes(b.id));
|
||
// A booking can sit in more than one set (allocated AND still reserved);
|
||
// it holds its wagons once.
|
||
const seen = new Set<string>();
|
||
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
|
||
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
|
||
seen.add(b.id);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Physical wagons marshalled in the schedule's built train, or null when the
|
||
* schedule has NO built train and the legacy locomotive-derived capacity must
|
||
* apply. This count is what caps a built train's bookings: 50 wagons coupled
|
||
* → 50 wagon slots, no more.
|
||
*
|
||
* A built train with an EMPTY consist returns 0, NOT null: zero coupled
|
||
* wagons means zero capacity. Folding that case into null used to hand an
|
||
* un-consisted train the abstract locomotive budget, so an empty train
|
||
* advertised its full maxWagons as free space and accepted bookings the
|
||
* allocator could never place.
|
||
*/
|
||
private async builtTrainWagonCount(
|
||
schedule: TrainSchedule,
|
||
): Promise<number | null> {
|
||
const trainId = schedule.trainSet?.train?.id;
|
||
if (!trainId) return null;
|
||
return this.dataSource.getRepository(Wagon).count({ where: { trainId } });
|
||
}
|
||
|
||
/**
|
||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
|
||
* FULL signal is {@link isTrainFull}, which also closes weight/length-bound
|
||
* trains that still show free slots.
|
||
*/
|
||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||
const wagonDims = await this.loadWagonDims();
|
||
const budget = await this.remainingBudget(
|
||
schedule,
|
||
{
|
||
base: {
|
||
wagons: schedule.maxWagons ?? 0,
|
||
weightTons: Number.POSITIVE_INFINITY,
|
||
lengthMeters: Number.POSITIVE_INFINITY,
|
||
},
|
||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||
},
|
||
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 — including its
|
||
* drain tail, so the cycle cannot conclude out from under a settlement that is
|
||
* still in flight.
|
||
*
|
||
* 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 &&
|
||
!payWindowLapsed(b.paymentDeadline, now),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* FULL is CORRIDOR-WIDE: the train is full only when NO leg can take one
|
||
* more minimal wagon on any axis — slots for built trains (the consist is
|
||
* the capacity, weight/length settled at build), all three axes otherwise
|
||
* (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
|
||
* A full DCT→Dire leg alone does NOT close the window while Dire→GMP still
|
||
* has room — sub-corridor bookings keep selling the open legs.
|
||
*/
|
||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) return false;
|
||
return this.isTrainFull(schedule);
|
||
}
|
||
|
||
/**
|
||
* Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the
|
||
* schedule's slot capacity, how many slots allocated + reserved bookings
|
||
* already hold on the busiest edge, how many are still free on the most-open
|
||
* edge, and by how many slots the consist has been trimmed BELOW what is
|
||
* already committed (0 when nothing is over-allocated).
|
||
*/
|
||
async scheduleWagonUsage(scheduleId: string): Promise<{
|
||
maxWagons: number;
|
||
allocatedWagons: number;
|
||
remainingSlots: number;
|
||
overAllocatedBy: number;
|
||
} | null> {
|
||
const schedule =
|
||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||
if (!schedule) return null;
|
||
const capacity =
|
||
(await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0;
|
||
const wagonDims = await this.loadWagonDims();
|
||
const budget = await this.remainingBudget(
|
||
schedule,
|
||
{
|
||
base: {
|
||
wagons: capacity,
|
||
weightTons: Number.POSITIVE_INFINITY,
|
||
lengthMeters: Number.POSITIVE_INFINITY,
|
||
},
|
||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||
},
|
||
wagonDims,
|
||
);
|
||
const tightest = budget.remainingFor(budget.fullLeg()).wagons;
|
||
return {
|
||
maxWagons: capacity,
|
||
allocatedWagons: capacity - tightest,
|
||
remainingSlots: Math.max(0, budget.maxRemaining().wagons),
|
||
overAllocatedBy: Math.max(0, -tightest),
|
||
};
|
||
}
|
||
|
||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||
// Full only when EVERY edge is closed on some axis: a full border edge
|
||
// still leaves the home-side legs bookable by sub-corridor cargo, so the
|
||
// window must stay open until not even the smallest wagon fits anywhere.
|
||
const wagonDims = await this.loadWagonDims();
|
||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||
let limits: TrainLimits;
|
||
if (physicalWagons != null) {
|
||
// The consist is the capacity; weight/length were settled at build time.
|
||
// remainingBudget swaps in the physical wagon count per edge itself.
|
||
limits = {
|
||
base: {
|
||
wagons: physicalWagons,
|
||
weightTons: Number.POSITIVE_INFINITY,
|
||
lengthMeters: Number.POSITIVE_INFINITY,
|
||
},
|
||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||
};
|
||
} else {
|
||
const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
|
||
// No loco, no built train: only the slot axis exists to bind against.
|
||
if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
|
||
limits = await this.capacityLimits(locomotive);
|
||
}
|
||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||
const minNeed = this.minPerWagonNeed(wagonDims);
|
||
return budget.isExhausted(minNeed);
|
||
}
|
||
|
||
/**
|
||
* Smallest gross weight / shortest length one more wagon could add: the
|
||
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
|
||
* so FULL is only declared when not even this wagon fits anywhere.
|
||
*/
|
||
private minPerWagonNeed(wagonDims: WagonDims): {
|
||
grossWeightTons: number;
|
||
lengthMeters: number;
|
||
} {
|
||
const all = [
|
||
wagonDims.container,
|
||
wagonDims.bulk,
|
||
...wagonDims.byWagonTypeId.values(),
|
||
];
|
||
return {
|
||
grossWeightTons: Math.min(
|
||
...all.map((d) => d.tareWeightTons + d.capacityTons),
|
||
),
|
||
lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
// Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
|
||
// just because slots remain — clearing it here would reopen a train
|
||
// nothing can board.
|
||
if (await this.isTrainFull(schedule)) return;
|
||
|
||
// FULL concluded the cycle (phase DONE) and DONE rows are skipped by the
|
||
// window tick forever — so when wagons free up before departure, restart
|
||
// the cycle or nobody (customer or batch) can ever book the freed space.
|
||
// ponytail: reopens now and closes at departure; the office-hours clamp
|
||
// reapplies on the next conclude cycle.
|
||
const departure = schedule.scheduledDepartureDate;
|
||
if (
|
||
schedule.windowPhase === "DONE" &&
|
||
["DRAFT", "SCHEDULED"].includes(schedule.status) &&
|
||
departure &&
|
||
departure.getTime() > Date.now()
|
||
) {
|
||
await this.dataSource.getRepository(TrainSchedule).update(scheduleId, {
|
||
windowPhase: "PRE_WINDOW",
|
||
windowOpensAt: new Date(),
|
||
windowClosesAt: departure,
|
||
});
|
||
await this.setWindow(scheduleId, "OPEN");
|
||
this.logger.log(
|
||
`[BATCH] ${scheduleId} FULL cleared after wagons freed — window revived ` +
|
||
`(PRE_WINDOW, reopens immediately, closes at departure)`,
|
||
);
|
||
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 -------------------------------------------------------
|
||
|
||
/**
|
||
* Effective customer pay window in ms for a target schedule: the staff
|
||
* per-schedule override wins, else the global value for the schedule's
|
||
* direction (export and import pay windows are tuned independently).
|
||
* No schedule (unknown target) falls back to the import global.
|
||
*/
|
||
private async paymentWindowMsFor(
|
||
schedule?: Pick<
|
||
TrainSchedule,
|
||
"direction" | "rulePaymentWindowMinutes"
|
||
> | null,
|
||
): Promise<number> {
|
||
if (schedule?.rulePaymentWindowMinutes != null) {
|
||
return schedule.rulePaymentWindowMinutes * 60_000;
|
||
}
|
||
const cfg = await this.trainSchedulingService.getWindowConfig();
|
||
const minutes =
|
||
schedule?.direction === "EXPORT"
|
||
? cfg.exportPaymentWindowMinutes
|
||
: cfg.paymentWindowMinutes;
|
||
return minutes * 60_000;
|
||
}
|
||
|
||
private scheduleById(id: string): Promise<TrainSchedule | null> {
|
||
return this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.findOne({ where: { id } });
|
||
}
|
||
|
||
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.scheduleById(scheduleId)
|
||
// + drain tail: firing at the raw deadline is a guaranteed no-op pass now
|
||
// that nothing expires until the tail passes.
|
||
.then((schedule) => this.paymentWindowMsFor(schedule))
|
||
.then((windowMs: number) => windowMs + paymentDrainMs())
|
||
.then((delayMs: number) => {
|
||
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.paymentWindowMsFor(schedule);
|
||
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
|
||
}
|
||
}
|
||
}
|