changes export flow

This commit is contained in:
Marshal
2026-07-30 11:30:34 +00:00
parent b671f07ce6
commit 2ca04b8f98
47 changed files with 1195 additions and 74 deletions

View File

@@ -61,11 +61,14 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_GRACE_MS,
PAYMENT_REMINDER_LEAD_MS,
} from "./booking-batch.constants";
import {
LocomotiveLimits,
WagonTypeDimensions,
bookingCargoTons,
bulkItemWagonsRequired,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -115,6 +118,32 @@ export interface ExportSpaceReport {
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;
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;
}>;
}
/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
originYardId: string;
@@ -663,12 +692,16 @@ export class BookingBatchService implements OnModuleInit {
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
// 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),
this.isFillable(s) &&
(!requestedId || s.id === requestedId),
)
.sort(
(a, b) =>
@@ -755,13 +788,18 @@ export class BookingBatchService implements OnModuleInit {
/** 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 'No export train is accepting bookings for this day';
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 =
'Not enough train space — an export booking must ride a single train whole, ' +
'and no open train on this day can carry it. ';
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.';
}
@@ -864,6 +902,88 @@ export class BookingBatchService implements OnModuleInit {
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,
): Promise<ExportTrainOption[]> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
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);
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);
const byWagonType = allowed.map(({ wagonTypeId, dims }) => {
const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined;
return {
wagonTypeId,
code: type?.code ?? null,
name: type?.name ?? null,
freeWagons: this.bookableWithin(room, dims).wagons,
};
});
const freeWagons = byWagonType.reduce(
(best, t) => Math.max(best, t.freeWagons),
0,
);
out.push({
scheduleId: schedule.id,
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
@@ -2207,7 +2327,10 @@ export class BookingBatchService implements OnModuleInit {
};
if (!this.fits(offeredNeed, budget)) return null;
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
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.
@@ -2216,6 +2339,7 @@ export class BookingBatchService implements OnModuleInit {
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: new Date(),
paymentDeadline: deadline,
paymentReminderSentAt: null,
} as never);
booking.trainScheduleId = scheduleId;
return offeredNeed;
@@ -2244,9 +2368,11 @@ export class BookingBatchService implements OnModuleInit {
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
// Grace: a payment started inside the window may land minutes late via the
// gateway webhook — don't expire until the slack has passed too.
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
? b.paymentDeadline.getTime() + PAYMENT_GRACE_MS <= now
: expireUnpaidUnknownDeadline;
for (const booking of reserved) {
@@ -2544,6 +2670,39 @@ export class BookingBatchService implements OnModuleInit {
this.notifyBoardChanged(newScheduleId, "booking_moved");
}
/**
* 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
@@ -2564,6 +2723,52 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* 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 ---------------------------------------------
/**
@@ -2648,14 +2853,14 @@ export class BookingBatchService implements OnModuleInit {
return;
}
const now = new Date();
let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
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.
const targetSchedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (targetSchedule?.direction === "EXPORT") {
const cutoff =
targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
@@ -2673,6 +2878,7 @@ export class BookingBatchService implements OnModuleInit {
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,
@@ -2860,10 +3066,13 @@ export class BookingBatchService implements OnModuleInit {
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
@@ -3406,7 +3615,11 @@ export class BookingBatchService implements OnModuleInit {
const byWeight =
cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
// Break-bulk (PER_ITEM): indivisible items can need more wagons than raw
// tonnage suggests (floor items-per-wagon loses the fractional capacity).
const byItems = bulkItemWagonsRequired(booking, capacityTons);
return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight, byItems);
}
/**
@@ -3602,6 +3815,18 @@ export class BookingBatchService implements OnModuleInit {
* 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 =
@@ -3611,19 +3836,23 @@ export class BookingBatchService implements OnModuleInit {
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wt) => wt.id);
const seen = new Set<string>();
const dims: PerWagonDims[] = [];
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) {
dims.push({
...d,
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
out.push({
wagonTypeId: id,
dims: {
...d,
capacityTons:
d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
},
});
}
}
return dims.length ? dims : [fallback];
return out.length ? out : [{ wagonTypeId: null, dims: fallback }];
}
/**
@@ -3808,8 +4037,18 @@ export class BookingBatchService implements OnModuleInit {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
const reserved = await this.bookingsRepository.findReservedForSchedule(
schedule.id,
// Lazy-expiry guard: a hold whose deadline + grace has lapsed no longer
// blocks capacity, even before the 10s sweep flips it to EXPIRED — so
// availability shown to the next customer is honest between ticks.
const graceCutoff = Date.now() - PAYMENT_GRACE_MS;
const reserved = (
await this.bookingsRepository.findReservedForSchedule(schedule.id)
).filter(
(b) =>
b.paymentStatus === "PAID" ||
b.status === "PAID" ||
b.paymentDeadline == null ||
b.paymentDeadline.getTime() > graceCutoff,
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
@@ -3905,7 +4144,7 @@ export class BookingBatchService implements OnModuleInit {
b.paymentStatus !== "PAID" &&
b.status !== "PAID" &&
b.paymentDeadline != null &&
b.paymentDeadline.getTime() > now,
b.paymentDeadline.getTime() + PAYMENT_GRACE_MS > now,
);
}
@@ -4089,10 +4328,33 @@ export class BookingBatchService implements OnModuleInit {
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */
private async paymentWindowMs(): Promise<number> {
/**
* 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();
return cfg.paymentWindowMinutes * 60_000;
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 {
@@ -4104,8 +4366,12 @@ export class BookingBatchService implements OnModuleInit {
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
*/
private armSettle(scheduleId: string): void {
void this.paymentWindowMs()
.then((delayMs) => {
void this.scheduleById(scheduleId)
.then((schedule) => this.paymentWindowMsFor(schedule))
// The timer covers the grace too — firing at the bare deadline would
// settle before the sweep's grace cutoff and find nothing to expire.
.then((windowMs: number) => {
const delayMs = windowMs + PAYMENT_GRACE_MS;
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
@@ -4138,7 +4404,7 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
const windowMs = await this.paymentWindowMs();
const windowMs = await this.paymentWindowMsFor(schedule);
let target = new Date(Date.now() + windowMs);
if (
schedule.scheduledDepartureDate &&