diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index e54e9cdc7..c576f5a43 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -20,6 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; @@ -96,6 +97,8 @@ export class BookingWagonCancellationService { 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, @@ -185,7 +188,24 @@ export class BookingWagonCancellationService { totalAmount: feeAmount, status: Freight.InvoiceStatus.Issued, }); - const updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id }); + 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, @@ -195,7 +215,13 @@ export class BookingWagonCancellationService { return updated ?? row; } - /** Void a FEE_PENDING request: fee invoice cancelled, nothing was released. */ + /** + * 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') { @@ -203,6 +229,31 @@ export class BookingWagonCancellationService { `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' }))!; } @@ -224,12 +275,17 @@ export class BookingWagonCancellationService { // 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). + // (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 = await this.dataSource.getRepository(WagonBookingAllocation).count({ - where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) }, - }); - if (bookingNow?.loadedAt || movingNow > 0) { + 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.`, ); @@ -261,20 +317,24 @@ export class BookingWagonCancellationService { : await this.reduceContainerLines(manager, booking, quantities.bySize); quantities.units = units; droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); - await this.releaseContainerAllocations( - manager, - booking.id, - units.map((u) => u.containerNumber), - ); + 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); - await this.releaseBulkAllocations( - manager, - booking.id, - Number(row.wagonsCancelled), - quantities.allocationIds, - ); + if (!releasedEarly) { + await this.releaseBulkAllocations( + manager, + booking.id, + Number(row.wagonsCancelled), + quantities.allocationIds, + ); + } } // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME @@ -484,10 +544,48 @@ export class BookingWagonCancellationService { 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', ); } + // Snapshot the LIFO-picked physical units up front (read-only — cargo is + // cut only when the fee settles) so the wagons carrying them can be + // released from the schedule at request time and the portal can show + // which containers are leaving. + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const units: CancelledUnitSnapshot[] = []; + let requested = 0; + for (const cut of dto.containers) { + requested += cut.quantity; + let need = cut.quantity; + const sizeLines = lines + .filter((l) => (l.containerSize ?? '') === cut.containerSize) + .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); + for (const line of sizeLines) { + if (need <= 0) break; + const us = await unitRepo.find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: need, + }); + for (const u of us) { + units.push({ + containerSize: cut.containerSize, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + need--; + } + } + } const weightShare = round3( Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), ); - return { wagons, weightTons: weightShare, quantities: { bySize } }; + 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 @@ -707,6 +805,42 @@ export class BookingWagonCancellationService { 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 diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts index c9438b048..723ea5d1c 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -50,6 +50,13 @@ export interface CancelledQuantities { * newest-first for any id that no longer exists, e.g. after a re-batch). */ allocationIds?: string[]; + /** + * The wagon allocations were already released from the schedule at REQUEST + * time (policy: wagons free up immediately; the fee is still owed before the + * credit can be rebooked). Tells T2 to skip its release step so it never + * deletes wagons the batch engine re-assigned in between. + */ + releasedAtRequest?: boolean; } /** diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index 61f8251c9..f665f388b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -225,7 +225,13 @@ export function ExportClearanceStepper({ diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 88074735b..926637db2 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -357,7 +357,14 @@ export function PhasedClearanceActionPanel({ diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx index 0133b496e..00b3195a2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx @@ -79,7 +79,7 @@ const th = { color: "#9AA8B5", fontSize: 11 } as const; * Partial wagon cancellation on a PAID contract booking: request a cut (fee * previewed first), pay the cancellation fee, then rebook the freed credit * onto another shipment day — plus the booking's cancellation history. - * Wagons stay allocated until the fee invoice settles. + * Wagons leave the schedule at request time; the fee settles the credit. */ export function WagonCancellationCard({ booking, @@ -251,9 +251,10 @@ export function WagonCancellationCard({ {fmtMoney(openRow.feeAmount, openRow.feeCurrency)} - . Your wagons stay allocated until the fee is paid — pay it to - release them and unlock the rebooking credit, or withdraw the - request to keep the booking as it is. + . The cancelled wagons have left the train. Pay the fee to unlock + the rebooking credit, or withdraw the request to get the wagons + back — withdrawing works only while the train still has free space + for them.