From eb1349e8463bb5f358c47208b743cf57a51aef6a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 7 Aug 2026 05:48:23 +0000 Subject: [PATCH] fix wagon cncellation --- .../src/common/booking-guards.ts | 12 + ...3300000000000-BookingWagonCancellations.ts | 65 ++ .../bookings/booking-invoice.service.ts | 11 + .../booking-wagon-cancellation.service.ts | 778 ++++++++++++++++++ .../booking-wagon-cancellations.repository.ts | 85 ++ .../modules/bookings/bookings.controller.ts | 141 +++- .../src/modules/bookings/bookings.module.ts | 7 + .../bookings/dto/wagon-cancellation.dto.ts | 99 +++ .../booking-wagon-cancellation.entity.ts | 123 +++ .../src/seed/freight-permissions.registry.ts | 16 +- apps/edr-freight-web/backoffice/src/App.tsx | 18 + .../backoffice/src/lib/permissions.ts | 4 + .../pages/bookings/WagonCancellationsPage.tsx | 420 ++++++++++ .../BookingDetailPage/ReadonlyBookingView.tsx | 6 + .../BookingDetailPage/booking-detail-types.ts | 2 + .../components/WagonCancellationCard.tsx | 495 +++++++++++ .../bookings/payments/useBookingPayment.ts | 47 +- .../portal/src/services/api.ts | 18 + .../portal/src/services/bookings.service.ts | 131 +++ 19 files changed, 2467 insertions(+), 11 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonCancellationCard.tsx diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 854594ffc..aba4ce495 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -31,6 +31,18 @@ export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const BookingDocReviewAlert = () => BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert); +/** Staff wagon-cancellation history list (admin side). */ +export const WagonCancellationView = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationView); + +/** Staff void of a customer's pending (fee-unpaid) wagon cancellation. */ +export const WagonCancellationVoid = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationVoid); + +/** Staff rebook of a customer's wagon-cancellation credit on their behalf. */ +export const WagonCancellationRebook = () => + BookingStaff(FREIGHT_PERMS.bookings.wagonCancellationRebook); + export const TrainSchedulingView = () => BookingStaff(FREIGHT_PERMS.trainScheduling.view); diff --git a/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts new file mode 100644 index 000000000..0d506cff0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3300000000000-BookingWagonCancellations.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_wagon_cancellations`); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index faeab8cee..91b8362e4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { BookingsRepository } from "./bookings.repository"; +import { + BookingWagonCancellationService, + WAGON_CANCEL_FEE_INVOICE_TYPE, +} from "./booking-wagon-cancellation.service"; import { Booking } from "./entities/booking.entity"; /** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ @@ -58,6 +62,8 @@ export class BookingInvoiceService { private readonly firstMile: FirstMileService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatch: BookingBatchService, + @Inject(forwardRef(() => BookingWagonCancellationService)) + private readonly wagonCancellations: BookingWagonCancellationService, ) { } /** @@ -123,6 +129,11 @@ export class BookingInvoiceService { await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId); await this.advanceBookingOnPayment(payload.sourceId); break; + case WAGON_CANCEL_FEE_INVOICE_TYPE: + // Partial wagon cancellation: the fee settled — reduce the booking and + // release the cancelled wagons (T2 of the cancellation cycle). + await this.wagonCancellations.onFeePaid(payload.invoiceId); + break; default: this.logger.warn( `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts new file mode 100644 index 000000000..2e859fd94 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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 { + 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 { + 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 { + 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 { + return this.mustFind(id); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private async mustFind(id: string): Promise { + 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 { + 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 { + 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(); + for (const line of lines) { + const size = line.containerSize ?? ''; + liveBySize.set(size, (liveBySize.get(size) ?? 0) + Number(line.quantity ?? 0)); + } + const bySize: Record = {}; + 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 { + 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, + ): Promise { + 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 { + 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 { + 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 { + 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 }> { + 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 = {}; + 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 { + 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 }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts new file mode 100644 index 000000000..64cde269f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellations.repository.ts @@ -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 { + constructor( + @InjectRepository(BookingWagonCancellation) + repository: Repository, + ) { + super(repository); + } + + /** The one open (fee-unpaid) cancellation of a booking, if any. */ + findOpenForBooking(bookingId: string): Promise { + return this.repository.findOne({ + where: { bookingId, status: 'FEE_PENDING' }, + }); + } + + findByFeeInvoiceId(feeInvoiceId: string): Promise { + 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 { + return this.repository + .createQueryBuilder('bwc') + .leftJoinAndSelect('bwc.booking', 'booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking') + .leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice'); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 1e932cbc0..cad7c6061 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -21,7 +21,11 @@ import { import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; -import { BookingStaff, BookingView } from '../../common/booking-guards'; +import { + BookingStaff, + BookingView, + WagonCancellationView, +} from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { @@ -74,6 +78,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; +import { + FilterWagonCancellationsDto, + RebookCancelledWagonsDto, + RequestWagonCancellationDto, +} from './dto/wagon-cancellation.dto'; import { type AuthUserPayload, resolveAuthUserId, @@ -153,6 +163,7 @@ export class BookingsController { private readonly firstMileService: FirstMileService, private readonly lastMileService: LastMileService, private readonly userTradeAccessService: UserTradeAccessService, + private readonly wagonCancellationService: BookingWagonCancellationService, ) {} @Post() @@ -512,6 +523,134 @@ export class BookingsController { return this.bookingsService.wagonAllocations(id); } + // ── Partial wagon cancellation (paid bookings) ──────────────────────────── + // Customer endpoints are ownership-scoped (no portal permission keys); the + // staff history/void/rebook variants are permission-gated below. + + @Post(':id/wagon-cancellations/preview') + @ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' }) + async previewWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.previewCancellation(id, dto); + } + + @Post(':id/wagon-cancellations') + @ApiOperation({ + summary: + 'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles', + }) + async requestWagonCancellation( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestWagonCancellationDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.requestCancellation(id, dto, user?.id); + } + + @Get(':id/wagon-cancellations') + @ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' }) + async listBookingWagonCancellations( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + const staff = + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView); + if (!staff) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 }); + } + + @Get('wagon-cancellations/my') + @ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' }) + async listMyWagonCancellations( + @Query() filter: FilterWagonCancellationsDto, + @CurrentUser() user: TCurrentUser, + ) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? ''); + if (!companyId) throw new ForbiddenException('No customer company for this user.'); + return this.wagonCancellationService.list({ + companyId, + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Get('wagon-cancellations/history') + @WagonCancellationView() + @ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' }) + async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) { + return this.wagonCancellationService.list({ + status: filter.statuses, + search: filter.search, + from: filter.from ? new Date(filter.from) : undefined, + to: filter.to ? new Date(filter.to) : undefined, + page: filter.page, + pageSize: filter.pageSize, + }); + } + + @Post('wagon-cancellations/:cancellationId/withdraw') + @ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' }) + async withdrawWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + ); + return this.wagonCancellationService.withdraw(cancellationId); + } + + @Post('wagon-cancellations/:cancellationId/rebook') + @ApiOperation({ + summary: + 'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)', + }) + async rebookWagonCancellation( + @Param('cancellationId', ParseUUIDPipe) cancellationId: string, + @Body() dto: RebookCancelledWagonsDto, + @CurrentUser() user: TCurrentUser, + ) { + await this.assertWagonCancellationActor( + cancellationId, + user, + FREIGHT_PERMS.bookings.wagonCancellationRebook, + ); + return this.wagonCancellationService.rebook(cancellationId, dto, user?.id); + } + + /** Owner-or-staff gate shared by the per-cancellation actions. */ + private async assertWagonCancellationActor( + cancellationId: string, + user: TCurrentUser, + staffPermission: string, + ): Promise { + if (hasFreightPermission(user, staffPermission)) return; + const row = await this.wagonCancellationService.findById(cancellationId); + const booking = await this.bookingsService.findById(row.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + @Get(':id/customer-trucks') @ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' }) async listCustomerTrucks( diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 84354d860..3f04bcd7a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -38,6 +38,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity'; +import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository'; +import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity'; import { CustomerTruckContainer } from './entities/customer-truck-container.entity'; import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository'; @@ -65,6 +68,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingReviewNote, BookingContractSignature, BookingContainerAllocation, + BookingWagonCancellation, CustomerTruckAssignment, CustomerTruckContainer, ]), @@ -109,6 +113,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckAssignmentsRepository, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationsRepository, + BookingWagonCancellationService, ], exports: [ BookingsService, @@ -120,6 +126,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; ConsolidationService, CustomerTruckService, ContainerReceiptService, + BookingWagonCancellationService, ], }) export class BookingsModule { } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts new file mode 100644 index 000000000..c2b5b39b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/wagon-cancellation.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts new file mode 100644 index 000000000..24d5edbf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-wagon-cancellation.entity.ts @@ -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; + /** + * 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; +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 889345733..9fbedf7e0 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -68,6 +68,11 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ // Header alarm for the document-review deadline: its own key so only the // position types that actually decide operation requests are alerted. perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'), + // Partial wagon cancellation (paid bookings): staff-side keys. The customer + // portal needs none — customer actions are ownership-scoped on the API. + perm('a1000001-0001-4000-8000-000000000026', 'edr_freight_app:bookings:wagon_cancellation_view', 'View wagon cancellation history'), + perm('a1000001-0001-4000-8000-000000000027', 'edr_freight_app:bookings:wagon_cancellation_void', 'Void a pending wagon cancellation'), + perm('a1000001-0001-4000-8000-000000000028', 'edr_freight_app:bookings:wagon_cancellation_rebook', 'Rebook cancelled wagons for a customer'), ]; /** @@ -431,6 +436,9 @@ export const FREIGHT_PERMS = { uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', docReviewAlert: 'edr_freight_app:bookings:doc_review_alert', + wagonCancellationView: 'edr_freight_app:bookings:wagon_cancellation_view', + wagonCancellationVoid: 'edr_freight_app:bookings:wagon_cancellation_void', + wagonCancellationRebook: 'edr_freight_app:bookings:wagon_cancellation_rebook', }, contracts: { view: 'edr_freight_app:contracts:view', @@ -825,6 +833,8 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, FREIGHT_PERMS.contracts.view, ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), @@ -837,6 +847,7 @@ export const ROLE_PERMISSION_PRESETS = { operationsOfficer: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, + FREIGHT_PERMS.bookings.wagonCancellationView, // They are the ones who accept/reject operation requests, so they are the // ones the doc-review countdown is for. FREIGHT_PERMS.bookings.docReviewAlert, @@ -877,7 +888,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), ], - finance: [FREIGHT_PERMS.bookings.view], + finance: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.wagonCancellationView], // Global Logistics: manages ONLY the customs-clearance queue. Scoped out of // the general booking-request list (no bookings:view) — instead a dedicated // clearance:view permission lists the clearance bookings. Reviews customer @@ -920,6 +931,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.bookings.wagonCancellationView, + FREIGHT_PERMS.bookings.wagonCancellationVoid, + FREIGHT_PERMS.bookings.wagonCancellationRebook, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, FREIGHT_PERMS.bookings.reviewDocuments, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index bda88d912..6733df0d7 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -29,6 +29,7 @@ import { Wallet, LifeBuoy, TrainFront, + XCircle, } from "lucide-react"; import { useEffect } from "react"; import { @@ -54,6 +55,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import WagonCancellationsPage from "./pages/bookings/WagonCancellationsPage"; import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; import ContractViewPage from "./pages/contracts/ContractViewPage"; @@ -184,6 +186,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Wagon cancellations", + href: "/dashboard/wagon-cancellations", + icon: , + permission: FREIGHT_PERMS.bookings.wagonCancellationView, + }, // Operations hub: per-shipment clearance-document review for services // WITHOUT customs clearing (self-clearance) — bookings only. { @@ -897,6 +905,16 @@ const App = () => { } /> } /> + + + + } + /> } diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index ba7dd78ab..d6f4acdaa 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -27,6 +27,10 @@ export const FREIGHT_PERMS = { uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output", finalizeClearance: "edr_freight_app:bookings:finalize_clearance", docReviewAlert: "edr_freight_app:bookings:doc_review_alert", + wagonCancellationView: "edr_freight_app:bookings:wagon_cancellation_view", + wagonCancellationVoid: "edr_freight_app:bookings:wagon_cancellation_void", + wagonCancellationRebook: + "edr_freight_app:bookings:wagon_cancellation_rebook", }, contracts: { view: "edr_freight_app:contracts:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx new file mode 100644 index 000000000..fff83d479 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/WagonCancellationsPage.tsx @@ -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 ( + + {chip.label} + + ); +} + +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(null); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + const [from, setFrom] = useState(null); + const [to, setTo] = useState(null); + const [voiding, setVoiding] = useState(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( + "/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[] = [ + { + id: "requested", + header: () => Requested, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + { + id: "booking", + header: () => Booking, + cell: ({ row }) => ( + + {row.original.booking?.reference ?? row.original.bookingId} + + ), + }, + { + id: "company", + header: () => Company, + cell: ({ row }) => ( + {row.original.booking?.company?.name ?? "—"} + ), + }, + { + id: "wagons", + header: () => Wagons, + cell: ({ row }) => {row.original.wagonsCancelled}, + }, + { + id: "fee", + header: () => Fee, + cell: ({ row }) => ( + + {formatAmount(row.original.feeAmount, row.original.feeCurrency)} + + ), + }, + { + id: "credit", + header: () => Credit, + cell: ({ row }) => ( + + {formatAmount(row.original.creditAmount, row.original.feeCurrency)} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => , + }, + { + id: "rebookedAs", + header: () => Rebooked as, + cell: ({ row }) => { + const r = row.original; + if (!r.rebookedBookingId) return ; + return ( + + {r.rebookedBooking?.reference ?? r.rebookedBookingId} + + ); + }, + }, + { + id: "actions", + header: () => , + cell: ({ row }) => { + const r = row.original; + if (r.status !== "FEE_PENDING" || !canVoid) return null; + return ( + + + + ); + }, + }, + ]; + + return ( + + + + + + + + } + value={search} + onChange={(e) => { + setSearch(e.currentTarget.value); + resetPage(); + }} + w={260} + radius="md" + /> +