import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { Freight } from '@edr/types'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { BillingService } from '../billing/billing.service'; 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 { BookingBatchOffer, OfferedLine, } from './entities/booking-batch-offer.entity'; import { BookingNotifierService } from './booking-notifier.service'; export interface SizedOffer { offeredWagons: number; totalWagons: number; offeredLines: OfferedLine[] | null; offeredWeightTons: number; offeredAmount: number; offeredPricingBreakdown: Record; } /** * Partial-capacity booking splits (import batch). The offer is sized and priced * against an in-memory clone — the booking row is untouched until the customer * pays, which is the act of accepting the split (applySplit). No payment → * offer expires and the booking stays whole. * * Only GENERAL-contract commercial bookings are offered partials: the remainder * returns to the contract's quantity cap (derived live from booking_container * rows, so reducing the lines releases it automatically) and can be rebooked in * any later window within contract validity. */ @Injectable() export class BookingSplitService { private readonly logger = new Logger(BookingSplitService.name); constructor( @InjectDataSource() private readonly dataSource: DataSource, @Inject(forwardRef(() => BookingPricingService)) private readonly pricing: BookingPricingService, @Inject(forwardRef(() => BookingInvoiceService)) private readonly invoiceService: BookingInvoiceService, private readonly billing: BillingService, private readonly notifier: BookingNotifierService, ) {} /** * Size the largest part of the booking that fits `freeWagons`, priced via an * in-memory clone. Returns null when nothing meaningful fits (no whole * container unit / no bulk tonnage, or pricing failed). */ async sizeOffer( booking: Booking, freeWagons: number, totalWagons: number, bulkWagonCapacityTons: number, ): Promise { if (freeWagons < 1 || freeWagons >= totalWagons) return null; const containers = booking.bookingContainers ?? []; let offeredLines: OfferedLine[] | null = null; let offeredWeightTons = 0; let offeredWagons = 0; const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); clone.adjustedTotalAmount = null; if (containers.length) { offeredLines = []; let remaining = freeWagons; const clonedContainers: BookingContainer[] = []; for (const line of containers) { const quantity = Number(line.quantity ?? 0); const lineWagons = Number(line.wagonsRequired ?? 0); if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; const perUnit = lineWagons / quantity; // Largest unit count whose wagon need still fits the remaining budget. let take = Math.min(quantity, Math.floor(remaining / perUnit)); while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; if (take <= 0) continue; const takeWagons = Math.ceil(take * perUnit); const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); offeredLines.push({ bookingContainerId: line.id, quantity: take, wagonsRequired: takeWagons, totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, }); offeredWeightTons += take * vgmPerUnit; offeredWagons += takeWagons; remaining -= takeWagons; const clonedLine: BookingContainer = Object.assign( Object.create(Object.getPrototypeOf(line)), line, { quantity: take, wagonsRequired: takeWagons, totalVgmTons: take * vgmPerUnit, }, ); clonedContainers.push(clonedLine); } if (!offeredLines.length || offeredWagons <= 0) return null; clone.bookingContainers = clonedContainers; } else { // Bulk: split by weight — the offered part is what freeWagons can carry. const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); if (offeredWeightTons <= 0) return null; offeredWagons = Math.min( freeWagons, Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), ); } offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; clone.cargoTotalWeightVgm = offeredWeightTons; clone.wagonsRequired = offeredWagons; try { const priced = await this.pricing.computePriceForBooking(clone); return { offeredWagons, totalWagons, offeredLines, offeredWeightTons, offeredAmount: priced.totalAmount, offeredPricingBreakdown: { lineItems: priced.lineItems, totalAmount: priced.totalAmount, currency: priced.currency, generatedAt: new Date().toISOString(), partialOfWagons: totalWagons, }, }; } catch (err) { this.logger.warn( `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, ); return null; } } /** * Persist the offer and swap the booking's payable to a partial invoice for the * offered amount. Any previous open offer for the booking is superseded. */ async createOffer( booking: Booking, scheduleId: string, sized: SizedOffer, deadline: Date, ): Promise { const repo = this.dataSource.getRepository(BookingBatchOffer); await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); // The full-amount invoice must not stay payable next to the partial one. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); const invoice = await this.invoiceService.ensureInvoiceForBooking( { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, ); const offer = await repo.save( repo.create({ bookingId: booking.id, trainScheduleId: scheduleId, offeredWagons: sized.offeredWagons, totalWagons: sized.totalWagons, offeredLines: sized.offeredLines, offeredWeightTons: sized.offeredWeightTons, offeredAmount: sized.offeredAmount, offeredPricingBreakdown: sized.offeredPricingBreakdown, invoiceId: invoice.id, paymentDeadline: deadline, status: 'OFFERED', }), ); await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); return offer; } /** * Payment received inside the window — the customer accepted the split. * Reduce the booking to the offered lines/weight; the remainder returns to the * contract cap automatically (bookedQuantities derives from live lines). * Idempotent: no OFFERED offer → no-op. */ async applySplit(bookingId: string): Promise { const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ where: { bookingId, status: 'OFFERED' }, order: { createdAt: 'DESC' }, }); if (!offer) return; await this.dataSource.transaction(async (manager) => { if (offer.offeredLines?.length) { const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); const lines = await manager.getRepository(BookingContainer).find({ where: { bookingId }, }); for (const line of lines) { const kept = keptByLine.get(line.id); if (!kept) { await manager.getRepository(BookingContainer).softDelete(line.id); await manager .getRepository(BookingContainerUnit) .softDelete({ bookingContainerId: line.id }); continue; } const dropCount = Number(line.quantity) - kept.quantity; await manager.getRepository(BookingContainer).update(line.id, { quantity: kept.quantity, wagonsRequired: kept.wagonsRequired, totalVgmTons: kept.totalVgmTons, hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), }); if (dropCount > 0) { // Trim surplus physical units, last-entered first. const units = await manager.getRepository(BookingContainerUnit).find({ where: { bookingContainerId: line.id }, order: { sortOrder: 'DESC', createdAt: 'DESC' }, take: dropCount, }); if (units.length) { await manager .getRepository(BookingContainerUnit) .softDelete(units.map((u) => u.id)); } } } } await manager.getRepository(Booking).update(bookingId, { wagonsRequired: offer.offeredWagons, cargoTotalWeightVgm: offer.offeredWeightTons, totalAmount: offer.offeredAmount, pricingBreakdown: offer.offeredPricingBreakdown, } as never); await manager .getRepository(BookingBatchOffer) .update(offer.id, { status: 'APPLIED' }); }); this.logger.log( `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, ); } /** Pay window closed without payment — offer dies, booking stays whole. */ async expireOpenOffer(bookingId: string): Promise { await this.dataSource .getRepository(BookingBatchOffer) .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); } async findOpenOffer(bookingId: string): Promise { return this.dataSource.getRepository(BookingBatchOffer).findOne({ where: { bookingId, status: 'OFFERED' }, order: { createdAt: 'DESC' }, }); } }