Files
edr-platform/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts
2026-08-07 06:59:31 +00:00

1125 lines
45 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 { Freight, NotificationAudience, NotificationType } from '@edr/types';
import { DataSource, EntityManager, In } 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 { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
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; the wagon flow requires the PER_WAGON
* unit so the fee scales with the cancelled wagon count.
*/
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;
}
/**
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
*
* 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,
@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 rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
return {
wagons: cut.wagons,
weightTons: cut.weightTons,
feePerWagon: Number(rate.rateValue),
feeAmount,
feeCurrency: rate.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 rate = await this.feeRate();
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
const creditAmount = this.creditFor(booking, cut.wagons);
const row = await this.repo.create({
bookingId,
wagonsCancelled: cut.wagons,
weightTons: cut.weightTons,
cancelledQuantities: cut.quantities,
creditAmount,
feeRateId: rate.id,
feeAmount,
feeCurrency: rate.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: rate.currency,
lines: [
{
chargeType: 'CANCELLATION_FEE',
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
quantity: cut.wagons,
unitRate: Number(rate.rateValue),
amount: feeAmount,
currency: rate.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));
await manager.getRepository(Booking).update(booking.id, {
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
isSplit: true,
preSplitQuantities,
} 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) {
this.notifyCustomer(
booking,
'Wagon cancellation confirmed',
`${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).`,
);
}
// ── 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);
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.
await this.dataSource.getRepository(Booking).update(newBookingId, {
paymentStatus: 'PAID',
status: 'PAID',
});
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(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
// cut only when the fee settles) so the wagons carrying them can be
// released from the schedule at request time and the portal can show
// which containers are leaving.
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
const units: CancelledUnitSnapshot[] = [];
let requested = 0;
for (const cut of dto.containers) {
requested += cut.quantity;
let need = cut.quantity;
const sizeLines = lines
.filter((l) => (l.containerSize ?? '') === cut.containerSize)
.sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
for (const line of sizeLines) {
if (need <= 0) break;
const us = await unitRepo.find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: need,
});
for (const u of us) {
units.push({
containerSize: cut.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? null,
vgmTons: Number(u.vgmTons),
isHazardous: u.isHazardous,
isReefer: u.isReefer,
});
need--;
}
}
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
return {
wagons,
weightTons: weightShare,
// 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(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
// 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.
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons);
const isPerItem = booking.bulkTotalWeightTons != null;
tons = isPerItem ? 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(
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
);
}
if (booking.freightType !== 'CONTAINER') {
const allocated = allocations.reduce(
(s, a) => s + Number(a.allocatedWeightTons || 0),
0,
);
const tons =
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));
}
private async feeRate(): Promise<Rate> {
const rate = await this.dataSource.getRepository(Rate).findOne({
where: {
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
rateUnit: 'PER_WAGON',
status: 'LIVE',
},
order: { createdAt: 'DESC' },
});
if (!rate) {
throw new BadRequestException(
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
);
}
return rate;
}
/**
* 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 no longer leaves any cargo.',
);
}
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: { allBackoffice: true },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title,
body,
link: `/bookings/${booking.id}`,
data: { bookingId: booking.id, reference: booking.reference },
});
}
}