From 75cda22a48809e8077004bdaa569a3a40f96fd7d Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 3 Jul 2026 05:07:23 +0000 Subject: [PATCH] Implement consolidated booking handling and UI updates for shared wagons --- .../bookings/booking-transition.service.ts | 17 +- .../modules/bookings/bookings.repository.ts | 25 +- .../booking-batch.service.spec.ts | 51 +++ .../train-scheduling/booking-batch.service.ts | 316 ++++++++++++++---- .../BatchScheduleDetailPage.tsx | 20 ++ .../backoffice/src/types/trainScheduling.ts | 2 + .../components/BookingPaymentPanel.tsx | 15 + 7 files changed, 361 insertions(+), 85 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 031e521a9..227de08fe 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b99611c35..3f696bc6b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository { /** * 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 { ): Promise { 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 { .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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 2712c6b48..f112d16d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => { }), ); }); + + it('reserves both partners of a consolidated pair together on one train', async () => { + // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. + const consol = (id: string, partnerId: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: partnerId, + bookingContainers: [{ quantity: 1 }], + }) as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + consol('a', 'b', 30), + consol('b', 'a', 20), + ]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Both reserved on the same (first) train; neither reported unplaced. + const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedIds.sort()).toEqual(['a', 'b']); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); + + it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { + const lonely = { + id: 'a', + reference: 'a', + isGovernment: false, + priorityScore: 30, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: 'missing-partner', + bookingContainers: [{ quantity: 1 }], + } as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Never reserved — waits for its partner in a later cycle. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 722744c2a..2c30a5964 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -40,6 +40,7 @@ import { import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { BookingSplitService } from './booking-split.service'; +import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { 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 BatchWindowGroup { @@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit { * 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): Promise { + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { if (!booking.scheduledDate) { throw new BadRequestException('Booking has no scheduled date'); } @@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit { const rules = await this.loadGlobalRules(); const wagonLengths = await this.loadWagonLengths(); - const need = this.needFor(booking, wagonLengths); + const required = need ?? this.needFor(booking, wagonLengths); for (const candidate of candidates) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, @@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit { if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive, rules); const budget = await this.remainingCapacity(schedule, limits, wagonLengths); - if (this.fits(need, budget)) return schedule.id; + if (this.fits(required, budget)) return schedule.id; } throw new ConflictException('Train is full — no export capacity left for this day'); } /** - * Reserve an accepted export booking on its picked train and open the pay - * window immediately (payment notification goes out on reserve). Marks the - * train FULL when this reservation exhausts the wagon budget. + * 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 reserveExportBooking(booking: Booking, scheduleId: string): Promise { - await this.reserve(booking, scheduleId); + async acceptExportBooking(booking: Booking): Promise { + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + 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: 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 wagonLengths = await this.loadWagonLengths(); + const need = this.combinedNeed(booking, partner, wagonLengths); + 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 { + for (const b of bookings) await this.reserve(b, scheduleId); this.armSettle(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); @@ -622,6 +655,27 @@ export class BookingBatchService implements OnModuleInit { 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 items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonLengths); const alloc = allocationByBooking.get(b.id); @@ -647,6 +701,10 @@ export class BookingBatchService implements OnModuleInit { : null, allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, + consolidationPartnerId: b.consolidationPartnerId ?? null, + consolidationPartnerRef: b.consolidationPartnerId + ? (refById.get(b.consolidationPartnerId) ?? null) + : null, }; }); @@ -903,13 +961,19 @@ export class BookingBatchService implements OnModuleInit { } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + const units = this.groupConsolidatedPool(pool); let armed = false; - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); if (!this.fits(need, budget)) { - if (booking.isGovernment) { + if (isGov) { budget = await this.preemptForGovernment( scheduleId, need, @@ -918,14 +982,16 @@ export class BookingBatchService implements OnModuleInit { ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { - continue; // skip a booking that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons, try the next } } - if (booking.isGovernment) { + 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; } budget = this.subtract(budget, need); @@ -1013,15 +1079,23 @@ export class BookingBatchService implements OnModuleInit { destinationYardId, day, ); + // 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); - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - // First train (earliest departure) that fits this booking as-is. + // First train (earliest departure) that fits this unit as-is. let target = trains.find((t) => this.fits(need, t.budget)); - if (!target && booking.isGovernment) { - // Government booking fits nowhere on its own — try to preempt commercial + if (!target && isGov) { + // Government fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { t.budget = await this.preemptForGovernment( @@ -1038,41 +1112,45 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train whole. Import GENERAL-contract commercial bookings get a - // partial-capacity offer on the train with the most free wagons: pay = - // accept the split (remainder returns to the contract cap), no pay = - // booking stays whole and expires for this train. - const partialTarget = [...trains] - .filter((t) => t.budget.wagons >= 1) - .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; - if ( - partialTarget && - !booking.isGovernment && - booking.tradeDirection === "IMPORT" && - booking.contractKind === "GENERAL" && - this.splitService - ) { - const offered = await this.tryPartialOffer( - booking, - partialTarget.id, - partialTarget.budget, - need, - ); - if (offered) { - partialTarget.budget = this.subtract(partialTarget.budget, offered); - partialTarget.armed = true; - continue; + // A consolidated pair is placed whole or not at all — never split. + if (!isPair) { + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } } } // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); + if (partner) this.notifier.unplaced(partner, day); continue; } - if (booking.isGovernment) { + 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; } target.budget = this.subtract(target.budget, need); @@ -1099,6 +1177,8 @@ export class BookingBatchService implements OnModuleInit { need: Capacity, ): Promise { 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 wagonLengths = await this.loadWagonLengths(); @@ -1143,29 +1223,69 @@ export class BookingBatchService implements OnModuleInit { return capacity > 0 ? capacity : 60; } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ - async settleDueReservations(scheduleId: string): Promise { + /** + * 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; if either partner expires, both expire (a half-paid shared + * wagon must not ship). Returns whether anything changed. + */ + private async settleReserved( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { 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(); let anySettled = false; - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : false; + const isPaid = (b: Booking) => + b.paymentStatus === "PAID" || b.status === "PAID"; + const isExpired = (b: Booking) => + b.paymentDeadline + ? b.paymentDeadline.getTime() <= now + : expireUnpaidUnknownDeadline; - if (paid) { + 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)) { + await this.expire(booking); + await this.expire(partner); + anySettled = true; + } + continue; + } + + done.add(booking.id); + if (isPaid(booking)) { await this.allocate(scheduleId, booking, "paid"); anySettled = true; - } else if (expired) { + } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } + return anySettled; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const anySettled = await this.settleReserved(scheduleId, false); if (anySettled) await this.fillSchedule(scheduleId); } @@ -1174,25 +1294,7 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = - await this.bookingsRepository.findReservedForSchedule(scheduleId); - const now = Date.now(); - - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : true; - - if (paid) { - await this.allocate(scheduleId, booking, "paid"); - } else if (expired) { - await this.expire(booking); - } - // else: still within window (rare at settle) → leave for the re-armed timeout - } - + await this.settleReserved(scheduleId, true); await this.fillSchedule(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1452,6 +1554,74 @@ export class BookingBatchService implements OnModuleInit { // ---- 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(); + 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, + wagonLengths: WagonLengths, + ): Capacity { + const containers = (b: Booking): number => + (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const totalContainers = containers(primary) + containers(partner); + const sharedWagons = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary) + this.wagonsFor(partner); + const weightTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + return { + wagons: sharedWagons, + weightTons, + lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + private wagonsFor(booking: Booking): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx index 875724528..a4ebd531e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx @@ -20,6 +20,7 @@ import { import { AlertTriangle, ArrowLeft, + ArrowLeftRight, Boxes, CalendarDays, CheckCircle2, @@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef[] = [ Gov ) : null} + {b.consolidationPartnerRef ? ( + + } + style={{ textTransform: "none" }} + > + shared wagon · {b.consolidationPartnerRef} + + + ) : null} ); }, diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 2c9a54dbd..2e8ec2b21 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -277,6 +277,8 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; + consolidationPartnerId: string | null; + consolidationPartnerRef: string | null; } export interface BatchWindowGroup { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx index 9d050b0ad..a023b7850 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/BookingPaymentPanel.tsx @@ -197,6 +197,15 @@ export function BookingPaymentPanel({ : priceTotal(pricing); const items = priceLineItems(pricing); + // Consolidation: this shipment shares a wagon with a partner booking, and the + // wagon is only scheduled once both partners have paid. Surface a note while + // payment is still pending (pay-window open, or a deadline set and not paid). + const showConsolidationNote = + !paid && + Boolean(booking.consolidationPartnerId) && + (booking.status === "SELECTED_FOR_BATCH" || + Boolean(booking.paymentDeadline)); + const { data: invoices = [] } = useQuery({ queryKey: ["booking-invoices", booking.id], queryFn: () => invoicesService.listForSource("booking", booking.id), @@ -268,6 +277,12 @@ export function BookingPaymentPanel({ onPay={onPay} paying={paying} /> + {showConsolidationNote && ( + + This shipment shares a wagon with a consolidation partner — both + shipments must be paid for the wagon to be scheduled. + + )} )}