mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1148 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
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,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Wallet,
|
||||
LifeBuoy,
|
||||
TrainFront,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
|
||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
},
|
||||
{
|
||||
label: "Wagon cancellations",
|
||||
href: "/dashboard/wagon-cancellations",
|
||||
icon: <XCircle />,
|
||||
permission: FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||
},
|
||||
// Operations hub: per-shipment clearance-document review for services
|
||||
// WITHOUT customs clearing (self-clearance) — bookings only.
|
||||
{
|
||||
@@ -897,6 +905,16 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route
|
||||
path="wagon-cancellations"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
|
||||
>
|
||||
<WagonCancellationsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="booking-requests/:id"
|
||||
element={<BookingRequestDetailPage />}
|
||||
|
||||
@@ -27,6 +27,10 @@ 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",
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Search, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { toDayString } from "@/hooks/useListControls";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string; company?: { name: string } };
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
}
|
||||
|
||||
interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const STATUS_CHIP: Record<
|
||||
WagonCancellationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
FEE_PENDING: { label: "Fee pending", color: "yellow" },
|
||||
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
|
||||
REBOOKED: { label: "Rebooked", color: "indigo" },
|
||||
WITHDRAWN: { label: "Withdrawn", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
|
||||
const STATUS_FILTER_OPTIONS = (
|
||||
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
|
||||
).map((s) => ({ value: s, label: STATUS_CHIP[s].label }));
|
||||
|
||||
function StatusChip({ status }: { status: WagonCancellationStatus }) {
|
||||
const chip = STATUS_CHIP[status] ?? { label: status, color: "gray" };
|
||||
return (
|
||||
<Badge
|
||||
color={chip.color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="md"
|
||||
tt="uppercase"
|
||||
fw={600}
|
||||
style={{ fontSize: "0.7rem", letterSpacing: "0.05em" }}
|
||||
>
|
||||
{chip.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatAmount(amount: number, currency: string): string {
|
||||
return `${currency} ${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff view of partial wagon cancellations: every slice of capacity a
|
||||
* customer gave back, its cancellation fee, and where the credit went
|
||||
* (rebooked, still available, expired, or the request was voided).
|
||||
*/
|
||||
export default function WagonCancellationsPage() {
|
||||
const { user } = useAuth();
|
||||
const canVoid = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
...(status ? { statuses: status } : {}),
|
||||
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||
...(from ? { from: toDayString(from) } : {}),
|
||||
...(to ? { to: toDayString(to) } : {}),
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, status, debouncedSearch, from, to],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["bookings", "wagon-cancellations", filter],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<WagonCancellationListResponse>(
|
||||
"/bookings/wagon-cancellations/history",
|
||||
{ params: filter },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
|
||||
const withdraw = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/bookings/wagon-cancellations/${id}/withdraw`),
|
||||
});
|
||||
|
||||
const columns: ColumnDef<WagonCancellation>[] = [
|
||||
{
|
||||
id: "requested",
|
||||
header: () => <span>Requested</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "booking",
|
||||
header: () => <span>Booking</span>,
|
||||
cell: ({ row }) => (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${row.original.bookingId}`}
|
||||
size="sm"
|
||||
fw={600}
|
||||
>
|
||||
{row.original.booking?.reference ?? row.original.bookingId}
|
||||
</Anchor>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
header: () => <span>Company</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{row.original.booking?.company?.name ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "wagons",
|
||||
header: () => <span>Wagons</span>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.wagonsCancelled}</Text>,
|
||||
},
|
||||
{
|
||||
id: "fee",
|
||||
header: () => <span>Fee</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.feeAmount, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "credit",
|
||||
header: () => <span>Credit</span>,
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(row.original.creditAmount, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <span>Status</span>,
|
||||
cell: ({ row }) => <StatusChip status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "rebookedAs",
|
||||
header: () => <span>Rebooked as</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (!r.rebookedBookingId) return <Text size="sm">—</Text>;
|
||||
return (
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/booking-requests/${r.rebookedBookingId}`}
|
||||
size="sm"
|
||||
>
|
||||
{r.rebookedBooking?.reference ?? r.rebookedBookingId}
|
||||
</Anchor>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
if (r.status !== "FEE_PENDING" || !canVoid) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setVoiding(r)}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Wagon cancellations"
|
||||
subtitle="Partial wagon cancellations — fees charged, credits held, and where each credit was rebooked"
|
||||
breadcrumbs={[
|
||||
{ label: "Bookings", href: "/dashboard/booking-requests" },
|
||||
{ label: "Wagon cancellations" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" p="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search booking ref or company…"
|
||||
leftSection={<Search size={15} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
w={260}
|
||||
radius="md"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
data={STATUS_FILTER_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
w={190}
|
||||
radius="md"
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="From"
|
||||
value={from}
|
||||
onChange={(v) => {
|
||||
setFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={to ?? undefined}
|
||||
clearable
|
||||
radius="md"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="To"
|
||||
value={to}
|
||||
onChange={(v) => {
|
||||
setTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={from ?? undefined}
|
||||
clearable
|
||||
radius="md"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setStatus(null);
|
||||
setSearch("");
|
||||
setFrom(null);
|
||||
setTo(null);
|
||||
resetPage();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(voiding)}
|
||||
onClose={() => setVoiding(null)}
|
||||
radius="md"
|
||||
title="Void this cancellation?"
|
||||
>
|
||||
{!voiding ? null : (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{voiding.booking?.reference ?? voiding.bookingId} ·{" "}
|
||||
{voiding.wagonsCancelled} wagon(s) · fee{" "}
|
||||
{formatAmount(voiding.feeAmount, voiding.feeCurrency)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
The pending fee is dropped and the wagons stay on the booking.
|
||||
Voiding can't be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setVoiding(null)}
|
||||
>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<XCircle size={15} />}
|
||||
loading={withdraw.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await withdraw.mutateAsync(voiding.id);
|
||||
toast.success("Cancellation voided");
|
||||
setVoiding(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Void
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { ShipmentDetailsCard } from "./components/ShipmentDetailsCard";
|
||||
import { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||
import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { WagonCancellationCard } from "./components/WagonCancellationCard";
|
||||
import { WagonsTab } from "./components/WagonsTab";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
@@ -325,6 +326,11 @@ export function ReadonlyBookingView({
|
||||
title="Consignment & Schedule"
|
||||
consignment
|
||||
/>
|
||||
{/* Renders only on PAID + paid + contract-backed bookings. */}
|
||||
<WagonCancellationCard
|
||||
booking={booking}
|
||||
onBookingUpdated={onBookingUpdated}
|
||||
/>
|
||||
<CompanyInfoCard booking={booking} />
|
||||
<SupportCard />
|
||||
</>
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | string | null;
|
||||
containerNumber?: string | null;
|
||||
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
|
||||
containerSize?: string | null;
|
||||
containerType?: {
|
||||
code: string;
|
||||
label?: string | null;
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Clock, CreditCard, TrainTrack } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
bookingsService,
|
||||
type RequestWagonCancellationPayload,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationPreview,
|
||||
} from "@/services/bookings.service";
|
||||
import { OperationDatePicker } from "@/pages/bookings/clearance";
|
||||
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
|
||||
// Same pill treatment as WagonsTab's STATUS_TONES so the page reads as one.
|
||||
const STATUS_TONES: Record<
|
||||
WagonCancellation["status"],
|
||||
{ bg: string; color: string; label: string }
|
||||
> = {
|
||||
FEE_PENDING: { bg: "#FFFBEB", color: "#92400E", label: "Fee pending" },
|
||||
CREDIT_AVAILABLE: { bg: "#EAF1FE", color: "#1E40AF", label: "Credit available" },
|
||||
REBOOKED: { bg: "#E8F5EF", color: "#0A6F4D", label: "Rebooked" },
|
||||
WITHDRAWN: { bg: "#F1F4F7", color: "#475569", label: "Withdrawn" },
|
||||
EXPIRED: { bg: "#FEF2F2", color: "#B91C1C", label: "Expired" },
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: WagonCancellation["status"] }) {
|
||||
const tone = STATUS_TONES[status] ?? STATUS_TONES.WITHDRAWN;
|
||||
return (
|
||||
<Text
|
||||
component="span"
|
||||
fz={11}
|
||||
fw={700}
|
||||
px={9}
|
||||
py={3}
|
||||
style={{ borderRadius: 999, backgroundColor: tone.bg, color: tone.color }}
|
||||
>
|
||||
{tone.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtMoney = (amount: number | string, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency}`;
|
||||
|
||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (
|
||||
error as { response?: { data?: { message?: string | string[] } } }
|
||||
)?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
|
||||
* previewed first), pay the cancellation fee, then rebook the freed credit
|
||||
* onto another shipment day — plus the booking's cancellation history.
|
||||
* Wagons stay allocated until the fee invoice settles.
|
||||
*/
|
||||
export function WagonCancellationCard({
|
||||
booking,
|
||||
onBookingUpdated,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onBookingUpdated?: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const status = booking.status as string;
|
||||
const eligible =
|
||||
status === "PAID" &&
|
||||
(booking.paymentStatus as string) === "PAID" &&
|
||||
!!booking.contractId;
|
||||
|
||||
const isBulk = booking.freightType === "BULK";
|
||||
const detail = booking as BookingDetail;
|
||||
// The entity field the API serializes on the detail read; not on the DTO type.
|
||||
const wagonsRequired = Number(
|
||||
(booking as { wagonsRequired?: number | string | null }).wagonsRequired ?? 0,
|
||||
);
|
||||
|
||||
// Live units per container size ("20"/"40"), summed across lines.
|
||||
const containerLines = useMemo(() => {
|
||||
const bySize = new Map<string, number>();
|
||||
for (const line of detail.bookingContainers ?? []) {
|
||||
const size =
|
||||
line.containerSize ??
|
||||
(line.containerType?.sizeFt != null
|
||||
? String(line.containerType.sizeFt)
|
||||
: null);
|
||||
if (!size) continue;
|
||||
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, quantity]) => ({
|
||||
containerSize,
|
||||
quantity,
|
||||
}));
|
||||
}, [detail.bookingContainers]);
|
||||
|
||||
const { data, refetch } = useQuery({
|
||||
...api.bookings.listWagonCancellations.queryOptions({
|
||||
input: { bookingId: booking.id },
|
||||
}),
|
||||
enabled: eligible,
|
||||
});
|
||||
// History includes rows where this booking is the rebooked TARGET — only
|
||||
// rows this booking opened itself can be paid/withdrawn/rebooked from here.
|
||||
const rows = data?.items ?? [];
|
||||
const ownRows = rows.filter((r) => r.bookingId === booking.id);
|
||||
const openRow = ownRows.find((r) => r.status === "FEE_PENDING");
|
||||
const creditRow = ownRows.find((r) => r.status === "CREDIT_AVAILABLE");
|
||||
|
||||
const feePay = useFeeInvoicePayment(booking.id);
|
||||
|
||||
// ── Request modal state ──
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [wagons, setWagons] = useState<number | string>(1);
|
||||
const [cancelBySize, setCancelBySize] = useState<Record<string, number>>({});
|
||||
const [reason, setReason] = useState("");
|
||||
const [preview, setPreview] = useState<WagonCancellationPreview | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setWagons(1);
|
||||
setCancelBySize({});
|
||||
setReason("");
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const requestPayload = (): RequestWagonCancellationPayload | null => {
|
||||
const trimmedReason = reason.trim();
|
||||
if (isBulk) {
|
||||
const n = Number(wagons);
|
||||
if (!n || n <= 0) return null;
|
||||
return { wagons: n, ...(trimmedReason ? { reason: trimmedReason } : {}) };
|
||||
}
|
||||
const containers = containerLines
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: cancelBySize[l.containerSize] ?? 0,
|
||||
}))
|
||||
.filter((c) => c.quantity > 0);
|
||||
if (!containers.length) return null;
|
||||
return { containers, ...(trimmedReason ? { reason: trimmedReason } : {}) };
|
||||
};
|
||||
const payload = requestPayload();
|
||||
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: (body: RequestWagonCancellationPayload) =>
|
||||
bookingsService.previewWagonCancellation(booking.id, body),
|
||||
onSuccess: setPreview,
|
||||
onError: (e) => {
|
||||
setPreview(null);
|
||||
toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again."));
|
||||
},
|
||||
});
|
||||
|
||||
const requestMutation = useMutation({
|
||||
mutationFn: (body: RequestWagonCancellationPayload) =>
|
||||
bookingsService.requestWagonCancellation(booking.id, body),
|
||||
onSuccess: () => {
|
||||
closeModal();
|
||||
toast.success(
|
||||
"Cancellation requested — pay the fee to release the wagons.",
|
||||
{ duration: 6000 },
|
||||
);
|
||||
void refetch();
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
apiErrorMessage(e, "Could not request the cancellation. Please try again."),
|
||||
),
|
||||
});
|
||||
|
||||
const withdrawMutation = useMutation({
|
||||
mutationFn: () => bookingsService.withdrawWagonCancellation(openRow!.id),
|
||||
onSuccess: () => {
|
||||
toast.success("Cancellation withdrawn — the fee invoice was voided.");
|
||||
void refetch();
|
||||
onBookingUpdated?.();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
apiErrorMessage(e, "Could not withdraw the cancellation. Please try again."),
|
||||
),
|
||||
});
|
||||
|
||||
const [rebookDate, setRebookDate] = useState("");
|
||||
const rebookMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.rebookWagonCancellation(creditRow!.id, {
|
||||
scheduledDate: rebookDate,
|
||||
}),
|
||||
onSuccess: ({ bookingId }) => {
|
||||
toast.success("Wagons rebooked — taking you to the new booking.", {
|
||||
duration: 6000,
|
||||
});
|
||||
navigate(`/bookings/${bookingId}`);
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||
});
|
||||
|
||||
if (!eligible) return null;
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<CardTitle>Wagon Cancellation</CardTitle>
|
||||
{!openRow && !creditRow && (
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<TrainTrack size={16} />}
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
Cancel wagons
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{openRow ? (
|
||||
<Stack gap="sm">
|
||||
<Alert color="yellow" radius="md" icon={<Clock size={18} />}>
|
||||
A cancellation of {Number(openRow.wagonsCancelled)} wagon(s) is
|
||||
awaiting its fee of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(openRow.feeAmount, openRow.feeCurrency)}
|
||||
</Text>
|
||||
. Your wagons stay allocated until the fee is paid — pay it to
|
||||
release them and unlock the rebooking credit, or withdraw the
|
||||
request to keep the booking as it is.
|
||||
</Alert>
|
||||
<Group gap={8}>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CreditCard size={16} />}
|
||||
onClick={feePay.open}
|
||||
>
|
||||
Pay cancellation fee
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
loading={withdrawMutation.isPending}
|
||||
onClick={() => withdrawMutation.mutate()}
|
||||
>
|
||||
Withdraw request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : creditRow ? (
|
||||
<Stack gap="sm">
|
||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||
{Number(creditRow.wagonsCancelled)} wagon(s) were released — a
|
||||
credit of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(creditRow.creditAmount, booking.paymentCurrency)}
|
||||
</Text>{" "}
|
||||
is available. Pick a shipment day to rebook them as a new paid
|
||||
booking (no further payment needed).
|
||||
</Alert>
|
||||
<OperationDatePicker
|
||||
bookingId={booking.id}
|
||||
value={rebookDate}
|
||||
onChange={setRebookDate}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
disabled={!rebookDate}
|
||||
loading={rebookMutation.isPending}
|
||||
onClick={() => rebookMutation.mutate()}
|
||||
>
|
||||
Rebook wagons
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text fz={13} c="#475569">
|
||||
Need fewer wagons than you paid for? Cancel part of this booking for
|
||||
a per-wagon fee — the freed freight amount becomes a credit you can
|
||||
rebook onto another shipment day.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Box style={{ overflowX: "auto" }} mt="md">
|
||||
<Table verticalSpacing={6} horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={th}>Date</Table.Th>
|
||||
<Table.Th style={th}>Wagons</Table.Th>
|
||||
<Table.Th style={th}>Fee</Table.Th>
|
||||
<Table.Th style={th}>Status</Table.Th>
|
||||
<Table.Th style={th}>Rebooked as</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{fmtDate(r.createdAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} fw={700} c="#10202F">
|
||||
{Number(r.wagonsCancelled)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz={12.5} c="#475569">
|
||||
{fmtMoney(r.feeAmount, r.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<StatusPill status={r.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{r.rebookedBookingId ? (
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/bookings/${r.rebookedBookingId}`}
|
||||
fz={12.5}
|
||||
fw={700}
|
||||
c="#0A6F4D"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{r.rebookedBooking?.reference ?? "View booking"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fz={12.5} c="#9AA8B5">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={feePay.modalOpen}
|
||||
onClose={feePay.close}
|
||||
amountLabel={
|
||||
openRow ? fmtMoney(openRow.feeAmount, openRow.feeCurrency) : undefined
|
||||
}
|
||||
currency={openRow?.feeCurrency}
|
||||
processing={feePay.processing}
|
||||
error={feePay.error}
|
||||
otp={feePay.otp}
|
||||
bill={feePay.bill}
|
||||
onConfirm={feePay.confirm}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={modalOpen}
|
||||
onClose={closeModal}
|
||||
title={
|
||||
<Text fw={800} fz={18} c="#10202F">
|
||||
Cancel wagons
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
radius={16}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="#475569">
|
||||
Choose how much of booking{" "}
|
||||
<Text span fw={700} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>{" "}
|
||||
to cancel. A per-wagon fee applies; once it's paid the wagons
|
||||
are released and the freed amount becomes a rebooking credit. At
|
||||
least one wagon must remain — to cancel everything, cancel the
|
||||
whole booking instead.
|
||||
</Text>
|
||||
|
||||
{isBulk ? (
|
||||
<NumberInput
|
||||
label="Wagons to cancel"
|
||||
min={1}
|
||||
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
|
||||
allowDecimal={false}
|
||||
value={wagons}
|
||||
onChange={(v) => {
|
||||
setWagons(v);
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
containerLines.map((line) => (
|
||||
<NumberInput
|
||||
key={line.containerSize}
|
||||
label={`${line.containerSize}ft containers to cancel`}
|
||||
description={`${line.quantity} on this booking`}
|
||||
min={0}
|
||||
max={line.quantity}
|
||||
allowDecimal={false}
|
||||
value={cancelBySize[line.containerSize] ?? 0}
|
||||
onChange={(v) => {
|
||||
setCancelBySize((prev) => ({
|
||||
...prev,
|
||||
[line.containerSize]: Number(v) || 0,
|
||||
}));
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
placeholder="Why are these wagons no longer needed?"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{preview && (
|
||||
<Alert color="blue" radius="md">
|
||||
<Text fz={13}>
|
||||
Cancelling{" "}
|
||||
<Text span fw={700}>
|
||||
{preview.wagons} wagon(s)
|
||||
</Text>{" "}
|
||||
(~{preview.weightTons} t) costs a fee of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(preview.feeAmount, preview.feeCurrency)}
|
||||
</Text>{" "}
|
||||
({fmtMoney(preview.feePerWagon, preview.feeCurrency)} per
|
||||
wagon) and frees a rebooking credit of{" "}
|
||||
<Text span fw={700}>
|
||||
{fmtMoney(preview.creditAmount, booking.paymentCurrency)}
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap={8}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={10}
|
||||
disabled={!payload}
|
||||
loading={previewMutation.isPending}
|
||||
onClick={() => payload && previewMutation.mutate(payload)}
|
||||
>
|
||||
Calculate fee
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius={10}
|
||||
disabled={!payload}
|
||||
loading={requestMutation.isPending}
|
||||
onClick={() => payload && requestMutation.mutate(payload)}
|
||||
>
|
||||
Request cancellation
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -3,18 +3,22 @@ import { useState } from "react";
|
||||
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { type PaymentMethod } from "@/services/payments.service";
|
||||
import { invoicesService } from "@/services/invoices.service";
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { isPayable } from "@/pages/billing/invoice-ui";
|
||||
|
||||
/** Fee invoice opened by a partial wagon cancellation (see WagonCancellationCard). */
|
||||
export const WAGON_CANCEL_FEE_INVOICE_TYPE = "WAGON_CANCEL_FEE";
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
* Core of the booking payment flows: resolves the booking's invoices (shared
|
||||
* query/key with BookingPaymentPanel, so they share that cache), picks the one
|
||||
* matching `match`, and charges it through the ownership-checked portal route —
|
||||
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
function useBookingInvoicePayment(
|
||||
bookingId: string,
|
||||
match: (invoice: PortalInvoice) => boolean,
|
||||
) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [noInvoice, setNoInvoice] = useState(false);
|
||||
|
||||
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
|
||||
queryKey: ["booking-invoices", bookingId],
|
||||
queryFn: () => invoicesService.listForSource("booking", bookingId),
|
||||
});
|
||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
||||
const payableInvoiceId = invoices.find(match)?.id;
|
||||
|
||||
const flow = useInvoicePayment();
|
||||
|
||||
@@ -56,3 +60,28 @@ export function useBookingPayment(bookingId: string) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared payment flow for a single booking: opens the method modal, fires
|
||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
||||
* behaves identically everywhere.
|
||||
*/
|
||||
export function useBookingPayment(bookingId: string) {
|
||||
return useBookingInvoicePayment(bookingId, (inv) => isPayable(inv.status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same flow, but targets the booking's payable wagon-cancellation FEE invoice
|
||||
* (type WAGON_CANCEL_FEE) — the freight invoice is already paid on these
|
||||
* bookings, so the generic "first payable" pick would work today, but pinning
|
||||
* the type keeps the two buttons from ever racing over the same invoice.
|
||||
*/
|
||||
export function useFeeInvoicePayment(bookingId: string) {
|
||||
return useBookingInvoicePayment(
|
||||
bookingId,
|
||||
(inv) => inv.type === WAGON_CANCEL_FEE_INVOICE_TYPE && isPayable(inv.status),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ function mapBookingToShipmentValues(
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
||||
values.containers = (sizes.length ? sizes : ["20ft", "40ft"]).map(lineFor);
|
||||
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
|
||||
} else {
|
||||
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||
const amount = Number(b.cargoTotalWeightVgm ?? 0);
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
GeneratePriceResponse,
|
||||
type MyBookingWindow,
|
||||
SubmitBookingResponse,
|
||||
type WagonCancellationListFilter,
|
||||
type WagonCancellationListResponse,
|
||||
} from "./bookings.service";
|
||||
import {
|
||||
contractsService,
|
||||
@@ -483,6 +485,22 @@ export const api = {
|
||||
bookingsService.getExportTrains(bookingId, date, cargo),
|
||||
),
|
||||
|
||||
listWagonCancellations: endpoint<
|
||||
{ bookingId: string },
|
||||
WagonCancellationListResponse
|
||||
>("bookings", "listWagonCancellations", ({ bookingId }) =>
|
||||
bookingsService.listWagonCancellations(bookingId),
|
||||
),
|
||||
|
||||
listMyWagonCancellations: endpoint<
|
||||
WagonCancellationListFilter | void,
|
||||
WagonCancellationListResponse
|
||||
>(
|
||||
"bookings",
|
||||
"listMyWagonCancellations",
|
||||
bookingsService.listMyWagonCancellations,
|
||||
),
|
||||
|
||||
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||
"train-scheduling",
|
||||
"myBookingWindows",
|
||||
|
||||
@@ -223,6 +223,70 @@ export interface BookingWagonAllocation {
|
||||
containers: BookingWagonContainer[];
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ──────────────────────────────
|
||||
|
||||
export type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
/** One partial-cancellation ledger row of a paid booking. */
|
||||
export interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
/** What was cut: bulk tons, or container units per size (ft). */
|
||||
cancelledQuantities: { bulkTons?: number; bySize?: Record<string, number> };
|
||||
/** Rebooking credit — the cancelled share of the original freight price. */
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: { id: string; reference: string } | null;
|
||||
rebookedBooking?: { id: string; reference: string } | null;
|
||||
}
|
||||
|
||||
/** Fee/credit preview of a partial wagon cancellation (no writes). */
|
||||
export interface WagonCancellationPreview {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
feePerWagon: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
creditAmount: number;
|
||||
}
|
||||
|
||||
export interface RequestWagonCancellationPayload {
|
||||
/** BULK bookings: number of wagons to cancel (tons derived proportionally). */
|
||||
wagons?: number;
|
||||
/** CONTAINER bookings: units to cancel per size ("20"/"40", as stored on the line). */
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WagonCancellationListFilter {
|
||||
statuses?: string[];
|
||||
search?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const bookingsService = {
|
||||
list: async (
|
||||
filter: BookingListFilter | void = {},
|
||||
@@ -597,6 +661,73 @@ export const bookingsService = {
|
||||
return (data.data ?? data) as BookingWagonAllocation[];
|
||||
},
|
||||
|
||||
// ── Partial wagon cancellation ──
|
||||
/** Fee/credit preview for the confirm dialog — same math as the request, no writes. */
|
||||
previewWagonCancellation: async (
|
||||
id: string,
|
||||
payload: RequestWagonCancellationPayload,
|
||||
): Promise<WagonCancellationPreview> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/wagon-cancellations/preview`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Open a cancellation: issues the fee invoice; wagons release once the fee settles. */
|
||||
requestWagonCancellation: async (
|
||||
id: string,
|
||||
payload: RequestWagonCancellationPayload,
|
||||
): Promise<WagonCancellation> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/wagon-cancellations`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Cancellation history of one booking (as source and as rebooked target). */
|
||||
listWagonCancellations: async (
|
||||
bookingId: string,
|
||||
): Promise<WagonCancellationListResponse> => {
|
||||
const { data } = await client.get(
|
||||
`/api/bookings/${bookingId}/wagon-cancellations`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** The signed-in customer's wagon cancellations (paginated, filterable). */
|
||||
listMyWagonCancellations: async (
|
||||
filter: WagonCancellationListFilter | void = {},
|
||||
): Promise<WagonCancellationListResponse> => {
|
||||
const { data } = await client.get("/api/bookings/wagon-cancellations/my", {
|
||||
params: filter,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Void a FEE_PENDING request — the fee invoice is cancelled, nothing was released. */
|
||||
withdrawWagonCancellation: async (
|
||||
cancellationId: string,
|
||||
): Promise<WagonCancellation> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/wagon-cancellations/${cancellationId}/withdraw`,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Rebook a CREDIT_AVAILABLE cancellation onto a shipment day → new PAID booking. */
|
||||
rebookWagonCancellation: async (
|
||||
cancellationId: string,
|
||||
payload: { scheduledDate: string },
|
||||
): Promise<{ cancellation: WagonCancellation; bookingId: string }> => {
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/wagon-cancellations/${cancellationId}/rebook`,
|
||||
payload,
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||
|
||||
Reference in New Issue
Block a user