import { BadRequestException, ConflictException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { OnEvent } from '@nestjs/event-emitter'; import { insertWithGeneratedReference } from '@edr/api-common'; 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 { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { hasFreightPermission } from '../../common/freight-permission.util'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { CreateBookingContainerLineDto, CreateBookingUnderContractDto, } from './dto/create-booking-under-contract.dto'; /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; /** Bookings that never shipped release their quantity hold on the contract. */ const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED']; const round3 = (n: number): number => Math.round(n * 1000) / 1000; export interface CreateBookingUnderContractResult { booking: Booking; warnings: string[]; } /** * Outstanding split remainder of a contract: what was booked in the first split * booking's pre-split snapshot MINUS everything currently booked. Container * contracts report per size; bulk reports one tonnage figure. `null` when the * contract has no live split chain. Consumed by the remainder-placement engine * to size the auto-created remainder booking. */ export type SplitOutstanding = { bySize: Map; bulk: { total: number; outstanding: number } | null; }; /** * The single create path for shipment bookings under a contract. * * - Path A (transport only): the customer creates the booking once the contract * is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled. * - Path B (customs clearance): only GL Ethiopia creates the booking, once the * contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters * shipment data. * * From booking creation onward the existing batch/payment/allocation pipeline * runs unchanged. See docs/new-doc.md §8, §13. */ @Injectable() export class ContractBookingService { private readonly logger = new Logger(ContractBookingService.name); constructor( private readonly contractsRepository: ContractsRepository, private readonly bookingsRepository: BookingsRepository, private readonly bookingPricingService: BookingPricingService, private readonly consolidationService: ConsolidationService, private readonly containerTypesService: ContainerTypesService, private readonly ruleEngineService: RuleEngineService, private readonly milestoneService: ClearanceMilestoneService, private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly clearanceFeeService: ClearanceFeeService, private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingTransitionService)) private readonly bookingTransitionService: BookingTransitionService, ) {} async createUnderContract( contractId: string, dto: CreateBookingUnderContractDto, user?: { id?: string } | null, actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); // A contract whose quantity cap was fully booked is completed — no further // bookings, even while contract validity and a booking window are still // open. Capacity released after closure (a cancelled/expired booking) // reopens the contract on the next booking attempt. A ONE_TIME contract // only closes via a finished split chain, so its room is the outstanding // split remainder rather than a cap line. if (contract.status === 'CONTRACT_CLOSED') { let hasRoom: boolean; if (contract.contractKind !== 'GENERAL') { const outstanding = await this.splitOutstanding(contract); hasRoom = outstanding ? contract.freightType === 'CONTAINER' ? [...outstanding.bySize.values()].some((s) => s.outstanding > 0) : (outstanding.bulk?.outstanding ?? 0) > 0.001 : false; } else { const capacity = await this.computeCapacity(contract); hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0); } if (!hasRoom) { throw new BadRequestException( 'This contract is completed — the full contracted quantity has been booked.', ); } await this.contractsRepository.update(contract.id, { status: 'CONTRACT_ACTIVE', } as never); contract.status = 'CONTRACT_ACTIVE'; } // GL Ethiopia is identified by the dedicated contract create-booking permission // (granted to the edr_gl_ethiopia preset). const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); const createdByRole = await this.assertGate(contract, isGlActor); // Validity window must still be open. if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); } // ONE_TIME: a single shipment at a time. The slot frees only if the prior // booking reached a terminal state (e.g. payment expired without shipping), // letting the customer re-book within contract validity (doc §10.4). // EXCEPTION — split chain: a paid partial split (booking.isSplit) releases // the slot for the leftover, but the next booking must take the WHOLE // remainder; the customer cannot start any other booking on the contract. // If the remainder splits again the same rule repeats until the cap is // exhausted and the contract completes. if (contract.contractKind === 'ONE_TIME') { if (await this.hasSplitBooking(contractId)) { await this.assertExactRemainder(contract, dto); } else { const active = await this.countActiveBookings(contractId); if (active > 0) { throw new BadRequestException( 'This one-time contract already has an active booking.', ); } } } else { // GENERAL: draw down against the cargo quantity cap until it is full. await this.assertWithinQuantityCap(contract, dto); } const route = await this.resolveRoute(contract, dto.contractRouteId); const warnings: string[] = []; const freightType = contract.freightType; // GENERAL + customs (Path B) runs per-booking clearance: the booking starts // in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to // operations, and there is NO contract-level clearance cycle to link. const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); // GENERAL without customs (Path A) ALSO clears per booking: the customer // uploads his own clearance proof on each booking and Operations reviews it // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY → // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same // per-booking gate with the intercity document set — ops finalize then puts // the booking straight into the ride-along pool (FULLY_EXECUTED), since // intercity has no shipment-day request step. const generalSelfClear = contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled; // Intercity (DOMESTIC) bookings ride on a passing import/export train: // there is no window and no date — staff accept them onto a train at // finalize time, so both the window gate and scheduledDate are skipped. const isIntercity = contract.tradeDirection === 'DOMESTIC'; if (isIntercity && dto.scheduledDate) { throw new BadRequestException( 'Intercity bookings do not pick a date — staff assign them to a passing train', ); } // Every other direction keeps the binding shipment day (the DTO field went // optional only for intercity). if (!isIntercity && !dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } // Booking-window gate (config-driven): an operations booking may only be // created while the route's booking window is open — import: the day's window // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); // export: within exportBookingLeadHours of departure. Bookings that enter the // clearance gate first (Path B customs AND Path A per-booking self-clearance) // are scheduled later, so they are not gated here. if (!generalCustoms && !generalSelfClear && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, scheduledDate: dto.scheduledDate ?? null, direction: contract.tradeDirection ?? null, }); // EXPORT rides whole or not at all (no split concept): reject the booking // up front when no single open train on the day can carry it, telling the // customer how much space is still bookable. await this.assertExportTrainSpace(contract, route, dto); } // Hard capacity gate: a container line whose total weight exceeds the // container type's max capacity can never be booked — no surcharge path, // no override. Checked before any row is written. if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ // ≤ the cap, and drawdown bookings never pass through submit — so this is // their only chance to hard-block an unbalanceable set. Entry order is // irrelevant (the check sorts by weight before pairing). await this.assert20ftPairableAtCreate(dto); // A container number may appear once per train (same day + route). await this.assertContainerNumbersAvailable(dto, { originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, }); } // Denormalize route/direction/freight onto the booking for the scheduling engine. // Retry past a concurrent insert that grabbed the same BK sequence number. const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, status: generalCustoms || generalSelfClear ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, contractKind: contract.contractKind, createdByRole, createdByUserId: user?.id ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, serviceTypeId: contract.serviceTypeId, paymentCurrency: contract.paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, } as never), ); // Everything between the insert and the priced update must be all-or-nothing: // a throw part-way (container persist, weight rules, pricing) would otherwise // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that // occupies the one-time contract's single active-booking slot until the // doc-review sweep expires it — and the clearance cycle still points at the // previous booking, so the hub keeps offering "Rebook" against a dead draft. try { // Persist container lines + per-unit container numbers (container freight only). if (freightType === 'CONTAINER') { await this.persistContainers(booking.id, contract, dto); } // Reload with containers to compute the total from contract unit rates × qty. const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); if (loaded) { if (freightType === 'CONTAINER') { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has // a positive total, so the zero-price gate below misses it — enforce // the pricing hard blocks first. The catch below rolls everything back. if (computed.hardBlocked.length > 0) { throw new BadRequestException(computed.hardBlocked.join('; ')); } // Reject a zero-price booking outright. A total of 0 means no contract rate // matched the route/container (or the rate is unset), so the booking is not // valid to ship or invoice. The catch below rolls back the row + its lines. if (!(computed.totalAmount > 0)) { throw new BadRequestException( 'Booking price came out as 0 — no contract rate matches this ' + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, priorityScore: computed.priorityScore, pricingBreakdown: { lineItems: computed.lineItems, totalAmount: computed.totalAmount, currency: computed.currency, generatedAt: new Date().toISOString(), }, } as never); await this.bookingPricingService.createPricingSnapshots( booking.id, computed.usedRates, computed.appliedModifiers, ); warnings.push(...computed.warnings); } } catch (err) { await this.bookingsRepository .deleteContainers(booking.id) .catch(() => undefined); await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined); throw err; } // Wagon consolidation gate. A container drawdown whose lines leave a partial // wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner // before it can ship. Direct bookings do this at submit; drawdowns have no // submit step, so we run it here — BEFORE invoicing/milestones. When it parks // for a partner the booking is NOT invoiced or scheduled: those steps run // later in finalizeContractBooking, triggered by the pairing event. When it // pairs (or needs no consolidation) we finalize inline. const withContainers = await this.bookingsRepository.findByIdWithFiles( booking.id, ); // Tell staff the booking exists. Placed after the zero-price rollback (which // hard-deletes the row) and before the consolidation gate, so it fires // exactly once whether the booking parks for a partner or finalizes inline. this.bookingNotifier.createdToStaff(withContainers ?? booking); const intendedStatus = generalCustoms || generalSelfClear ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING'; if ( withContainers && freightType === 'CONTAINER' && (await this.consolidationService.needsConsolidationFromBooking( withContainers, )) ) { const parked = await this.consolidateDrawdown( withContainers, intendedStatus, ); warnings.push(parked.message); if (!parked.paired) { // Waiting for a partner — stop here. The booking sits in // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs. // A parked booking still holds contract capacity, so the cap may // already be exhausted by it. await this.maybeCompleteContract(contract); const pendingResult = await this.bookingsRepository.findByIdWithFiles( booking.id, ); return { booking: pendingResult ?? booking, warnings }; } } await this.finalizeContractBooking( booking.id, contract, generalCustoms, ); await this.maybeCompleteContract(contract); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: result ?? booking, warnings }; } /** * Initiate a BARE booking instance under a GENERAL non-customs contract * (Path A per-booking self-clearance). One click, zero input: no schedule * date, no cargo, no window check, no pricing. The instance starts in the * clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs, * Operations reviews and finalizes, and only then does the customer complete * the booking (cargo + binding day + window check) via * {@link completeUnderContract} — the same machinery a one-time shipment uses. */ async initiateUnderContract( contractId: string, dto: Pick, user?: { id?: string } | null, actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const generalSelfClear = contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled && contract.tradeDirection !== 'DOMESTIC'; if (!generalSelfClear) { throw new BadRequestException( 'Initiate booking applies only to general import/export contracts without customs clearing.', ); } if (contract.status === 'CONTRACT_CLOSED') { throw new BadRequestException( 'This contract is completed — the full contracted quantity has been booked.', ); } const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); const createdByRole = await this.assertGate(contract, isGlActor); if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); } const route = await this.resolveRoute(contract, dto.contractRouteId); // Bare instance: no cargo, no date, no price. Draws no contract capacity // until the customer completes it after clearance. const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, status: 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, contractKind: contract.contractKind, createdByRole, createdByUserId: user?.id ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, paymentCurrency: contract.paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: this.resolveCargoTypeId(contract, {}), isHazardous: contract.isHazardous, isReefer: contract.isReefer, cargoTotalWeightVgm: 0, firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, } as never), ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); this.bookingNotifier.createdToStaff(result ?? booking); return { booking: result ?? booking, warnings: [] }; } /** * Initiate a BARE booking instance for a GENERAL + customs shipment request * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it * validated the contract (general customs, active, capacity) — the request * itself carries the quantities; the instance carries none. Pre-booking * customs milestones are seeded immediately so the instance enters the same * phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking. * GL completes the booking (cargo + day) via {@link completeUnderContract} * once the clearance reaches CLEARANCE_READY. */ async initiateForShipmentRequest( contract: Contract, opts: { contractRouteId?: string; userId?: string | null }, ): Promise { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); if (!generalCustoms) { throw new BadRequestException( 'Shipment-request initiation applies only to general customs contracts.', ); } if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); } const route = await this.resolveRoute(contract, opts.contractRouteId); // Prepay gate: each shipment request owes its own flat clearance service // fee before the document step opens (the paid event advances the booking // to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate. const feeGate = await this.clearanceFeeService.gateApplies(contract); const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => this.bookingsRepository.create({ reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, contractKind: contract.contractKind, createdByRole: 'CUSTOMER', createdByUserId: opts.userId ?? null, scheduledDate: null, serviceTypeId: contract.serviceTypeId, paymentCurrency: contract.paymentCurrency, contractType: 'NEW', customsClearingEnabled: contract.customsClearingEnabled, customsClearingAgent: contract.customsClearingAgent ?? null, equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: this.resolveCargoTypeId(contract, {}), isHazardous: contract.isHazardous, isReefer: contract.isReefer, cargoTotalWeightVgm: 0, firstMilePickupAddress: contract.firstMilePickupAddress ?? null, firstMilePickupLat: contract.firstMilePickupLat ?? null, firstMilePickupLng: contract.firstMilePickupLng ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, } as never), ); // Pre-booking phase only — the post-booking milestones (loading, transit) // are seeded when GL completes the booking, mirroring the ONE_TIME flow // where GL's booking creation seeds them. await this.milestoneService.seedPreBookingMilestonesOnBooking( booking.id, contract.tradeDirection, ); if (feeGate) { await this.clearanceFeeService.issueForBooking(booking, contract); } const created = (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; this.bookingNotifier.createdToStaff(created); return created; } /** * Complete a bare initiated booking after its per-booking clearance is * finalized (CLEARANCE_READY) or operations returned it for changes * (OPERATION_CHANGES_REQUESTED). This is the deferred half of * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking * window + open-departure checks, pricing, consolidation and invoicing all run * here — the same gates a one-time shipment passes at creation. * * Actor rules mirror {@link assertGate}: a customs (Path B) instance is * completed by GL Ethiopia only; a non-customs (Path A) instance by the * customer (or staff). */ async completeUnderContract( contractId: string, bookingId: string, dto: CreateBookingUnderContractDto, actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); if (!booking || booking.contractId !== contract.id) { throw new NotFoundException(`Booking ${bookingId} not found on this contract`); } if ( !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes( booking.status, ) ) { throw new BadRequestException( 'Clearance must be finalized before the booking can be completed.', ); } // An unpaid booking that expired at train dispatch keeps its finished // per-booking clearance — GL rebooks it onto a new shipment day instead of // forcing the customer through a new shipment request + clearance fee. if (booking.status === 'EXPIRED') { // Only a booking that completed once (it has a price, so its clearance // finished and cargo is persisted) can be rebooked after expiry. if (!(Number(booking.totalAmount) > 0)) { throw new BadRequestException( 'Only a previously completed booking can be rebooked after it expires.', ); } // Expiry released the booking's contract-capacity hold; if the payload // re-states the cargo, make sure the released share is still free. if (dto.containers?.length || dto.bulkLines?.length) { await this.assertWithinQuantityCap(contract, dto); } // Drop the departed train's link and fall into the day-only resubmit // path below — same machinery as OPERATION_CHANGES_REQUESTED. await this.bookingsRepository.update(booking.id, { status: 'OPERATION_CHANGES_REQUESTED', trainScheduleId: null, } as never); booking.status = 'OPERATION_CHANGES_REQUESTED'; booking.trainScheduleId = null; } // Path B: only GL Ethiopia completes a customs instance — the customer // never enters shipment data on a customs contract. if (contract.customsClearingEnabled) { const isGlActor = actorPermissions != null && hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); if (!isGlActor) { throw new ForbiddenException( 'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.', ); } } if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { throw new BadRequestException('Contract validity has expired — no new bookings.'); } // Completion is booking time: the route's booking window must be open — // the same config-driven gate a direct one-time booking passes at create. await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: booking.originYardId ?? null, destinationYardId: booking.destinationYardId ?? null, scheduledDate: dto.scheduledDate, direction: contract.tradeDirection ?? null, }); const freightType = contract.freightType; const hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || Number(booking.cargoTotalWeightVgm) > 0; const warnings: string[] = []; // EXPORT rides whole or not at all (no split concept): the chosen day must // have a single open train that carries the whole booking. First completion // sizes from the dto's cargo; a changes-requested resubmit (cargo already // persisted, only the day re-picked) sizes from the booking itself. if (contract.tradeDirection === 'EXPORT') { if (hasCargo) { const probe = Object.assign( Object.create(Object.getPrototypeOf(booking)), booking, { scheduledDate: new Date(dto.scheduledDate) }, ) as Booking; const report = await this.bookingBatchService.exportSpaceReport(probe); if (!report.scheduleId) { throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); } } else { await this.assertExportTrainSpace(contract, null, dto, { originYardId: booking.originYardId ?? null, destinationYardId: booking.destinationYardId ?? null, }); } } // First completion persists cargo and draws contract capacity; a resubmit // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks // the shipment day. if (!hasCargo) { await this.assertWithinQuantityCap(contract, dto); if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); await this.assert20ftPairableAtCreate(dto); // A container number may appear once per train (same day + route). await this.assertContainerNumbersAvailable( dto, { originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, }, booking.id, ); await this.persistContainers(booking.id, contract, dto); } await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), } as never); const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); if (loaded) { if (freightType === 'CONTAINER') { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once // the contract rates are fixed (the clearance work is not lost). A // pricing hard block (e.g. one of two container sizes has no rate) // rolls back the same way: a partially-priced total is positive but // the booking must not proceed. if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( computed.hardBlocked.length > 0 ? computed.hardBlocked.join('; ') : 'Booking price came out as 0 — no contract rate matches this ' + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { totalAmount: computed.totalAmount, priorityScore: computed.priorityScore, pricingBreakdown: { lineItems: computed.lineItems, totalAmount: computed.totalAmount, currency: computed.currency, generatedAt: new Date().toISOString(), }, } as never); await this.bookingPricingService.createPricingSnapshots( booking.id, computed.usedRates, computed.appliedModifiers, ); warnings.push(...computed.warnings); } // Wagon consolidation gate — a partial-wagon 20ft set parks for a partner // exactly like a drawdown created with cargo does. The shipment day is // stored first so the pairing event can resume straight into the // operations queue. const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id); if ( withContainers && freightType === 'CONTAINER' && (await this.consolidationService.needsConsolidationFromBooking(withContainers)) ) { await this.bookingsRepository.update(booking.id, { scheduledDate: new Date(dto.scheduledDate), } as never); const parked = await this.consolidateDrawdown( withContainers, 'OPERATION_REQUEST_PENDING', ); warnings.push(parked.message); if (!parked.paired) { await this.maybeCompleteContract(contract); const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: pendingResult ?? booking, warnings }; } } // Invoice the now-priced booking and, for a customs instance, seed the // post-booking milestones (pre-booking ones exist since initiation — // ensure* fills only what is missing). Idempotent, non-blocking. const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); } else if (freightType === 'CONTAINER') { // Resubmit only re-picks the shipment day — the persisted container // numbers must be free on the newly chosen train day too. await this.assertPersistedContainersAvailable(booking, dto.scheduledDate); } // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING // and the staff notification — the exact machine a one-time booking uses. const completed = await this.bookingTransitionService.requestOperation( booking.id, dto.scheduledDate, ); return { booking: completed, warnings }; } /** * Search for a complementary partner for a parked-eligible drawdown, pair it or * park it in PENDING_CONSOLIDATION with the resume status it should return to. * Pairing (via BookingsRepository.pairConsolidation) resumes both partners and * emits booking.consolidation.paired, which finalizes any deferred contract * booking. Returns whether a partner was found plus a customer-facing message. */ private async consolidateDrawdown( booking: Booking, resumeStatus: string, ): Promise<{ paired: boolean; message: string }> { const slots = await this.consolidationService.slotsFromBooking(booking); if (!slots.length) { return { paired: false, message: '' }; } const partner = await this.bookingsRepository.findConsolidationPartner( booking, slots, ); if (partner) { await this.bookingsRepository.pairConsolidation(booking.id, partner.id); return { paired: true, message: this.consolidationService.describePaired( partner.reference, slots, ), }; } await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus); return { paired: false, message: this.consolidationService.describePending(booking, slots), }; } /** * Finalize a contract booking once it is cleared to proceed (needed no * consolidation, or has just paired): seed clearance milestones / link the * contract cycle, then generate the invoice. Idempotent — safe to call again * for a booking that pairs after having waited. Skips a booking that is still * PENDING_CONSOLIDATION (guards the pairing event against a stray partner). */ private async finalizeContractBooking( bookingId: string, contract: Contract, generalCustoms: boolean, ): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(bookingId); if (!booking || booking.status === 'PENDING_CONSOLIDATION') return; // ONE_TIME customs (legacy contract-cycle path): link the contract clearance // cycle to this booking, seed post-booking milestones, and lock the contract // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle // and must stay CONTRACT_ACTIVE so further shipment requests can be accepted. if (contract.customsClearingEnabled && !generalCustoms) { const cycle = await this.contractsRepository.currentCycle(contract.id); if (cycle) { await this.contractsRepository.linkBooking(cycle.id, bookingId); } await this.milestoneService.seedPostBookingMilestones( bookingId, contract.tradeDirection, ); await this.contractsRepository.update(contract.id, { status: 'ACTIVE_SHIPMENT_IN_PROGRESS', clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { // Per-booking clearance: seed the full milestone timeline on the booking. // ensure* skips codes that already exist — an initiated instance carries // its pre-booking milestones from initiation, and a consolidation pairing // replay must not duplicate the timeline. await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, ); } // Contract bookings are born past the billable gate (the contract is already // executed), so the invoice is generated here — they never pass through the // legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings. // Idempotent and non-blocking: a billing hiccup must not undo the booking. // Skips silently when unbillable (no company / no priced amount). await this.invoiceService .ensureInvoiceForBooking(booking) .catch((err) => this.logger.error( `Failed to generate invoice for contract booking ${booking.reference}: ${ err instanceof Error ? err.message : String(err) }`, ), ); } /** * A parked drawdown just paired — finalize whichever partner is a contract * booking that was waiting (invoice + milestones deferred at creation). The * pairing already resumed the booking's status from consolidationResumeStatus; * this runs the create-time tail that was skipped. Non-contract partners have * their own finalize path (staff accept) and are ignored here. */ @OnEvent('booking.consolidation.paired') async onConsolidationPaired(payload: { bookingIds: string[]; }): Promise { for (const id of payload.bookingIds ?? []) { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') { continue; } const contract = await this.contractsRepository.findByIdWithRelations( booking.contractId, ); if (!contract) continue; const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); await this.finalizeContractBooking(id, contract, generalCustoms).catch( (err) => this.logger.error( `Failed to finalize paired contract booking ${booking.reference}: ${ err instanceof Error ? err.message : String(err) }`, ), ); } } /** * Returns the role to stamp on the booking, or throws if the caller is not * allowed to create one for this contract's execution path. */ private async assertGate(contract: Contract, isGlActor: boolean): Promise { if (contract.customsClearingEnabled) { // Path B — Global Logistics creates the booking ON BEHALF OF the customer. // The customer never books a customs contract himself. if (!isGlActor) { throw new ForbiddenException( 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', ); } if (contract.contractKind === 'GENERAL') { // GENERAL customs has NO contract clearance cycle — GL books per accepted // shipment request while the contract is active; clearance is per booking. if (contract.status !== 'CONTRACT_ACTIVE') { throw new BadRequestException( 'Contract must be active to book a shipment.', ); } return 'GL_ET'; } // ONE_TIME customs — pre-booking boundary milestone must be complete. const boundaryOk = await this.workflowService.isBoundaryComplete( contract.id, contract.tradeDirection, ); if (!boundaryOk) { throw new BadRequestException( 'Pre-booking clearance is not complete — booking cannot be created yet.', ); } return 'GL_ET'; } // Path A — customer (or staff) once the contract is executed. if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { throw new BadRequestException( 'Contract must be fully executed before booking a shipment.', ); } return isGlActor ? 'STAFF' : 'CUSTOMER'; } private async countActiveBookings(contractId: string): Promise { return this.dataSource .getRepository(Booking) .createQueryBuilder('b') .where('b.contract_id = :contractId', { contractId }) .andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES }) .getCount(); } /** * Whether the contract is in split-remainder mode: some booking on it was * reduced by a paid partial batch offer and still holds capacity. A split * booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its * hold and the contract falls back to the plain single-slot rule — the * customer can rebook the whole quantity again. */ private async hasSplitBooking(contractId: string): Promise { const count = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') .where('b.contract_id = :contractId', { contractId }) .andWhere('b.is_split = true') .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES }) .getCount(); return count > 0; } /** * Outstanding split remainder of a ONE_TIME contract: what the FIRST split * booking carried before its reduction (its pre_split_quantities snapshot — * one-time contracts have no quantity cap to derive this from) minus * everything currently booked on the contract. Bookings that never shipped * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the * contract has no live split booking. */ /** * Public: the remainder-placement engine reads this to size the auto-created * remainder booking. Returns `null` when there is no live split chain. */ async splitOutstanding(contract: Contract): Promise { const first = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') .where('b.contract_id = :contractId', { contractId: contract.id }) .andWhere('b.is_split = true') .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES }) .orderBy('b.created_at', 'ASC') .getOne(); if (!first?.preSplitQuantities) return null; const booked = await this.bookedQuantities(contract); if (contract.freightType === 'CONTAINER') { const bySize = new Map(); for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) { bySize.set(size, { total: Number(total), outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)), }); } return { bySize, bulk: null }; } const total = Number(first.preSplitQuantities.bulkTons ?? 0); return { bySize: new Map(), bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) }, }; } /** * EXPORT whole-booking single-train gate. Export bookings never split — the * entire booking must ride ONE open train on the chosen day. When no train * fits it whole (trying every fillable train on the corridor, earliest * first), reject BEFORE anything is written, with the largest still-bookable * space (tons for bulk via the cargo type's wagon type; wagons/containers * for container freight) so the customer knows what he CAN book. */ private async assertExportTrainSpace( contract: Contract, route: ContractRoute | null, dto: CreateBookingUnderContractDto, yards?: { originYardId: string | null; destinationYardId: string | null }, ): Promise { if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return; const probe = await this.buildExportProbe(contract, route, dto, yards); const report = await this.bookingBatchService.exportSpaceReport(probe); if (report.scheduleId) return; // With export split ON a booking no longer has to ride ONE train whole: the // largest fitting part is offered and the leftover is rebooked on the next // train. Rejecting on the single-train fit here would block exactly the // bookings the split exists to serve — including the auto-created remainder, // which by definition did not fit the train it was split off. Fall back to // the day total: unbookable only when NO export train that day has room. if (process.env.FREIGHT_EXPORT_SPLIT === 'true') { const fitting = await this.bookingBatchService.fittingTrainsForDay( probe, eatDay(new Date(dto.scheduledDate)), 'EXPORT', ); if (fitting.length > 0) return; throw new BadRequestException( 'No export train on this day has space left — pick another shipment day.', ); } throw new BadRequestException( report.fullMessage ?? 'Not enough train space for this day.', ); } /** * Unsaved booking twin carrying exactly what the batch engine's capacity * math reads: yards + day for the leg, container lines WITH their container * type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type * (wagon-type FK) for tons→wagons conversion. */ private async buildExportProbe( contract: Contract, route: ContractRoute | null, dto: CreateBookingUnderContractDto, yards?: { originYardId: string | null; destinationYardId: string | null }, ): Promise { const probe = new Booking(); probe.freightType = contract.freightType; probe.tradeDirection = contract.tradeDirection; probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null; // Entity types are non-nullable; a missing yard just makes legOf() match no // train, which surfaces as "no export train for this day" — the right failure. probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string; probe.destinationYardId = (yards?.destinationYardId ?? route?.destinationYardId) as string; if (contract.freightType === 'CONTAINER') { const lines = await Promise.all( (dto.containers ?? []).map(async (line) => { const ct = await this.resolveContainerTypeForSize( line.containerSize, contract.isReefer || (line.reeferQuantity ?? 0) > 0, ); const bc = new BookingContainer(); bc.containerSize = line.containerSize; bc.quantity = line.quantity; bc.containerTypeId = ct.id; bc.containerType = ct; bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)); bc.totalVgmTons = (line.units ?? []).reduce( (sum, u) => sum + Number(u.vgmTons ?? 0), 0, ); return bc; }), ); probe.bookingContainers = lines; probe.cargoTotalWeightVgm = lines.reduce( (sum, l) => sum + Number(l.totalVgmTons ?? 0), 0, ); return probe; } probe.cargoTotalWeightVgm = this.resolveBulkTons(dto); const cargoTypeId = this.resolveCargoTypeId(contract, dto); probe.cargoTypeId = cargoTypeId; if (cargoTypeId) { probe.cargoType = (await this.dataSource .getRepository(CargoType) .findOne({ where: { id: cargoTypeId } })) ?? undefined; } return probe; } /** * ONE_TIME split chain: the next booking must take the WHOLE outstanding * remainder — a one-time contract is a single shipment, so the only way it * fragments is the system splitting it on train capacity, never the customer * choosing a partial amount. */ private async assertExactRemainder( contract: Contract, dto: CreateBookingUnderContractDto, ): Promise { const outstanding = await this.splitOutstanding(contract); if (!outstanding) return; // no live split booking — nothing to pin the remainder to if (contract.freightType === 'CONTAINER') { const sizes = new Set([ ...outstanding.bySize.keys(), ...(dto.containers ?? []).map((l) => l.containerSize ?? ''), ]); for (const size of sizes) { const remaining = outstanding.bySize.get(size)?.outstanding ?? 0; const requested = (dto.containers ?? []) .filter((l) => (l.containerSize ?? '') === size) .reduce((sum, l) => sum + Number(l.quantity ?? 0), 0); if (requested !== remaining) { throw new BadRequestException( `This one-time contract was split — the next booking must take the whole remainder: ` + `${remaining} × ${size || 'container'} container(s), got ${requested}.`, ); } } return; } const requested = (dto.bulkLines ?? []).reduce( (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), 0, ) || this.resolveBulkTons(dto) || 0; const remaining = outstanding.bulk?.outstanding ?? 0; // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights. if (Math.abs(requested - remaining) > 0.001) { throw new BadRequestException( `This one-time contract was split — the next booking must take the whole ` + `remaining ${remaining} tons, got ${requested}.`, ); } } // ── GENERAL contract quantity cap (draw-down) ────────────────────────────── /** * Reject a GENERAL booking whose cargo would exceed the contract's quantity * cap. Container caps are per size; bulk is a single tons/items cap. Bookings * that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold. */ /** * Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors * {@link assertWithinQuantityCap} but reads the request's quantity shape. */ async assertRequestWithinCapacity( contract: Contract, lines: { containers?: Array<{ containerSize: string; quantity: number }>; bulk?: { cargoWeightTons?: number; itemCount?: number }; }, ): Promise { const capacity = await this.computeCapacity(contract); if (capacity.length === 0) return; // uncapped contract if (contract.freightType === 'CONTAINER') { for (const line of lines.containers ?? []) { const cap = capacity.find((c) => c.containerSize === line.containerSize); if (!cap || cap.remaining == null) continue; if (line.quantity > cap.remaining) { throw new BadRequestException( `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`, ); } } } else { const requested = (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( `Only ${cap.remaining} of ${cap.cap} remain on this contract.`, ); } } } private async assertWithinQuantityCap( contract: Contract, dto: CreateBookingUnderContractDto, ): Promise { const capacity = await this.computeCapacity(contract); if (capacity.length === 0) return; // uncapped contract if (contract.freightType === 'CONTAINER') { for (const line of dto.containers ?? []) { const cap = capacity.find((c) => c.containerSize === line.containerSize); if (!cap || cap.remaining == null) continue; // size uncapped if (line.quantity > cap.remaining) { throw new BadRequestException( `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`, ); } } } else { const requested = (dto.bulkLines ?? []).reduce( (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), 0, ) || this.resolveBulkTons(dto) || 0; const cap = capacity.find((c) => c.cap != null); if (cap && cap.remaining != null && requested > cap.remaining) { throw new BadRequestException( `Only ${cap.remaining} of ${cap.cap} remain on this contract.`, ); } } } /** * Remaining bookable quantity per cargo-scope line: cap minus what prior * bookings already consumed. Returns [] when the contract has no caps. */ async computeCapacity( contract: Contract, ): Promise< Array<{ containerSize?: string | null; cargoTypeId?: string | null; cap: number | null; booked: number; remaining: number | null; }> > { const scope = contract.cargoScope ?? []; const capped = scope.filter((s) => s.quantityCap != null); if (capped.length === 0) return []; const booked = await this.bookedQuantities(contract); return capped.map((s) => { const cap = Number(s.quantityCap); const used = contract.freightType === 'CONTAINER' ? (booked.bySize.get(s.containerSize ?? '') ?? 0) : booked.bulk; return { containerSize: s.containerSize, cargoTypeId: s.cargoTypeId, cap, booked: used, remaining: Math.max(0, cap - used), }; }); } /** * Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap * lines as-is, or — for a ONE_TIME contract in split-remainder mode — * synthesized lines whose cap is the first split booking's pre-split * snapshot and whose remaining is the outstanding remainder, i.e. the exact * quantity the next booking must take. */ async capacityView( contract: Contract, ): Promise< Array<{ containerSize?: string | null; cargoTypeId?: string | null; cap: number | null; booked: number; remaining: number | null; }> > { const capacity = await this.computeCapacity(contract); if (capacity.length > 0 || contract.contractKind === 'GENERAL') { return capacity; } const outstanding = await this.splitOutstanding(contract); if (!outstanding) return capacity; if (contract.freightType === 'CONTAINER') { return [...outstanding.bySize.entries()].map(([size, s]) => ({ containerSize: size, cargoTypeId: null, cap: s.total, booked: s.total - s.outstanding, remaining: s.outstanding, })); } const bulk = outstanding.bulk; if (!bulk) return []; return [ { containerSize: null, cargoTypeId: null, cap: bulk.total, booked: round3(bulk.total - bulk.outstanding), remaining: bulk.outstanding, }, ]; } /** * Complete the contract once its quantity cap is fully consumed. Runs after * every booking created under a GENERAL contract, and under a ONE_TIME * contract in split-remainder mode (a split remainder being rebooked): when * no capped scope line has capacity left, the contract moves to * CONTRACT_CLOSED even though its validity window is still open — blocking * further bookings and shipment requests, including inside an open booking * window. Never throws: a status hiccup must not undo the booking that was * just created. */ private async maybeCompleteContract(contract: Contract): Promise { try { if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return; // ONE_TIME contracts are governed by the single-active-booking slot, so // they normally complete by expiry — EXCEPT once a booking was split: the // remainder chain draws down the split booking's pre-split snapshot, and // the contract completes when the outstanding remainder hits zero. // (An unsplit ONE_TIME never completes here, so re-booking after an // expired unpaid booking keeps working.) if (contract.contractKind !== 'GENERAL') { const outstanding = await this.splitOutstanding(contract); if (!outstanding) return; // 0.001 tolerance absorbs bulk-ton float rounding (split weights round // to 3 decimals); container quantities are integers and unaffected. const exhausted = contract.freightType === 'CONTAINER' ? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0) : (outstanding.bulk?.outstanding ?? 0) <= 0.001; if (!exhausted) return; } else { const capacity = await this.computeCapacity(contract); if (capacity.length === 0) return; // uncapped — completes only by expiry const exhausted = capacity.every( (c) => c.remaining != null && c.remaining <= 0.001, ); if (!exhausted) return; } await this.contractsRepository.update(contract.id, { status: 'CONTRACT_CLOSED', } as never); this.logger.log( `Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`, ); } catch (err) { this.logger.error( `Could not evaluate completion for contract ${contract.id}: ${ err instanceof Error ? err.message : String(err) }`, ); } } /** * Quantities already booked under a contract that still hold capacity. Excludes * bookings that never shipped (CANCELLED / REJECTED / EXPIRED). */ private async bookedQuantities( contract: Contract, ): Promise<{ bySize: Map; bulk: number }> { const releasing = RELEASING_BOOKING_STATUSES; if (contract.freightType === 'CONTAINER') { const rows = await this.dataSource .getRepository(BookingContainer) .createQueryBuilder('bc') .innerJoin(Booking, 'b', 'b.id = bc.booking_id') .select('bc.container_size', 'size') .addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty') .where('b.contract_id = :contractId', { contractId: contract.id }) .andWhere('b.status NOT IN (:...releasing)', { releasing }) .groupBy('bc.container_size') .getRawMany<{ size: string | null; qty: string }>(); const bySize = new Map(); for (const r of rows) bySize.set(r.size ?? '', Number(r.qty)); return { bySize, bulk: 0 }; } const row = await this.dataSource .getRepository(Booking) .createQueryBuilder('b') .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons') .where('b.contract_id = :contractId', { contractId: contract.id }) .andWhere('b.status NOT IN (:...releasing)', { releasing }) .getRawOne<{ tons: string }>(); return { bySize: new Map(), bulk: Number(row?.tons ?? 0) }; } private async resolveRoute( contract: Contract, contractRouteId?: string, ): Promise { const routes = contract.routes ?? []; if (contractRouteId) { const found = routes.find((r) => r.id === contractRouteId); if (!found) { throw new BadRequestException('Selected route is not part of this contract.'); } return found; } // ONE_TIME (or single-route GENERAL): auto-select the only route. if (routes.length === 1) return routes[0]; if (routes.length === 0) return null; throw new BadRequestException( 'contractRouteId is required for multi-route general contracts.', ); } private resolveCargoTypeId( contract: Contract, dto: CreateBookingUnderContractDto, ): string | null { if (contract.freightType === 'BULK') { const bulk = dto.bulkLines?.[0]; if (bulk?.cargoTypeId) return bulk.cargoTypeId; const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); return scope?.cargoTypeId ?? null; } const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); return scope?.cargoTypeId ?? null; } private resolveBulkTons(dto: CreateBookingUnderContractDto): number { if (!dto.bulkLines?.length) return 0; return dto.bulkLines.reduce( (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0), 0, ); } /** * Per-line handling counts. Each physical container carries its own hazardous * / reefer / return switch (entered next to its VGM), so the count is however * many units opted in. Forms that predate per-unit switches send line-level * counts and no unit flags — those are honoured as-is. */ private handlingCounts(line: CreateBookingContainerLineDto): { hazardousQuantity: number; reeferQuantity: number; returnQuantity: number; } { const units = line.units ?? []; const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn); if (!flagged) { return { hazardousQuantity: Number(line.hazardousQuantity ?? 0), reeferQuantity: Number(line.reeferQuantity ?? 0), returnQuantity: Number(line.returnQuantity ?? 0), }; } return { hazardousQuantity: units.filter((u) => u.isHazardous).length, reeferQuantity: units.filter((u) => u.isReefer).length, returnQuantity: units.filter((u) => u.isReturn).length, }; } /** * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the * per-container opt-ins decide whether THIS shipment actually uses it. A * container contract that allows hazardous but a booking where nobody ticked * the switch is not a hazardous booking, and must not fire the surcharge. * Bulk keeps the contract flag — it has its own bulk*Quantity fields. */ private resolveShipmentHandlingFlag( contract: Contract, dto: CreateBookingUnderContractDto, field: 'hazardousQuantity' | 'reeferQuantity', ): boolean { const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer; if (!gated) return false; if (contract.freightType !== 'CONTAINER') return true; const lines = dto.containers ?? []; if (!lines.length) return Boolean(gated); return lines.some((l) => this.handlingCounts(l)[field] > 0); } /** * Resolve the booking's equipment return from the per-line return quantities * (container freight). The CONTRACT gates the service — like hazardous: * - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any * line > 0 makes the booking WITH_RETURN (fires the pricing surcharge). * - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy * booking-level override (dto.equipmentReturn ?? contract default) applies. * Bulk freight keeps the legacy behaviour untouched. */ private resolveShipmentEquipmentReturn( contract: Contract, dto: CreateBookingUnderContractDto, ): string { const legacy = dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN'; if (contract.freightType !== 'CONTAINER') return legacy; const lines = dto.containers ?? []; for (const line of lines) { const qty = this.handlingCounts(line).returnQuantity; if (qty === 0) continue; if (contract.equipmentReturn !== 'WITH_RETURN') { throw new BadRequestException( 'This contract was not created with the empty-container return ' + 'service — return quantities are not allowed on its bookings.', ); } if (qty > line.quantity) { throw new BadRequestException( `Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`, ); } } if (contract.equipmentReturn === 'WITH_RETURN') { const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0); return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN'; } return legacy; } /** * Map each contract-scope container size to a concrete container type and * persist the booking_container line + its per-unit container numbers. Weight * rule results are filled in afterward by {@link applyWeightResults} once all * lines exist (a single rule-engine pass over the booking). */ private async persistContainers( bookingId: string, contract: Contract, dto: CreateBookingUnderContractDto, ): Promise { const lines = dto.containers ?? []; if (!lines.length) { throw new BadRequestException('At least one container line is required.'); } // Size strings arrive in mixed formats ("20ft" from the contract scope, // bare "20" from the rebook seed) — compare numerically so format never // fails a size that IS in scope. const allowedSizesFt = new Set( (contract.cargoScope ?? []) .map((c) => parseInt(c.containerSize ?? '', 10)) .filter((n) => Number.isFinite(n)), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { if ( allowedSizesFt.size && !allowedSizesFt.has(parseInt(line.containerSize, 10)) ) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); } const counts = this.handlingCounts(line); const containerType = await this.resolveContainerTypeForSize( line.containerSize, contract.isReefer || counts.reeferQuantity > 0, ); const vgmPerUnit = line.units.length ? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length : 0; const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0); const containerRow = await containerRepo.save( containerRepo.create({ bookingId, containerTypeId: containerType.id, containerSize: line.containerSize, quantity: line.quantity, hazardousQuantity: counts.hazardousQuantity, reeferQuantity: counts.reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)), isOverweight: false, overweightExcessTons: null, } as Partial), ); let sortOrder = 0; for (const unit of line.units) { await unitRepo.save( unitRepo.create({ bookingContainerId: containerRow.id, containerNumber: unit.containerNumber, sealNumber: unit.sealNumber ?? null, vgmTons: unit.vgmTons, isHazardous: unit.isHazardous ?? false, isReefer: unit.isReefer ?? false, isReturn: contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false), sortOrder: sortOrder++, }), ); } } } /** * Run the rule engine once over the freshly-created booking and persist the * overweight result per container line (same ordering the engine returns). */ private async applyWeightResults(booking: Booking): Promise { const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking); const ruleResult = await this.ruleEngineService.evaluate(evalInput); const containers = booking.bookingContainers ?? []; const containerRepo = this.dataSource.getRepository(BookingContainer); for (let i = 0; i < containers.length; i++) { const wr = ruleResult.containerWeightResults[i]; if (!wr) continue; await containerRepo.update(containers[i].id, { weightLimitRuleId: wr.weightLimitRuleId, isOverweight: wr.isOverweight, overweightExcessTons: wr.overweightExcessTons, }); } } /** * Pre-create validation + authoritative price preview for the shipment form: * build an UNSAVED booking shaped exactly like {@link createUnderContract} * would persist it and run the same BookingPricingService compute over it — * base rail freight, first/last-mile trucking, and every rule-engine surcharge * (overweight, hazard, reefer, consolidation, …). The portal and the GL * backoffice form call this from the price-confirm modal, so the breakdown the * user confirms is line-for-line what the booking will be charged. Also runs * the 20ft weight-pairing rule, which hard-blocks creation. */ async validateShipment( contractId: string, dto: CreateBookingUnderContractDto, ): Promise<{ overweightLines: Array<{ containerTypeCode: string; totalVgmTons: number; maxAllowedTons: number; excessTons: number; }>; overweightSurchargeAmount: number; currency: string | null; pairingErrors: string[]; capacityErrors: string[]; containerClashErrors: string[]; spaceErrors: string[]; lineItems: PriceLineItemDto[]; totalAmount: number; }> { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); const lines = dto.containers ?? []; if (contract.freightType === 'CONTAINER' && !lines.length) { return { overweightLines: [], overweightSurchargeAmount: 0, currency: null, pairingErrors: [], capacityErrors: [], containerClashErrors: [], spaceErrors: [], lineItems: [], totalAmount: 0, }; } // Resolve each container line's type + total VGM (sum of unit weights) — // mirrors persistContainers so the preview lines match the persisted ones. const resolved = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( line.containerSize, contract.isReefer || (line.reeferQuantity ?? 0) > 0, ); const totalVgmTons = (line.units ?? []).reduce( (s, u) => s + Number(u.vgmTons ?? 0), 0, ); return { line, ct, totalVgmTons }; }), ); // Same size-scope gate persistContainers enforces at create, surfaced as a // blocking preview error so the form can't confirm a size the contract does // not cover. Numeric compare — "20" and "20ft" are the same size. const allowedSizesFt = new Set( (contract.cargoScope ?? []) .map((c) => parseInt(c.containerSize ?? '', 10)) .filter((n) => Number.isFinite(n)), ); const scopeErrors = allowedSizesFt.size ? [ ...new Set( lines .map((l) => l.containerSize) .filter((s) => !allowedSizesFt.has(parseInt(s, 10))), ), ].map((s) => `Container size ${s} is outside the contract scope.`) : []; // The unsaved twin of the booking createUnderContract would write: same // denormalized contract fields, same container-line math. No id → the // pricing service derives wagon counts from the in-memory lines. const route = await this.resolveRoute(contract, dto.contractRouteId); const previewBooking = Object.assign(new Booking(), { // contractId makes the preview price off the contract's frozen rate // snapshots exactly like the persisted booking will — without it the // preview total is 0 on a leg with no live rate and the form blocks. contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? null, originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, cargoTotalWeightVgm: this.resolveBulkTons(dto), firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, bookingContainers: resolved.map(({ line, ct, totalVgmTons }) => Object.assign(new BookingContainer(), { containerTypeId: ct.id, containerSize: line.containerSize, quantity: line.quantity, hazardousQuantity: this.handlingCounts(line).hazardousQuantity, reeferQuantity: this.handlingCounts(line).reeferQuantity, returnQuantity: contract.equipmentReturn === 'WITH_RETURN' ? this.handlingCounts(line).returnQuantity : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), }) as Booking; const computed = await this.bookingPricingService.computePriceForBooking(previewBooking); // The overweight surcharge line is already currency-converted; surface its // amount separately so the warning alert can reference the exact charge. const overweightSurchargeAmount = computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0; // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. const twentyFtUnits = resolved .filter((r) => (r.line.containerSize ?? '').includes('20')) .flatMap((r) => (r.line.units ?? []).map((u, idx) => ({ label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, grossWeightTons: Number(u.vgmTons ?? 0), })), ); const maxDiff = await this.max20ftPairDiffTons(); const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( (v) => v.message, ); // Hard capacity ceiling — a non-empty result means the create call will be // rejected, so the form can block submit up front. const capacityErrors = await this.ruleEngineService.capacityViolations( resolved.map(({ line, ct, totalVgmTons }) => ({ containerTypeId: ct.id, quantity: line.quantity, totalVgmTons, })), contract.tradeDirection, ); // A physical container rides one train only — surface a clash with another // active booking on the same day + route in the preview, so the form can // hard-block before the create call rejects with the same rule. let containerClashErrors: string[] = []; if (dto.scheduledDate) { const numbers = lines.flatMap((line) => (line.units ?? []) .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) .filter((n) => n.length > 0), ); const clashes = await this.findContainerClashesOnTrain( [...new Set(numbers)], dto.scheduledDate, { originYardId: route?.originYardId, destinationYardId: route?.destinationYardId, }, ); containerClashErrors = clashes.map( (c) => `${c.containerNumber} is already booked on ${c.reference} for this shipment day.`, ); } // EXPORT rides whole or not at all — surface the single-train space check // in the preview so the form hard-blocks BEFORE the create call rejects // with the same message (including how much space is still bookable). let spaceErrors: string[] = []; if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) { const probe = await this.buildExportProbe(contract, route, dto); const report = await this.bookingBatchService.exportSpaceReport(probe); if (!report.scheduleId) { spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.']; } } return { overweightLines: computed.overweightLines, overweightSurchargeAmount, currency: computed.currency, pairingErrors, // Pricing hard blocks (missing rate for a container size / requested // service) ride the capacity-errors channel so the form hard-blocks in // the preview instead of failing at the create call. capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, totalAmount: computed.totalAmount, }; } /** * Throws when any container line's total weight exceeds the hard capacity * ceiling of its weight limit rule. Mirrors validateShipment's line * resolution so the gate matches what the form preview reported. */ private async assertWithinMaxCapacity( contract: Contract, dto: CreateBookingUnderContractDto, ): Promise { const lines = dto.containers ?? []; if (!lines.length) return; const containers = await Promise.all( lines.map(async (line) => { const ct = await this.resolveContainerTypeForSize( line.containerSize, contract.isReefer || (line.reeferQuantity ?? 0) > 0, ); const totalVgmTons = (line.units ?? []).reduce( (s, u) => s + Number(u.vgmTons ?? 0), 0, ); return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; }), ); const violations = await this.ruleEngineService.capacityViolations( containers, contract.tradeDirection, ); if (violations.length) { throw new BadRequestException(violations.join('; ')); } } /** * Hard-block booking creation when the 20ft container weights cannot be * balanced onto wagons (pair diff over the global cap). Same rule the * shipment-form preview reports as `pairingErrors`, enforced server-side. */ /** * A physical container rides one train only. Reject the submission when a * container number is entered twice in the same booking (the portal checks * this client-side, the API must not trust it) or already sits on another * customer's active booking for the same train — same shipment day AND same * route (origin/destination yards). */ private async assertContainerNumbersAvailable( dto: CreateBookingUnderContractDto, route: { originYardId?: string | null; destinationYardId?: string | null }, excludeBookingId?: string, ): Promise { const numbers = (dto.containers ?? []).flatMap((line) => (line.units ?? []) .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) .filter((n) => n.length > 0), ); if (!numbers.length) return; const seen = new Set(); const withinBooking = new Set(); for (const n of numbers) { if (seen.has(n)) withinBooking.add(n); seen.add(n); } if (withinBooking.size) { throw new BadRequestException( `Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`, ); } // Intercity bookings have no shipment day yet — nothing to clash with. if (!dto.scheduledDate) return; await this.assertNumbersFreeOnTrain( numbers, dto.scheduledDate, route, excludeBookingId, ); } /** * Same train guard for a booking whose containers are already persisted * (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its * stored numbers must be free on the newly chosen day for its route. */ private async assertPersistedContainersAvailable( booking: Booking, scheduledDate: string, ): Promise { const rows: Array<{ containerNumber: string }> = await this.dataSource .getRepository(BookingContainerUnit) .createQueryBuilder('unit') .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') .select('unit.container_number', 'containerNumber') .where('line.booking_id = :bookingId', { bookingId: booking.id }) .getRawMany(); const numbers = rows.map((r) => r.containerNumber).filter(Boolean); if (!numbers.length) return; await this.assertNumbersFreeOnTrain( numbers, scheduledDate, { originYardId: booking.originYardId, destinationYardId: booking.destinationYardId, }, booking.id, ); } /** * Reject when any of `numbers` sits on another active booking of the same * train — same day and same route. Bookings without route yards (legacy * rows) are matched on the day alone rather than let through. */ private async assertNumbersFreeOnTrain( numbers: string[], scheduledDate: string, route: { originYardId?: string | null; destinationYardId?: string | null }, excludeBookingId?: string, ): Promise { const clashes = await this.findContainerClashesOnTrain( numbers, scheduledDate, route, excludeBookingId, ); if (clashes.length) { const detail = clashes .map((c) => `${c.containerNumber} (booking ${c.reference})`) .join(', '); throw new ConflictException( `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + 'A container can only be on one booking per train — remove it or pick another shipment day.', ); } } /** * Container numbers among `numbers` that already sit on another active * booking of the same train — same day and same route. One row per clashing * number. Bookings without route yards (legacy rows) match on the day alone * rather than let through. */ private async findContainerClashesOnTrain( numbers: string[], scheduledDate: string, route: { originYardId?: string | null; destinationYardId?: string | null }, excludeBookingId?: string, ): Promise> { if (!numbers.length) return []; const qb = this.dataSource .getRepository(BookingContainerUnit) .createQueryBuilder('unit') .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') .innerJoin(Booking, 'b', 'b.id = line.booking_id') .select('unit.container_number', 'containerNumber') .addSelect('b.reference', 'reference') .where('unit.container_number IN (:...numbers)', { numbers }) .andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate }) .andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES, }) .andWhere('b.deleted_at IS NULL'); if (route.originYardId && route.destinationYardId) { // Same train = same day + same corridor. A clashing booking whose yards // were never denormalized still blocks (NULL yards match any route). qb.andWhere( '(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)', { originYardId: route.originYardId }, ).andWhere( '(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)', { destinationYardId: route.destinationYardId }, ); } if (excludeBookingId) { qb.andWhere('b.id != :excludeBookingId', { excludeBookingId }); } const clashes: Array<{ containerNumber: string; reference: string }> = await qb.getRawMany(); return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()]; } private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { const twentyFtUnits = (dto.containers ?? []) .filter((line) => (line.containerSize ?? '').includes('20')) .flatMap((line, lineIdx) => (line.units ?? []).map((u, idx) => ({ label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`, grossWeightTons: Number(u.vgmTons ?? 0), })), ); if (twentyFtUnits.length < 2) return; const maxDiff = await this.max20ftPairDiffTons(); const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff); if (violations.length) { throw new BadRequestException( `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations .map((v) => v.message) .join(' ')}`, ); } } private async max20ftPairDiffTons(): Promise { const row = await this.dataSource .getRepository(TrainSchedulingGlobalRules) .find({ order: { createdAt: 'ASC' }, take: 1 }) .then((rows) => rows[0] ?? null) .catch(() => null); const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); return Number.isFinite(n) ? n : 10; } /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, preferReefer: boolean, ): Promise { const sizeFt = parseInt(size, 10); const { items } = await this.containerTypesService.findAll({ pageSize: 100 }); const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false); if (!types.length) { throw new BadRequestException(`No container type configured for size ${size}.`); } if (preferReefer) { const reefer = types.find((t) => t.isReefer); if (reefer) return reefer; } return types.find((t) => !t.isReefer) ?? types[0]; } private async generateReference(): Promise { const year = new Date().getFullYear(); const seq = await this.bookingsRepository.maxReferenceSequence(year); return `BK-${year}-${String(seq + 1).padStart(6, '0')}`; } }