diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts deleted file mode 100644 index 210b65b3d..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ /dev/null @@ -1,470 +0,0 @@ -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, - // Drawdown orders inherit the contract's company + profile (both required). - companyId: contract.companyId, - companyProfileId: contract.companyProfileId, - 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')}`; - } -} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 4eacedc13..6cd2f4be5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -52,7 +52,7 @@ export class BookingInvoiceService { private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, - ) {} + ) { } /** * Ensure the booking has its invoice, generating one from the snapshotted @@ -122,7 +122,8 @@ export class BookingInvoiceService { if (booking.paymentStatus === 'PAID') return; const paidAt = new Date(); - const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + const isGeneralContract = false + // booking.bookingType === 'GENERAL_CONTRACT'; let contractExpiresAt: Date | null = null; if (isGeneralContract) {