import { BadRequestException, 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 { 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 { 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 { 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 { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { 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']; export interface CreateBookingUnderContractResult { booking: Booking; warnings: string[]; } /** * 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 dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} 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`); // 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). if (contract.contractKind === 'ONE_TIME') { 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); // 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. Customs Path B bookings // enter clearance first and are scheduled later, so they are not gated here. if (!generalCustoms && !isIntercity) { await this.trainSchedulingService.assertBookingWindowOpen({ originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, scheduledDate: dto.scheduledDate ?? null, direction: contract.tradeDirection ?? null, }); } // 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); } // 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 ? '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: contract.equipmentReturn ?? 'WITHOUT_RETURN', originYardId: route?.originYardId ?? null, destinationYardId: route?.destinationYardId ?? null, tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: contract.isHazardous, isReefer: contract.isReefer, 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), ); // 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); // 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. Roll back the just-inserted row + its lines so it // does NOT occupy the one-time contract's single active-booking slot — else // the customer's retry hits "already has an active booking" against a broken // draft. The customer must fix the contract's rates, then rebook. if (!(computed.totalAmount > 0)) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.hardDelete(booking.id); 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); } // 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, ); const intendedStatus = generalCustoms ? '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. const pendingResult = await this.bookingsRepository.findByIdWithFiles( booking.id, ); return { booking: pendingResult ?? booking, warnings }; } } await this.finalizeContractBooking( booking.id, contract, generalCustoms, ); const result = await this.bookingsRepository.findByIdWithFiles(booking.id); return { booking: result ?? booking, 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 full milestone timeline on the booking. await this.milestoneService.seedPreBookingMilestonesOnBooking( bookingId, contract.tradeDirection, ); await this.milestoneService.seedPostBookingMilestones( 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(); } // ── 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), }; }); } /** * 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 = ['CANCELLED', 'REJECTED', 'EXPIRED']; 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, ); } /** * 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.'); } const allowedSizes = new Set( (contract.cargoScope ?? []) .map((c) => c.containerSize) .filter((s): s is string => !!s), ); const containerRepo = this.dataSource.getRepository(BookingContainer); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); for (const line of lines) { if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { throw new BadRequestException( `Container size ${line.containerSize} is outside the contract scope.`, ); } const containerType = await this.resolveContainerTypeForSize( line.containerSize, contract.isReefer || (line.reeferQuantity ?? 0) > 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: line.hazardousQuantity ?? 0, reeferQuantity: line.reeferQuantity ?? 0, vgmPerUnitTons: vgmPerUnit, totalVgmTons: totalVgm, wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), 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, 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[]; 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: [], 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 }; }), ); // 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(), { freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, serviceTypeId: contract.serviceTypeId, cargoTypeId: this.resolveCargoTypeId(contract, dto), isHazardous: contract.isHazardous, isReefer: contract.isReefer, isGovernment: contract.isGovernment, shippingLineId: null, contractRouteId: route?.id ?? 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: line.hazardousQuantity ?? 0, reeferQuantity: line.reeferQuantity ?? 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)), }), ), }) 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, ); return { overweightLines: computed.overweightLines, overweightSurchargeAmount, currency: computed.currency, pairingErrors, capacityErrors, 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. */ 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 { data } = await this.containerTypesService.findAll({ pageSize: 200 }); const types = data.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')}`; } }