Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts

1603 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<BookingWagonCancellation> {
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<BookingWagonCancellation> {
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<BookingWagonCancellation> {
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<BookingWagonCancellation> {
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<RequestedCut> {
if (booking.freightType === 'CONTAINER') {
const lines = await this.dataSource.getRepository(BookingContainer).find({
where: { bookingId: booking.id },
});
const bySize = new Map<string, number>();
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<void> {
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<void> {
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<void> {
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<BookingWagonCancellation> {
return this.mustFind(id);
}
// ── internals ──────────────────────────────────────────────────────────────
private async mustFind(id: string): Promise<BookingWagonCancellation> {
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<Booking> {
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<RequestedCut> {
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<string, number>();
for (const line of lines) {
const size = line.containerSize ?? '';
liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0));
}
const bySize: Record<string, number> = {};
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<RequestedCut> {
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<string, number> = {};
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<PricedFee> {
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<PricedFee> {
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<string, number>,
): Promise<CancelledUnitSnapshot[]> {
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<boolean> {
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<CancelledUnitSnapshot[]> {
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<void> {
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<void> {
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<void> {
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<string, number> }> {
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<string, number> = {};
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<void> {
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 },
});
}
}