Implement consolidated booking handling and UI updates for shared wagons

This commit is contained in:
Marshal
2026-07-03 05:07:23 +00:00
parent bb2cedc87f
commit 75cda22a48
7 changed files with 361 additions and 85 deletions

View File

@@ -993,12 +993,14 @@ export class BookingTransitionService {
// Export is FCFS: fail the accept up-front (409) when no export train on the
// booking's day still has capacity — nothing below runs and the request stays
// pending for staff to move/decline.
// pending for staff to move/decline. (For a consolidated pair this is a rough
// solo pre-check; the real combined-capacity reservation happens after the
// booking is FULLY_EXECUTED, once both partners are ready.)
const isExportTrain =
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
const exportScheduleId = isExportTrain
? await this.bookingBatchService.pickExportSchedule(booking)
: null;
if (isExportTrain) {
await this.bookingBatchService.pickExportSchedule(booking);
}
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
@@ -1023,11 +1025,12 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
if (exportScheduleId) {
if (isExportTrain) {
// FCFS: reserve the slot and send the payment notification immediately;
// paid → auto-allocated by the settle/paid pipeline.
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
// only reserve once both partners are FULLY_EXECUTED (handled inside).
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
await this.bookingBatchService.acceptExportBooking(fresh);
} else if (booking.tradeDirection === "IMPORT") {
// Import bookings wait for their booking-day window cycle — the batch runs
// after staff document review, never at accept time.

View File

@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
*
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
* the same train is useless. The day filter is applied only when THIS booking already has
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
*/
async findComplementaryConsolidationPartner(
booking: Booking,
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
): Promise<Booking | null> {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
return this.repository
const qb = this.repository
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
quantity,
perWagon,
})
.orderBy('b.createdAt', 'ASC')
.getOne();
});
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
// when this booking has no date yet (matched again once it picks its day).
if (booking.scheduledDate) {
qb.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: booking.scheduledDate },
);
}
return qb.orderBy('b.createdAt', 'ASC').getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */