From 3798b28bcd7e2996984d37ccb1f919576786df13 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 16:46:44 +0000 Subject: [PATCH] implement auto-consolidation for freight bookings at submission time --- .../bookings/booking-transition.service.ts | 26 ++++---- .../modules/bookings/bookings.repository.ts | 5 +- .../src/modules/bookings/bookings.service.ts | 62 +++++++------------ 3 files changed, 40 insertions(+), 53 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 b2c3ffbfb..c41013ddb 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 @@ -1,6 +1,5 @@ import { BadRequestException, - ConflictException, forwardRef, Inject, Injectable, @@ -69,7 +68,12 @@ export class BookingTransitionService { priorityScore, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Auto-consolidate now: a partial-wagon booking either pairs with a waiting + // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one + // arrives. The returned status reflects that outcome. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -143,7 +147,10 @@ export class BookingTransitionService { }, } as never); - const finalBooking = await this.bookingsService.findById(updated!.id); + // Same consolidation treatment as the direct submit path. + const finalBooking = await this.bookingsService.runConsolidationOnSubmit( + updated!.id, + ); return { bookingId: finalBooking.id, status: finalBooking.status, @@ -188,18 +195,11 @@ export class BookingTransitionService { async acceptIntake(bookingId: string, actorId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + // Only SUBMITTED bookings are acceptable. A booking that still needs + // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and + // is therefore never offered for accept until a partner moves it to SUBMITTED. assertBookingStatus(booking, ['SUBMITTED']); - // Consolidation gate: a booking whose containers don't fill whole wagons - // cannot be accepted until it is paired with a complementary booking. - const gate = await this.bookingsService.resolveConsolidationGate(bookingId); - if (gate.blocked) { - throw new ConflictException( - gate.message ?? - 'Booking requires consolidation and cannot be accepted until a partner is found.', - ); - } - await this.ruleEngineService.instantiateApprovalSteps(bookingId, { freightType: booking.freightType as 'CONTAINER' | 'BULK', cargoTypeId: booking.cargoTypeId, 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 d304e2946..edda990de 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository { .where('b.id != :bookingId', { bookingId: booking.id }) .andWhere('b.allowConsolidation = true') .andWhere('b.consolidationPartnerId IS NULL') + // Only pair bookings the customer has committed (SUBMITTED) or that are + // already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so + // pairing never prematurely submits an unfinished/unpriced draft. .andWhere('b.status IN (:...statuses)', { - statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'], + statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'], }) .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, 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 65c773f3c..b32b16b32 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -152,13 +152,17 @@ export class BookingsService { /** * Enable consolidation when any container line leaves a wagon partially filled - * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon), unless opted out. + * (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon). + * + * Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a + * half-empty wagon, so `explicit === false` is ignored when consolidation is + * actually needed. The opt-in flag only matters for cargo that already fills + * whole wagons (where consolidation is moot anyway). */ private async resolveConsolidation( containers: CreateBookingContainerDto[], explicit?: boolean, ): Promise { - if (explicit === false) return false; const needs = await this.consolidationService.needsConsolidation( containers.map((c) => ({ containerTypeId: c.containerTypeId, @@ -199,10 +203,11 @@ export class BookingsService { return { booking: paired, messages }; } - if (booking.status === 'DRAFT') { - await this.bookingsRepository.update(booking.id, { - status: 'PENDING_CONSOLIDATION', - } as never); + // No partner yet — park the booking so it waits. Applies both pre-submit + // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never + // reach this method. + if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') { + await this.bookingsRepository.parkForConsolidation(booking.id); } const pending = await this.findById(booking.id); @@ -211,45 +216,24 @@ export class BookingsService { } /** - * Consolidation gate used at staff-accept time. Returns the (possibly newly - * paired) booking plus whether it still needs a consolidation partner. - * When a booking needs consolidation and none is found, it is parked in - * PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept. + * Run consolidation right after a booking reaches SUBMITTED. If a complementary + * partner already exists, both are paired and moved (back) to SUBMITTED so staff + * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and + * waits for a later complementary booking to complete the wagon. + * + * Returns the re-fetched booking, so callers can reflect the resulting status + * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting). */ - async resolveConsolidationGate(bookingId: string): Promise<{ - booking: Booking; - blocked: boolean; - message?: string; - }> { - let booking = await this.findById(bookingId); + async runConsolidationOnSubmit(bookingId: string): Promise { + const booking = await this.findById(bookingId); - // Already paired — passes the gate. + // Already paired (e.g. a partner submitted first) — nothing to do. if (booking.consolidationPartnerId) { - return { booking, blocked: false }; + return booking; } - const needs = - await this.consolidationService.needsConsolidationFromBooking(booking); - if (!needs) { - return { booking, blocked: false }; - } - - // A partner may have appeared since submission — try to pair now. const result = await this.tryAutoConsolidate(booking); - booking = result.booking; - if (booking.consolidationPartnerId) { - return { booking, blocked: false, message: result.messages.join(' ') }; - } - - // Still no partner — park it and block the accept. - await this.bookingsRepository.parkForConsolidation(booking.id); - booking = await this.findById(booking.id); - const slots = await this.consolidationService.slotsFromBooking(booking); - return { - booking, - blocked: true, - message: this.consolidationService.describePending(booking, slots), - }; + return result.booking; } /** Create a new freight booking. */