import { BadRequestException, ConflictException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { 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 { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.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 { 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'; 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 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. */ @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, ) {} // ── 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: this.creditFor(booking, Number(booking.wagonsRequired ?? 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), }; } 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); 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(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' }))!; } // ── 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', this.creditFor(booking, Number(booking.wagonsRequired ?? 0)), reason ?? 'Consolidated pair cancelled', userId, ); if (partnerPaid) { await this.openConsolidationBreak( partner, 'floor', this.creditFor(partner, Number(partner.wagonsRequired ?? 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, }); 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, { wagons: Number(booking.wagonsRequired ?? 0), } 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', this.creditFor(booking, Number(booking.wagonsRequired ?? 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, }); 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)}`, ); } } // ── 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)); // 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. const wagonsLeft = round2( Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), ); const isFull = wagonsLeft <= 0; await manager.getRepository(Booking).update(booking.id, { wagonsRequired: Math.max(0, wagonsLeft), 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', 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.`, ); } this.logger.log( `Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`, ); } /** * 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.', ); } 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; 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)}`, ); } 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} × ${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 } : {}) }, }; } // 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 } }; } /** * 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 }, }; } // 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)); } /** * 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; ETB // bookings pay ETB) — same USD→ETB conversion booking pricing applies. const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; 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 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, 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, sealNumber: replacement ? (replacement[i]?.sealNumber ?? undefined) : (u.sealNumber ?? undefined), 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) }]; 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 }, }); } }