import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } 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 { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { requestedBulkWagons } from '../train-scheduling/train-capacity.util'; import { wagonsRequiredForBooking } from '../train-scheduling/utils/fleet-plan.util'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; 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 { CancelRemainingWagonsDto, RebookCancelledWagonsDto, RebookContainerLineDto, 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, WAGON_CANCEL_FEE_INVOICE_TYPE, } from './entities/booking-wagon-cancellation.entity'; import { WagonEventType } from '@edr/types'; import { WagonHistoryService } from '../wagon-history/wagon-history.service'; export { WAGON_CANCEL_FEE_INVOICE_TYPE }; /** * 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, one PER_WAGON rate per trade direction * + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the * fee scales with the cancelled wagon count and differs by what was booked. */ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; /** `booking_container.container_size` is stored as "20ft"/"40ft" — `Number()` on it is NaN. */ const sizeFtOf = (size: string | number | null | undefined): number => parseInt(String(size ?? ''), 10); 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; /** The booking's whole wagon footprint the cut came out of — credit divides by it. */ totalWagons: number; } /** The priced fee for a cut: total, currency and the rate(s) it came from. */ interface PricedFee { amount: number; currency: string; /** Effective per-wagon fee (amount / wagons) — one number for the customer. */ perWagon: number; /** Rate rows used; the first is recorded on the ledger row. */ rates: Rate[]; } /** * Wagon cancellation on a PAID booking (partial or whole), with a rebooking * credit. Cutting every wagon ends the source booking CANCELLED at T2; the * credit then rebooks as a fresh booking under the same contract. * * 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. */ /** Validates a stored currency string against the supported set, defaulting to USD. */ function toCurrencyCode(currency?: string | null): CurrencyCode { const code = currency?.toUpperCase(); return (CURRENCY_CODES as readonly string[]).includes(code ?? '') ? (code as CurrencyCode) : 'USD'; } @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, private readonly exchangeService: ExchangeService, @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, private readonly events: EventEmitter2, private readonly wagonHistory: WagonHistoryService, ) {} // ── 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); // Empty dto = the whole booking ("Cancel booking" button). const cut = this.isEmptyCut(dto) ? await this.resolveFullCut(booking) : await this.resolveRequestedCut(booking, dto); // Consolidated booking: preview the same rules the request enforces — a // full cut breaks the pair (canceller fee = ceil of its fractional // wagons); a partial cut must spare the shared wagon. if (booking.consolidationPartnerId) { const full = await this.resolveFullCut(booking); if (cut.wagons >= full.wagons) { const feeWagons = Math.ceil(cut.wagons); const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); return { wagons: cut.wagons, weightTons: cut.weightTons, feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, creditAmount: round2(Number(booking.totalAmount ?? 0)), }; } this.assertCutSparesSharedWagon(cut); } const fee = await this.priceFee(booking, cut); return { wagons: cut.wagons, weightTons: cut.weightTons, feePerWagon: fee.perWagon, feeAmount: fee.amount, feeCurrency: fee.currency, creditAmount: this.creditFor(booking, cut.wagons, cut.totalWagons), }; } async requestCancellation( bookingId: string, dto: RequestWagonCancellationDto, userId?: string, ): Promise { const booking = await this.loadCancellableBooking(bookingId); // Consolidated booking: the shared wagon itself is untouchable — its other // half belongs to the partner. The customer may still cancel // - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or // - a PARTIAL cut of their own full wagons — an EVEN number of 20ft // containers, so the odd one stays on the shared wagon and the pair // survives untouched. if (booking.consolidationPartnerId) { if (this.isEmptyCut(dto)) { return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); } const full = await this.resolveFullCut(booking); const cut = await this.resolveRequestedCut(booking, dto); if (cut.wagons >= full.wagons) { return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId); } this.assertCutSparesSharedWagon(cut); // fall through: a pair-safe partial cut rides the normal partial flow. } 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.', ); } // Empty dto = the whole booking ("Cancel booking" button). const cut = this.isEmptyCut(dto) ? await this.resolveFullCut(booking) : await this.resolveRequestedCut(booking, dto); const fee = await this.priceFee(booking, cut); const feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, wagonsCancelled: cut.wagons, weightTons: cut.weightTons, cancelledQuantities: cut.quantities, creditAmount, // ponytail: one FK for a mixed-size container cut records the first // size's rate; the invoice line carries the effective per-wagon fee. feeRateId: fee.rates[0].id, feeAmount, feeCurrency: fee.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: fee.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, quantity: cut.wagons, unitRate: fee.perWagon, amount: feeAmount, currency: fee.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(await this.wagonFootprint(booking))) { 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' }))!; } // ── Consolidated-pair cancellation ────────────────────────────────────────── /** * Cancel BOTH halves of a consolidated pair — a shared wagon never ships * half-full, so a paired booking always cancels whole, together with its * partner. * * Fee split (the canceller's leftover 20ft claims the shared wagon): * canceller pays ceil(its wagons), the partner floor(its wagons) — e.g. * 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side * keeps its full freight as a rebooking credit (rebooked by GL through the * normal rebook endpoint once its fee settles); an UNPAID partner is * cancelled with no fee and no credit. */ private async cancelConsolidatedPair( booking: Booking, reason: string | null, userId?: string, ): Promise { const partnerId = booking.consolidationPartnerId!; const partner = await this.bookingsRepository.findById(partnerId); if (!partner) { throw new NotFoundException(`Partner booking ${partnerId} not found.`); } const partnerPaid = partner.paymentStatus === 'PAID' || partner.status === 'PAID'; // Break the link first — every write below treats each side singly. await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId); const row = await this.openConsolidationBreak( booking, 'ceil', round2(Number(booking.totalAmount ?? 0)), reason ?? 'Consolidated pair cancelled', userId, ); if (partnerPaid) { await this.openConsolidationBreak( partner, 'floor', round2(Number(partner.totalAmount ?? 0)), `Cancelled with its consolidation partner ${booking.reference}`, userId, ); } else { // Unpaid partner: no fee — just make sure no payable invoice stays open. await this.billing .expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID') .catch(() => undefined); } for (const b of [booking, partner]) { await this.dataSource.getRepository(Booking).update(b.id, { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null, // A dead booking holds no shipment day — leaving it set lets the // stranded-PAID day sweep pick the booking up and resurrect it. scheduledDate: null, }); await this.detachFromSchedule(b); } this.notifyCustomer( booking, 'Consolidated booking cancelled', `${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`, ); this.notifyCustomer( partner, 'Consolidated booking cancelled', partnerPaid ? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.` : `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`, ); this.notifyStaff( booking, 'Consolidated pair cancelled', `${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`, ); return row; } /** * Open one side's ledger row for a consolidation break: a FULL cut whose fee * is priced on the ceil/floor split of the cut's own FRACTIONAL wagons — * never booking.wagonsRequired, which the contract flow persists already * ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge * the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner * floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space. * feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the * row goes straight to CREDIT_AVAILABLE. */ private async openConsolidationBreak( booking: Booking, mode: 'ceil' | 'floor', creditAmount: number, reason: string, userId?: string, ): Promise { const open = await this.repo.findOpenForBooking(booking.id); if (open) { throw new ConflictException( `Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`, ); } const cut = await this.resolveFullCut(booking); const feeWagons = mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons); // The pair is dead the moment it breaks — the wagons leave the schedule // with the cancel itself, so T2 must not release them again. const quantities = { ...cut.quantities, releasedAtRequest: true }; if (feeWagons <= 0) { return this.repo.create({ bookingId: booking.id, wagonsCancelled: cut.wagons, weightTons: cut.weightTons, cancelledQuantities: quantities, creditAmount, feeAmount: 0, feeCurrency: booking.paymentCurrency ?? 'ETB', status: 'CREDIT_AVAILABLE', feePaidAt: new Date(), reason, requestedByUserId: userId ?? null, }); } const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons }); const row = await this.repo.create({ bookingId: booking.id, wagonsCancelled: cut.wagons, weightTons: cut.weightTons, cancelledQuantities: quantities, creditAmount, feeRateId: fee.rates[0].id, feeAmount: fee.amount, feeCurrency: fee.currency, status: 'FEE_PENDING', reason, requestedByUserId: userId ?? null, }); const invoice = await this.billing.generateInvoice({ source: Freight.InvoiceSource.Booking, sourceId: booking.id, type: WAGON_CANCEL_FEE_INVOICE_TYPE, companyId: booking.companyId, companyProfileId: booking.companyProfileId, currency: fee.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`, quantity: feeWagons, unitRate: fee.perWagon, amount: fee.amount, currency: fee.currency, metadata: { wagonCancellationId: row.id }, }, ], totalAmount: fee.amount, status: Freight.InvoiceStatus.Issued, }); return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row; } /** No cut named at all — the "Cancel booking" button cancelling everything. */ private isEmptyCut(dto: RequestWagonCancellationDto): boolean { return ( !dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons ); } /** * A partial cut on a consolidated booking must leave the shared wagon whole: * the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole * own wagons only). An odd cut — including picking the shared wagon itself in * the Wagons tab (it contributes exactly one 20ft) — is rejected. */ private assertCutSparesSharedWagon(cut: RequestedCut): void { const ft20Cut = Object.entries(cut.quantities.bySize ?? {}) .filter(([size]) => sizeFtOf(size) === 20) .reduce((sum, [, qty]) => sum + qty, 0); if (ft20Cut % 2 === 1) { throw new BadRequestException( 'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.', ); } } /** The whole booking as a cut — everything it still carries. */ private async resolveFullCut(booking: Booking): Promise { if (booking.freightType === 'CONTAINER') { const lines = await this.dataSource.getRepository(BookingContainer).find({ where: { bookingId: booking.id }, }); const bySize = new Map(); for (const line of lines) { const size = line.containerSize ?? ''; bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0)); } const containers = [...bySize.entries()] .filter(([, quantity]) => quantity > 0) .map(([containerSize, quantity]) => ({ containerSize, quantity })); return this.resolveRequestedCut(booking, { containers, } as RequestWagonCancellationDto); } return this.resolveRequestedCut(booking, { // Footprint, not the live wagonsRequired: unassign clears that to NULL. wagons: await this.wagonFootprint(booking), } as RequestWagonCancellationDto); } /** * A consolidation pair broke with only one side PAID: the unpaid half * expired/cancelled fee-free (cancellation fees only ever apply to a paid * booking), and the PAID half cannot board either — its odd 20ft has no * partner for the shared wagon. So the PAID booking is cancelled too, owing * the cancellation fee on ceil of its own fractional wagons (shared wagon * included); its paid freight is kept as rebooking credit. Once the fee * settles, GL staff rebook it through a normal new booking, where its odd * 20ft goes through consolidation pairing again. */ @OnEvent('booking.consolidation.partnerLapsed') async onConsolidationPartnerLapsed(payload: { paidBookingId: string; }): Promise { try { const booking = await this.bookingsRepository.findById( payload.paidBookingId, ); if (!booking) return; if (['CANCELLED', 'EXPIRED', 'COMPLETED'].includes(booking.status)) return; if (await this.repo.findOpenForBooking(booking.id)) return; // already charged const row = await this.openConsolidationBreak( booking, 'ceil', round2(Number(booking.totalAmount ?? 0)), 'Consolidation partner lapsed unpaid — paired booking cancelled, cancellation fee applies', ); await this.dataSource.getRepository(Booking).update(booking.id, { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null, // A dead booking holds no shipment day — leaving it set lets the // stranded-PAID day sweep pick the booking up and resurrect it. scheduledDate: null, }); await this.detachFromSchedule(booking); this.notifyCustomer( booking, 'Consolidated booking cancelled', `${booking.reference} shared a wagon with a booking that was never paid, so it cannot board and is cancelled. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced; your paid freight is kept as credit — settle the fee and EDR staff will rebook you.`, ); this.notifyStaff( booking, 'Consolidation partner lapsed — paid booking cancelled', `${booking.reference}: its consolidation partner lapsed unpaid, so the paid booking is cancelled with a cancellation fee invoice. Rebook it from its credit once the fee settles (it must pair up again).`, ); } catch (err) { this.logger.error( `Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`, ); // A silent failure here leaves a PAID half-wagon booking boarding alone // (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee // pricing threw and the booking stayed PAID). Scream to staff so it is // fixed and the booking cancelled by hand instead of shipping. try { const failed = await this.bookingsRepository.findById( payload.paidBookingId, ); if (failed) { this.notifyStaff( failed, 'Consolidation-lapse cancellation FAILED — action needed', `${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`, ); } } catch { // Notification is best-effort — the error log above already fired. } } } // ── 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') { // At-loading cancels apply the cut immediately and leave the invoice // open — settle only the payment stamp when the customer pays later. if (!row.feePaidAt) { await this.repo.update(row.id, { feePaidAt: new Date() }); } 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.applyCut(row, releasedEarly, { feeSettled: true }); this.logger.log( `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, ); } /** * Apply the cut to the booking: reduce quantities/wagons/amount, release the * cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee * settlement for the customer-requested flow (feeSettled: true) and * immediately for at-loading cancels (feeSettled only when no fee is owed — * EDR fault; a customer-fault cut leaves feePaidAt null until the open * invoice settles via onFeePaid). */ private async applyCut( row: BookingWagonCancellation, releasedEarly: boolean, opts: { feeSettled: boolean }, ): Promise { 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)); // Whole-booking cut: nothing is left to ship, so the booking ends // CANCELLED (frees the contract slot/cap for the rebook) and drops off its // train. The credit row still points at it for T3. // Off the pinned footprint, not the live wagonsRequired — unassign // clears that to NULL, which read as a full cut on any partial cancel. const footprint = await this.wagonFootprint(booking); const wagonsLeft = round2(footprint - Number(row.wagonsCancelled)); const isFull = wagonsLeft <= 0; // NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which // bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the // booking to its pre-cancel count on the next allocation (and shrinks // tons-per-wagon to tons / stale-count), so shrink it with the cut. const requestedWagonsLeft = booking.bulkRequestedWagons ? Math.max( 0, Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)), ) : null; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), // Keep the cancellation footprint in step, so a second partial cancel // prices against what is actually left, not the original booking. cancellationWagons: Math.max(0, wagonsLeft), ...(requestedWagonsLeft !== null ? { bulkRequestedWagons: requestedWagonsLeft } : {}), cargoTotalWeightVgm: Math.max( 0, round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), ), totalAmount: Math.max( 0, round2(Number(booking.totalAmount) - Number(row.creditAmount)), ), isSplit: true, preSplitQuantities, ...(isFull ? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null } : {}), } as never); await manager.getRepository(BookingWagonCancellation).update(row.id, { status: 'CREDIT_AVAILABLE', ...(opts.feeSettled ? { feePaidAt: new Date() } : {}), weightTons: droppedWeight, cancelledQuantities: quantities, }); }); const booking = await this.bookingsRepository.findById(row.bookingId); if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking); if (booking) { const whole = booking.status === 'CANCELLED'; this.notifyCustomer( booking, whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', whole ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.` : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`, ); } } /** * Staff cancel of the never-loaded remainder mid-load: the operator loaded * what physically rides and cuts the rest, so the booking shrinks to its * loaded wagons, dispatch unblocks, and the warehouse only ever sees the * final (smaller) booking. Unlike the customer flow the cut applies * IMMEDIATELY — the train cannot wait for a fee payment: * - CUSTOMER fault: cancellation fee invoiced, payable after; the credit * row opens right away (feePaidAt stamps when the invoice settles). * - EDR fault: no fee at all; the credit is rebookable in full. */ async cancelRemainingAtLoading( bookingId: string, dto: CancelRemainingWagonsDto, userId?: 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 still loading can cancel its remaining wagons.', ); } if (booking.loadedAt) { throw new BadRequestException( 'This booking is already fully loaded — there is nothing left to cancel.', ); } if (!booking.contractId) { throw new BadRequestException( 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', ); } 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 allocations = await this.dataSource .getRepository(WagonBookingAllocation) .createQueryBuilder('alloc') .innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id') .innerJoin( TrainSchedule, 'schedule', 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', { scheduleId: dto.scheduleId }, ) .where('alloc.booking_id = :bookingId', { bookingId }) .getMany(); const remaining = allocations.filter( (a) => a.status !== 'LOADED' && a.status !== 'DEPARTED', ); // A booking whose cargo never showed up at all (0 loaded) is cancelled the // same way — the gate that holds the train does not care whether loading // started, only that nothing is left unresolved. if (!allocations.length) { throw new BadRequestException( 'This booking has no wagons on this schedule — use the normal wagon cancellation flow.', ); } if (!remaining.length) { throw new BadRequestException( 'Every wagon of this booking is loaded — there is nothing to cancel.', ); } // Staff may cut a SUBSET of the never-loaded wagons (picked in the loading // modal) instead of the whole remainder. Anything already LOADED is // rejected rather than silently dropped: the operator believes they are // cancelling that wagon, and it is on the train. let target = remaining; if (dto.wagonAllocationIds?.length) { const wanted = new Set(dto.wagonAllocationIds); const known = new Set(allocations.map((a) => a.id)); const unknown = dto.wagonAllocationIds.filter((id) => !known.has(id)); if (unknown.length) { throw new BadRequestException( 'Some selected wagons are not allocated to this booking on this schedule.', ); } const loaded = allocations.filter((a) => wanted.has(a.id) && !remaining.includes(a)); if (loaded.length) { throw new BadRequestException( `${loaded.length} selected wagon(s) are already loaded and cannot be cancelled.`, ); } target = remaining.filter((a) => wanted.has(a.id)); } const cut = await this.resolveRequestedCut(booking, { wagonAllocationIds: target.map((r) => r.id), } as RequestWagonCancellationDto); if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut); const edrFault = !!dto.edrFault; const fee = edrFault ? null : await this.priceFee(booking, cut); const creditAmount = this.creditFor(booking, cut.wagons, cut.totalWagons); const row = await this.repo.create({ bookingId, wagonsCancelled: cut.wagons, weightTons: cut.weightTons, cancelledQuantities: cut.quantities, creditAmount, feeRateId: fee?.rates[0]?.id ?? null, feeAmount: fee?.amount ?? 0, feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB', status: 'FEE_PENDING', reason: dto.reason, fault: edrFault ? 'EDR' : 'CUSTOMER', requestedByUserId: userId ?? null, }); let current = row; if (fee && fee.amount > 0) { 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: fee.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`, quantity: cut.wagons, unitRate: fee.perWagon, amount: fee.amount, currency: fee.currency, metadata: { wagonCancellationId: row.id }, }, ], totalAmount: fee.amount, status: Freight.InvoiceStatus.Issued, }); current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row; } // The cut applies NOW — booking shrinks, allocations release, credit opens. // EDR fault (or a zero fee) settles the fee side immediately; a customer- // fault fee stays owed and stamps feePaidAt via onFeePaid when it settles. await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 }); // The booking now holds only loaded wagons — let the journey complete the // load (PAID → IN_TRANSIT, warehouse inventory, milestones). this.events.emit('booking.wagonsCancelledAtLoading', { bookingId, scheduleId: dto.scheduleId, userId: userId ?? null, }); this.notifyStaff( booking, 'Wagons cancelled at loading', `${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`, ); return this.mustFind(row.id); } /** * Whole-booking cut: take the cancelled booking OFF its train entirely — * schedule link, leftover wagon slots, window status — via the ops unassign * path (no "removed from train" notice: the customer cancelled it). A stale * link would keep showing the booking on the schedule AND poison every later * auto wagon allocation on that train (the whole-train re-plan rejects a * CANCELLED booking). Then re-run allocation so bookings held back by it * (e.g. the rebooked credit) get their wagons. */ private async detachFromSchedule(booking: Booking): Promise { const links = await this.dataSource .getRepository(TrainScheduleBooking) .find({ where: { bookingId: booking.id } }); for (const link of links) { try { await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, { notifyCustomer: false, }); await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId); } catch (err) { this.logger.error( `Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`, ); } } } // ── 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}).`, ); } // A consolidation-lapse row on an UNPAID booking carries no credit — the // customer never paid freight, so there is nothing to redeem. Book fresh. if (Number(row.creditAmount) <= 0) { throw new BadRequestException( 'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.', ); } // Customer-fault fee settles BEFORE the credit is redeemed. An at-loading // cut applies immediately and opens the credit while its invoice stays // open, so CREDIT_AVAILABLE alone does not mean the fee was paid — without // this the customer rebooks the wagons and never pays the cancellation // fee the notice already promised. EDR fault carries no fee and is // unaffected; onFeePaid stamps feePaidAt and the gate opens by itself. if (row.fault === 'CUSTOMER' && Number(row.feeAmount) > 0 && !row.feePaidAt) { throw new BadRequestException( `Pay the ${row.feeCurrency} ${Number(row.feeAmount).toFixed(2)} cancellation fee for ` + `${Math.ceil(Number(row.wagonsCancelled))} wagon(s) before rebooking this credit.`, ); } 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.'); } const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers); // Same currency as the source booking — the credit is in it. createDto.paymentCurrency = source.paymentCurrency ?? undefined; // An odd-20ft credit shares a wagon again on rebook. GL picks who — never // the auto-matcher (it could claim a partner behind GL's back), so the // create below runs with auto-consolidation off and the chosen partner is // linked once the booking exists and is PAID. const oddFt20 = this.creditFt20(row) % 2 === 1; let partner: Booking | null = null; if (oddFt20) { createDto.skipAutoConsolidation = true; // An odd credit always leaves a half-empty wagon, so GL names who fills // it. The candidate list is wide enough (any unpaired, unspent booking on // the day) that a partner is expected to exist. if (!dto.partnerBookingId) { throw new BadRequestException( 'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).', ); } partner = await this.loadRebookPartner( source, dto.partnerBookingId, dto.scheduledDate, ); // A dead partner cannot be paid where it stands — its cargo moves to a // fresh booking that can carry its own invoice and pay window. if (['EXPIRED', 'CANCELLED'].includes(partner.status)) { partner = await this.cloneDeadPartner( partner, dto.scheduledDate, userId, ); } } 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 }] }, // The freight was paid while the contract was live — the credit stays // redeemable even after the contract's validity lapses. { allowExpiredContract: true }, ); 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. // Its price IS the credit (already paid, in the source currency) — not a // fresh live-rate quote; a later cut of the rebooked booking credits from it. await this.dataSource.getRepository(Booking).update(newBookingId, { paymentStatus: 'PAID', status: 'PAID', totalAmount: Number(row.creditAmount), paymentCurrency: source.paymentCurrency, }); 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)}`, ); } if (partner) { // Corrections GL made to the partner's own containers while pairing — // scoped to that booking by the repository, so a stray id cannot touch // another booking's cargo. if (dto.partnerUnits?.length) { await this.bookingsRepository.patchContainerUnitsForBooking( partner.id, dto.partnerUnits, ); } // Consolidated rebook: never allocate the half-wagon booking alone. It // rides PAID and the batch engine settles the pair atomically once the // partner's own invoice is paid. await this.pairRebookedBooking(newBookingId, partner); } else { 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 }; } /** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */ private creditFt20(row: BookingWagonCancellation): number { return Object.entries(row.cancelledQuantities?.bySize ?? {}) .filter(([size]) => sizeFtOf(size) === 20) .reduce((sum, [, qty]) => sum + Number(qty || 0), 0); } /** * Partner candidates for rebooking an odd-20ft credit — what the GL rebook * form lists. Empty when the credit is even (no shared wagon) or spent. */ async rebookPartnerCandidates( cancellationId: string, scheduledDate: string, ): Promise< Array<{ id: string; reference: string; companyName: string | null; status: string; scheduledDate: string | null; ft20Quantity: number; units: Array<{ id: string; containerSize: string; containerNumber: string; sealNumber: string | null; vgmTons: number; }>; }> > { const row = await this.mustFind(cancellationId); if (row.status !== 'CREDIT_AVAILABLE') return []; if (this.creditFt20(row) % 2 === 0) return []; const source = await this.bookingsRepository.findById(row.bookingId); if (!source) return []; const rows = await this.bookingsRepository.findRebookConsolidationCandidates( source, new Date(scheduledDate), ); return rows.map((b) => ({ id: b.id, reference: b.reference, companyName: b.company?.name ?? null, status: b.status, scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null, ft20Quantity: (b.bookingContainers ?? []) .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0), // Editable while pairing — GL corrects these on the rebook form. units: (b.bookingContainers ?? []).flatMap((line) => (line.units ?? []).map((u) => ({ id: u.id, containerSize: line.containerType?.sizeFt ? `${line.containerType.sizeFt}ft` : '', containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? null, vgmTons: Number(u.vgmTons ?? 0), })), ), })); } /** The GL-picked partner, validated to actually fit the rebooked shared wagon. */ private async loadRebookPartner( source: Booking, partnerId: string, scheduledDate: string, ): Promise { const partner = await this.bookingsRepository.findByIdWithFiles(partnerId); if (!partner) { throw new NotFoundException(`Partner booking ${partnerId} not found.`); } if (partner.consolidationPartnerId) { throw new ConflictException( `Booking ${partner.reference} already shares a wagon with another booking.`, ); } // Mirrors findRebookConsolidationCandidates: a partner need not be a live // committed shipment. One that lost its slot or was called off still has // cargo to move, and the rebooked wagon is how it moves. if ( ![ 'SUBMITTED', 'PENDING_CONSOLIDATION', 'CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED', 'CANCELLED', ].includes(partner.status) ) { throw new BadRequestException( `Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`, ); } // A booking whose own credit was already rebooked elsewhere is spent. if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) { throw new BadRequestException( `Booking ${partner.reference} has already been rebooked from its cancellation credit.`, ); } if ( partner.originYardId !== source.originYardId || partner.destinationYardId !== source.destinationYardId || partner.tradeDirection !== source.tradeDirection ) { throw new BadRequestException( `Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`, ); } const eatDay = (d: Date | string) => new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) { throw new BadRequestException( `Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`, ); } const ft20 = (partner.bookingContainers ?? []) .filter((line) => Number(line.containerType?.sizeFt) === 20) .reduce((sum, line) => sum + Number(line.quantity || 0), 0); if (ft20 % 2 !== 1) { throw new BadRequestException( `Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`, ); } return partner; } /** * A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no * longer be paid: its pay window is gone and finalizing it issues nothing a * customer can settle, so pairing the PAID rebook with it strands the shared * wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook, * no payment_deadline — neither half could ever board). So the cargo is * cloned into a fresh booking under the same contract, which finalizes * normally into its own invoice and pay window; the dead booking stays dead. */ private async cloneDeadPartner( partner: Booking, scheduledDate: string, userId?: string, ): Promise { if (!partner.contractId) { throw new BadRequestException( `Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`, ); } const dto: CreateBookingUnderContractDto = { scheduledDate, paymentCurrency: partner.paymentCurrency ?? undefined, // GL already chose this pairing — the auto-matcher must not re-home the // clone behind their back (same reasoning as the rebooked side). skipAutoConsolidation: true, containers: (partner.bookingContainers ?? []).map((line) => { const units = line.units ?? []; return { containerSize: line.containerSize ?? undefined, quantity: Number(line.quantity), units: units.map((u) => ({ containerNumber: u.containerNumber, sealNumber: u.sealNumber ?? '', vgmTons: u.vgmTons, isHazardous: u.isHazardous, isReefer: u.isReefer, })), hazardousQuantity: Number(line.hazardousQuantity ?? 0), reeferQuantity: Number(line.reeferQuantity ?? 0), }; }) as CreateBookingUnderContractDto['containers'], }; const created = await this.contractBooking.createUnderContract( partner.contractId, dto, { id: userId ?? partner.createdByUserId ?? undefined }, { permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] }, // The dead partner's own contract may have lapsed while it sat expired; // its cargo is still the cargo GL picked to fill the shared wagon. { allowExpiredContract: true }, ); const clone = await this.bookingsRepository.findByIdWithFiles( created.booking.id, ); if (!clone) { throw new NotFoundException( `Replacement booking for ${partner.reference} could not be loaded.`, ); } this.notifyCustomer( partner, 'Replacement booking created', `${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`, clone.id, ); return clone; } /** * Link the rebooked (already PAID) booking with the GL-picked partner. A * parked partner is resumed the way pairConsolidation would resume it — * but only the partner: the rebooked side's PAID status must survive, so * the link is written directly. The paired event then runs the partner's * deferred contract finalize (invoice → pay window); the shared wagon * boards once that invoice is paid. */ private async pairRebookedBooking( newBookingId: string, partner: Booking, ): Promise { // ponytail: validate-then-link without a row lock — a concurrent claim in // this window loses silently; move to pairConsolidationIfUnpaired-style // locking if it ever bites. const fresh = await this.dataSource.getRepository(Booking).findOne({ where: { id: partner.id }, select: { id: true, consolidationPartnerId: true, status: true }, }); if (!fresh || fresh.consolidationPartnerId) { throw new ConflictException( `Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`, ); } if (fresh.status === 'PENDING_CONSOLIDATION') { await this.dataSource.getRepository(Booking).update(partner.id, { status: partner.consolidationResumeStatus ?? 'SUBMITTED', consolidationResumeStatus: null, }); } await this.bookingsRepository.linkConsolidationPartners( newBookingId, partner.id, ); this.events.emit('booking.consolidation.paired', { bookingIds: [partner.id], }); this.notifyCustomer( partner, 'Consolidation partner found', `${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`, ); } // ── 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 = await this.wagonFootprint(booking); 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} × ${sizeFtOf(cut.containerSize)}ft — the booking only has ${live}.`, ); } bySize[cut.containerSize] = cut.quantity; wagons += cut.quantity * wagonsPerUnitForSize(sizeFtOf(cut.containerSize)); } wagons = round2(wagons); if (wagons > totalWagons) { throw new BadRequestException( `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } // 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--; } } } // Whole-booking cut takes the exact total, no ratio rounding. const weightShare = wagons >= totalWagons ? round3(Number(booking.cargoTotalWeightVgm)) : 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 } : {}) }, totalWagons, }; } // 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( `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } // Whole-booking cut: all cargo, exactly. Otherwise proportional sizing. // 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. const isFull = wagons >= totalWagons; let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons); const isPerItem = booking.bulkTotalWeightTons != null; tons = isPerItem && !isFull ? 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 }, totalWagons }; } /** * 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( `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } const isFull = wagons >= totalWagons; if (booking.freightType !== 'CONTAINER') { const allocated = allocations.reduce( (s, a) => s + Number(a.allocatedWeightTons || 0), 0, ); // Whole-booking cut takes the exact total; partial takes the wagons' // allocated tonnage (ratio fallback when nothing is allocated yet). const tons = isFull ? round3(Number(booking.cargoTotalWeightVgm)) : allocated > 0 ? round3(allocated) : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { wagons, weightTons: tons, quantities: { bulkTons: tons, allocationIds }, totalWagons, }; } // 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 }, totalWagons, }; } /** * The booking's wagon footprint for cancellation pricing. * * `wagonsRequired` is a LIVE scheduling field: unassign clears it to NULL, so * a paid booking pulled off a train read 0 wagons and could not be cancelled * at all. `cancellationWagons` is stamped once at first allocation and never * cleared — read it first. A booking never allocated has neither, so size it * from the cargo the same way the scheduler would: TEU geometry for * containers, the customer's pinned count for NUMBER_OF_WAGONS bulk, tonnage * ÷ wagon capacity for PER_TON bulk. */ private async wagonFootprint(booking: Booking): Promise { const pinned = Number(booking.cancellationWagons ?? 0); if (pinned > 0) return round2(pinned); const stored = Number(booking.wagonsRequired ?? 0); if (stored > 0) return round2(stored); const requested = requestedBulkWagons(booking); if (requested > 0) return requested; // Cargo relations drive the sizing — reload when the caller passed a bare // booking (findById does not always hydrate them). const full = booking.bookingContainers || booking.cargoType ? booking : ((await this.dataSource.getRepository(Booking).findOne({ where: { id: booking.id }, relations: { bookingContainers: { containerType: true }, cargoType: { wagonTypes: true }, }, })) ?? booking); const capacities = (full.cargoType?.wagonTypes ?? []) .map((wt) => Number(wt.capacityTons)) .filter((c) => c > 0); const bulkCapacity = full.freightType === 'BULK' && capacities.length ? Math.max(...capacities) : undefined; return round2(wagonsRequiredForBooking(full, bulkCapacity)); } /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ private creditFor(booking: Booking, wagons: number, totalWagons: number): number { if (totalWagons <= 0) return 0; return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } /** * Price the cut off the LIVE per-wagon cancellation rates for the booking's * trade direction. Bulk bills the rate scoped to the booking's commodity × * cancelled wagons; a container cut bills each size at its own container * type's rate × the wagons that size occupies (two 20ft share one). A * booking owned by a shipping line prices off that line's rates only — * standard rates are never a fallback, matching booking pricing. */ private async priceFee(booking: Booking, cut: RequestedCut): Promise { const raw = await this.priceFeeInRateCurrency(booking, cut); // Bill in the booking's own currency (rates are configured in USD; a // non-USD booking converts) — same conversion booking pricing applies. const target = toCurrencyCode(booking.paymentCurrency); const from = toCurrencyCode(raw.currency); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { ...raw, amount: round2(raw.amount * fx), perWagon: round2(raw.perWagon * fx), currency: target, }; } private async priceFeeInRateCurrency( booking: Booking, cut: RequestedCut, ): Promise { const rates = await this.dataSource.getRepository(Rate).find({ where: { rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, rateUnit: 'PER_WAGON', status: 'LIVE', tradeDirection: booking.tradeDirection, shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(), }, order: { createdAt: 'DESC' }, }); const missing = (scope: string): BadRequestException => new BadRequestException( `No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`, ); if (booking.freightType !== 'CONTAINER') { const rate = rates.find( (r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId, ); if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`); const amount = round2(Number(rate.rateValue) * cut.wagons); return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] }; } // Container: split the cancelled wagons across sizes in proportion to the // wagon-space each size's units occupy, so the total always equals // cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut). const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0); const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(sizeFtOf(size)); const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0); if (!bySize.length || totalSpace <= 0) throw missing('containers'); const containerTypes = await this.dataSource.getRepository(ContainerType).find(); const used: Rate[] = []; let amount = 0; let currency = ''; for (const entry of bySize) { const [size] = entry; const sizeFt = sizeFtOf(size); const typeIds = new Set( containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id), ); const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId)); if (!rate) throw missing(`${sizeFt || '?'}ft containers`); currency = rate.currency; used.push(rate); amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace); } amount = round2(amount); return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used }; } /** * 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} × ${sizeFtOf(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(sizeFtOf(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(sizeFtOf(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 exceeds the cargo left on the booking.', ); } 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 this.recordAllocationRelease(manager, [alloc.id], bookingId, 'Containers cancelled from booking'); 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 this.recordAllocationRelease(manager, ids, bookingId, 'Wagons cancelled from booking'); await manager.getRepository(WagonBookingAllocation).delete(ids); } /** * BOOKING_CANCELLED history row for every physical wagon behind the released * allocations — resolved through the slot BEFORE the allocation rows go, one * query for the whole batch. Slots with no wagon pinned yet leave no row. */ private async recordAllocationRelease( manager: EntityManager, allocationIds: string[], bookingId: string, reason: string, ): Promise { if (!allocationIds.length) return; const rows: Array<{ allocationId: string; wagonId: string; wagonNumber: string; yardId: string | null; trainId: string | null; scheduleId: string | null; weightTons: string | null; loadType: string | null; }> = await manager.query( `SELECT a.id AS "allocationId", w.id AS "wagonId", w.wagon_number AS "wagonNumber", w.current_yard_id AS "yardId", w.train_id AS "trainId", w.current_train_schedule_id AS "scheduleId", a.allocated_weight_tons AS "weightTons", a.load_type AS "loadType" FROM freight.wagon_booking_allocations a JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.wagons w ON w.id = tsw.physical_wagon_id WHERE a.id = ANY($1::uuid[])`, [allocationIds], ); await this.wagonHistory.record( manager, rows.map((r) => ({ wagonId: r.wagonId, wagonNumber: r.wagonNumber, type: WagonEventType.BookingCancelled, fromYardId: r.yardId, trainId: r.trainId, trainScheduleId: r.scheduleId, bookingId, reason, metadata: { allocationId: r.allocationId, loadType: r.loadType, weightTons: r.weightTons == null ? null : Number(r.weightTons), }, })), ); } /** 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, overrides?: RebookContainerLineDto[], ): CreateBookingUnderContractDto { const dto: CreateBookingUnderContractDto = { scheduledDate }; const q = row.cancelledQuantities; if (q.bySize && Object.keys(q.bySize).length) { // Unit overrides may rename containers, change seals and VGM — but the // cancelled sizes and quantities are the contract of the credit: a size // not on the credit, or a wrong unit count, is rejected. const overrideBySize = new Map( (overrides ?? []).map((o) => [o.containerSize, o.units]), ); for (const size of overrideBySize.keys()) { if (!(size in q.bySize)) { throw new BadRequestException( `The credit has no ${size} containers — sizes and quantities must match the cancelled booking.`, ); } } 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.`, ); } const replacement = overrideBySize.get(size); if (replacement && replacement.length !== quantity) { throw new BadRequestException( `The credit covers exactly ${quantity} × ${size} — you entered ${replacement.length}. Quantities cannot change on a rebook.`, ); } return { containerSize: size, quantity, // Hazardous/reefer flags always ride from the snapshot (the cargo is // the same cargo); number/seal/VGM come from the override when given. units: sized.map((u, i) => ({ containerNumber: replacement?.[i]?.containerNumber ?? u.containerNumber, // A credit snapshot taken before seals were mandatory can carry // none; the booking service normalizes the blank back to null // rather than blocking the rebook of already-paid cargo. sealNumber: replacement ? (replacement[i]?.sealNumber ?? '') : (u.sealNumber ?? ''), vgmTons: replacement?.[i]?.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) }]; // NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create // path rejects the rebook outright without it. The count is not in the // quantities snapshot (which only carries tons) — it is the cancellation's // own wagonsCancelled, so every existing credit rebooks without a backfill. // Rounded UP: a fractional cut still needs a whole wagon to ride on, and // flooring 0.5 would send 0 into a check that demands >= 1. const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0)); if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons; 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: { permissionKeys: [FREIGHT_PERMS.bookings.getNotification] }, audience: NotificationAudience.BACKOFFICE, type: NotificationType.BOOKING_STATUS, title, body, // The portal path `/bookings/:id` used to be sent here, which 404s in the // dashboard. The staff view of these lives on the queue page. link: '/dashboard/wagon-cancellations', data: { bookingId: booking.id, reference: booking.reference }, }); } }