import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In } from 'typeorm'; import { BillingService } from '../billing/billing.service'; import { ContractBookingService } from '../contracts/contract-booking.service'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; import { CreateBookingUnderContractDto } from '../contracts/dto/create-booking-under-contract.dto'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; import { FirstMileService } from '../first-mile/first-mile.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BookingWagonCancellationsRepository, WagonCancellationListFilter, } from './booking-wagon-cancellations.repository'; import { BookingsRepository } from './bookings.repository'; import { RebookCancelledWagonsDto, RequestWagonCancellationDto, } from './dto/wagon-cancellation.dto'; import { Booking } from './entities/booking.entity'; import { BookingContainer } from './entities/booking-container.entity'; import { BookingContainerUnit } from './entities/booking-container-unit.entity'; import { BookingWagonCancellation, CancelledQuantities, CancelledUnitSnapshot, } from './entities/booking-wagon-cancellation.entity'; /** * rates.rate_type of the cancellation fee — an existing rate-engine type * (trigger CANCELLATION, never auto-applied to booking pricing). Staff * configure it in the normal rates UI; the wagon flow requires the PER_WAGON * unit so the fee scales with the cancelled wagon count. */ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; /** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE'; const round2 = (n: number): number => Math.round(n * 100) / 100; const round3 = (n: number): number => Math.round(n * 1000) / 1000; interface RequestedCut { wagons: number; weightTons: number; quantities: CancelledQuantities; } /** * Partial wagon cancellation on a PAID booking, with a rebooking credit. * * Lifecycle (one ledger row per cycle, see BookingWagonCancellation): * T1 request — validate + price the fee, open the fee invoice. Nothing else * moves: the wagons stay allocated until the fee is money. * T2 fee paid — reduce the booking in place (applySplit mechanics: soft-delete * the cut units LIFO), release the surplus wagon allocations, * snapshot the cut units on the ledger row → CREDIT_AVAILABLE. * T3 rebook — customer picks a day only. The credit becomes a REAL booking * via ContractBookingService.createUnderContract (which re-checks * contract validity + caps), immediately marked PAID — the * freight was paid on the original booking; only the fee was new * money. Clearance milestones are copied from the source booking * (the cargo is already cleared; clearance follows cargo, not * train date). * * The cycle is repeatable by construction: the rebooked booking is a normal * PAID booking, so it can itself be partially cancelled again. */ @Injectable() export class BookingWagonCancellationService { private readonly logger = new Logger(BookingWagonCancellationService.name); constructor( private readonly dataSource: DataSource, private readonly repo: BookingWagonCancellationsRepository, private readonly bookingsRepository: BookingsRepository, private readonly billing: BillingService, @Inject(forwardRef(() => ContractBookingService)) private readonly contractBooking: ContractBookingService, @Inject(forwardRef(() => ClearanceMilestoneService)) private readonly clearanceMilestones: ClearanceMilestoneService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainScheduling: TrainSchedulingService, @Inject(forwardRef(() => FirstMileService)) private readonly firstMile: FirstMileService, private readonly inbox: NotificationInboxService, ) {} // ── T1: request ──────────────────────────────────────────────────────────── /** Fee/credit preview for the confirm dialog — same math as the request, no writes. */ async previewCancellation( bookingId: string, dto: RequestWagonCancellationDto, ): Promise<{ wagons: number; weightTons: number; feePerWagon: number; feeAmount: number; feeCurrency: string; creditAmount: number; }> { const booking = await this.loadCancellableBooking(bookingId); const cut = await this.resolveRequestedCut(booking, dto); const rate = await this.feeRate(); const feeAmount = round2(Number(rate.rateValue) * cut.wagons); return { wagons: cut.wagons, weightTons: cut.weightTons, feePerWagon: Number(rate.rateValue), feeAmount, feeCurrency: rate.currency, creditAmount: this.creditFor(booking, cut.wagons), }; } async requestCancellation( bookingId: string, dto: RequestWagonCancellationDto, userId?: string, ): Promise { const booking = await this.loadCancellableBooking(bookingId); const open = await this.repo.findOpenForBooking(bookingId); if (open) { throw new ConflictException( 'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.', ); } const cut = await this.resolveRequestedCut(booking, dto); const rate = await this.feeRate(); const feeAmount = round2(Number(rate.rateValue) * cut.wagons); const creditAmount = this.creditFor(booking, cut.wagons); const row = await this.repo.create({ bookingId, wagonsCancelled: cut.wagons, weightTons: cut.weightTons, cancelledQuantities: cut.quantities, creditAmount, feeRateId: rate.id, feeAmount, feeCurrency: rate.currency, status: 'FEE_PENDING', reason: dto.reason ?? null, requestedByUserId: userId ?? null, }); // The fee invoice rides the booking's own invoice list (source=booking), so // the portal's existing invoice/pay stack picks it up with zero new payment // code. Settlement branches on type in BookingInvoiceService. const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.Booking, sourceId: bookingId, type: WAGON_CANCEL_FEE_INVOICE_TYPE, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: rate.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, quantity: cut.wagons, unitRate: Number(rate.rateValue), amount: feeAmount, currency: rate.currency, metadata: { wagonCancellationId: row.id }, }, ], totalAmount: feeAmount, status: Freight.InvoiceStatus.Issued, }); let updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id }); // Policy: the cancelled wagons leave the schedule NOW — capacity frees for // other customers immediately; the fee is still owed before the credit can // be rebooked. A withdraw/void re-allocates (or errors when the train has // no room left). If this release fails, T2 releases instead (flag unset). try { const released = await this.releaseAtRequest(bookingId, cut); if (released) { updated = await this.repo.update(row.id, { cancelledQuantities: { ...cut.quantities, releasedAtRequest: true }, }); } } catch (err) { this.logger.error( `Request-time wagon release failed for cancellation ${row.id}: ${err instanceof Error ? err.message : String(err)}`, ); } this.notifyStaff( booking, 'Wagon cancellation requested', `${booking.reference}: customer asked to cancel ${cut.wagons} wagon(s); fee invoice ${invoice.invoiceNumber} issued.`, ); return updated ?? row; } /** * Void a FEE_PENDING request (customer withdraw or staff void). The wagons * left the schedule at request time, so voiding must first put them back: * the schedule's auto-allocation is re-run and the result verified — if the * train has no room left, the void FAILS with a clear error and the request * stays FEE_PENDING (pay the fee and rebook the credit instead). */ async withdraw(cancellationId: string): Promise { const row = await this.mustFind(cancellationId); if (row.status !== 'FEE_PENDING') { throw new BadRequestException( `Only a fee-pending cancellation can be withdrawn (status is ${row.status}).`, ); } if (row.cancelledQuantities.releasedAtRequest) { const booking = await this.bookingsRepository.findById(row.bookingId); const scheduleId = booking?.trainScheduleId; if (booking && scheduleId) { try { await this.trainScheduling.tryAutoWagonAllocation(scheduleId); } catch (err) { this.logger.warn( `Re-allocation on withdraw failed for booking ${row.bookingId}: ${err instanceof Error ? err.message : String(err)}`, ); } // ponytail: allocation rows ≈ wagons (20ft pairs share one row/wagon); // switch to a weight-based check if mixed loads ever make this lie. const rows = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId: row.bookingId }, }); if (rows < Math.round(Number(booking.wagonsRequired ?? 0))) { throw new ConflictException( 'The train has no free wagon space left to restore the cancelled wagons — the request cannot be withdrawn. Pay the cancellation fee and rebook the credit on another day instead.', ); } } } if (row.feeInvoiceId) await this.billing.cancelInvoice(row.feeInvoiceId); return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!; } // ── T2: fee settled ───────────────────────────────────────────────────────── /** * The fee invoice settled — reduce the booking and free the wagons. Called * from BookingInvoiceService's paid handler. Idempotent: a duplicate webhook * finds the row already past FEE_PENDING and returns. */ async onFeePaid(feeInvoiceId: string): Promise { const row = await this.repo.findByFeeInvoiceId(feeInvoiceId); if (!row) { this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`); return; } if (row.status !== 'FEE_PENDING') return; // The fee can settle after loading started (slow payment). Never cut // loaded cargo: leave the row FEE_PENDING and alert staff to resolve // (reschedule the cut or refund the fee by hand). Skipped when the wagons // already left the schedule at request time — loading of the KEPT wagons // is then irrelevant to this cut. const releasedEarly = !!row.cancelledQuantities.releasedAtRequest; const bookingNow = await this.bookingsRepository.findById(row.bookingId); const movingNow = releasedEarly ? 0 : await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) }, }); if (!releasedEarly && (bookingNow?.loadedAt || movingNow > 0)) { this.logger.error( `Wagon cancellation ${row.id}: fee paid but loading already started on booking ${row.bookingId} — left FEE_PENDING for manual resolution.`, ); if (bookingNow) { this.notifyStaff( bookingNow, 'Wagon cancellation fee paid after loading started', `${bookingNow.reference}: the customer paid the cancellation fee for ${row.wagonsCancelled} wagon(s), but loading has already started. Resolve manually (adjust the cut or refund the fee).`, ); } return; } await this.dataSource.transaction(async (manager) => { const booking = await manager.getRepository(Booking).findOne({ where: { id: row.bookingId }, lock: { mode: 'pessimistic_write' }, }); if (!booking) throw new NotFoundException(`Booking ${row.bookingId} not found.`); const quantities = { ...row.cancelledQuantities }; let droppedWeight = 0; if (quantities.bySize && Object.keys(quantities.bySize).length) { // Specific-wagon requests already carry the exact unit snapshots; // quantity requests trim LIFO and snapshot here. const units = quantities.units?.length ? await this.reduceContainerUnitsExact(manager, booking, quantities.units) : await this.reduceContainerLines(manager, booking, quantities.bySize); quantities.units = units; droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); if (!releasedEarly) { await this.releaseContainerAllocations( manager, booking.id, units.map((u) => u.containerNumber), ); } } else { droppedWeight = Number(quantities.bulkTons ?? row.weightTons); await this.reduceBulk(manager, booking, droppedWeight); if (!releasedEarly) { await this.releaseBulkAllocations( manager, booking.id, Number(row.wagonsCancelled), quantities.allocationIds, ); } } // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME // exact-remainder assertion at rebook time; isSplit releases the // single-active-booking slot so the rebooked booking may be created. const preSplitQuantities = booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); await manager.getRepository(Booking).update(booking.id, { wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), isSplit: true, preSplitQuantities, } as never); await manager.getRepository(BookingWagonCancellation).update(row.id, { status: 'CREDIT_AVAILABLE', feePaidAt: new Date(), weightTons: droppedWeight, cancelledQuantities: quantities, }); }); const booking = await this.bookingsRepository.findById(row.bookingId); if (booking) { this.notifyCustomer( booking, 'Wagon cancellation confirmed', `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, ); } this.logger.log( `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, ); } // ── T3: rebook ────────────────────────────────────────────────────────────── async rebook( cancellationId: string, dto: RebookCancelledWagonsDto, userId?: string, ): Promise<{ cancellation: BookingWagonCancellation; bookingId: string }> { const row = await this.mustFind(cancellationId); if (row.status !== 'CREDIT_AVAILABLE') { throw new BadRequestException( `This credit cannot be rebooked (status is ${row.status}).`, ); } const source = await this.bookingsRepository.findById(row.bookingId); if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`); if (!source.contractId) { throw new BadRequestException('The original booking has no contract to rebook under.'); } // Friendly pre-check; createUnderContract re-asserts inside its own guards. if ( source.contractValidUntil && new Date(source.contractValidUntil).getTime() < Date.now() ) { throw new BadRequestException( 'Contract validity has expired — ask EDR staff to extend the contract before rebooking.', ); } const createDto = this.buildRebookDto(row, dto.scheduledDate); const created = await this.contractBooking.createUnderContract( source.contractId, createDto, { id: userId ?? source.createdByUserId ?? undefined }, // System actor: carries the create-booking key so the GL gate passes on // Path B (customs-clearance) contracts; harmless on Path A. { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, ); const newBookingId = created.booking.id; // The freight is already paid (credit) — mark PAID and let the existing // paid-booking machinery place it. No invoice is generated for it. await this.dataSource.getRepository(Booking).update(newBookingId, { paymentStatus: 'PAID', status: 'PAID', }); await this.copyClearanceState(source, newBookingId); try { await this.firstMile.acceptBooking(newBookingId); } catch (err) { this.logger.error( `First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); } try { await this.bookingBatch.ensurePaidBookingAllocated(newBookingId); } catch (err) { this.logger.error( `Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); } const updated = (await this.repo.update(row.id, { status: 'REBOOKED', rebookedBookingId: newBookingId, rebookedAt: new Date(), }))!; this.notifyCustomer( source, 'Cancelled wagons rebooked', `Your ${row.wagonsCancelled} cancelled wagon(s) from ${source.reference} are rebooked for ${dto.scheduledDate}. No new freight charge — your credit covered it.`, newBookingId, ); return { cancellation: updated, bookingId: newBookingId }; } // ── History ──────────────────────────────────────────────────────────────── list(filter: WagonCancellationListFilter) { return this.repo.list(filter); } findById(id: string): Promise { return this.mustFind(id); } // ── internals ────────────────────────────────────────────────────────────── private async mustFind(id: string): Promise { const row = await this.repo.findById(id); if (!row) throw new NotFoundException(`Wagon cancellation ${id} not found.`); return row; } /** PAID booking, not yet moving, with a contract to rebook under later. */ private async loadCancellableBooking(bookingId: string): Promise { const booking = await this.bookingsRepository.findById(bookingId); if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`); if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') { throw new BadRequestException( 'Only a paid booking can cancel wagons. Before payment, cancel the booking itself — no fee applies.', ); } if (!booking.contractId) { throw new BadRequestException( 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', ); } // Cancellation is allowed strictly BEFORE loading/dispatch: both signals // checked — per-wagon allocation status and the booking-level loading stamp // (some flows confirm loading on the booking without flipping allocations). if (booking.loadedAt) { throw new BadRequestException( 'Cargo loading is confirmed for this booking — wagons can no longer be cancelled.', ); } const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId, status: In(['LOADED', 'DEPARTED']) }, }); if (moving > 0) { throw new BadRequestException( 'Loading has started for this booking — wagons can no longer be cancelled.', ); } return booking; } /** Validate the requested cut against the live booking and size it in wagons/tons. */ private async resolveRequestedCut( booking: Booking, dto: RequestWagonCancellationDto, ): Promise { const totalWagons = Number(booking.wagonsRequired ?? 0); if (totalWagons <= 0) { throw new BadRequestException('This booking has no wagon requirement to cancel from.'); } if (dto.wagonAllocationIds?.length) { return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons); } if (booking.freightType === 'CONTAINER') { if (!dto.containers?.length) { throw new BadRequestException('Specify the container units to cancel per size.'); } const lines = await this.dataSource.getRepository(BookingContainer).find({ where: { bookingId: booking.id }, }); const liveBySize = new Map(); for (const line of lines) { const size = line.containerSize ?? ''; liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0)); } const bySize: Record = {}; let wagons = 0; for (const cut of dto.containers) { const live = liveBySize.get(cut.containerSize) ?? 0; if (cut.quantity > live) { throw new BadRequestException( `Cannot cancel ${cut.quantity} × ${cut.containerSize}ft — the booking only has ${live}.`, ); } bySize[cut.containerSize] = cut.quantity; wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); } wagons = round2(wagons); if (wagons >= totalWagons) { throw new BadRequestException( 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', ); } // Snapshot the LIFO-picked physical units up front (read-only — cargo is // cut only when the fee settles) so the wagons carrying them can be // released from the schedule at request time and the portal can show // which containers are leaving. const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const units: CancelledUnitSnapshot[] = []; let requested = 0; for (const cut of dto.containers) { requested += cut.quantity; let need = cut.quantity; const sizeLines = lines .filter((l) => (l.containerSize ?? '') === cut.containerSize) .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); for (const line of sizeLines) { if (need <= 0) break; const us = await unitRepo.find({ where: { bookingContainerId: line.id }, order: { sortOrder: 'DESC', createdAt: 'DESC' }, take: need, }); for (const u of us) { units.push({ containerSize: cut.containerSize, containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? null, vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, }); need--; } } } const weightShare = round3( Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), ); return { wagons, weightTons: weightShare, // Bookings without unit records fall back to the T2 LIFO trim. quantities: { bySize, ...(units.length === requested ? { units } : {}) }, }; } // BULK: the customer cancels wagons; tons follow the booking's own // tons-per-wagon ratio. const wagons = round2(Number(dto.wagons ?? 0)); if (!wagons || wagons <= 0) { throw new BadRequestException('Specify how many wagons to cancel.'); } if (wagons >= totalWagons) { throw new BadRequestException( 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', ); } // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item // rounding happens here too; switch to items_per_wagon_map sizing if bulk // PER_ITEM cancels ever need to be exact per item. let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); const isPerItem = booking.bulkTotalWeightTons != null; tons = isPerItem ? Math.floor(tons) : round3(tons); if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; } /** * Specific-wagon cancellation: the customer picked wagons in the Wagons tab. * Everything is derived from the selected allocations — container bookings * get their exact unit snapshots up front (T2 then cuts precisely these, * not a LIFO guess), bulk gets the wagons' actual allocated tonnage. */ private async resolveCutFromAllocations( booking: Booking, allocationIds: string[], totalWagons: number, ): Promise { const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({ where: { id: In(allocationIds), bookingId: booking.id }, relations: { containerItems: true }, }); if (allocations.length !== allocationIds.length) { throw new BadRequestException( 'Some selected wagons no longer belong to this booking — refresh and pick again.', ); } const notCancellable = allocations.filter( (a) => a.status !== 'PLANNED' && a.status !== 'RESERVED', ); if (notCancellable.length) { throw new BadRequestException( 'A selected wagon is already loaded or departed and cannot be cancelled.', ); } const wagons = allocations.length; if (wagons >= totalWagons) { throw new BadRequestException( 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', ); } if (booking.freightType !== 'CONTAINER') { const allocated = allocations.reduce( (s, a) => s + Number(a.allocatedWeightTons || 0), 0, ); const tons = allocated > 0 ? round3(allocated) : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { wagons, weightTons: tons, quantities: { bulkTons: tons, allocationIds }, }; } // Container: the selected wagons' items name the exact physical boxes. const numbers = allocations .flatMap((a) => a.containerItems ?? []) .map((i) => i.containerNumber) .filter((n): n is string => !!n); if (!numbers.length) { throw new BadRequestException( 'The selected wagons carry no container records — cancel by quantity instead.', ); } const lines = await this.dataSource.getRepository(BookingContainer).find({ where: { bookingId: booking.id }, }); const unitRepo = this.dataSource.getRepository(BookingContainerUnit); const units: CancelledUnitSnapshot[] = []; const bySize: Record = {}; for (const line of lines) { const size = line.containerSize ?? ''; const lineUnits = await unitRepo.find({ where: { bookingContainerId: line.id } }); for (const u of lineUnits) { if (!numbers.includes(u.containerNumber)) continue; units.push({ containerSize: size, containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? null, vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, }); bySize[size] = (bySize[size] ?? 0) + 1; } } if (units.length !== numbers.length) { throw new BadRequestException( 'Wagon container records are out of sync with the booking — contact EDR support.', ); } return { wagons, weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), quantities: { bySize, units, allocationIds }, }; } /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ private creditFor(booking: Booking, wagons: number): number { const totalWagons = Number(booking.wagonsRequired ?? 0); if (totalWagons <= 0) return 0; return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } private async feeRate(): Promise { const rate = await this.dataSource.getRepository(Rate).findOne({ where: { rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, rateUnit: 'PER_WAGON', status: 'LIVE', }, order: { createdAt: 'DESC' }, }); if (!rate) { throw new BadRequestException( 'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', ); } return rate; } /** * Trim `bySize` units off the booking's container lines, newest line first, * LIFO within a line — the exact applySplit mechanics. Returns snapshots of * every physical unit soft-deleted, for later reconstruction. */ private async reduceContainerLines( manager: EntityManager, booking: Booking, bySize: Record, ): Promise { const snapshots: CancelledUnitSnapshot[] = []; for (const [size, toDrop] of Object.entries(bySize)) { let remaining = toDrop; const lines = await manager.getRepository(BookingContainer).find({ where: { bookingId: booking.id, containerSize: size }, order: { createdAt: 'DESC' }, }); const live = lines.reduce((s, l) => s + Number(l.quantity ?? 0), 0); if (live < toDrop) { throw new BadRequestException( `Booking changed since the request: only ${live} × ${size}ft left, cannot cancel ${toDrop}.`, ); } for (const line of lines) { if (remaining <= 0) break; const qty = Number(line.quantity ?? 0); const drop = Math.min(remaining, qty); remaining -= drop; const units = await manager.getRepository(BookingContainerUnit).find({ where: { bookingContainerId: line.id }, order: { sortOrder: 'DESC', createdAt: 'DESC' }, take: drop, }); for (const u of units) { snapshots.push({ containerSize: size, containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? null, vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, }); } if (units.length < drop) { throw new BadRequestException( `Booking line ${line.id} has ${units.length} physical unit record(s) but ${drop} must be cancelled — units out of sync.`, ); } const droppedVgm = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); if (drop === qty) { await manager.getRepository(BookingContainer).softDelete(line.id); await manager .getRepository(BookingContainerUnit) .softDelete(units.map((u) => u.id)); continue; } await manager.getRepository(BookingContainerUnit).softDelete(units.map((u) => u.id)); const keptUnits = await manager.getRepository(BookingContainerUnit).find({ where: { bookingContainerId: line.id }, }); await manager.getRepository(BookingContainer).update(line.id, { quantity: qty - drop, wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))), totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm), hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length, reeferQuantity: keptUnits.filter((u) => u.isReefer).length, }); } } return snapshots; } /** * Release the cancelled wagons from the schedule at REQUEST time. Returns * true when something was actually released (booking was on a train) — the * caller then stamps `releasedAtRequest` so T2 skips its release step. */ private async releaseAtRequest(bookingId: string, cut: RequestedCut): Promise { const had = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId }, }); if (had === 0) return false; await this.dataSource.transaction(async (manager) => { if (cut.quantities.units?.length) { await this.releaseContainerAllocations( manager, bookingId, cut.quantities.units.map((u) => u.containerNumber), ); } else if (!cut.quantities.bySize) { await this.releaseBulkAllocations( manager, bookingId, cut.wagons, cut.quantities.allocationIds, ); } // Container booking without unit records: nothing to match on — the // wagons release at T2 via the LIFO trim instead. }); const left = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId }, }); return left < had; } /** * Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete * them and rebalance each affected line. Returns the snapshots of the units * actually cut, so drift since the request fails loudly instead of guessing. */ private async reduceContainerUnitsExact( manager: EntityManager, booking: Booking, wanted: CancelledUnitSnapshot[], ): Promise { const numbers = wanted.map((u) => u.containerNumber); const lines = await manager.getRepository(BookingContainer).find({ where: { bookingId: booking.id }, }); const cut: CancelledUnitSnapshot[] = []; for (const line of lines) { const size = line.containerSize ?? ''; const lineUnits = await manager.getRepository(BookingContainerUnit).find({ where: { bookingContainerId: line.id }, }); const doomed = lineUnits.filter((u) => numbers.includes(u.containerNumber)); if (!doomed.length) continue; await manager.getRepository(BookingContainerUnit).softDelete(doomed.map((u) => u.id)); for (const u of doomed) { cut.push({ containerSize: size, containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? null, vgmTons: Number(u.vgmTons), isHazardous: u.isHazardous, isReefer: u.isReefer, }); } const kept = lineUnits.filter((u) => !numbers.includes(u.containerNumber)); if (!kept.length) { await manager.getRepository(BookingContainer).softDelete(line.id); continue; } const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); await manager.getRepository(BookingContainer).update(line.id, { quantity: kept.length, wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))), totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm), hazardousQuantity: kept.filter((u) => u.isHazardous).length, reeferQuantity: kept.filter((u) => u.isReefer).length, }); } if (cut.length !== wanted.length) { throw new BadRequestException( `Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`, ); } return cut; } private async reduceBulk( manager: EntityManager, booking: Booking, tons: number, ): Promise { if (tons >= Number(booking.cargoTotalWeightVgm)) { throw new BadRequestException( 'Booking changed since the request: the cut no longer leaves any cargo.', ); } if (booking.bulkTotalWeightTons != null) { const share = tons / Number(booking.cargoTotalWeightVgm); await manager.getRepository(Booking).update(booking.id, { bulkTotalWeightTons: round3(Number(booking.bulkTotalWeightTons) * (1 - share)), }); } } /** * Free the wagon capacity of the cancelled container units. Items are matched * by container number; an allocation left with no items is deleted whole * (hard delete — the unassignBooking convention for allocation rows). * A booking not yet placed on a train simply has nothing to release. */ private async releaseContainerAllocations( manager: EntityManager, bookingId: string, containerNumbers: string[], ): Promise { if (!containerNumbers.length) return; const allocations = await manager.getRepository(WagonBookingAllocation).find({ where: { bookingId }, relations: { containerItems: true }, }); for (const alloc of allocations) { const items = alloc.containerItems ?? []; const cut = items.filter( (i) => i.containerNumber && containerNumbers.includes(i.containerNumber), ); if (!cut.length) continue; await manager .getRepository(WagonAllocationContainerItem) .delete(cut.map((i) => i.id)); if (cut.length === items.length) { await manager.getRepository(WagonBookingAllocation).delete(alloc.id); } else { const cutWeight = cut.reduce((s, i) => s + Number(i.grossWeightTons ?? 0), 0); await manager.getRepository(WagonBookingAllocation).update(alloc.id, { allocatedWeightTons: round3(Number(alloc.allocatedWeightTons) - cutWeight), }); } } } /** * Free whole bulk wagons — the customer-picked allocations when given * (specific-wagon cancel), topping up newest-first for any picked id that no * longer exists (re-batch between request and fee payment). */ private async releaseBulkAllocations( manager: EntityManager, bookingId: string, wagons: number, pickedIds?: string[], ): Promise { const toFree = Math.round(wagons); if (toFree <= 0) return; let allocations: WagonBookingAllocation[] = []; if (pickedIds?.length) { allocations = await manager.getRepository(WagonBookingAllocation).find({ where: { id: In(pickedIds), bookingId }, }); } if (allocations.length < toFree) { const have = new Set(allocations.map((a) => a.id)); const fill = await manager.getRepository(WagonBookingAllocation).find({ where: { bookingId }, order: { createdAt: 'DESC' }, }); for (const a of fill) { if (allocations.length >= toFree) break; if (!have.has(a.id)) allocations.push(a); } } allocations = allocations.slice(0, toFree); if (!allocations.length) return; const ids = allocations.map((a) => a.id); await manager .getRepository(WagonAllocationBulkLoad) .delete({ wagonBookingAllocationId: In(ids) }); await manager.getRepository(WagonBookingAllocation).delete(ids); } /** Pre-reduction quantities snapshot (only when the booking was never split before). */ private async currentQuantities( manager: EntityManager, booking: Booking, _droppedWeight: number, ): Promise<{ bulkTons?: number; bySize?: Record }> { if (booking.freightType !== 'CONTAINER') { return { bulkTons: Number(booking.cargoTotalWeightVgm) }; } // Lines were already reduced inside this transaction — read them with // deleted rows included to reconstruct the pre-cut ledger. const lines = await manager.getRepository(BookingContainer).find({ where: { bookingId: booking.id }, withDeleted: true, }); const bySize: Record = {}; for (const line of lines) { const size = line.containerSize ?? ''; bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0); } return { bySize }; } /** The create-DTO that reconstructs the cancelled cargo on the chosen day. */ private buildRebookDto( row: BookingWagonCancellation, scheduledDate: string, ): CreateBookingUnderContractDto { const dto: CreateBookingUnderContractDto = { scheduledDate }; const q = row.cancelledQuantities; if (q.bySize && Object.keys(q.bySize).length) { const units = q.units ?? []; dto.containers = Object.entries(q.bySize).map(([size, quantity]) => { const sized = units.filter((u) => u.containerSize === size); if (sized.length !== quantity) { throw new BadRequestException( `Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`, ); } return { containerSize: size, quantity, units: sized.map((u) => ({ containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? undefined, vgmTons: u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, })), hazardousQuantity: sized.filter((u) => u.isHazardous).length, reeferQuantity: sized.filter((u) => u.isReefer).length, }; }); return dto; } dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }]; return dto; } /** * Carry the source booking's finished clearance onto the rebooked one: the * cargo is already cleared; a new train date needs no new customs cycle. * Seeds the standard milestone set idempotently, then mirrors every * non-pending milestone status from the source by milestone code. */ private async copyClearanceState(source: Booking, newBookingId: string): Promise { const repo = this.dataSource.getRepository(ClearanceMilestone); const sourceMilestones = await repo.find({ where: { bookingId: source.id } }); if (!sourceMilestones.length) return; try { await this.clearanceMilestones.ensureBookingMilestones( newBookingId, source.tradeDirection, ); const targets = await repo.find({ where: { bookingId: newBookingId } }); const byCode = new Map(targets.map((m) => [m.milestoneCode, m])); for (const src of sourceMilestones) { if (src.status === 'PENDING') continue; const target = byCode.get(src.milestoneCode); if (!target) continue; await repo.update(target.id, { status: src.status, triggeredAt: src.triggeredAt, triggeredByUserId: src.triggeredByUserId, triggeredByDoc: src.triggeredByDoc, note: src.note, metadata: src.metadata, }); } if (source.clearanceCurrentPhase) { await this.dataSource.getRepository(Booking).update(newBookingId, { clearanceCurrentPhase: source.clearanceCurrentPhase, preClearanceFinalizedAt: source.preClearanceFinalizedAt, dutyRequired: source.dutyRequired, }); } } catch (err) { // Clearance copy must never lose a paid rebooking — staff can re-complete // milestones by hand if this ever fails. this.logger.error( `Clearance copy ${source.id} → ${newBookingId} failed: ${err instanceof Error ? err.message : String(err)}`, ); } } private notifyCustomer(booking: Booking, title: string, body: string, linkBookingId?: string): void { void this.inbox.notify({ recipients: { companyId: booking.companyId }, audience: NotificationAudience.PORTAL, type: NotificationType.BOOKING_STATUS, title, body, link: `/bookings/${linkBookingId ?? booking.id}`, data: { bookingId: linkBookingId ?? booking.id, reference: booking.reference }, }); } private notifyStaff(booking: Booking, title: string, body: string): void { void this.inbox.notify({ recipients: { allBackoffice: true }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, link: `/bookings/${booking.id}`, data: { bookingId: booking.id, reference: booking.reference }, }); } }