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 { // Aggregate by container type first: two lines of the same type on one // booking share the same wagons. Counting them separately would flag a // self-complete booking (e.g. 2× 20FT = exactly one wagon) as a partial // wagon and wrongly park it in PENDING_CONSOLIDATION. const quantityByType = new Map(); for (const line of lines) { if (!line.containerTypeId) continue; quantityByType.set( line.containerTypeId, (quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0), ); } const slots: ConsolidationSlot[] = []; for (const [containerTypeId, quantity] of quantityByType) { const ct = await this.containerTypesService.findById(containerTypeId); const perWagon = containersPerWagon(Number(ct.wagonsPerUnit)); const remainder = wagonRemainder(quantity, perWagon); if (remainder === 0) continue; slots.push({ containerTypeId, containerTypeCode: ct.code, quantity, containersPerWagon: perWagon, remainder, slotsNeeded: perWagon - remainder, }); } return slots; } async slotsFromBooking(booking: Booking): Promise { const lines = (booking.bookingContainers ?? []) .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null) .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('; ')}.` ); } }