fix wagon cncellation

This commit is contained in:
Marshal
2026-08-07 05:48:23 +00:00
parent 2321c9b893
commit eb1349e846
19 changed files with 2467 additions and 11 deletions

View File

@@ -31,6 +31,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
export const BookingDocReviewAlert = () =>
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
/** Staff wagon-cancellation history list (admin side). */
export const WagonCancellationView = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView);
/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */
export const WagonCancellationVoid = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid);
/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */
export const WagonCancellationRebook = () =>
BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook);
export const TrainSchedulingView = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.view);

View File

@@ -0,0 +1,65 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Partial wagon cancellation with rebooking credit.
*
* One row per cancellation cycle on a PAID booking: the customer asks to drop
* N wagons, pays a per-wagon cancellation fee (rates row
* rate_type = 'CANCELLATION_FEE', rate_unit = 'PER_WAGON'), and the dropped cargo becomes a
* rebookable credit. The credit is redeemed by creating a fresh booking
* through the normal under-contract create path (which re-checks contract
* validity and caps), immediately marked PAID — the freight was already paid
* on the original booking, only the fee is new money.
*
* cancelled_quantities carries what was cut, in the booking's own terms:
* `{ bulkTons }` for bulk, `{ bySize: { "20": 4, "40": 3 } }` for container.
* Container numbers are NOT stored here — they are recovered at rebook time
* from the unit rows the reduction soft-deleted (same hybrid pattern as
* RemainderPlacementService).
*/
export class BookingWagonCancellations3300000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_wagon_cancellations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id),
rebooked_booking_id uuid REFERENCES freight.bookings(id),
wagons_cancelled numeric(6,2) NOT NULL CHECK (wagons_cancelled > 0),
weight_tons numeric(12,3) NOT NULL DEFAULT 0,
cancelled_quantities jsonb NOT NULL,
credit_amount numeric(14,2) NOT NULL DEFAULT 0,
fee_rate_id uuid REFERENCES freight.rates(id),
fee_amount numeric(14,2) NOT NULL CHECK (fee_amount >= 0),
fee_currency varchar(8) NOT NULL DEFAULT 'ETB',
fee_invoice_id uuid REFERENCES freight.invoices(id),
fee_paid_at timestamptz,
status varchar(30) NOT NULL DEFAULT 'FEE_PENDING',
reason text,
requested_by_user_id uuid,
rebooked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// One open (fee-unpaid) cancellation per booking — closes the double-click
// race without app-level locking.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_open_wagon_cancellation_per_booking
ON freight.booking_wagon_cancellations (booking_id)
WHERE status = 'FEE_PENDING' AND deleted_at IS NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_booking
ON freight.booking_wagon_cancellations (booking_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bwc_status
ON freight.booking_wagon_cancellations (status)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`);
}
}

View File

@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
import { BookingsRepository } from "./bookings.repository";
import {
BookingWagonCancellationService,
WAGON_CANCEL_FEE_INVOICE_TYPE,
} from "./booking-wagon-cancellation.service";
import { Booking } from "./entities/booking.entity";
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
@Inject(forwardRef(() => BookingWagonCancellationService))
private readonly wagonCancellations: BookingWagonCancellationService,
) { }
/**
@@ -123,6 +129,11 @@ export class BookingInvoiceService {
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
await this.advanceBookingOnPayment(payload.sourceId);
break;
case WAGON_CANCEL_FEE_INVOICE_TYPE:
// Partial wagon cancellation: the fee settled — reduce the booking and
// release the cancelled wagons (T2 of the cancellation cycle).
await this.wagonCancellations.onFeePaid(payload.invoiceId);
break;
default:
this.logger.warn(
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,

View File

@@ -0,0 +1,778 @@
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 { 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(() => 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,
});
const updated = await this.repo.update(row.id, { feeInvoiceId: invoice.id });
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: fee invoice cancelled, nothing was released. */
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.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;
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) {
const units = await this.reduceContainerLines(manager, booking, quantities.bySize);
quantities.units = units;
droppedWeight = round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0));
await this.releaseContainerAllocations(
manager,
booking.id,
units.map((u) => u.containerNumber),
);
} else {
droppedWeight = Number(quantities.bulkTons ?? row.weightTons);
await this.reduceBulk(manager, booking, droppedWeight);
await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled));
}
// 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).',
);
}
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 (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.',
);
}
const weightShare = round3(
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
);
return { wagons, weightTons: weightShare, quantities: { bySize } };
}
// 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 } };
}
/** 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;
}
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, newest allocations first. */
private async releaseBulkAllocations(
manager: EntityManager,
bookingId: string,
wagons: number,
): Promise<void> {
const toFree = Math.round(wagons);
if (toFree <= 0) return;
const allocations = await manager.getRepository(WagonBookingAllocation).find({
where: { bookingId },
order: { createdAt: 'DESC' },
take: 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 },
});
}
}

View File

@@ -0,0 +1,85 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
export interface WagonCancellationListFilter {
status?: string[];
/** Booking reference / company name search (staff list). */
search?: string;
companyId?: string;
bookingId?: string;
from?: Date;
to?: Date;
page?: number;
pageSize?: number;
}
@Injectable()
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
constructor(
@InjectRepository(BookingWagonCancellation)
repository: Repository<BookingWagonCancellation>,
) {
super(repository);
}
/** The one open (fee-unpaid) cancellation of a booking, if any. */
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({
where: { bookingId, status: 'FEE_PENDING' },
});
}
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
return this.repository.findOne({ where: { feeInvoiceId } });
}
/** Paged history — staff see everything, customers are scoped by companyId. */
async list(
filter: WagonCancellationListFilter,
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
const page = Math.max(1, filter.page ?? 1);
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
const qb = this.baseQuery();
if (filter.bookingId) {
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
bookingId: filter.bookingId,
});
}
if (filter.companyId) {
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
}
if (filter.status?.length) {
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
}
if (filter.search) {
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
search: `%${filter.search}%`,
});
}
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
// Property path (not raw column): skip/take builds a distinct-id subquery
// and the ORDER BY must resolve inside it.
const [items, total] = await qb
.orderBy('bwc.createdAt', 'DESC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
return this.repository
.createQueryBuilder('bwc')
.leftJoinAndSelect('bwc.booking', 'booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
}
}

View File

@@ -21,7 +21,11 @@ import {
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { BookingStaff, BookingView } from '../../common/booking-guards';
import {
BookingStaff,
BookingView,
WagonCancellationView,
} from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import {
@@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
FilterWagonCancellationsDto,
RebookCancelledWagonsDto,
RequestWagonCancellationDto,
} from './dto/wagon-cancellation.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -153,6 +163,7 @@ export class BookingsController {
private readonly firstMileService: FirstMileService,
private readonly lastMileService: LastMileService,
private readonly userTradeAccessService: UserTradeAccessService,
private readonly wagonCancellationService: BookingWagonCancellationService,
) {}
@Post()
@@ -512,6 +523,134 @@ export class BookingsController {
return this.bookingsService.wagonAllocations(id);
}
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
// Customer endpoints are ownership-scoped (no portal permission keys); the
// staff history/void/rebook variants are permission-gated below.
@Post(':id/wagon-cancellations/preview')
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
async previewWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.previewCancellation(id, dto);
}
@Post(':id/wagon-cancellations')
@ApiOperation({
summary:
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
})
async requestWagonCancellation(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestWagonCancellationDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
}
@Get(':id/wagon-cancellations')
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
async listBookingWagonCancellations(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
const staff =
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
if (!staff) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
}
@Get('wagon-cancellations/my')
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
async listMyWagonCancellations(
@Query() filter: FilterWagonCancellationsDto,
@CurrentUser() user: TCurrentUser,
) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
if (!companyId) throw new ForbiddenException('No customer company for this user.');
return this.wagonCancellationService.list({
companyId,
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Get('wagon-cancellations/history')
@WagonCancellationView()
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
return this.wagonCancellationService.list({
status: filter.statuses,
search: filter.search,
from: filter.from ? new Date(filter.from) : undefined,
to: filter.to ? new Date(filter.to) : undefined,
page: filter.page,
pageSize: filter.pageSize,
});
}
@Post('wagon-cancellations/:cancellationId/withdraw')
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
async withdrawWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
);
return this.wagonCancellationService.withdraw(cancellationId);
}
@Post('wagon-cancellations/:cancellationId/rebook')
@ApiOperation({
summary:
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
})
async rebookWagonCancellation(
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
@Body() dto: RebookCancelledWagonsDto,
@CurrentUser() user: TCurrentUser,
) {
await this.assertWagonCancellationActor(
cancellationId,
user,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
);
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
}
/** Owner-or-staff gate shared by the per-cancellation actions. */
private async assertWagonCancellationActor(
cancellationId: string,
user: TCurrentUser,
staffPermission: string,
): Promise<void> {
if (hasFreightPermission(user, staffPermission)) return;
const row = await this.wagonCancellationService.findById(cancellationId);
const booking = await this.bookingsService.findById(row.bookingId);
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(

View File

@@ -38,6 +38,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
@@ -65,6 +68,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
BookingWagonCancellation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
@@ -109,6 +113,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationsRepository,
BookingWagonCancellationService,
],
exports: [
BookingsService,
@@ -120,6 +126,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ConsolidationService,
CustomerTruckService,
ContainerReceiptService,
BookingWagonCancellationService,
],
})
export class BookingsModule { }

View File

@@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
export class CancelContainerLineDto {
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
@IsString()
containerSize!: string;
@ApiProperty({ description: 'How many units of this size to cancel' })
@IsInt()
@Min(1)
quantity!: number;
}
export class RequestWagonCancellationDto {
@ApiPropertyOptional({
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
})
@IsOptional()
@IsNumber()
@Min(0.5)
wagons?: number;
@ApiPropertyOptional({
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
type: [CancelContainerLineDto],
})
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@ValidateNested({ each: true })
@Type(() => CancelContainerLineDto)
containers?: CancelContainerLineDto[];
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
export class RebookCancelledWagonsDto {
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
@IsDateString()
scheduledDate!: string;
}
export class FilterWagonCancellationsDto {
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
@IsOptional()
@IsArray()
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
statuses?: string[];
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
@IsOptional()
@IsString()
@MaxLength(120)
search?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ default: 10 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
}

View File

@@ -0,0 +1,123 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Invoice } from '../../billing/entities/invoice.entity';
import { Rate } from '../../rule-engine/entities/rate.entity';
import { Booking } from './booking.entity';
export const WAGON_CANCELLATION_STATUSES = [
// Requested; fee invoice open; wagons still allocated to the customer.
'FEE_PENDING',
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
'CREDIT_AVAILABLE',
// Credit redeemed into a new PAID booking (rebookedBookingId).
'REBOOKED',
// Customer/staff voided the request before paying the fee. Nothing changed.
'WITHDRAWN',
// Reserved for a future expiry policy; not set by code today.
'EXPIRED',
] as const;
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
/** Snapshot of one physical container unit cut by the cancellation. */
export interface CancelledUnitSnapshot {
containerSize: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
}
/** What the cancellation cut, in the booking's own quantity terms. */
export interface CancelledQuantities {
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
bulkTons?: number;
/** Container bookings: units cut per container size. */
bySize?: Record<string, number>;
/**
* Container bookings: the exact physical units cut, snapshotted at fee
* settlement. The rebook reconstructs the new booking from THESE — never
* from a soft-deleted-row scan, which could pick up units dropped by an
* unrelated batch split on the same booking.
*/
units?: CancelledUnitSnapshot[];
}
/**
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
* the state machine. The credit itself is not a wallet balance: redeeming it
* creates a real booking through the under-contract create path and marks it
* PAID (see BookingWagonCancellationService).
*/
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
@Index(['bookingId'])
@Index(['status'])
export class BookingWagonCancellation extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
rebookedBookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'rebooked_booking_id' })
rebookedBooking?: Booking | null;
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
wagonsCancelled!: number;
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
weightTons!: number;
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
cancelledQuantities!: CancelledQuantities;
/**
* The freight value of the cancelled part at the ORIGINAL booking's price —
* informational (shown to the customer as "credit worth"); no refund is ever
* issued from it, the credit is redeemed by rebooking.
*/
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
creditAmount!: number;
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
feeRateId?: string | null;
@ManyToOne(() => Rate, { nullable: true })
@JoinColumn({ name: 'fee_rate_id' })
feeRate?: Rate | null;
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
feeAmount!: number;
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
feeCurrency!: string;
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
feeInvoiceId?: string | null;
@ManyToOne(() => Invoice, { nullable: true })
@JoinColumn({ name: 'fee_invoice_id' })
feeInvoice?: Invoice | null;
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
feePaidAt?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
status!: string;
@Column({ name: 'reason', type: 'text', nullable: true })
reason?: string | null;
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
requestedByUserId?: string | null;
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
rebookedAt?: Date | null;
}

View File

@@ -68,6 +68,11 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
// Header alarm for the document-review deadline: its own key so only the
// position types that actually decide operation requests are alerted.
perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'),
// Partial wagon cancellation (paid bookings): staff-side keys. The customer
// portal needs none — customer actions are ownership-scoped on the API.
perm('a1000001-0001-4000-8000-000000000026', 'edr_freight_app:bookings:wagon_cancellation_view', 'View wagon cancellation history'),
perm('a1000001-0001-4000-8000-000000000027', 'edr_freight_app:bookings:wagon_cancellation_void', 'Void a pending wagon cancellation'),
perm('a1000001-0001-4000-8000-000000000028', 'edr_freight_app:bookings:wagon_cancellation_rebook', 'Rebook cancelled wagons for a customer'),
];
/**
@@ -431,6 +436,9 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
docReviewAlert: 'edr_freight_app:bookings:doc_review_alert',
wagonCancellationView: 'edr_freight_app:bookings:wagon_cancellation_view',
wagonCancellationVoid: 'edr_freight_app:bookings:wagon_cancellation_void',
wagonCancellationRebook: 'edr_freight_app:bookings:wagon_cancellation_rebook',
},
contracts: {
view: 'edr_freight_app:contracts:view',
@@ -825,6 +833,8 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
FREIGHT_PERMS.contracts.view,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
@@ -837,6 +847,7 @@ export const ROLE_PERMISSION_PRESETS = {
operationsOfficer: [
FREIGHT_PERMS.bookings.view,
FREIGHT_PERMS.bookings.operations,
FREIGHT_PERMS.bookings.wagonCancellationView,
// They are the ones who accept/reject operation requests, so they are the
// ones the doc-review countdown is for.
FREIGHT_PERMS.bookings.docReviewAlert,
@@ -877,7 +888,7 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.contracts.approveCeo,
...allRuleEngineViewKeys(),
],
finance: [FREIGHT_PERMS.bookings.view],
finance: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.wagonCancellationView],
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
// the general booking-request list (no bookings:view) — instead a dedicated
// clearance:view permission lists the clearance bookings. Reviews customer
@@ -920,6 +931,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.bookings.wagonCancellationView,
FREIGHT_PERMS.bookings.wagonCancellationVoid,
FREIGHT_PERMS.bookings.wagonCancellationRebook,
FREIGHT_PERMS.bookings.generateContract,
FREIGHT_PERMS.bookings.signStaff,
FREIGHT_PERMS.bookings.reviewDocuments,