From 09deecd04cfea00f895736be900d4c2355d835cf Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 21 Aug 2026 23:16:06 +0000 Subject: [PATCH] fix issue and add consolidation --- ...booking-transition.paired-decision.spec.ts | 12 +- .../bookings/booking-transition.service.ts | 126 +++-- .../booking-wagon-cancellation.service.ts | 302 +++++++++++- .../modules/bookings/bookings.repository.ts | 15 + .../src/modules/bookings/bookings.service.ts | 4 +- .../contracts/contract-booking.service.ts | 22 +- .../train-scheduling/booking-batch.service.ts | 441 ++++++++++++++++-- .../booking-batch.smart-need.spec.ts | 104 +++++ .../services/train-scheduling.service.ts | 12 + .../utils/wagon-plan.util.spec.ts | 80 ++++ .../train-scheduling/utils/wagon-plan.util.ts | 50 +- .../wagon-plan-flex.util.spec.ts | 109 +++++ .../train-scheduling/wagon-plan-flex.util.ts | 121 +++-- .../contracts/GlCreateBookingForm.tsx | 23 +- .../InteractiveTrainConsist.tsx | 8 +- .../TrainScheduleV2ListPage.tsx | 41 +- .../BookingDetailPage/ReadonlyBookingView.tsx | 124 ++++- .../components/WagonsTab.tsx | 52 ++- .../src/pages/contracts/NewShipmentPage.tsx | 61 ++- .../contracts/NewShipmentRequestPage.tsx | 19 +- .../portal/src/services/bookings.service.ts | 2 + 21 files changed, 1464 insertions(+), 264 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts index 6496208e7..ada450c50 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.paired-decision.spec.ts @@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => { expect(result.partner.id).toBe('b-2'); }); - it('cancels both halves with the same reason', async () => { + it('cancels via cancel() once — its pair cascade settles the partner', async () => { const { service } = makeService(paired); const cancel = jest .spyOn(service, 'cancel') @@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => { reason: 'customer withdrew', }); - expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew'); - expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew'); }); it('propagates a failure on the second half so neither is committed', async () => { const { service, dataSource } = makeService(paired); jest - .spyOn(service, 'cancel') + .spyOn(service, 'acceptIntake') .mockImplementationOnce(async (id) => ({ id }) as Booking) .mockImplementationOnce(async () => { throw new Error('partner is already in transit'); }); await expect( - service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }), + service.applyPairedDecision('b-1', 'accept', 'staff-1', { + validityDays: 30, + }), ).rejects.toThrow('partner is already in transit'); // Both halves ran inside one transaction, so the throw rolls the first back. 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 72261627e..399f14123 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 @@ -91,28 +91,10 @@ export class BookingTransitionService { /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ private async assert20ftPairable(booking: Booking): Promise { - // Parity gate. 20ft ride two per wagon, so an odd total leaves one container - // that cannot be placed. Consolidation (pairing it with another customer's - // odd booking) is built end to end but switched off for now, so an odd total - // is rejected here rather than parked for a partner. - // containerSize is not always populated (some rows carry only the container - // type), so fall back to the type's sizeFt rather than silently skipping - // those lines and letting an odd booking through. - const ft20Quantity = (booking.bookingContainers ?? []) - .filter((bc) => - bc.containerSize - ? bc.containerSize.includes("20") - : Number(bc.containerType?.sizeFt) === 20, - ) - .reduce((sum, bc) => sum + Number(bc.quantity || 0), 0); - if (ft20Quantity % 2 === 1) { - throw new BadRequestException( - `20ft containers travel two per wagon, so they must be booked in even ` + - `numbers. This booking has ${ft20Quantity} — add one more or remove ` + - `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, - ); - } - + // Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called + // right after this gate) auto-pairs the odd leftover with another + // customer's odd booking or parks the booking as PENDING_CONSOLIDATION. + // Only the weight-pairing rule hard-blocks. const violations = await this.containerValidationService.validate20ftPairing(booking); if (violations.length) { @@ -456,11 +438,42 @@ export class BookingTransitionService { async cancelHold(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]); - if (booking.consolidationPartnerId) { - throw new BadRequestException( - "This booking shares a consolidated wagon with another booking — " + - "contact support to cancel it.", + // Consolidated pair: the shared wagon dies with this hold. An unpaid + // partner's hold is released with it (both cancel, no fee); a PAID partner + // keeps the whole wagon and this canceller owes the cancellation fee. + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + if (partner.status === "SELECTED_FOR_BATCH") { + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.invoiceService.expireOpenInvoices(partnerId); + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } } await this.bookingsRepository.createReviewNote( bookingId, @@ -514,6 +527,17 @@ export class BookingTransitionService { ); } + // cancel() carries its own pair cascade (it settles the partner too), so + // running it twice would trip on the already-cancelled partner. + if (decision === "cancel") { + const own = await this.cancel( + bookingId, + options.reason ?? "Cancelled with its consolidation partner", + ); + const other = await this.bookingsService.findById(partnerId); + return { booking: own, partner: other }; + } + const runOne = async (id: string): Promise => { switch (decision) { case "accept": @@ -525,11 +549,6 @@ export class BookingTransitionService { ); } return this.acceptIntake(id, actorId, Number(options.validityDays)); - case "cancel": - return this.cancel( - id, - options.reason ?? "Cancelled with its consolidation partner", - ); case "operationAccept": return this.reviewOperationRequest(id, "ACCEPT", actorId, { note: options.note, @@ -566,8 +585,53 @@ export class BookingTransitionService { "PENDING_APPROVAL", "CONTRACT_READY", "OPERATION_REQUEST_PENDING", + // A booking parked waiting for a consolidation partner can be walked + // away from — nothing is reserved yet. + "PENDING_CONSOLIDATION", ]); + // Consolidated pair: a shared wagon never ships half-full, so cancelling + // one half settles the other too. Neither paid → both cancel, no fee. A + // PAID partner instead keeps the whole wagon and the unpaid canceller + // owes the cancellation fee (opened by the partnerLapsed listener). A + // PAID booking itself never comes through here (status gate above) — it + // cancels via wagon cancellation, where the fee machinery lives. + const partnerId = booking.consolidationPartnerId; + if (partnerId) { + const partner = await this.bookingsService.findById(partnerId); + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, + ); + if (partnerPaid) { + this.events.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) { + const partnerReason = "Cancelled with its consolidation partner"; + await this.bookingsRepository.createReviewNote( + partnerId, + partnerReason, + "REJECTION", + ); + await this.invoiceService.expireOpenInvoices(partnerId); + if (partner.status === "SELECTED_FOR_BATCH") { + // Reserved hold: release the wagons through the batch engine. + await this.bookingBatchService.cancelReservation(partnerId); + } else { + await this.bookingsRepository.update(partnerId, { + status: "CANCELLED", + } as never); + } + this.notifier.cancelled( + await this.bookingsService.findById(partnerId), + partnerReason, + ); + } + } + await this.bookingsRepository.createReviewNote( bookingId, reason, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 0b4315d62..387a20824 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,6 +7,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -140,7 +141,29 @@ export class BookingWagonCancellationService { creditAmount: number; }> { const booking = await this.loadCancellableBooking(bookingId); - const cut = await this.resolveRequestedCut(booking, dto); + // Empty dto = the whole booking ("Cancel booking" button). + const cut = this.isEmptyCut(dto) + ? await this.resolveFullCut(booking) + : await this.resolveRequestedCut(booking, dto); + // Consolidated booking: preview the same rules the request enforces — a + // full cut breaks the pair (canceller fee = ceil of its fractional + // wagons); a partial cut must spare the shared wagon. + if (booking.consolidationPartnerId) { + const full = await this.resolveFullCut(booking); + if (cut.wagons >= full.wagons) { + const feeWagons = Math.ceil(cut.wagons); + const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); + return { + wagons: cut.wagons, + weightTons: cut.weightTons, + feePerWagon: fee.perWagon, + feeAmount: fee.amount, + feeCurrency: fee.currency, + creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + }; + } + this.assertCutSparesSharedWagon(cut); + } const fee = await this.priceFee(booking, cut); return { wagons: cut.wagons, @@ -158,6 +181,24 @@ export class BookingWagonCancellationService { userId?: string, ): Promise { const booking = await this.loadCancellableBooking(bookingId); + // Consolidated booking: the shared wagon itself is untouchable — its other + // half belongs to the partner. The customer may still cancel + // - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or + // - a PARTIAL cut of their own full wagons — an EVEN number of 20ft + // containers, so the odd one stays on the shared wagon and the pair + // survives untouched. + if (booking.consolidationPartnerId) { + if (this.isEmptyCut(dto)) { + return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); + } + const full = await this.resolveFullCut(booking); + const cut = await this.resolveRequestedCut(booking, dto); + if (cut.wagons >= full.wagons) { + return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); + } + this.assertCutSparesSharedWagon(cut); + // fall through: a pair-safe partial cut rides the normal partial flow. + } const open = await this.repo.findOpenForBooking(bookingId); if (open) { throw new ConflictException( @@ -165,7 +206,10 @@ export class BookingWagonCancellationService { ); } - const cut = await this.resolveRequestedCut(booking, dto); + // Empty dto = the whole booking ("Cancel booking" button). + const cut = this.isEmptyCut(dto) + ? await this.resolveFullCut(booking) + : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons); @@ -280,6 +324,253 @@ export class BookingWagonCancellationService { return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; } + // ── Consolidated-pair cancellation ────────────────────────────────────────── + + /** + * Cancel BOTH halves of a consolidated pair — a shared wagon never ships + * half-full, so a paired booking always cancels whole, together with its + * partner. + * + * Fee split (the canceller's leftover 20ft claims the shared wagon): + * canceller pays ceil(its wagons), the partner floor(its wagons) — e.g. + * 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side + * keeps its full freight as a rebooking credit (rebooked by GL through the + * normal rebook endpoint once its fee settles); an UNPAID partner is + * cancelled with no fee and no credit. + */ + private async cancelConsolidatedPair( + booking: Booking, + reason: string | null, + userId?: string, + ): Promise { + const partnerId = booking.consolidationPartnerId!; + const partner = await this.bookingsRepository.findById(partnerId); + if (!partner) { + throw new NotFoundException(`Partner booking ${partnerId} not found.`); + } + const partnerPaid = + partner.paymentStatus === 'PAID' || partner.status === 'PAID'; + + // Break the link first — every write below treats each side singly. + await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId); + + const row = await this.openConsolidationBreak( + booking, + 'ceil', + this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), + reason ?? 'Consolidated pair cancelled', + userId, + ); + if (partnerPaid) { + await this.openConsolidationBreak( + partner, + 'floor', + this.creditFor(partner, Number(partner.wagonsRequired ?? 0)), + `Cancelled with its consolidation partner ${booking.reference}`, + userId, + ); + } else { + // Unpaid partner: no fee — just make sure no payable invoice stays open. + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID') + .catch(() => undefined); + } + + for (const b of [booking, partner]) { + await this.dataSource.getRepository(Booking).update(b.id, { + status: 'CANCELLED', + trainScheduleId: null, + requestedTrainScheduleId: null, + }); + await this.detachFromSchedule(b); + } + this.notifyCustomer( + booking, + 'Consolidated booking cancelled', + `${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`, + ); + this.notifyCustomer( + partner, + 'Consolidated booking cancelled', + partnerPaid + ? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.` + : `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`, + ); + this.notifyStaff( + booking, + 'Consolidated pair cancelled', + `${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`, + ); + return row; + } + + /** + * Open one side's ledger row for a consolidation break: a FULL cut whose fee + * is priced on the ceil/floor split of the cut's own FRACTIONAL wagons — + * never booking.wagonsRequired, which the contract flow persists already + * ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge + * the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner + * floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space. + * feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the + * row goes straight to CREDIT_AVAILABLE. + */ + private async openConsolidationBreak( + booking: Booking, + mode: 'ceil' | 'floor', + creditAmount: number, + reason: string, + userId?: string, + ): Promise { + const open = await this.repo.findOpenForBooking(booking.id); + if (open) { + throw new ConflictException( + `Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`, + ); + } + const cut = await this.resolveFullCut(booking); + const feeWagons = + mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons); + // The pair is dead the moment it breaks — the wagons leave the schedule + // with the cancel itself, so T2 must not release them again. + const quantities = { ...cut.quantities, releasedAtRequest: true }; + + if (feeWagons <= 0) { + return this.repo.create({ + bookingId: booking.id, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: quantities, + creditAmount, + feeAmount: 0, + feeCurrency: booking.paymentCurrency ?? 'ETB', + status: 'CREDIT_AVAILABLE', + feePaidAt: new Date(), + reason, + requestedByUserId: userId ?? null, + }); + } + + const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); + const row = await this.repo.create({ + bookingId: booking.id, + wagonsCancelled: cut.wagons, + weightTons: cut.weightTons, + cancelledQuantities: quantities, + creditAmount, + feeRateId: fee.rates[0].id, + feeAmount: fee.amount, + feeCurrency: fee.currency, + status: 'FEE_PENDING', + reason, + requestedByUserId: userId ?? null, + }); + const invoice = await this.billing.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: booking.id, + type: WAGON_CANCEL_FEE_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: fee.currency, + lines: [ + { + chargeType: 'CANCELLATION_FEE', + description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`, + quantity: feeWagons, + unitRate: fee.perWagon, + amount: fee.amount, + currency: fee.currency, + metadata: { wagonCancellationId: row.id }, + }, + ], + totalAmount: fee.amount, + status: Freight.InvoiceStatus.Issued, + }); + return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row; + } + + /** No cut named at all — the "Cancel booking" button cancelling everything. */ + private isEmptyCut(dto: RequestWagonCancellationDto): boolean { + return ( + !dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons + ); + } + + /** + * A partial cut on a consolidated booking must leave the shared wagon whole: + * the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole + * own wagons only). An odd cut — including picking the shared wagon itself in + * the Wagons tab (it contributes exactly one 20ft) — is rejected. + */ + private assertCutSparesSharedWagon(cut: RequestedCut): void { + const ft20Cut = Object.entries(cut.quantities.bySize ?? {}) + .filter(([size]) => sizeFtOf(size) === 20) + .reduce((sum, [, qty]) => sum + qty, 0); + if (ft20Cut % 2 === 1) { + throw new BadRequestException( + 'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.', + ); + } + } + + /** The whole booking as a cut — everything it still carries. */ + private async resolveFullCut(booking: Booking): Promise { + if (booking.freightType === 'CONTAINER') { + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const bySize = new Map(); + for (const line of lines) { + const size = line.containerSize ?? ''; + bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0)); + } + const containers = [...bySize.entries()] + .filter(([, quantity]) => quantity > 0) + .map(([containerSize, quantity]) => ({ containerSize, quantity })); + return this.resolveRequestedCut(booking, { + containers, + } as RequestWagonCancellationDto); + } + return this.resolveRequestedCut(booking, { + wagons: Number(booking.wagonsRequired ?? 0), + } as RequestWagonCancellationDto); + } + + /** + * The batch engine expired an UNPAID booking whose consolidation partner had + * already PAID: the paid partner keeps the whole wagon at no extra cost; the + * lapsed side owes the cancellation fee on its own wagons — shared wagon + * included (ceil). Credit is 0 (nothing was paid); once the fee settles GL + * rebooks the customer through a normal new booking. + */ + @OnEvent('booking.consolidation.partnerLapsed') + async onConsolidationPartnerLapsed(payload: { + expiredBookingId: string; + }): Promise { + try { + const booking = await this.bookingsRepository.findById( + payload.expiredBookingId, + ); + if (!booking) return; + if (await this.repo.findOpenForBooking(booking.id)) return; // already charged + const row = await this.openConsolidationBreak( + booking, + 'ceil', + 0, + 'Expired while its consolidation partner had paid — cancellation fee applies', + ); + if (row.status !== 'FEE_PENDING') return; // nothing owed + this.notifyCustomer( + booking, + 'Cancellation fee due', + `${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`, + ); + } catch (err) { + this.logger.error( + `Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + // ── T2: fee settled ───────────────────────────────────────────────────────── /** @@ -454,6 +745,13 @@ export class BookingWagonCancellationService { `This credit cannot be rebooked (status is ${row.status}).`, ); } + // A consolidation-lapse row on an UNPAID booking carries no credit — the + // customer never paid freight, so there is nothing to redeem. Book fresh. + if (Number(row.creditAmount) <= 0) { + throw new BadRequestException( + 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', + ); + } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { 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 efd6ed217..ff0888bef 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** + * Terminal un-pair: break the consolidation link only, touching neither + * status. Used when one half of a pair is cancelled/expired — the caller + * decides each side's fate ({@link unpairConsolidation} instead re-parks + * BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking). + */ + async clearConsolidationPair(bookingId: string, partnerId: string): Promise { + await this.repository.update(bookingId, { + consolidationPartnerId: null, + } as never); + await this.repository.update(partnerId, { + consolidationPartnerId: null, + } as never); + } + /** Un-pair a consolidation. */ async unpairConsolidation(bookingId: string, partnerId: string): Promise { await this.repository.update(bookingId, { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 0b5bf5a0a..6aa7eeb46 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -427,7 +427,8 @@ export class BookingsService { 'containerNumber', ci.container_number, 'sealNumber', ci.seal_number, 'positionOnWagon', ci.position_on_wagon, - 'grossWeightTons', ci.gross_weight_tons + 'grossWeightTons', ci.gross_weight_tons, + 'sizeFt', cit.size_ft ) ORDER BY ci.position_on_wagon, ci.container_number ) FILTER (WHERE ci.id IS NOT NULL), '[]' @@ -443,6 +444,7 @@ export class BookingsService { LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id LEFT JOIN freight.wagon_allocation_bulk_loads bl ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index e093bd9bf..52cc2a51d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2458,22 +2458,12 @@ export class ContractBookingService { private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { - // Parity gate. 20ft containers ride two per wagon, so an odd total leaves - // one container that cannot be placed. Consolidation (pairing it with - // another customer's odd booking) is built end to end but switched off for - // now, so an odd total is rejected outright — server-side, because the - // frontend block alone is not a guarantee. - const ft20Quantity = (dto.containers ?? []) - .filter((line) => (line.containerSize ?? '').includes('20')) - .reduce((sum, line) => sum + Number(line.quantity || 0), 0); - if (ft20Quantity % 2 === 1) { - throw new BadRequestException( - `20ft containers travel two per wagon, so they must be booked in even ` + - `numbers. This booking has ${ft20Quantity} — add one more or remove ` + - `one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`, - ); - } - + // Odd 20ft totals are no longer rejected here: the wagon consolidation gate + // that runs right after (consolidateDrawdown / needsConsolidationFromBooking, + // same machinery the plain booking flow already uses live) auto-pairs an odd + // total with another customer's odd booking or parks it as + // PENDING_CONSOLIDATION until one appears. This assert now only checks that + // any 20ft containers actually present can be weight-paired on a wagon. const twentyFtUnits = (dto.containers ?? []) .filter((line) => (line.containerSize ?? '').includes('20')) .flatMap((line, lineIdx) => 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 0a7d87498..72ffb2a96 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 @@ -10,6 +10,7 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { SchedulerRegistry } from '@nestjs/schedule'; import { Between, @@ -92,6 +93,7 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, + roundTons, } from './utils/wagon-plan.util'; import { Capacity, @@ -398,6 +400,8 @@ export class BookingBatchService implements OnModuleInit { @Optional() private readonly milestoneService?: ClearanceMilestoneService, @Optional() private readonly splitService?: BookingSplitService, + // Optional so hand-constructed spec instances keep compiling. + @Optional() private readonly eventEmitter?: EventEmitter2, @Optional() @Inject(forwardRef(() => RemainderPlacementService)) private readonly remainderPlacement?: RemainderPlacementService, @@ -1484,6 +1488,43 @@ export class BookingBatchService implements OnModuleInit { "Train is full — no export capacity left for this day", ); } + // Physical wagon gate — a pay window must never open for wagons that do + // not exist in a type this cargo can ride. PER_TON bulk is seated + // type-by-type at its per-wagon caps (the count allocation will really + // need); everything else checks the summed free stock of its types. + const stock = await this.stockLedgerFor( + schedule, + budget, + bookings.map((b) => b.id), + ); + const allowedWagonTypes = await this.loadAllowedWagonTypeIds(); + const primary = bookings[0]; + const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes); + const perItemBulk = + Number(primary.bulkTotalWeightTons ?? 0) > 0 && + Number(primary.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + bookings.length === 1 && + primary.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + const smart = useSmart + ? this.smartBulkNeed( + primary, + wagonDims, + stock, + leg, + this.scarcityRankForPool([primary], allowedWagonTypes), + ) + : null; + const seated = useSmart + ? smart != null && budget.fits(smart.need, leg) + : this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); + if (!seated) { + throw new ConflictException( + "Train has no free wagons of a type this cargo can ride — payment was not opened", + ); + } for (const b of bookings) await this.reserve(b, scheduleId); }); @@ -2275,6 +2316,7 @@ export class BookingBatchService implements OnModuleInit { await this.recomputeBulkPriorities(pool, wagonDims); this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule)); const units = this.groupConsolidatedPool(pool); + const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); let armed = false; let preempted = false; let reservedThisPass = 0; @@ -2301,17 +2343,34 @@ export class BookingBatchService implements OnModuleInit { const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); // Abstract room AND real wagons of a type this booking can ride — see - // fillRouteDayInternal for why both gates are needed. - const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); + // fillRouteDayInternal for why both gates are needed. PER_TON bulk + // singles get the smart gate (exact per-type seating at the cargo's + // caps); a booking is only reserved — and only ever invoiced — when + // that seating is proven against the train's actual free wagons. + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + const smart = useSmart + ? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank) + : null; + const admitted = useSmart + ? smart != null && budget.fits(smart.need, leg) + : budget.fits(need, leg) && + this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg); // Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects. this.logger.debug( - `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` + - `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` + - `stocked=${stocked}`, + `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify( + smart?.need ?? need, + )} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`, ); - if (!budget.fits(need, leg) || !stocked) { + if (!admitted) { if (isGov) { const freed = await this.preemptForGovernment( scheduleId, @@ -2355,9 +2414,16 @@ export class BookingBatchService implements OnModuleInit { armed = true; commercialReserved += 1; } - budget.subtract(need, leg); + budget.subtract(smart?.need ?? need, leg); // Hold the physical wagons too — the next unit must not re-count them. - stock.consume(wagonTypeIds, need.wagons, leg); + // The smart gate holds the exact per-type counts it seated. + if (smart) { + for (const part of smart.perType) { + stock.consume([part.wagonTypeId], part.wagons, leg); + } + } else { + stock.consume(wagonTypeIds, need.wagons, leg); + } reservedThisPass += 1; } catch (err) { this.logger.error( @@ -2532,6 +2598,10 @@ export class BookingBatchService implements OnModuleInit { // 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); + // Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked + // once against the whole pool, so what containers will need is known + // before any bulk booking picks its wagons. + const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes); // Batch fill trace: each train's caps + the day pool size at entry. this.logger.debug( @@ -2555,18 +2625,47 @@ export class BookingBatchService implements OnModuleInit { // Consolidated pairs share one wagon set; the primary's types stand for both. const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes); + // PER_TON bulk singles get the smart gate: seated type-by-type at the + // cargo's per-wagon caps, scarcest type first — the count the allocator + // will actually need, not a one-type estimate. Pairs, PER_ITEM and + // unconfigured cargo keep the generic gate (gov preemption and partial + // offers below also still size on the generic `need`). + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const useSmart = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; + let smart: { + need: Capacity; + perType: Array<{ wagonTypeId: string; wagons: number }>; + } | null = null; + // First train (earliest departure) whose corridor carries this booking's // leg, still fits it as-is AND physically holds enough wagons of a type the // booking can ride. Both gates matter: abstract room without the right // wagon type is space the allocator can never turn into a loaded consist. - let target = trains.find((t) => { + let target: (typeof trains)[number] | undefined; + for (const t of trains) { const leg = legOn(t); - return ( - leg != null && + if (leg == null) continue; + if (useSmart) { + const probe = this.smartBulkNeed(booking, wagonDims, t.stock, leg, scarcityRank); + if (probe != null && t.budget.fits(probe.need, leg)) { + smart = probe; + target = t; + break; + } + } else if ( t.budget.fits(need, leg) && this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg) - ); - }); + ) { + target = t; + break; + } + } // Per-unit trace: chosen train + each train's remaining room on this leg. this.logger.debug( @@ -2645,10 +2744,18 @@ export class BookingBatchService implements OnModuleInit { target.armed = true; commercialReserved += 1; } - target.budget.subtract(need, legOn(target)!); + target.budget.subtract(smart?.need ?? need, legOn(target)!); // Hold the physical wagons too, so the next unit in this pass sees them - // gone — otherwise two bookings both "fit" the same 16 NW5. - target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); + // gone — otherwise two bookings both "fit" the same 16 NW5. The smart + // gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5), + // not a type-blind total drained deepest-first. + if (smart) { + for (const part of smart.perType) { + target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!); + } + } else { + target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!); + } target.changed = true; reservedThisPass += 1; } catch (err) { @@ -2724,6 +2831,21 @@ export class BookingBatchService implements OnModuleInit { wagonTypeIds: string[] = [], ): Promise { if (!this.isSplitEligible(booking, isPair)) return false; + const wagonDims = await this.loadWagonDims(); + // PER_TON bulk partials are sized on ONE concrete wagon type at the + // cargo's per-wagon cap — sizing on the first type's raw 70T rating + // offered tonnage the wagons could never carry (Perishable caps at + // 20/30T), taking payment for cargo that stalls at allocation. + // ponytail: single-type bulk partials; a multi-type partial (PW2+NW5 + // mixed) is the upgrade path if offers come out too small. + const perItemBulk = + Number(booking.bulkTotalWeightTons ?? 0) > 0 && + Number(booking.cargoTotalWeightVgm ?? 0) > 0; + const cappedBulk = + !isPair && + booking.freightType === "BULK" && + !perItemBulk && + wagonTypeIds.length > 0; const target = candidates .map((c) => { const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId); @@ -2734,12 +2856,39 @@ export class BookingBatchService implements OnModuleInit { // them NW5" into an offer for 16 — the customer pays for 16 and the // other 4 leave as the usual remainder booking, instead of paying for // 20 and stalling at allocation on wagon 17. + if (cappedBulk) { + const best = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => + o.wagonTypeId != null, + ) + .map((o) => ({ + ...o, + free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0, + takePerWagon: bulkTonsPerWagon( + booking.cargoType, + o.wagonTypeId, + o.dims.capacityTons, + ), + })) + .filter((o) => o.free > 0 && o.takePerWagon > 0) + .sort((a, b) => b.takePerWagon - a.takePerWagon)[0]; + if (!best) return null; + return { + c, + leg, + room: { ...room, wagons: Math.min(room.wagons, best.free) }, + seat: { + wagonTypeId: best.wagonTypeId, + perWagon: { ...best.dims, capacityTons: best.takePerWagon }, + }, + }; + } const physical = wagonTypeIds.length ? c.stock?.availableFor(wagonTypeIds, leg) : undefined; const wagons = physical == null ? room.wagons : Math.min(room.wagons, physical); - return { c, leg, room: { ...room, wagons } }; + return { c, leg, room: { ...room, wagons }, seat: undefined }; }) .filter((x): x is NonNullable => x != null && x.room.wagons >= 1) .sort((a, b) => b.room.wagons - a.room.wagons)[0]; @@ -2749,10 +2898,15 @@ export class BookingBatchService implements OnModuleInit { target.c.id, target.room, need, + target.seat, ); if (!offered) return false; target.c.budget.subtract(offered, target.leg); - target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg); + target.c.stock?.consume( + target.seat ? [target.seat.wagonTypeId] : wagonTypeIds, + offered.wagons, + target.leg, + ); target.c.armed = true; return true; } @@ -2767,6 +2921,12 @@ export class BookingBatchService implements OnModuleInit { scheduleId: string, budget: Capacity, need: Capacity, + /** + * Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this + * offer rides, with capacityTons already reduced to the cargo's per-wagon + * cap — so the offered tonnage is what those wagons can really carry. + */ + seat?: { wagonTypeId: string; perWagon: PerWagonDims }, ): Promise { if (!this.splitService) return null; // A consolidated booking is already half of a shared wagon — never split it. @@ -2784,8 +2944,14 @@ export class BookingBatchService implements OnModuleInit { // measured on the booking's REAL wagon type — the same one allocation // validates against. Bulk splits ride FULL wagons only: the offer never // part-loads its last wagon. - const perWagon = this.dimsFor(booking, wagonDims); - const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, { + const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims); + // With a capped seat, the whole booking's wagon count follows the cap too + // (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a + // strict subset of THAT count. + const wholeWagons = seat + ? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons)) + : need.wagons; + const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, { fullWagonsOnly: booking.freightType === "BULK", }); if (!partial) return null; @@ -2793,7 +2959,7 @@ export class BookingBatchService implements OnModuleInit { const sized = await this.splitService.sizeOffer( booking, partial.wagons, - need.wagons, + wholeWagons, perWagon.capacityTons, partial.maxCargoTons, ); @@ -2832,8 +2998,9 @@ export class BookingBatchService implements OnModuleInit { * 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. + * when both paid; when neither paid, both expire. A half-paid pair splits: + * the paid half keeps the whole wagon, the lapsed half expires and owes the + * cancellation fee (expire()'s pair cascade). Returns whether anything changed. */ private async settleReserved( scheduleId: string, @@ -2876,8 +3043,10 @@ export class BookingBatchService implements OnModuleInit { await this.allocate(scheduleId, partner, "paid"); anySettled = true; } else if (isExpired(booking) || isExpired(partner)) { + // One call is enough: expire()'s pair cascade settles both sides — + // both expire when neither paid; a paid half is rescued (keeps the + // whole wagon) while the lapsed half expires with its fee. await this.expire(booking); - await this.expire(partner); anySettled = true; } continue; @@ -3816,6 +3985,55 @@ export class BookingBatchService implements OnModuleInit { booking: Booking, reason: "payment" | "no-capacity" = "payment", ): Promise { + // Consolidated pair: break the link FIRST, then settle each side singly. + // - neither paid → both expire, no fee. + // - one side paid → the paid half keeps the whole wagon (rescued by the + // paid guard below at no extra cost); the lapsed half expires and owes + // the cancellation fee (the 'partnerLapsed' event opens the fee invoice + // in BookingWagonCancellationService). + // - both paid → nothing to expire; the paid guard rescues. + if (booking.consolidationPartnerId) { + const partnerId = booking.consolidationPartnerId; + const bookingRepo = this.dataSource.getRepository(Booking); + const partnerRow = await bookingRepo.findOne({ + where: { id: partnerId }, + relations: { company: true }, + }); + const freshSelf = await bookingRepo.findOne({ + where: { id: booking.id }, + }); + const paidOf = (b: Booking | null) => + b != null && (b.paymentStatus === "PAID" || b.status === "PAID"); + const selfPaid = paidOf(freshSelf); + const partnerPaid = paidOf(partnerRow); + + await this.bookingsRepository.clearConsolidationPair( + booking.id, + partnerId, + ); + booking.consolidationPartnerId = null; + if (partnerRow) partnerRow.consolidationPartnerId = null; + + if (selfPaid && !partnerPaid) { + // Wrong side called first: the lapsed partner is the one that expires + // (with its fee); this paid booking falls through to the rescue below. + if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: partnerRow.id, + }); + await this.expire(partnerRow, reason); + } + } else if (!selfPaid && partnerPaid) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + // fall through: this side expires below; the paid partner is untouched. + } else if (!selfPaid && !partnerPaid) { + if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) { + await this.expire(partnerRow, reason); + } + } + } if (!booking.consolidationPartnerId) { const fresh = await this.dataSource .getRepository(Booking) @@ -4097,7 +4315,50 @@ export class BookingBatchService implements OnModuleInit { // and push once per schedule after the sweep (most unaccepted rows are // unpinned under day-level pooling, so this usually emits nothing). const touchedScheduleIds = new Set(); + const swept = new Set(); for (const booking of unaccepted) { + if (swept.has(booking.id)) continue; + swept.add(booking.id); + // Consolidated pair: the partner may sit outside this route-day's result + // set (different yards/day/status), so cascade explicitly — an unpaid + // partner expires with this booking; a PAID partner keeps the whole + // wagon and this booking owes the cancellation fee (partnerLapsed). + if (booking.consolidationPartnerId) { + const partner = await this.dataSource.getRepository(Booking).findOne({ + where: { id: booking.consolidationPartnerId }, + relations: { company: true }, + }); + await this.bookingsRepository.clearConsolidationPair( + booking.id, + booking.consolidationPartnerId, + ); + booking.consolidationPartnerId = null; + if (partner) { + const partnerPaid = + partner.paymentStatus === "PAID" || partner.status === "PAID"; + if (partnerPaid) { + this.eventEmitter?.emit("booking.consolidation.partnerLapsed", { + expiredBookingId: booking.id, + }); + } else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) { + swept.add(partner.id); + partner.consolidationPartnerId = null; + if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId); + await this.bookingsRepository.update(partner.id, { + status: "EXPIRED", + schedulingStatus: "ELIGIBLE", + scheduledDate: null, + } as never); + await this.billing + .expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID") + .catch(() => undefined); + this.notifier.expired(partner); + this.logger.log( + `[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`, + ); + } + } + } if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId); await this.bookingsRepository.update(booking.id, { status: "EXPIRED", @@ -4798,12 +5059,34 @@ export class BookingBatchService implements OnModuleInit { this.loadAllowedWagonTypeIds(), ]); const anyType = [...stock.remainingByTypeId.keys()]; - for (const b of await this.committedBookings(schedule, excludeBookingIds)) { + const committed = await this.committedBookings(schedule, excludeBookingIds); + // Debit committed PER_TON bulk the way it was SEATED — per type at the + // cargo's caps, scarcest type first — not a one-type wagon count drained + // deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds + // 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that + // were already spoken for). + const rank = this.scarcityRankForPool(committed, allowed); + for (const b of committed) { const typeIds = this.allowedWagonTypeIdsFor(b, allowed); + const leg = budget.legForYards(b.originYardId, b.destinationYardId); + const perItemBulk = + Number(b.bulkTotalWeightTons ?? 0) > 0 && + Number(b.cargoTotalWeightVgm ?? 0) > 0; + if (b.freightType === "BULK" && !perItemBulk && typeIds.length) { + const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank); + if (smart) { + for (const part of smart.perType) { + ledger.consume([part.wagonTypeId], part.wagons, leg); + } + continue; + } + // Over-committed (stock cannot seat it any more) — drain what exists, + // same as before, so the shortage stays visible to the gates. + } ledger.consume( typeIds.length ? typeIds : anyType, this.wagonsFor(b, wagonDims), - budget.legForYards(b.originYardId, b.destinationYardId), + leg, ); } return ledger; @@ -4825,6 +5108,112 @@ export class BookingBatchService implements OnModuleInit { return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded; } + /** + * Scarcity rank over the day pool: how many distinct demand groups (bulk + * cargo types / container types among these bookings) may ride each wagon + * type. The batch seats least-shareable types first, so bulk with a + * bulk-only alternative (PW2) never eats the container-capable stock (NW5) + * that containers cannot substitute. + */ + private scarcityRankForPool( + pool: Booking[], + allowed: { + byCargoTypeId: Map; + byContainerTypeId: Map; + }, + ): Map { + const groups = new Map(); + for (const b of pool) { + if (b.freightType === "BULK") { + const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; + if (cargoTypeId) { + groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); + } + } else { + for (const line of b.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (containerTypeId) { + groups.set( + `C:${containerTypeId}`, + allowed.byContainerTypeId.get(containerTypeId) ?? [], + ); + } + } + } + } + const rank = new Map(); + for (const ids of groups.values()) { + for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1); + } + return rank; + } + + /** + * Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the + * wagon types this train actually has free on its leg — the same policy the + * wagon planner applies at allocation time (least-shareable type first, each + * wagon filled to the cargo type's per-wagon cap, one booking per wagon). + * + * This is the payment gate's real fit check for bulk: the generic + * `hasWagonStock` sums free wagons across allowed types against a count + * sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free" + * when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons. + * Returns the exact per-type counts and the three-axis capacity they + * consume, or null when the free stock cannot seat the whole booking. + */ + private smartBulkNeed( + booking: Booking, + wagonDims: WagonDims, + stock: WagonStockLedger, + leg: CorridorLeg, + scarcityRank: Map, + ): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null { + const options = this.allowedDimsWithTypes(booking, wagonDims) + .filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.wagonTypeId != null) + .map((o) => ({ + ...o, + free: stock.availableFor([o.wagonTypeId], leg), + takePerWagon: bulkTonsPerWagon( + booking.cargoType, + o.wagonTypeId, + o.dims.capacityTons, + ), + })) + .filter((o) => o.free > 0 && o.takePerWagon > 0) + .sort( + (a, b) => + (scarcityRank.get(a.wagonTypeId) ?? 1) - + (scarcityRank.get(b.wagonTypeId) ?? 1) || + b.takePerWagon - a.takePerWagon, + ); + + let remaining = bookingCargoTons(booking); + if (remaining <= 0) return null; + const perType: Array<{ wagonTypeId: string; wagons: number }> = []; + let weightTons = remaining; // gross: cargo plus each seated wagon's tare + let lengthMeters = 0; + let wagons = 0; + for (const option of options) { + if (remaining <= 1e-9) break; + const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon)); + if (take <= 0) continue; + remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon)); + wagons += take; + weightTons += take * option.dims.tareWeightTons; + lengthMeters += take * option.dims.lengthMeters; + perType.push({ wagonTypeId: option.wagonTypeId, wagons: take }); + } + if (remaining > 1e-9) return null; + return { + need: { + wagons, + weightTons: roundTons(weightTons), + lengthMeters: roundTons(lengthMeters), + }, + perType, + }; + } + private allowedWagonTypeCache: { byCargoTypeId: Map; byContainerTypeId: Map; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts new file mode 100644 index 000000000..bf64f0413 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.smart-need.spec.ts @@ -0,0 +1,104 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingBatchService } from './booking-batch.service'; +import { WagonStockLedger } from './wagon-stock-ledger.util'; + +/** + * smartBulkNeed math in isolation: the private helpers it touches + * (allowedDimsWithTypes) read only their arguments, so a bare prototype + * instance is enough — no Nest wiring. + */ +describe('BookingBatchService.smartBulkNeed', () => { + const service = Object.create(BookingBatchService.prototype) as BookingBatchService; + const call = ( + booking: Booking, + stock: WagonStockLedger, + rank: Map, + ) => + ( + service as unknown as { + smartBulkNeed: ( + b: Booking, + d: unknown, + s: WagonStockLedger, + l: { fromEdge: number; toEdge: number }, + r: Map, + ) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null; + } + ).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank); + + const nw5 = { id: 'wt-nw5', capacityTons: 70 }; + const pw2 = { id: 'wt-pw2', capacityTons: 70 }; + const perishable = { + id: 'cargo-perishable', + wagonTypes: [nw5, pw2], + tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, + }; + const wagonDims = { + container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, + bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }, + byWagonTypeId: new Map([ + [nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], + [pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }], + ]), + }; + const booking = (tons: number): Booking => + ({ + id: 'b1', + reference: 'b1', + freightType: 'BULK', + cargoTotalWeightVgm: tons, + cargoTypeId: perishable.id, + cargoType: perishable, + bookingContainers: [], + }) as unknown as Booking; + // Containers compete for NW5 → NW5 rank 2, PW2 rank 1. + const contested = new Map([ + [nw5.id, 2], + [pw2.id, 1], + ]); + + it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 18], + [pw2.id, 10], + ]), + 1, + ); + const smart = call(booking(695), stock, contested); + expect(smart).not.toBeNull(); + expect(smart!.need.wagons).toBe(27); + expect(smart!.perType).toEqual([ + { wagonTypeId: pw2.id, wagons: 10 }, + { wagonTypeId: nw5.id, wagons: 17 }, + ]); + }); + + it('returns null when the free stock cannot seat the whole booking', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 5], + [pw2.id, 10], + ]), + 1, + ); + // 10×20 + 5×30 = 350T < 695T. + expect(call(booking(695), stock, contested)).toBeNull(); + }); + + it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => { + const stock = new WagonStockLedger( + new Map([ + [nw5.id, 10], + [pw2.id, 10], + ]), + 1, + ); + const even = new Map([ + [nw5.id, 1], + [pw2.id, 1], + ]); + const smart = call(booking(60), stock, even); + expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 6f758f053..b36ed4639 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -6081,6 +6081,18 @@ export class TrainSchedulingService { const trainSetWagon = savedWagons[i]; if (!slot || !trainSetWagon) continue; + // Last line of defense behind validateWagonCargoExclusivity: a wagon + // with bulk on it carries that one load only — never a container and + // never a second bulk booking. + if ( + slot.allocations.length > 1 && + slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk) + ) { + throw new BadRequestException( + `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, + ); + } + for (const alloc of slot.allocations) { const savedAllocation = await manager.getRepository(WagonBookingAllocation).save( manager.getRepository(WagonBookingAllocation).create({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index 52176662a..bac2db62a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -14,6 +14,7 @@ import { sumWagonsRequired, validate20ftContainerRules, validateContainerPlacements, + validateWagonCargoExclusivity, } from './wagon-plan.util'; const nw5: WagonType = { @@ -222,6 +223,85 @@ describe('wagon-plan.util', () => { expect(plan[0]?.slotLoadType).toBe('BULK'); expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1); }); + + it('never pools two bulk bookings on one wagon', () => { + // 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk + // takes that one load only, so each booking gets its own wagon. + const small = { + id: 'bulk-5', + reference: 'bulk-5', + freightType: 'BULK', + cargoTotalWeightVgm: 5, + bookingContainers: [], + } as unknown as Booking; + const other = { + id: 'bulk-40', + reference: 'bulk-40', + freightType: 'BULK', + cargoTotalWeightVgm: 40, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([small, other], cw3); + expect(plan).toHaveLength(2); + for (const slot of plan) { + expect(slot.allocations).toHaveLength(1); + } + expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5'); + expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40'); + expect(validateWagonCargoExclusivity(plan)).toEqual([]); + }); + + it('a multi-wagon bulk booking still spreads over its own wagons', () => { + const big = { + id: 'bulk-130', + reference: 'bulk-130', + freightType: 'BULK', + cargoTotalWeightVgm: 130, + bookingContainers: [], + } as unknown as Booking; + const plan = buildBulkWagonPlan([big], cw3); + expect(plan).toHaveLength(3); + expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]); + }); + + it('flags a wagon mixing bulk with anything else', () => { + const bulkAlloc = { + bookingId: 'b', + bookingReference: 'b', + allocatedWeightTons: 5, + loadType: AllocationLoadType.Bulk, + }; + const containerAlloc = { + bookingId: 'c', + bookingReference: 'c', + allocatedWeightTons: 25, + loadType: AllocationLoadType.Container, + }; + const slot = (allocations: (typeof bulkAlloc)[]) => ({ + sequenceNo: 1, + wagonTypeId: cw3.id, + wagonTypeCode: cw3.code, + capacityTons: 60, + lengthMeters: 14, + tareWeightTons: 24, + assignedWeightTons: 0, + allocations, + }); + // bulk + container on one wagon + expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])])) + .toHaveLength(1); + // bulk + bulk on one wagon + expect( + validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]), + ).toHaveLength(1); + // bulk alone, and containers sharing, are fine + expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]); + expect( + validateWagonCargoExclusivity([ + slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]), + ]), + ).toEqual([]); + }); }); describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 01c497262..5447a0b6e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -200,16 +200,14 @@ export function buildBulkWagonPlan( ); const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0); - const totalWeight = roundTons( - bookings.reduce( - (sum, b, i) => - itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0 - ? sum - : sum + Number(b.cargoTotalWeightVgm ?? 0), - 0, - ), - ); - const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0; + // One bulk booking per wagon — bookings never pool tonnage on a shared + // wagon, so each uncapped booking sizes its own wagons (ceil per booking, + // not over the pooled total). + const tonSlots = bookings.reduce((sum, b, i) => { + if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum; + const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0)); + return weight > 0 ? sum + Math.ceil(weight / capacity) : sum; + }, 0); const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots); const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({ @@ -374,13 +372,12 @@ function allocateBookingsToSlots( if (booking.remainingWeightTons <= 0) { bookingIndex += 1; - } else if (allocatedWeightTons >= takeCap) { - // The cap stopped this wagon short of its rating and the booking has - // more to load. The leftover room is NOT free: `buildBulkWagonPlan` - // already reserved a wagon for the rest, so backfilling another booking - // here would double-book the consist. Close the wagon. - break; } + // One bulk booking per wagon: a wagon carrying bulk takes nothing else — + // never a second booking's cargo. `buildBulkWagonPlan` sized the slots + // per booking, so leftover room on this wagon is not free capacity. + // Close the wagon after its single allocation. + break; } return { ...slot, assignedWeightTons, allocations }; @@ -504,6 +501,26 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[]) ); } +/** + * One wagon carries one kind of cargo: a slot with a BULK allocation holds + * nothing else — no container beside it and no second bulk booking. Container + * allocations may still share a wagon with each other (TEU rules apply). + */ +export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] { + const violations: string[] = []; + for (const slot of wagonPlan) { + const hasBulk = slot.allocations.some( + (a) => a.loadType === AllocationLoadType.Bulk, + ); + if (hasBulk && slot.allocations.length > 1) { + violations.push( + `Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`, + ); + } + } + return violations; +} + export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] { const violations: string[] = []; for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) { @@ -547,6 +564,7 @@ export function validateTrainLimits( ); violations.push(...validateBulkWagonSlotWeights(wagonPlan)); + violations.push(...validateWagonCargoExclusivity(wagonPlan)); return violations; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 127167567..967215f57 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -490,3 +490,112 @@ describe('planWagonsWithStock — consist split across yards', () => { expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']); }); }); + +describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => { + // The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap); + // containers ride only NW5. NW5 is the shared, scarce type. + const nw5: WagonType = { + id: 'wt-nw5', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, + } as WagonType; + const pw2: WagonType = { + id: 'wt-pw2', + code: 'PW2', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, + } as WagonType; + const perishable = { + id: 'cargo-perishable', + cargoTypeName: 'Perishable', + wagonTypes: [nw5, pw2], + tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 }, + }; + const bulkBooking = (id: string, tons: number): Booking => + ({ + id, + reference: id, + freightType: 'BULK', + cargoTotalWeightVgm: tons, + cargoTypeId: perishable.id, + cargoType: perishable, + bookingContainers: [], + }) as unknown as Booking; + const allowed = { + byContainerTypeId: new Map([['ct-1', [nw5]]]), + byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]), + }; + const stockOf = (nw5Count: number, pw2Count: number) => ({ + mode: 'YARD' as const, + remainingByTypeId: new Map([ + [nw5.id, nw5Count], + [pw2.id, pw2Count], + ]), + codesByTypeId: new Map([ + [nw5.id, nw5.code], + [pw2.id, pw2.code], + ]), + }); + + it('fills the bulk-only PW2s first when containers compete for NW5', () => { + // 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T, + // remainder 495T → 17 NW5 × 30T. The container still gets an NW5. + const container = containerBooking('BKG-C', 1, 1); + container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never; + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-BULK', 695), container], + allowed, + stock: stockOf(18, 10), + }); + + expect(result.deferred).toEqual([]); + const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK'); + expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10); + expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17); + // Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T. + for (const slot of bulkSlots) { + expect(slot.assignedWeightTons).toBeLessThanOrEqual( + slot.wagonTypeCode === 'PW2' ? 20 : 30, + ); + } + const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER'); + expect(containerSlots).toHaveLength(1); + expect(containerSlots[0]?.wagonTypeCode).toBe('NW5'); + }); + + it('prefers the bigger per-cargo take when nothing competes for the shared type', () => { + // Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest + // wagons wins, PW2-first would waste consist length. + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-BULK', 60)], + allowed, + stock: stockOf(10, 10), + }); + expect(result.deferred).toEqual([]); + expect(result.plan).toHaveLength(2); + expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true); + }); + + it('never puts two bulk bookings on one wagon, even same cargo type', () => { + // 5T + 40T both fit one wagon's cap by tonnage — each still gets its own. + const result = planWagonsWithStock({ + bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)], + allowed, + stock: stockOf(10, 0), + }); + expect(result.deferred).toEqual([]); + expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons + for (const slot of result.plan) { + expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index ad117663e..d30094b30 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, + bulkTonsPerWagon, bulkWagonsForAllowedTypes, } from './train-capacity.util'; import { @@ -187,8 +188,10 @@ const addAllocation = ( * containers/tonnage placed on wagons whose type is allowed for its container * or cargo type) or is deferred with the shortfall reason. Wagon purity rules: * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or - * two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon - * with a different cargo type. + * two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only, + * filled to the cargo type's per-wagon cap. Type choice is scarcity-aware: + * least-shareable wagon type first, so bulk with a PW2 alternative leaves the + * container-capable NW5s to the containers. */ export function planWagonsWithStock(params: { bookings: Booking[]; @@ -221,6 +224,38 @@ export function planWagonsWithStock(params: { const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + // Scarcity rank: how many distinct demand groups (container types / bulk + // cargo types) among THESE bookings can ride each wagon type. When a cargo + // can choose, it takes the least-shareable type first, keeping versatile + // types (e.g. container-capable NW5) free for the cargo that has no + // alternative. A type nobody else wants ranks 1; unranked types rank 1 too + // (nothing competes for them). + const demandGroups = new Map(); + for (const b of bookings) { + if (b.freightType === 'CONTAINER') { + for (const line of b.bookingContainers ?? []) { + const containerTypeId = line.containerTypeId ?? line.containerType?.id; + if (!containerTypeId) continue; + demandGroups.set( + `C:${containerTypeId}`, + allowed.byContainerTypeId.get(containerTypeId) ?? [], + ); + } + } else { + const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id; + if (cargoTypeId) { + demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []); + } + } + } + const scarcityRank = new Map(); + for (const types of demandGroups.values()) { + for (const wt of types) { + scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1); + } + } + const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1; + const legFor = (booking: Booking): BookingLeg => { const leg = legs?.get(booking.id); if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { @@ -277,18 +312,26 @@ export function planWagonsWithStock(params: { kind: SlotLoadType, cargoTypeId: string | null, leg: BookingLeg, + /** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */ + cargoType?: Booking['cargoType'], ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates, leg), candidates }; } - // Bulk favors the largest wagon (fewest wagons for the tonnage); containers + // Least-shareable type first (see scarcityRank) so cargo with alternatives + // never starves cargo without one. Bulk then favors the biggest per-wagon + // take for THIS cargo (its configured cap, not the raw rating); containers // favor the deepest stock so the consist drains evenly. Ties keep config order. + const bulkTakeOf = (wt: WagonType): number => + bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)); const chosen = [...inStock].sort((a, b) => kind === 'BULK' - ? Number(b.capacityTons) - Number(a.capacityTons) || + ? rankOf(a) - rankOf(b) || + bulkTakeOf(b) - bulkTakeOf(a) || availableFor(b.id, leg) - availableFor(a.id, leg) - : availableFor(b.id, leg) - availableFor(a.id, leg), + : rankOf(a) - rankOf(b) || + availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; const pool = poolOf(leg); const row = usedRow(rowKeyFor(chosen.id, pool)); @@ -298,7 +341,10 @@ export function planWagonsWithStock(params: { teuPerEdge: new Array(edgeCount).fill(0), kind, cargoTypeId, - freeCapacityTons: Number(chosen.capacityTons), + // A bulk wagon fills to the cargo type's configured per-wagon cap + // (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating. + freeCapacityTons: + kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons), legKey: legKeyOf(leg), covered: { ...leg }, pool, @@ -411,7 +457,6 @@ export function planWagonsWithStock(params: { message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, }; } - const allowedIds = new Set(candidates.map((wt) => wt.id)); // Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the // real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves // it either way. Items are indivisible, so a wagon takes whole items only, @@ -423,68 +468,41 @@ export function planWagonsWithStock(params: { const perItemTons = perItem ? remainingWeight / quantity : 0; let remainingItems = perItem ? quantity : 0; - /** Whole items one wagon of this slot's type can still take. */ - const itemRoomOf = (open: OpenSlot): number => - Math.min( - open.freeItems ?? Number.MAX_SAFE_INTEGER, - perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0, - ); - /** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */ + /** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */ const itemBudgetOf = (open: OpenSlot): number => { const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId); const byTonnage = perItemTons > 0 - ? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons)) + ? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons)) : 1; return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage); }; let placedAnywhere = false; - // Per-item: prefer the type carrying the most whole items per wagon. - // openSlot's own capacity sort is stable, so this order breaks its ties. + // Per-item: least-shareable type first (same scarcity rule as openSlot), + // then the type carrying the most whole items per wagon. const itemBudgetOfType = (wt: WagonType): number => Math.min( bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER, perItemTons > 0 - ? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons)) + ? Math.max( + 1, + Math.floor( + bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) / + perItemTons, + ), + ) : 1, ); const orderedCandidates = perItem - ? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a)) + ? [...candidates].sort( + (a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a), + ) : candidates; - // Top off wagons already carrying THIS cargo type before opening new ones. - // ponytail: per-item cargo only shares wagons that were opened per-item - // (freeItems tracked); mixing itemized and loose loads of one cargo type - // on one wagon is not modeled — open a new wagon instead. - for (const open of openSlots) { - if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break; - if (open.kind !== 'BULK') continue; - if (open.legKey !== legKey) continue; - if (open.cargoTypeId !== cargoTypeId) continue; - if (!allowedIds.has(open.slot.wagonTypeId)) continue; - if (open.freeCapacityTons <= 0) continue; - if (perItem !== (open.freeItems !== undefined)) continue; - const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0; - if (perItem && takeItems <= 0) continue; - const take = perItem - ? roundTons(takeItems * perItemTons) - : roundTons(Math.min(open.freeCapacityTons, remainingWeight)); - addAllocation( - open.slot, - booking.id, - booking.reference, - take, - AllocationLoadType.Bulk, - ); - open.freeCapacityTons = roundTons(open.freeCapacityTons - take); - if (perItem) { - open.freeItems = (open.freeItems ?? 0) - takeItems; - remainingItems -= takeItems; - } - remainingWeight = roundTons(remainingWeight - take); - placedAnywhere = true; - } + // One bulk booking per wagon: a wagon carrying bulk takes that one + // booking's cargo only — never topped up from another booking, even of + // the same cargo type. Every bulk booking therefore opens its own wagons. while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) { // Per-item: openSlot's stock-depth tie-break would override the fit @@ -498,6 +516,7 @@ export function planWagonsWithStock(params: { 'BULK', cargoTypeId, leg, + booking.cargoType, ); if ('message' in openedSlot) return openedSlot; let take: number; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 06ae71384..e80eb9e99 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -873,15 +873,10 @@ export default function GlCreateBookingForm() { // Only a customs (Path B) instance being COMPLETED by GL can use the shared // wagon: it is GL, not the customer, who links the two bookings. Anything else - // keeps the historical hard block on odd 20ft. - // - // Switched OFF for now: consolidation is built end to end (toggle, parent - // picker, split entry, paired pricing, approval gate) but not in use, so an - // odd 20ft total is rejected outright instead of offering the shared wagon. - // Drop the `false &&` to bring the whole flow back. - const oddConsolidationAvailable = - false && - Boolean(completeBookingId && isContainer && contract?.customsClearingEnabled); + // falls through to the server's automatic consolidation gate. + const oddConsolidationAvailable = Boolean( + completeBookingId && isContainer && contract?.customsClearingEnabled, + ); // Auto-on: entering an odd 20ft total opens the consolidation panel by itself, // once. GL can still switch it off — then odd is blocked exactly as before. @@ -970,11 +965,11 @@ export default function GlCreateBookingForm() { !cargoDescriptionError : !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer; - // Consolidation (sharing the wagon with another customer's odd booking) is - // built but switched off for now, so an odd 20ft total always blocks — the - // shared wagon no longer resolves the unpaired container. Flip this back to - // `hasOdd20ft && !consolidationActive` to re-enable the shared-wagon path. - const oddBlocksSubmit = hasOdd20ft; + // COMPLETION never blocks on an odd 20ft total: a customs instance can share + // the wagon via the manual pair (consolidationActive), and anything else is + // auto-paired or parked as PENDING_CONSOLIDATION by the server's + // consolidation gate. Creating a booking from scratch keeps the block. + const oddBlocksSubmit = hasOdd20ft && !completeBookingId; // Partner side: a linked partner must be picked, carry an odd 20ft count of // its own (odd + odd = even fills the wagon) and have complete unit details. diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx index 4c1185cb2..20d81695b 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/compositionEditor/InteractiveTrainConsist.tsx @@ -250,7 +250,7 @@ function WagonCar({ onSelectSlot(loaded[0] ?? wagon)} - style={{ width: 120, flexShrink: 0, cursor: "pointer" }} + style={{ width: 148, flexShrink: 0, cursor: "pointer" }} > { @@ -270,7 +270,9 @@ function WagonCar({ }} style={{ position: "relative", - height: 70, + // A leg-sharing wagon stacks its loads (bulk and container rows + // top/bottom) — give the stack real height so both stay legible. + height: shared ? 88 : 70, borderRadius: 11, background: isEmpty ? "var(--mantine-color-gray-0)" @@ -393,7 +395,7 @@ function WagonCar({ ); const rowBlocks = wagonItems(slot).slice(0, 2); const rowSelected = shared && slot.id === selectedWagonId; - const rowHeight = shared ? 13 : 26; + const rowHeight = shared ? 20 : 26; return ( ( - - - - - - ), + cell: ({ row }) => , }, { id: "actions", @@ -884,14 +878,6 @@ export default function TrainScheduleV2ListPage() { ); } -/** - * The row's wagon chips: used is slots carrying a booking allocation, the - * denominator is the schedule's capacity (API-computed: the larger of coupled - * consist and planned `maxWagons`, since wagons are coupled on demand). Both - * are consist-wide totals, so on a multi-leg schedule they do not describe any - * single leg — the bookable/planned counts were dropped for that reason; the - * detail page's wagon plan is the per-leg source of truth. - */ /** Green tint for departures dedicated to a shipping line (overrides direction tint). */ const SHIPPING_LINE_ROW_STYLE = { backgroundColor: "var(--mantine-color-edr-green-0)", @@ -956,25 +942,6 @@ function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) { ); } -function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) { - // Pre-deploy API rows carry only wagonCount; fall back so the chip still - // renders rather than reading 0 used on every train. - const total = schedule.wagonsTotal ?? schedule.wagonCount; - const used = schedule.wagonsUsed; - const reserved = schedule.wagonsReserved ?? 0; - - if (used == null || schedule.wagonCount === 0) { - return ; - } - - return ( - <> - - {reserved > used ? : null} - - ); -} - function MetricChip({ value, label, @@ -1071,11 +1038,7 @@ function ScheduleCard({ ) : null} - - - - - + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index f5906ceea..de1286ffd 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,5 +1,5 @@ -import { Button, Group, Modal, Stack, Tabs, Text } from "@mantine/core"; -import { useMutation } from "@tanstack/react-query"; +import { Button, Group, Modal, Skeleton, Stack, Tabs, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Clock, CreditCard, @@ -67,6 +67,8 @@ const CUSTOMER_CANCELLABLE_STATUSES = [ "CONTRACT_READY", "OPERATION_REQUEST_PENDING", "SELECTED_FOR_BATCH", + // Parked waiting for a consolidation partner — nothing reserved yet. + "PENDING_CONSOLIDATION", ]; const cancelErrorMessage = (error: unknown) => { @@ -136,6 +138,32 @@ export function ReadonlyBookingView({ const canCancel = booking.paymentStatus !== "PAID" && CUSTOMER_CANCELLABLE_STATUSES.includes(status); + // PAID booking (allocated or not): the same button cancels the WHOLE booking + // through wagon cancellation — a per-wagon fee is invoiced and the paid + // freight becomes a rebooking credit. Blocked once loading starts (server + // enforces; loading flips status past PAID/TRUCK_ASSIGNED). + const canCancelPaid = + booking.paymentStatus === "PAID" && + ["PAID", "TRUCK_ASSIGNED"].includes(status) && + Boolean(booking.contractId); + const [paidCancelOpen, setPaidCancelOpen] = useState(false); + const paidPreview = useQuery({ + queryKey: ["whole-cancel-preview", booking.id], + queryFn: () => bookingsService.previewWagonCancellation(booking.id, {}), + enabled: paidCancelOpen, + }); + const paidCancelMutation = useMutation({ + mutationFn: () => bookingsService.requestWagonCancellation(booking.id, {}), + onSuccess: () => { + setPaidCancelOpen(false); + toast.success( + "Cancellation requested — pay the cancellation fee to settle it. Your paid freight is kept as credit for rebooking.", + { duration: 8000 }, + ); + onBookingUpdated?.(); + }, + onError: (e) => toast.error(cancelErrorMessage(e)), + }); const pricing = booking.pricingBreakdown; // A general contract is paid once it's FULLY_EXECUTED (signed) — it never @@ -206,7 +234,8 @@ export function ReadonlyBookingView({ actions={ (canApproveDelivery || (payables.items.length > 0 && tab !== "payments") || - canCancel) && ( + canCancel || + canCancelPaid) && ( {canApproveDelivery && ( @@ -227,6 +256,14 @@ export function ReadonlyBookingView({ onClick={() => setCancelOpen(true)} /> )} + {canCancelPaid && ( + } + label="Cancel booking" + onClick={() => setPaidCancelOpen(true)} + /> + )} ) } @@ -414,6 +451,7 @@ export function ReadonlyBookingView({ booking.paymentStatus === "PAID" && Boolean(booking.contractId) } + consolidated={Boolean(booking.consolidationPartnerId)} onCancellationRequested={onBookingUpdated} /> @@ -509,6 +547,86 @@ export function ReadonlyBookingView({ + setPaidCancelOpen(false)} + title={ + + Cancel this booking? + + } + centered + radius={16} + > + + + You're about to cancel the whole booking{" "} + + {booking.reference} + + . A cancellation fee applies per wagon; your paid freight is kept as + a credit you can rebook with once the fee is settled. + {booking.consolidationPartnerId + ? " This booking shares a wagon with another customer — both bookings will be cancelled, and the shared wagon's fee is charged to you, not to them." + : ""} + + {paidPreview.isLoading && } + {paidPreview.data && ( + + + Wagons cancelled: {paidPreview.data.wagons} + + + Cancellation fee:{" "} + + {Number(paidPreview.data.feeAmount).toLocaleString()}{" "} + {paidPreview.data.feeCurrency} + {" "} + ({Number(paidPreview.data.feePerWagon).toLocaleString()} per + wagon) + + + Rebooking credit:{" "} + + {Number(paidPreview.data.creditAmount).toLocaleString()}{" "} + {booking.paymentCurrency} + + + + )} + {paidPreview.isError && ( + + {cancelErrorMessage(paidPreview.error)} + + )} + + + + + + {viewer} ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx index bb775555f..2d0522e1b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx @@ -303,11 +303,14 @@ function WagonCard({ wagon, selectable, selected, + shared, onToggle, }: { wagon: BookingWagonAllocation; selectable?: boolean; selected?: boolean; + /** Shared consolidation wagon — not selectable for cancellation. */ + shared?: boolean; onToggle?: () => void; }) { const allocated = Number(wagon.allocatedWeightTons || 0); @@ -368,6 +371,14 @@ function WagonCard({ + {shared && ( + + Shared wagon — the other half belongs to another customer's + booking, so it cannot be cancelled on its own. Cancel the whole + booking to release it. + + )} + @@ -464,6 +475,7 @@ export function WagonsTab({ bookingId, currency, cancellable, + consolidated, onCancellationRequested, }: { bookingId: string; @@ -471,6 +483,8 @@ export function WagonsTab({ currency?: string; /** PAID contract booking — specific wagons may be selected for cancellation. */ cancellable?: boolean; + /** Consolidated booking — its shared wagon (a lone 20ft) cannot be cancelled alone. */ + consolidated?: boolean; onCancellationRequested?: () => void; }) { const queryClient = useQueryClient(); @@ -711,19 +725,31 @@ export function WagonsTab({ - {wagons.map((w) => ( - w.allocationId && toggle(w.allocationId)} - /> - ))} + {wagons.map((w) => { + // The shared consolidation wagon carries this booking's lone 20ft — + // its other half belongs to the partner booking, so it can never be + // cancelled on its own (the server rejects it too). + const isSharedWagon = + !!consolidated && + w.loadType === "CONTAINER" && + (w.containers ?? []).length === 1 && + Number(w.containers?.[0]?.sizeFt) === 20; + return ( + w.allocationId && toggle(w.allocationId)} + /> + ); + })} { - // An unpaired 20ft can never be planned onto a wagon — don't even price it. - if (hasOdd20ft) return; setPendingValues(values); validateMutation.reset(); validateMutation.mutate(buildDto(values)); @@ -747,27 +744,27 @@ function NewShipmentBookingForm({ Fix the highlighted fields before reviewing the price. ) : null} - - } + mb="sm" > - {/* Mantine tooltips get no pointer events from a disabled button, - so the wrapper carries the hover target. */} - - - - + {`${ft20Total} is an odd number of 20ft containers — this booking will be paired with another customer's odd booking to share a wagon, or held until one is available.`} + + ) : null} + + @@ -1687,18 +1684,16 @@ function CargoStep({ if (ft20 % 2 !== 1) return null; return ( } title={`Odd number of 20ft containers (${ft20})`} > - 20ft containers travel two per wagon, so they must be booked - in even numbers. Please add one more 20ft container or remove - one (e.g. book {ft20 + 1} or {ft20 - 1} instead of {ft20}) — - the booking cannot be submitted with an unpaired 20ft - container. + 20ft containers travel two per wagon. This booking will be + paired with another customer's odd booking to share a + wagon, or held until one is available. ); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index b69c83b05..76f568f5d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -106,15 +106,14 @@ export default function NewShipmentRequestPage() { contract.cargoScope?.[0]; const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM"; - // 20ft containers ride two per wagon, so an odd total leaves one unpaired and - // the request cannot be planned. Consolidation (pairing the odd container with - // another customer's odd booking) is built but switched off for now, so an odd - // request is blocked here rather than dead-ending downstream. + // 20ft containers ride two per wagon. An odd total no longer blocks the + // request — the server auto-pairs it with another customer's odd booking, or + // parks it as PENDING_CONSOLIDATION until one shows up (same consolidation + // gate the direct-booking flow already uses). const ft20Requested = isContainer ? Number(qtyBySize["20ft"]) || 0 : 0; const hasOdd20ft = ft20Requested % 2 === 1; const handleSubmit = () => { - if (hasOdd20ft) return; const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, @@ -221,17 +220,16 @@ export default function NewShipmentRequestPage() { {hasOdd20ft ? ( } title={`Odd number of 20ft containers (${ft20Requested})`} > - 20ft containers travel two per wagon, so they must be requested - in even numbers. Please add one more 20ft container or remove - one (e.g. request {ft20Requested + 1} or {ft20Requested - 1}{" "} - instead of {ft20Requested}). + 20ft containers travel two per wagon. This request will be + paired with another customer's odd booking to share a + wagon, or held until one is available. ) : null} @@ -282,7 +280,6 @@ export default function NewShipmentRequestPage() { leftSection={} loading={submit.isPending} onClick={handleSubmit} - disabled={hasOdd20ft} > Submit shipment request diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 6312c8738..949fad3c9 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -204,6 +204,8 @@ export interface BookingWagonContainer { sealNumber: string | null; positionOnWagon: number | null; grossWeightTons: string | null; + /** Container size in feet (20/40) — identifies the shared consolidation wagon. */ + sizeFt: number | null; } /** One allocated wagon of a booking, as returned by GET /bookings/:id/wagons. */