import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common'; import { DataSource, IsNull, Not } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; import { Contract } from '../contracts/entities/contract.entity'; import { ContractBookingService, SplitOutstanding, } from '../contracts/contract-booking.service'; import { ContractsRepository } from '../contracts/contracts.repository'; import { CreateBookingUnderContractDto, CreateContainerUnitDto, } from '../contracts/dto/create-booking-under-contract.dto'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; import { eatDay } from './batch-window.util'; /** * Auto-creates and places the OUTSTANDING split remainder of a contract as a new * booking, so the customer doesn't have to manually rebook the wagons that did * not fit the train they just paid for. * * Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only * once the customer has actually accepted+paid the offered part. Before payment * nothing is split: the booking stays whole and the customer may still edit or * cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}. * * IMPORT/DOMESTIC: the remainder booking is created with the next fitting * shipment day set and then follows the normal windowed batch flow (train * assigned at window close, paid in its own window). It is NOT force-reserved on * a specific train — import is not FCFS. * * Container reconstruction is HYBRID: the remainder's quantities come from the * split snapshot (`splitOutstanding`), but the actual container numbers / VGM / * seals are read back from the units `applySplit` SOFT-DELETED off the parent * (they survive as valid ISO records). We never `restore()` those rows — the new * booking gets fresh rows — so the contract cap is never double-counted. */ @Injectable() export class RemainderPlacementService { private readonly logger = new Logger(RemainderPlacementService.name); constructor( private readonly dataSource: DataSource, private readonly contractsRepository: ContractsRepository, @Inject(forwardRef(() => ContractBookingService)) private readonly contractBookingService: ContractBookingService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, private readonly notifier: BookingNotifierService, ) {} /** * Create + place the outstanding split remainder of the contract that owns * `splitBooking`. No-op when there is no live remainder or no fitting day. * Returns the created remainder booking id, or null when nothing was placed * (residual falls back to the customer's manual rebook, as today). */ async placeRemainder(splitBooking: Booking): Promise { if (!splitBooking.contractId) return null; // Two payment webhooks for the same contract landing together would both see // the remainder as unbooked (the placing create has not committed yet) and // each create one — double-booking the leftover. Serialize per contract: the // second caller returns immediately and the first one's create is what the // (now smaller) outstanding reflects. if (this.inFlight.has(splitBooking.contractId)) { this.logger.debug( `Remainder placement already running for contract ${splitBooking.contractId} — skipped.`, ); return null; } this.inFlight.add(splitBooking.contractId); try { return await this.placeRemainderInner(splitBooking); } finally { this.inFlight.delete(splitBooking.contractId); } } /** Contracts with a placement in flight — see {@link placeRemainder}. */ private readonly inFlight = new Set(); private async placeRemainderInner( splitBooking: Booking, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations( splitBooking.contractId!, ); if (!contract) return null; // ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total // from a SINGLE booking's pre-split snapshot, which is only coherent when // the contract has exactly one live chain — that is the ONE_TIME invariant // (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL // contract with other live bookings the subtraction mixes scopes: it either // clamps to 0 and silently drops a real remainder, or sizes one that then // draws the quantity cap a second time. GENERAL remainders keep the existing // manual-rebook behaviour until the remainder can be derived from the // offer's own dropped lines rather than from the contract-wide ledger. if (contract.contractKind !== 'ONE_TIME') { this.logger.debug( `Contract ${contract.id} is ${contract.contractKind} — remainder left ` + `for manual rebook (auto-placement is ONE_TIME only).`, ); return null; } const outstanding = await this.contractBookingService.splitOutstanding( contract, ); if (!outstanding || !this.hasOutstanding(contract, outstanding)) { return null; } // The next fitting day: the earliest day on/after the split booking's own day // that still has an import train with room for this cargo type. We reuse the // split booking as the capacity probe — it carries the leg + cargo relations. const day = await this.nextFittingDay(splitBooking); if (!day) { this.logger.warn( `No train with room for the remainder of contract ${contract.id} ` + `(booking ${splitBooking.reference}) — left for manual rebook.`, ); return null; } let dto: CreateBookingUnderContractDto; try { dto = await this.buildRemainderDto( contract, outstanding, splitBooking.id, day, ); } catch (err) { // A reconstruction shortfall (fewer recoverable units than outstanding) // must NOT fabricate container numbers — fail loudly, leave manual rebook. this.logger.error( `Could not reconstruct the remainder of contract ${contract.id}: ` + `${err instanceof Error ? err.message : String(err)} — left for manual rebook.`, ); return null; } // Any create-gate rejection (no train space, cap, container clash) must not // escape: the customer's paid booking has already boarded, and a thrown // error here would only be logged upstream while the remainder vanished // silently. Fall back to leaving it rebookable, which is the pre-feature // behaviour, and say so in the log. let created: Awaited< ReturnType >; try { created = await this.contractBookingService.createUnderContract( contract.id, dto, { id: splitBooking.createdByUserId ?? undefined }, // System actor: a permission-bag carrying the contract create-booking key // so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B // contracts; harmless for customer (Path A) contracts. { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, ); } catch (err) { this.logger.error( `Could not create the remainder booking for contract ${contract.id} ` + `(from ${splitBooking.reference}): ${ err instanceof Error ? err.message : String(err) } — left for manual rebook.`, ); return null; } // EXPORT is FCFS — there is no window to wait for, so the remainder is // reserved on the next export train right away (its own pay window opens). // If it does not fit one train whole either, the export accept offers it a // partial and the chain repeats on ITS payment: each pass leaves a strictly // smaller remainder, so it terminates at the day's train count. // IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next // fitting day and rides the normal windowed batch flow. if (splitBooking.tradeDirection === 'EXPORT') { const fresh = await this.dataSource .getRepository(Booking) .findOne({ where: { id: created.booking.id }, relations: { company: true, bookingContainers: { containerType: true }, cargoType: true, }, }); if (fresh) { await this.bookingBatchService .acceptExportBooking(fresh) .catch((err) => // No export train took it — it stays created and rebookable, which // is the same place a customer-driven rebook would leave it. this.logger.warn( `Export remainder ${fresh.reference} created but not reserved: ${ err instanceof Error ? err.message : String(err) }`, ), ); } } this.notifier.remainderPlaced( created.booking, splitBooking.reference ?? splitBooking.id, ); this.logger.log( `Auto-placed split remainder of contract ${contract.id} as booking ` + `${created.booking.reference} on ${day}.`, ); return created.booking.id; } private hasOutstanding( contract: Contract, outstanding: SplitOutstanding, ): boolean { if (contract.freightType === 'CONTAINER') { return [...outstanding.bySize.values()].some((s) => s.outstanding > 0); } return (outstanding.bulk?.outstanding ?? 0) > 0.001; } /** * The shipment day to create the remainder on — the split booking's own day. * * EXPORT is FCFS and must actually board a train that day, so a day with NO * export train having room is rejected (null → left for manual rebook on a day * the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is * assigned by the batch engine at window close, not now, and the window may * still free up — forcing a different day here would override the customer's * binding shipment day. */ private async nextFittingDay(booking: Booking): Promise { if (!booking.scheduledDate) return null; const day = eatDay(new Date(booking.scheduledDate)); if (booking.tradeDirection !== 'EXPORT') return day; const fitting = await this.bookingBatchService.fittingTrainsForDay( booking, day, 'EXPORT', ); return fitting.length > 0 ? day : null; } /** * Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the * outstanding tonnage directly. Container reads the deferred (soft-deleted) * units of the split booking back into real unit records. */ private async buildRemainderDto( contract: Contract, outstanding: SplitOutstanding, splitBookingId: string, day: string, ): Promise { const dto: CreateBookingUnderContractDto = { scheduledDate: day }; if (contract.freightType !== 'CONTAINER') { const tons = outstanding.bulk?.outstanding ?? 0; dto.bulkLines = [{ cargoWeightTons: tons }]; return dto; } // Container: recover the deferred units per size from the split booking's // soft-deleted rows and reshape into DTO units. const containers: NonNullable = []; for (const [size, { outstanding: need }] of outstanding.bySize) { if (need <= 0) continue; const units = await this.recoverDeferredUnits(splitBookingId, size, need); if (units.length < need) { throw new Error( `size ${size}: recovered ${units.length} deferred container(s) but ` + `${need} are outstanding`, ); } const line: NonNullable[number] = { containerSize: size, quantity: need, units, }; line.hazardousQuantity = units.filter((u) => u.isHazardous).length; line.reeferQuantity = units.filter((u) => u.isReefer).length; containers.push(line); } dto.containers = containers; return dto; } /** * The `need` deferred container units of a given size for the split booking, * read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the * LIFO trim in applySplit so the same physical containers deferred are the * ones rebooked). Returns them as DTO units; does NOT restore the rows. */ private async recoverDeferredUnits( splitBookingId: string, containerSize: string, need: number, ): Promise { // The line ids of this booking for this size (live + soft-deleted): units // key on bookingContainerId, so gather every line of the size first. const lines = await this.dataSource .getRepository(BookingContainer) .find({ where: { bookingId: splitBookingId, containerSize }, withDeleted: true, select: { id: true }, }); const lineIds = lines.map((l) => l.id); if (!lineIds.length) return []; // Only the DELETED units are the deferred ones (live units stayed on the // paid part). Oldest-first to match the deferred set. const deferred = await this.dataSource .getRepository(BookingContainerUnit) .find({ where: lineIds.map((bookingContainerId) => ({ bookingContainerId, deletedAt: Not(IsNull()), })), withDeleted: true, order: { sortOrder: 'ASC', createdAt: 'ASC' }, take: need, }); return deferred.map((u) => ({ containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? undefined, vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, })); } }