import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { CompaniesService } from '../companies/companies.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { RatesService } from '../rule-engine/services/rates.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { BookingOrdersRepository } from './booking-orders.repository'; import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; import { BookingOrder } from './entities/booking-order.entity'; import { BookingOrderLine } from './entities/booking-order-line.entity'; import { GeneralContractService } from './general-contract.service'; import { isRoadService, roadKmPrice } from './road.util'; @Injectable() export class BookingOrdersService { private readonly logger = new Logger(BookingOrdersService.name); constructor( private readonly dataSource: DataSource, private readonly ordersRepository: BookingOrdersRepository, private readonly bookingsRepository: BookingsRepository, private readonly companiesService: CompaniesService, private readonly generalContractService: GeneralContractService, private readonly pricingService: BookingPricingService, private readonly ratesService: RatesService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} /** Orders placed against a contract, with their lines and child booking. */ async listByContract(contractBookingId: string): Promise { const orders = await this.ordersRepository.findByContract(contractBookingId); await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); return orders; } async findById(id: string): Promise { const order = await this.ordersRepository.findById(id); if (order) await this.syncOrderFromChild(order); return order; } /** * The order is a ledger row; the spawned child ONE_TIME booking is what * actually moves through the workflow (clearance → marketing/ops accept → * pay → allocate), exactly like a one-time booking. Nothing writes the order * row after creation, so its stored status would stay 'PENDING' forever. * * Mirror the child onto the order whenever it is read: copy the child's * status, schedulingStatus and trainScheduleId onto the order (mutating the * in-memory instance the caller gets back), and persist that snapshot when it * has drifted so list/detail views and any stored reporting stay in sync. */ private async syncOrderFromChild(order: BookingOrder): Promise { const child = order.booking; if (!child) return; const nextStatus = child.status; const nextScheduling = child.schedulingStatus; const nextTrainScheduleId = child.trainScheduleId ?? null; const drifted = order.status !== nextStatus || order.schedulingStatus !== nextScheduling || (order.trainScheduleId ?? null) !== nextTrainScheduleId; // Reflect the child onto the instance returned to the caller. order.status = nextStatus; order.schedulingStatus = nextScheduling; order.trainScheduleId = nextTrainScheduleId; if (drifted) { await this.ordersRepository.update(order.id, { status: nextStatus, schedulingStatus: nextScheduling, trainScheduleId: nextTrainScheduleId, }); } } /** * Place a drawdown order against an ACTIVE general contract. * * Validates the requested quantities against the remaining pool, then spawns a * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's * route/cargo/service) so it flows through the existing train-scheduling * pipeline. The order row is the ledger entry linking contract → child booking. */ async create( dto: CreateBookingOrderDto, userId?: string, ): Promise { const contract = await this.bookingsRepository.findById(dto.contractBookingId); if (!contract) { throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); } if (!this.generalContractService.isGeneralContract(contract)) { throw new BadRequestException('Booking is not a general contract'); } if (contract.status !== 'CONTRACT_ACTIVE') { throw new BadRequestException( `Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`, ); } if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { throw new BadRequestException('Contract ordering window has expired'); } // The customer placing the order must own the contract. if (userId && !(await this.userOwnsContract(userId, contract))) { throw new BadRequestException('You do not have access to this contract'); } // Resolve the route the order ships on: a chosen contract route line for a // multi-route contract, else the contract's own origin/destination. const routeLines = await this.generalContractService.getRouteLines( contract.id, ); let originYardId = contract.originYardId; let destinationYardId = contract.destinationYardId; let routeLineId: string | null = null; let routeKm: number | null = null; if (routeLines.length > 0) { if (!dto.routeLineId) { throw new BadRequestException( 'This contract has multiple routes — select a route to draw from', ); } const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); if (!chosen) { throw new BadRequestException( 'Selected route is not part of this contract', ); } originYardId = chosen.originYardId; destinationYardId = chosen.destinationYardId; routeLineId = chosen.routeLineId; routeKm = chosen.km ?? null; } // Validate the route has a departure on the chosen day. const day = eatDay(new Date(dto.scheduledDate)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( originYardId, destinationYardId, day, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } const isContainer = contract.freightType === 'CONTAINER'; // Hazardous/reefer counts the customer entered cannot exceed the line they // belong to. Validated for every order regardless of routing. for (const line of dto.lines) { const haz = line.hazardousQuantity ?? 0; const reefer = line.reeferQuantity ?? 0; if (haz < 0 || reefer < 0) { throw new BadRequestException('Hazardous/reefer quantities cannot be negative'); } if (haz > line.quantity || reefer > line.quantity) { throw new BadRequestException( 'Hazardous/reefer quantity cannot exceed the line quantity', ); } } // The contract has a single shared drawdown pool (per container type for // CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route // only fixed origin/destination/km above — so every order, routed or not, // validates each line against the same shared pool. const poolLines = await this.generalContractService.getQuantityLines( contract.id, ); for (const line of dto.lines) { if (line.quantity <= 0) { throw new BadRequestException('Order quantities must be greater than zero'); } const key = isContainer ? (line.containerTypeId ?? '') : ''; const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); if (!poolLine) { throw new BadRequestException( isContainer ? `Container type ${line.containerTypeId} is not part of this contract` : 'This contract has no matching quantity pool', ); } if (line.quantity > poolLine.remainingQuantity) { throw new BadRequestException( `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), ); } } // Persist the order + its child shipment booking atomically. const order = await this.dataSource.transaction(async (manager) => { const childBooking = await this.spawnChildBooking( contract, dto, { originYardId, destinationYardId, km: routeKm }, manager, ); const reference = await this.generateReference(); const orderRow = manager.create(BookingOrder, { reference, contractBookingId: contract.id, bookingId: childBooking.id, routeLineId, companyId: contract.companyId ?? null, scheduledDate: new Date(dto.scheduledDate), // The order is a ledger row; the child booking drives the workflow // (review → pay → allocate), so the order tracks PENDING until done. status: 'PENDING', schedulingStatus: 'NOT_SCHEDULED', }); const savedOrder = await manager.save(orderRow); const lines = dto.lines.map((l) => manager.create(BookingOrderLine, { orderId: savedOrder.id, containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, quantity: l.quantity, hazardousQuantity: l.hazardousQuantity ?? 0, reeferQuantity: l.reeferQuantity ?? 0, }), ); await manager.save(lines); savedOrder.lines = lines; return savedOrder; }); // The child does NOT enter the train batch pool here. It is priced and // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs // clearance first; the batch enqueue happens only on accept. // Close the contract once its pool is exhausted (pending orders count, so // the pool reserves quantity as soon as an order is placed). if (await this.generalContractService.isExhausted(contract.id)) { await this.dataSource .getRepository(Booking) .update(contract.id, { status: 'CONTRACT_CLOSED' }); this.logger.log( `Contract ${contract.reference} CLOSED — quantity exhausted`, ); } return (await this.ordersRepository.findById(order.id)) ?? order; } /** * Create the ONE_TIME child booking for an order, inheriting the contract's * shipment context. Unlike the contract (which is no longer paid up front), * the child is PRICED and UNPAID and waits for Marketing review — going * through the customs clearance gate first when the service includes customs, * mirroring a one-time booking. It only enters the train pool on accept. */ private async spawnChildBooking( contract: Booking, dto: CreateBookingOrderDto, route: { originYardId: string; destinationYardId: string; km: number | null }, manager: import('typeorm').EntityManager, ): Promise { const reference = await this.generateChildBookingReference(); const isContainer = contract.freightType === 'CONTAINER'; // Sum line quantities × the contract's per-unit weight for the child total. const containerByType = new Map( (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), ); let totalWeight = 0; if (isContainer) { for (const line of dto.lines) { const src = containerByType.get(line.containerTypeId ?? ''); const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; totalWeight += vgmPerUnit * line.quantity; } } else { totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); } // Per-order hazardous/reefer: set the child flags from the order's line // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply. const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0); const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0); // Customs orders flow through the one-time clearance gate first; others go // straight to operations review with the chosen shipment day. const { includesCustoms } = clearanceCodesForBooking(contract); const spawnStatus = includesCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING'; const child = manager.create(Booking, { reference, companyId: contract.companyId ?? null, companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, contractType: contract.contractType, previousContractId: contract.id, serviceTypeId: contract.serviceTypeId, firstMilePickupAddress: contract.firstMilePickupAddress ?? null, lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, equipmentReturn: contract.equipmentReturn, originYardId: route.originYardId, destinationYardId: route.destinationYardId, tradeDirection: contract.tradeDirection, freightType: contract.freightType, cargoTypeId: contract.cargoTypeId ?? null, cargoFreeText: contract.cargoFreeText ?? null, shippingLineId: contract.shippingLineId ?? null, cargoTotalWeightVgm: totalWeight, isHazardous: hasHazardous, isReefer: hasReefer, paymentCurrency: contract.paymentCurrency, bookingType: 'ONE_TIME', scheduledDate: new Date(dto.scheduledDate), // Priced + unpaid: the customer pays this order on its own. status: spawnStatus, paymentStatus: 'PENDING', priorityScore: contract.priorityScore, totalAmount: 0, schedulingStatus: 'NOT_SCHEDULED', }); const savedChild = await manager.save(child); if (isContainer) { for (const line of dto.lines) { const src = containerByType.get(line.containerTypeId ?? ''); const ct = line.containerTypeId ? await manager.getRepository(ContainerType).findOne({ where: { id: line.containerTypeId }, }) : null; const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; const row = manager.create(BookingContainer, { bookingId: savedChild.id, containerTypeId: line.containerTypeId ?? null, quantity: line.quantity, vgmPerUnitTons: vgmPerUnit, totalVgmTons: vgmPerUnit * line.quantity, wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), isOverweight: false, }); await manager.save(row); } } // Price the order: base freight for the drawn quantity + haz/reefer // surcharges, plus a road KM charge when the service ships by road. const roadKm = isRoadService(contract.serviceType) ? route.km : null; await this.priceChildBooking(savedChild.id, roadKm, manager); return savedChild; } /** * Compute and persist the child order's price (base + surcharges) inside the * order transaction. The contract is no longer paid up front, so each order * carries its own total that the customer pays. */ private async priceChildBooking( childId: string, roadKm: number | null, manager: import('typeorm').EntityManager, ): Promise { const child = await manager.getRepository(Booking).findOne({ where: { id: childId }, relations: { bookingContainers: true }, }); if (!child) return; try { const computed = await this.pricingService.computePriceForBooking(child); const lineItems = [...computed.lineItems]; let total = computed.totalAmount; // Road KM charge: distance × the live PER_KM rate, added as its own line. if (roadKm && roadKm > 0) { const perKmRate = await this.findPerKmRate(child.paymentCurrency); const kmAmount = roadKmPrice(roadKm, perKmRate); if (kmAmount > 0) { lineItems.push({ code: 'ROAD_KM', description: `Road transport (${roadKm} km)`, amount: kmAmount, unitAmount: perKmRate!, unit: 'PER_KM', quantity: roadKm, currency: child.paymentCurrency, }); total += kmAmount; } } await manager.getRepository(Booking).update(childId, { totalAmount: total, priorityScore: computed.priorityScore, pricingBreakdown: { lineItems, totalAmount: total, currency: computed.currency, generatedAt: new Date().toISOString(), }, } as never); } catch (err) { this.logger.error( `Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`, ); } } /** The live PER_KM rate value for road billing, in the given currency. */ private async findPerKmRate(currency: string): Promise { const rates = await this.ratesService.findLiveRates(); const rate = rates.find( (r) => r.rateUnit === 'PER_KM' && r.currency === currency, ); return rate ? Number(rate.rateValue) : null; } private async userOwnsContract( userId: string, contract: Booking, ): Promise { if (!contract.companyId) return true; // government / staff-created try { const { company } = await this.companiesService.getCompanyInfoByUserId( userId, ); return company.id === contract.companyId; } catch { return false; } } private async generateReference(): Promise { const year = new Date().getFullYear(); const count = await this.ordersRepository.countByYear(year); return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; } private async generateChildBookingReference(): Promise { const year = new Date().getFullYear(); const count = await this.bookingsRepository.countByYear(year); return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } }