Implement clearance-first booking flow and completion process for customs contracts

This commit is contained in:
Marshal
2026-07-10 21:51:59 +00:00
parent 9ed473c309
commit 1121e9ce82
19 changed files with 482 additions and 106 deletions

View File

@@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit {
where: { id: bookingId },
relations: { company: true },
});
if (!booking?.trainScheduleId) return;
if (!booking) return;
if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding —
// scream so staff pin it to a schedule manually (batch board / assign).
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
this.logger.error(
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
`its reservation was likely expired before the payment landed. ` +
`Assign it to a schedule manually from the batch board.`,
);
}
return;
}
const isBatchPaid =
booking.status === "SELECTED_FOR_BATCH" ||
@@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit {
* 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): Promise<void> {
private async expire(
booking: Booking,
reason: "payment" | "no-capacity" = "payment",
): Promise<void> {
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`,
);
await this.allocate(paidScheduleId, fresh, "paid");
return;
}
}
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
@@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit {
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
if (reason === "no-capacity") {
this.notifier.expiredNoCapacity(booking);
} else {
this.notifier.expired(booking);
}
this.logger.log(
`[BATCH] EXPIRED ${booking.reference} payment window passed; freed its ` +
`wagons back to the pool for top-up`,
`[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);
for (const booking of leftovers) {
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)`,
);
}
return leftovers.length;
}
/**
* 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