mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Implement clearance-first booking flow and completion process for customs contracts
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user