diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 3f5bbe507..a1ca196ab 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -42,7 +42,7 @@ export class BookingsController { summary: "Create a new freight booking", description: "Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " + - "Auto-enables consolidation when containerType=20FT and odd quantity.", + "Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.", }) @ApiBody({ description: @@ -150,8 +150,8 @@ export class BookingsController { @ApiOperation({ summary: "Request freight consolidation", description: - "Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " + - "If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.", + "Searches for a partner whose container quantity complements yours to fill whole wagon(s) " + + "(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.", }) requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 43b38d123..b9a457b1b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -7,6 +7,7 @@ import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { BookingsController } from './bookings.controller'; import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -29,7 +30,7 @@ import { Booking } from './entities/booking.entity'; RuleEngineModule, ], controllers: [BookingsController], - providers: [BookingsService, BookingsRepository], + providers: [BookingsService, BookingsRepository, ConsolidationService], exports: [BookingsService], }) export class BookingsModule {} 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 32147db7d..f329c942f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,7 +1,7 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, In, IsNull, Not, Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; @@ -128,20 +128,63 @@ export class BookingsRepository extends BaseRepository { return Number(result?.total ?? 0); } - /** Find a compatible consolidation partner. */ - async findConsolidationPartner(booking: Booking): Promise { - return this.repository.findOne({ - where: { - allowConsolidation: true, + /** + * Find another booking whose container quantity complements this one to fill whole wagon(s) + * (same route, same container type, partial wagon on both sides). + */ + async findComplementaryConsolidationPartner( + booking: Booking, + slot: { + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }, + ): Promise { + const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; + + return this.repository + .createQueryBuilder('b') + .innerJoinAndSelect('b.bookingContainers', 'bc') + .innerJoin('bc.containerType', 'ct') + .where('b.id != :bookingId', { bookingId: booking.id }) + .andWhere('b.allowConsolidation = true') + .andWhere('b.consolidationPartnerId IS NULL') + .andWhere('b.status IN (:...statuses)', { + statuses: ['DRAFT', 'PENDING_CONSOLIDATION'], + }) + .andWhere('b.originYardId = :originYardId', { originYardId: booking.originYardId, + }) + .andWhere('b.destinationYardId = :destinationYardId', { destinationYardId: booking.destinationYardId, + }) + .andWhere('b.tradeDirection = :tradeDirection', { tradeDirection: booking.tradeDirection, - consolidationPartnerId: IsNull(), - status: In(['DRAFT', 'PENDING_CONSOLIDATION']), - id: Not(booking.id), - }, - order: { createdAt: 'ASC' }, - }); + }) + .andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId }) + .andWhere('(bc.quantity % :perWagon) > 0', { perWagon }) + .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { + quantity, + perWagon, + }) + .orderBy('b.createdAt', 'ASC') + .getOne(); + } + + /** Try each partial-wagon line until a complementary partner booking is found. */ + async findConsolidationPartner( + booking: Booking, + slots: Array<{ + containerTypeId: string; + quantity: number; + containersPerWagon: number; + }>, + ): Promise { + for (const slot of slots) { + const partner = await this.findComplementaryConsolidationPartner(booking, slot); + if (partner) return partner; + } + return null; } /** Pair two bookings for consolidation. */ 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 616960358..3aaa12a70 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -15,6 +15,7 @@ import { RuleEngineService, } from '../rule-engine/rule-engine.service'; import { BookingsRepository } from './bookings.repository'; +import { ConsolidationService } from './consolidation.service'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; @@ -31,6 +32,7 @@ export class BookingsService { private readonly customersService: CustomersService, private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, + private readonly consolidationService: ConsolidationService, ) {} /** Generate a unique booking reference number. */ @@ -79,19 +81,66 @@ export class BookingsService { }; } - /** Resolve auto-consolidation for odd-quantity 20ft containers. */ + /** + * 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. + */ private async resolveConsolidation( containers: CreateBookingContainerDto[], explicit?: boolean, ): Promise { if (explicit === false) return false; - for (const c of containers) { - const ct = await this.containerTypesService.findById(c.containerTypeId); - if (ct.sizeFt === 20 && c.quantity % 2 !== 0) return true; - } + const needs = await this.consolidationService.needsConsolidation( + containers.map((c) => ({ + containerTypeId: c.containerTypeId, + quantity: c.quantity, + })), + ); + if (needs) return true; return explicit ?? false; } + /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */ + private async tryAutoConsolidate(booking: Booking): Promise<{ + booking: Booking; + messages: string[]; + }> { + const messages: string[] = []; + + if (!booking.allowConsolidation || booking.consolidationPartnerId) { + return { booking, messages }; + } + + const slots = await this.consolidationService.slotsFromBooking(booking); + if (slots.length === 0) { + return { booking, messages }; + } + + const partner = await this.bookingsRepository.findConsolidationPartner( + booking, + slots, + ); + + if (partner) { + await this.bookingsRepository.pairConsolidation(booking.id, partner.id); + const paired = await this.findById(booking.id); + messages.push( + this.consolidationService.describePaired(partner.reference, slots), + ); + return { booking: paired, messages }; + } + + if (booking.status === 'DRAFT') { + await this.bookingsRepository.update(booking.id, { + status: 'PENDING_CONSOLIDATION', + } as never); + } + + const pending = await this.findById(booking.id); + messages.push(this.consolidationService.describePending(pending, slots)); + return { booking: pending, messages }; + } + /** Create a new freight booking. */ async create( dto: CreateBookingDto, @@ -175,7 +224,14 @@ export class BookingsService { } } - const full = await this.findById(booking.id); + let full = await this.findById(booking.id); + + if (allowConsolidation) { + const consolidation = await this.tryAutoConsolidate(full); + full = consolidation.booking; + warnings.push(...consolidation.messages); + } + return { booking: full, warnings }; } @@ -246,7 +302,14 @@ export class BookingsService { await this.filesService.uploadMany(id, 'bookings', files); } - const booking = await this.findById(id); + let booking = await this.findById(id); + + if (allowConsolidation && !booking.consolidationPartnerId) { + const consolidation = await this.tryAutoConsolidate(booking); + booking = consolidation.booking; + warnings.push(...consolidation.messages); + } + return { booking, warnings }; } @@ -528,6 +591,7 @@ export class BookingsService { booking: Booking; partner: Booking | null; paired: boolean; + message: string; }> { const booking = await this.findById(id); @@ -535,10 +599,12 @@ export class BookingsService { throw new BadRequestException('Booking is not eligible for consolidation'); } - const hasOdd20Ft = await this.hasOdd20FtContainer(booking); - if (!hasOdd20Ft) { + const needs = await this.consolidationService.needsConsolidationFromBooking( + booking, + ); + if (!needs) { throw new BadRequestException( - 'Only bookings with odd-quantity 20ft containers need consolidation', + 'Booking already fills whole wagon(s) for all container lines; consolidation is not required', ); } @@ -546,32 +612,17 @@ export class BookingsService { throw new ConflictException('Booking is already paired for consolidation'); } - const partner = await this.bookingsRepository.findConsolidationPartner(booking); + const result = await this.tryAutoConsolidate(booking); + const partner = result.booking.consolidationPartnerId + ? await this.findById(result.booking.consolidationPartnerId) + : null; - if (partner) { - await this.bookingsRepository.pairConsolidation(booking.id, partner.id); - return { - booking: await this.findById(id), - partner: await this.findById(partner.id), - paired: true, - }; - } - - await this.bookingsRepository.update(booking.id, { - status: 'PENDING_CONSOLIDATION', - } as never); - return { booking: await this.findById(id), partner: null, paired: false }; - } - - private async hasOdd20FtContainer(booking: Booking): Promise { - const containers = booking.bookingContainers ?? []; - for (const bc of containers) { - const ct = - bc.containerType ?? - (await this.containerTypesService.findById(bc.containerTypeId)); - if (ct.sizeFt === 20 && bc.quantity % 2 !== 0) return true; - } - return false; + return { + booking: result.booking, + partner, + paired: partner !== null, + message: result.messages[0] ?? '', + }; } async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> { @@ -593,11 +644,26 @@ export class BookingsService { booking: Booking; partner: Booking | null; splitBilling: { bookingShare: number; partnerShare: number } | null; + wagonSlots: Awaited>; + statusMessage: string; }> { const booking = await this.findById(id); + const wagonSlots = await this.consolidationService.slotsFromBooking(booking); if (!booking.consolidationPartnerId) { - return { booking, partner: null, splitBilling: null }; + const statusMessage = + booking.status === 'PENDING_CONSOLIDATION' + ? this.consolidationService.describePending(booking, wagonSlots) + : wagonSlots.length > 0 + ? 'Consolidation may be required; no partner paired yet.' + : 'No wagon consolidation needed.'; + return { + booking, + partner: null, + splitBilling: null, + wagonSlots, + statusMessage, + }; } const partner = await this.findById(booking.consolidationPartnerId); @@ -608,6 +674,11 @@ export class BookingsService { bookingShare: Number(booking.totalAmount), partnerShare: Number(partner.totalAmount), }, + wagonSlots, + statusMessage: this.consolidationService.describePaired( + partner.reference, + wagonSlots, + ), }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts new file mode 100644 index 000000000..2d805ba8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/consolidation.service.ts @@ -0,0 +1,123 @@ +import { Injectable } from '@nestjs/common'; + +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { Booking } from './entities/booking.entity'; + +export interface ConsolidationSlot { + containerTypeId: string; + containerTypeCode: string; + quantity: number; + containersPerWagon: number; + remainder: number; + slotsNeeded: number; +} + +export interface ConsolidationAttemptResult { + booking: Booking; + partner: Booking | null; + paired: boolean; + messages: string[]; +} + +/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */ +export function containersPerWagon(wagonsPerUnit: number): number { + const wpu = Number(wagonsPerUnit); + if (!wpu || wpu <= 0) return 1; + return Math.max(1, Math.round(1 / wpu)); +} + +export function wagonRemainder(quantity: number, perWagon: number): number { + const r = quantity % perWagon; + return r; +} + +export function slotsNeededToFillWagon(quantity: number, perWagon: number): number { + const remainder = wagonRemainder(quantity, perWagon); + if (remainder === 0) return 0; + return perWagon - remainder; +} + +/** Two bookings' quantities for the same type complete whole wagon(s). */ +export function quantitiesComplementWagon( + q1: number, + q2: number, + perWagon: number, +): boolean { + return ( + wagonRemainder(q1, perWagon) > 0 && + wagonRemainder(q2, perWagon) > 0 && + (q1 + q2) % perWagon === 0 + ); +} + +@Injectable() +export class ConsolidationService { + constructor(private readonly containerTypesService: ContainerTypesService) {} + + async slotsFromContainerLines( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots: ConsolidationSlot[] = []; + for (const line of lines) { + const ct = await this.containerTypesService.findById(line.containerTypeId); + const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); + const remainder = wagonRemainder(line.quantity, perWagon); + if (remainder === 0) continue; + slots.push({ + containerTypeId: line.containerTypeId, + containerTypeCode: ct.code, + quantity: line.quantity, + containersPerWagon: perWagon, + remainder, + slotsNeeded: perWagon - remainder, + }); + } + return slots; + } + + async slotsFromBooking(booking: Booking): Promise { + const lines = + booking.bookingContainers?.map((bc) => ({ + containerTypeId: bc.containerTypeId, + quantity: bc.quantity, + })) ?? []; + return this.slotsFromContainerLines(lines); + } + + async needsConsolidation( + lines: Array<{ containerTypeId: string; quantity: number }>, + ): Promise { + const slots = await this.slotsFromContainerLines(lines); + return slots.length > 0; + } + + async needsConsolidationFromBooking(booking: Booking): Promise { + const slots = await this.slotsFromBooking(booking); + return slots.length > 0; + } + + describePending(_booking: Booking, slots: ConsolidationSlot[]): string { + if (slots.length === 0) { + return 'Booking does not require wagon consolidation.'; + } + const parts = slots.map( + (s) => + `${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`, + ); + return ( + `No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` + + `Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.` + ); + } + + describePaired(partnerReference: string, slots: ConsolidationSlot[]): string { + const parts = slots.map( + (s) => + `${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`, + ); + return ( + `Consolidation partner found (${partnerReference}). ` + + `Shared wagon confirmed: ${parts.join('; ')}.` + ); + } +}