implement auto-consolidation for freight bookings at submission time

This commit is contained in:
Marshal
2026-06-17 16:46:44 +00:00
parent bd0d90eddd
commit 3798b28bcd
3 changed files with 40 additions and 53 deletions

View File

@@ -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<Booking> {
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,

View File

@@ -177,8 +177,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
.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,

View File

@@ -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<boolean> {
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<Booking> {
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. */