mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +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 = () =>
|
export const BookingDocReviewAlert = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
|
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 = () =>
|
export const TrainSchedulingView = () =>
|
||||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
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 { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||||
import { BookingsRepository } from "./bookings.repository";
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
|
import {
|
||||||
|
BookingWagonCancellationService,
|
||||||
|
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||||
|
} from "./booking-wagon-cancellation.service";
|
||||||
import { Booking } from "./entities/booking.entity";
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
|
||||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||||
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
|
|||||||
private readonly firstMile: FirstMileService,
|
private readonly firstMile: FirstMileService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
private readonly bookingBatch: 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.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
|
||||||
await this.advanceBookingOnPayment(payload.sourceId);
|
await this.advanceBookingOnPayment(payload.sourceId);
|
||||||
break;
|
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:
|
default:
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
`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 { CurrentUser } from '@edr/api-common';
|
||||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
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 { 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 { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
|
|||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.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 {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
@@ -153,6 +163,7 @@ export class BookingsController {
|
|||||||
private readonly firstMileService: FirstMileService,
|
private readonly firstMileService: FirstMileService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly userTradeAccessService: UserTradeAccessService,
|
private readonly userTradeAccessService: UserTradeAccessService,
|
||||||
|
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -512,6 +523,134 @@ export class BookingsController {
|
|||||||
return this.bookingsService.wagonAllocations(id);
|
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')
|
@Get(':id/customer-trucks')
|
||||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||||
async listCustomerTrucks(
|
async listCustomerTrucks(
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.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 { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
@@ -65,6 +68,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
BookingContainerAllocation,
|
BookingContainerAllocation,
|
||||||
|
BookingWagonCancellation,
|
||||||
CustomerTruckAssignment,
|
CustomerTruckAssignment,
|
||||||
CustomerTruckContainer,
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
@@ -109,6 +113,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
CustomerTruckAssignmentsRepository,
|
CustomerTruckAssignmentsRepository,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
|
BookingWagonCancellationsRepository,
|
||||||
|
BookingWagonCancellationService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
@@ -120,6 +126,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
CustomerTruckService,
|
CustomerTruckService,
|
||||||
ContainerReceiptService,
|
ContainerReceiptService,
|
||||||
|
BookingWagonCancellationService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BookingsModule { }
|
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
|
// Header alarm for the document-review deadline: its own key so only the
|
||||||
// position types that actually decide operation requests are alerted.
|
// 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'),
|
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',
|
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
|
||||||
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
||||||
docReviewAlert: 'edr_freight_app:bookings:doc_review_alert',
|
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: {
|
contracts: {
|
||||||
view: 'edr_freight_app:contracts:view',
|
view: 'edr_freight_app:contracts:view',
|
||||||
@@ -825,6 +833,8 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||||
FREIGHT_PERMS.contracts.view,
|
FREIGHT_PERMS.contracts.view,
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||||
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||||
@@ -837,6 +847,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
operationsOfficer: [
|
operationsOfficer: [
|
||||||
FREIGHT_PERMS.bookings.view,
|
FREIGHT_PERMS.bookings.view,
|
||||||
FREIGHT_PERMS.bookings.operations,
|
FREIGHT_PERMS.bookings.operations,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
// They are the ones who accept/reject operation requests, so they are the
|
// They are the ones who accept/reject operation requests, so they are the
|
||||||
// ones the doc-review countdown is for.
|
// ones the doc-review countdown is for.
|
||||||
FREIGHT_PERMS.bookings.docReviewAlert,
|
FREIGHT_PERMS.bookings.docReviewAlert,
|
||||||
@@ -877,7 +888,7 @@ export const ROLE_PERMISSION_PRESETS = {
|
|||||||
FREIGHT_PERMS.contracts.approveCeo,
|
FREIGHT_PERMS.contracts.approveCeo,
|
||||||
...allRuleEngineViewKeys(),
|
...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
|
// Global Logistics: manages ONLY the customs-clearance queue. Scoped out of
|
||||||
// the general booking-request list (no bookings:view) — instead a dedicated
|
// the general booking-request list (no bookings:view) — instead a dedicated
|
||||||
// clearance:view permission lists the clearance bookings. Reviews customer
|
// 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.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.rejectApproval,
|
FREIGHT_PERMS.bookings.rejectApproval,
|
||||||
FREIGHT_PERMS.bookings.cancel,
|
FREIGHT_PERMS.bookings.cancel,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationView,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||||
|
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||||
FREIGHT_PERMS.bookings.generateContract,
|
FREIGHT_PERMS.bookings.generateContract,
|
||||||
FREIGHT_PERMS.bookings.signStaff,
|
FREIGHT_PERMS.bookings.signStaff,
|
||||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
TrainFront,
|
TrainFront,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
|
|||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
|
import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage";
|
||||||
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
|
||||||
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
|
||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
@@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
permission: FREIGHT_PERMS.bookings.view,
|
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
|
// Operations hub: per-shipment clearance-document review for services
|
||||||
// WITHOUT customs clearing (self-clearance) — bookings only.
|
// WITHOUT customs clearing (self-clearance) — bookings only.
|
||||||
{
|
{
|
||||||
@@ -897,6 +905,16 @@ const App = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||||
|
<Route
|
||||||
|
path="wagon-cancellations"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.bookings.wagonCancellationView}
|
||||||
|
>
|
||||||
|
<WagonCancellationsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id"
|
path="booking-requests/:id"
|
||||||
element={<BookingRequestDetailPage />}
|
element={<BookingRequestDetailPage />}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const FREIGHT_PERMS = {
|
|||||||
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||||
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
|
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: {
|
contracts: {
|
||||||
view: "edr_freight_app:contracts:view",
|
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 { ShipmentTrackingCard } from "./components/ShipmentTrackingCard";
|
||||||
import { StatusHero } from "./components/StatusHero";
|
import { StatusHero } from "./components/StatusHero";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
|
import { WagonCancellationCard } from "./components/WagonCancellationCard";
|
||||||
import { WagonsTab } from "./components/WagonsTab";
|
import { WagonsTab } from "./components/WagonsTab";
|
||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
@@ -325,6 +326,11 @@ export function ReadonlyBookingView({
|
|||||||
title="Consignment & Schedule"
|
title="Consignment & Schedule"
|
||||||
consignment
|
consignment
|
||||||
/>
|
/>
|
||||||
|
{/* Renders only on PAID + paid + contract-backed bookings. */}
|
||||||
|
<WagonCancellationCard
|
||||||
|
booking={booking}
|
||||||
|
onBookingUpdated={onBookingUpdated}
|
||||||
|
/>
|
||||||
<CompanyInfoCard booking={booking} />
|
<CompanyInfoCard booking={booking} />
|
||||||
<SupportCard />
|
<SupportCard />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface BookingContainerLineDetail {
|
|||||||
isOverweight?: boolean;
|
isOverweight?: boolean;
|
||||||
overweightExcessTons?: number | string | null;
|
overweightExcessTons?: number | string | null;
|
||||||
containerNumber?: string | null;
|
containerNumber?: string | null;
|
||||||
|
/** Size (ft) as stored on the line ("20"/"40") — the wagon-cancellation key. */
|
||||||
|
containerSize?: string | null;
|
||||||
containerType?: {
|
containerType?: {
|
||||||
code: string;
|
code: string;
|
||||||
label?: string | null;
|
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 { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import { type PaymentMethod } from "@/services/payments.service";
|
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";
|
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
|
* Core of the booking payment flows: resolves the booking's invoices (shared
|
||||||
* POST /billing/my-invoices/:id/pay for the booking's currently payable
|
* query/key with BookingPaymentPanel, so they share that cache), picks the one
|
||||||
* invoice, and redirects the browser to the provider (or, for CAC Bank, an
|
* matching `match`, and charges it through the ownership-checked portal route —
|
||||||
* OTP debit with no redirect, collects the SMS'd code in the modal). Reused by
|
* redirect vs CAC Bank OTP handled by `useInvoicePayment`.
|
||||||
* the booking detail page, the booking list, and the home page so "Pay now"
|
|
||||||
* behaves identically everywhere.
|
|
||||||
*/
|
*/
|
||||||
export function useBookingPayment(bookingId: string) {
|
function useBookingInvoicePayment(
|
||||||
|
bookingId: string,
|
||||||
|
match: (invoice: PortalInvoice) => boolean,
|
||||||
|
) {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [noInvoice, setNoInvoice] = useState(false);
|
const [noInvoice, setNoInvoice] = useState(false);
|
||||||
|
|
||||||
@@ -22,7 +26,7 @@ export function useBookingPayment(bookingId: string) {
|
|||||||
queryKey: ["booking-invoices", bookingId],
|
queryKey: ["booking-invoices", bookingId],
|
||||||
queryFn: () => invoicesService.listForSource("booking", bookingId),
|
queryFn: () => invoicesService.listForSource("booking", bookingId),
|
||||||
});
|
});
|
||||||
const payableInvoiceId = invoices.find((inv) => isPayable(inv.status))?.id;
|
const payableInvoiceId = invoices.find(match)?.id;
|
||||||
|
|
||||||
const flow = useInvoicePayment();
|
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 ?? [])
|
const sizes = (contract.cargoScope ?? [])
|
||||||
.map((s) => s.containerSize)
|
.map((s) => s.containerSize)
|
||||||
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
|
.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 {
|
} else {
|
||||||
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
|
||||||
const amount = Number(b.cargoTotalWeightVgm ?? 0);
|
const amount = Number(b.cargoTotalWeightVgm ?? 0);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
GeneratePriceResponse,
|
GeneratePriceResponse,
|
||||||
type MyBookingWindow,
|
type MyBookingWindow,
|
||||||
SubmitBookingResponse,
|
SubmitBookingResponse,
|
||||||
|
type WagonCancellationListFilter,
|
||||||
|
type WagonCancellationListResponse,
|
||||||
} from "./bookings.service";
|
} from "./bookings.service";
|
||||||
import {
|
import {
|
||||||
contractsService,
|
contractsService,
|
||||||
@@ -483,6 +485,22 @@ export const api = {
|
|||||||
bookingsService.getExportTrains(bookingId, date, cargo),
|
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[]>(
|
getMyBookingWindows: endpoint<void, MyBookingWindow[]>(
|
||||||
"train-scheduling",
|
"train-scheduling",
|
||||||
"myBookingWindows",
|
"myBookingWindows",
|
||||||
|
|||||||
@@ -223,6 +223,70 @@ export interface BookingWagonAllocation {
|
|||||||
containers: BookingWagonContainer[];
|
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 = {
|
export const bookingsService = {
|
||||||
list: async (
|
list: async (
|
||||||
filter: BookingListFilter | void = {},
|
filter: BookingListFilter | void = {},
|
||||||
@@ -597,6 +661,73 @@ export const bookingsService = {
|
|||||||
return (data.data ?? data) as BookingWagonAllocation[];
|
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
|
* Upcoming/open booking windows on the signed-in customer's active-contract
|
||||||
* lanes (import booking-day windows + export 24h pre-departure windows).
|
* lanes (import booking-day windows + export 24h pre-departure windows).
|
||||||
|
|||||||
Reference in New Issue
Block a user