mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
fix wagon cncellation
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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})`,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user