Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts
2026-08-15 08:53:24 +00:00

1268 lines
52 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 { 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,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import { Booking } from './entities/booking.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import {
BookingWagonCancellation,
CancelledQuantities,
CancelledUnitSnapshot,
} from './entities/booking-wagon-cancellation.entity';
/**
* rates.rate_type of the cancellation fee — an existing rate-engine type
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
* configure it in the normal rates UI, 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';
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
export const WAGON_CANCEL_FEE_INVOICE_TYPE = 'WAGON_CANCEL_FEE';
const round2 = (n: number): number => Math.round(n * 100) / 100;
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
interface RequestedCut {
wagons: number;
weightTons: number;
quantities: CancelledQuantities;
}
/** 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);
const cut = await this.resolveRequestedCut(booking, dto);
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);
const open = await this.repo.findOpenForBooking(bookingId);
if (open) {
throw new ConflictException(
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
);
}
const cut = await this.resolveRequestedCut(booking, dto);
const 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' }))!;
}
// ── 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 any day while your contract is valid.`
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
);
}
this.logger.log(
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
);
}
/**
* 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}).`,
);
}
const source = await this.bookingsRepository.findById(row.bookingId);
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
if (!source.contractId) {
throw new BadRequestException('The original booking has no contract to rebook under.');
}
// Friendly pre-check; createUnderContract re-asserts inside its own guards.
if (
source.contractValidUntil &&
new Date(source.contractValidUntil).getTime() < Date.now()
) {
throw new BadRequestException(
'Contract validity has expired — ask EDR staff to extend the contract before rebooking.',
);
}
const createDto = this.buildRebookDto(row, dto.scheduledDate);
// 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 }] },
);
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} × ${cut.containerSize}ft — the booking only has ${live}.`,
);
}
bySize[cut.containerSize] = cut.quantity;
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
}
wagons = round2(wagons);
if (wagons > totalWagons) {
throw new BadRequestException(
`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(Number(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 = Number(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} × ${size}ft left, cannot cancel ${toDrop}.`,
);
}
for (const line of lines) {
if (remaining <= 0) break;
const qty = Number(line.quantity ?? 0);
const drop = Math.min(remaining, qty);
remaining -= drop;
const units = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: drop,
});
for (const u of units) {
snapshots.push({
containerSize: size,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
}
if (units.length < drop) {
throw new BadRequestException(
`Booking line ${line.id} has ${units.length} physical unit record(s) but ${drop} must be cancelled — units out of sync.`,
);
}
const droppedVgm = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
if (drop === qty) {
await manager.getRepository(BookingContainer).softDelete(line.id);
await manager
.getRepository(BookingContainerUnit)
.softDelete(units.map((u) => u.id));
continue;
}
await manager.getRepository(BookingContainerUnit).softDelete(units.map((u) => u.id));
const keptUnits = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
});
await manager.getRepository(BookingContainer).update(line.id, {
quantity: qty - drop,
wagonsRequired: round2((qty - drop) * wagonsPerUnitForSize(Number(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - droppedVgm),
hazardousQuantity: keptUnits.filter((u) => u.isHazardous).length,
reeferQuantity: keptUnits.filter((u) => u.isReefer).length,
});
}
}
return snapshots;
}
/**
* Release the cancelled wagons from the schedule at REQUEST time. Returns
* true when something was actually released (booking was on a train) — the
* caller then stamps `releasedAtRequest` so T2 skips its release step.
*/
private async releaseAtRequest(bookingId: string, cut: RequestedCut): Promise<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(Number(size))),
totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm),
hazardousQuantity: kept.filter((u) => u.isHazardous).length,
reeferQuantity: kept.filter((u) => u.isReefer).length,
});
}
if (cut.length !== wanted.length) {
throw new BadRequestException(
`Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`,
);
}
return cut;
}
private async reduceBulk(
manager: EntityManager,
booking: Booking,
tons: number,
): Promise<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,
): CreateBookingUnderContractDto {
const dto: CreateBookingUnderContractDto = { scheduledDate };
const q = row.cancelledQuantities;
if (q.bySize && Object.keys(q.bySize).length) {
const units = q.units ?? [];
dto.containers = Object.entries(q.bySize).map(([size, quantity]) => {
const sized = units.filter((u) => u.containerSize === size);
if (sized.length !== quantity) {
throw new BadRequestException(
`Credit is missing unit snapshots for size ${size} (${sized.length}/${quantity}) — contact EDR support.`,
);
}
return {
containerSize: size,
quantity,
units: sized.map((u) => ({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? undefined,
vgmTons: u.vgmTons,
isHazardous: u.isHazardous,
isReefer: u.isReefer,
})),
hazardousQuantity: sized.filter((u) => u.isHazardous).length,
reeferQuantity: sized.filter((u) => u.isReefer).length,
};
});
return dto;
}
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
return dto;
}
/**
* Carry the source booking's finished clearance onto the rebooked one: the
* cargo is already cleared; a new train date needs no new customs cycle.
* Seeds the standard milestone set idempotently, then mirrors every
* non-pending milestone status from the source by milestone code.
*/
private async copyClearanceState(source: Booking, newBookingId: string): Promise<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 },
});
}
}