mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1603 lines
66 KiB
TypeScript
1603 lines
66 KiB
TypeScript
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 },
|
||
});
|
||
}
|
||
}
|