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 index 2e859fd94..e54e9cdc7 100644 --- 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 @@ -222,6 +222,27 @@ export class BookingWagonCancellationService { } if (row.status !== 'FEE_PENDING') return; + // The fee can settle after loading started (slow payment). Never cut + // loaded cargo: leave the row FEE_PENDING and alert staff to resolve + // (reschedule the cut or refund the fee by hand). + const bookingNow = await this.bookingsRepository.findById(row.bookingId); + const movingNow = await this.dataSource.getRepository(WagonBookingAllocation).count({ + where: { bookingId: row.bookingId, status: In(['LOADED', 'DEPARTED']) }, + }); + if (bookingNow?.loadedAt || movingNow > 0) { + this.logger.error( + `Wagon cancellation ${row.id}: fee paid but loading already started on booking ${row.bookingId} — left FEE_PENDING for manual resolution.`, + ); + if (bookingNow) { + this.notifyStaff( + bookingNow, + 'Wagon cancellation fee paid after loading started', + `${bookingNow.reference}: the customer paid the cancellation fee for ${row.wagonsCancelled} wagon(s), but loading has already started. Resolve manually (adjust the cut or refund the fee).`, + ); + } + return; + } + await this.dataSource.transaction(async (manager) => { const booking = await manager.getRepository(Booking).findOne({ where: { id: row.bookingId }, @@ -233,7 +254,11 @@ export class BookingWagonCancellationService { let droppedWeight = 0; if (quantities.bySize && Object.keys(quantities.bySize).length) { - const units = await this.reduceContainerLines(manager, booking, quantities.bySize); + // Specific-wagon requests already carry the exact unit snapshots; + // quantity requests trim LIFO and snapshot here. + const units = quantities.units?.length + ? await this.reduceContainerUnitsExact(manager, booking, quantities.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( @@ -244,7 +269,12 @@ export class BookingWagonCancellationService { } else { droppedWeight = Number(quantities.bulkTons ?? row.weightTons); await this.reduceBulk(manager, booking, droppedWeight); - await this.releaseBulkAllocations(manager, booking.id, Number(row.wagonsCancelled)); + await this.releaseBulkAllocations( + manager, + booking.id, + Number(row.wagonsCancelled), + quantities.allocationIds, + ); } // Mirror applySplit's bookkeeping: preSplitQuantities feeds the ONE_TIME @@ -391,6 +421,14 @@ export class BookingWagonCancellationService { 'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).', ); } + // Cancellation is allowed strictly BEFORE loading/dispatch: both signals + // checked — per-wagon allocation status and the booking-level loading stamp + // (some flows confirm loading on the booking without flipping allocations). + if (booking.loadedAt) { + throw new BadRequestException( + 'Cargo loading is confirmed for this booking — wagons can no longer be cancelled.', + ); + } const moving = await this.dataSource.getRepository(WagonBookingAllocation).count({ where: { bookingId, status: In(['LOADED', 'DEPARTED']) }, }); @@ -412,6 +450,10 @@ export class BookingWagonCancellationService { throw new BadRequestException('This booking has no wagon requirement to cancel from.'); } + if (dto.wagonAllocationIds?.length) { + return this.resolveCutFromAllocations(booking, dto.wagonAllocationIds, totalWagons); + } + if (booking.freightType === 'CONTAINER') { if (!dto.containers?.length) { throw new BadRequestException('Specify the container units to cancel per size.'); @@ -471,6 +513,102 @@ export class BookingWagonCancellationService { return { wagons, weightTons: tons, quantities: { bulkTons: tons } }; } + /** + * Specific-wagon cancellation: the customer picked wagons in the Wagons tab. + * Everything is derived from the selected allocations — container bookings + * get their exact unit snapshots up front (T2 then cuts precisely these, + * not a LIFO guess), bulk gets the wagons' actual allocated tonnage. + */ + private async resolveCutFromAllocations( + booking: Booking, + allocationIds: string[], + totalWagons: number, + ): Promise { + const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { id: In(allocationIds), bookingId: booking.id }, + relations: { containerItems: true }, + }); + if (allocations.length !== allocationIds.length) { + throw new BadRequestException( + 'Some selected wagons no longer belong to this booking — refresh and pick again.', + ); + } + const notCancellable = allocations.filter( + (a) => a.status !== 'PLANNED' && a.status !== 'RESERVED', + ); + if (notCancellable.length) { + throw new BadRequestException( + 'A selected wagon is already loaded or departed and cannot be cancelled.', + ); + } + + const wagons = allocations.length; + if (wagons >= totalWagons) { + throw new BadRequestException( + 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + ); + } + + if (booking.freightType !== 'CONTAINER') { + const allocated = allocations.reduce( + (s, a) => s + Number(a.allocatedWeightTons || 0), + 0, + ); + const tons = + allocated > 0 + ? round3(allocated) + : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); + return { + wagons, + weightTons: tons, + quantities: { bulkTons: tons, allocationIds }, + }; + } + + // Container: the selected wagons' items name the exact physical boxes. + const numbers = allocations + .flatMap((a) => a.containerItems ?? []) + .map((i) => i.containerNumber) + .filter((n): n is string => !!n); + if (!numbers.length) { + throw new BadRequestException( + 'The selected wagons carry no container records — cancel by quantity instead.', + ); + } + const lines = await this.dataSource.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + const units: CancelledUnitSnapshot[] = []; + const bySize: Record = {}; + for (const line of lines) { + const size = line.containerSize ?? ''; + const lineUnits = await unitRepo.find({ where: { bookingContainerId: line.id } }); + for (const u of lineUnits) { + if (!numbers.includes(u.containerNumber)) continue; + units.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + bySize[size] = (bySize[size] ?? 0) + 1; + } + } + if (units.length !== numbers.length) { + throw new BadRequestException( + 'Wagon container records are out of sync with the booking — contact EDR support.', + ); + } + return { + wagons, + weightTons: round3(units.reduce((s, u) => s + Number(u.vgmTons || 0), 0)), + quantities: { bySize, units, allocationIds }, + }; + } + /** Credit = the cancelled share of the ORIGINAL price (old-price rebooking). */ private creditFor(booking: Booking, wagons: number): number { const totalWagons = Number(booking.wagonsRequired ?? 0); @@ -569,6 +707,62 @@ export class BookingWagonCancellationService { return snapshots; } + /** + * Cut EXACTLY the snapshotted units (specific-wagon cancellation): soft-delete + * them and rebalance each affected line. Returns the snapshots of the units + * actually cut, so drift since the request fails loudly instead of guessing. + */ + private async reduceContainerUnitsExact( + manager: EntityManager, + booking: Booking, + wanted: CancelledUnitSnapshot[], + ): Promise { + const numbers = wanted.map((u) => u.containerNumber); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId: booking.id }, + }); + const cut: CancelledUnitSnapshot[] = []; + for (const line of lines) { + const size = line.containerSize ?? ''; + const lineUnits = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + }); + const doomed = lineUnits.filter((u) => numbers.includes(u.containerNumber)); + if (!doomed.length) continue; + + await manager.getRepository(BookingContainerUnit).softDelete(doomed.map((u) => u.id)); + for (const u of doomed) { + cut.push({ + containerSize: size, + containerNumber: u.containerNumber, + sealNumber: u.sealNumber ?? null, + vgmTons: Number(u.vgmTons), + isHazardous: u.isHazardous, + isReefer: u.isReefer, + }); + } + const kept = lineUnits.filter((u) => !numbers.includes(u.containerNumber)); + if (!kept.length) { + await manager.getRepository(BookingContainer).softDelete(line.id); + continue; + } + const doomedVgm = round3(doomed.reduce((s, u) => s + Number(u.vgmTons || 0), 0)); + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.length, + wagonsRequired: round2(kept.length * wagonsPerUnitForSize(Number(size))), + totalVgmTons: round3(Number(line.totalVgmTons) - doomedVgm), + hazardousQuantity: kept.filter((u) => u.isHazardous).length, + reeferQuantity: kept.filter((u) => u.isReefer).length, + }); + } + if (cut.length !== wanted.length) { + throw new BadRequestException( + `Booking changed since the request: ${cut.length}/${wanted.length} selected container(s) still on it.`, + ); + } + return cut; + } + private async reduceBulk( manager: EntityManager, booking: Booking, @@ -623,19 +817,37 @@ export class BookingWagonCancellationService { } } - /** Free whole bulk wagons, newest allocations first. */ + /** + * Free whole bulk wagons — the customer-picked allocations when given + * (specific-wagon cancel), topping up newest-first for any picked id that no + * longer exists (re-batch between request and fee payment). + */ private async releaseBulkAllocations( manager: EntityManager, bookingId: string, wagons: number, + pickedIds?: string[], ): Promise { const toFree = Math.round(wagons); if (toFree <= 0) return; - const allocations = await manager.getRepository(WagonBookingAllocation).find({ - where: { bookingId }, - order: { createdAt: 'DESC' }, - take: toFree, - }); + let allocations: WagonBookingAllocation[] = []; + if (pickedIds?.length) { + allocations = await manager.getRepository(WagonBookingAllocation).find({ + where: { id: In(pickedIds), bookingId }, + }); + } + if (allocations.length < toFree) { + const have = new Set(allocations.map((a) => a.id)); + const fill = await manager.getRepository(WagonBookingAllocation).find({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + for (const a of fill) { + if (allocations.length >= toFree) break; + if (!have.has(a.id)) allocations.push(a); + } + } + allocations = allocations.slice(0, toFree); if (!allocations.length) return; const ids = allocations.map((a) => a.id); await manager diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 0f5b5fbbc..e4b902fe1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -347,7 +347,8 @@ export class BookingsService { */ async wagonAllocations(bookingId: string): Promise { return this.dataSource.query( - `SELECT tsw.sequence_no AS "sequenceNo", + `SELECT a.id AS "allocationId", + tsw.sequence_no AS "sequenceNo", w.wagon_number AS "wagonNumber", COALESCE(wt.name, wt.code) AS "wagonType", wt.code AS "wagonTypeCode", 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 index c2b5b39b1..6a5dfe112 100644 --- 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 @@ -9,6 +9,7 @@ import { IsNumber, IsOptional, IsString, + IsUUID, MaxLength, Min, ValidateNested, @@ -28,6 +29,18 @@ export class CancelContainerLineDto { } export class RequestWagonCancellationDto { + @ApiPropertyOptional({ + description: + 'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' + + 'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonAllocationIds?: string[]; + @ApiPropertyOptional({ description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)', }) 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 index 24d5edbf7..c9438b048 100644 --- 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 @@ -37,12 +37,19 @@ export interface CancelledQuantities { /** 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 + * Container bookings: the exact physical units cut. Snapshotted at request + * time when the customer picked specific wagons, otherwise at fee settlement + * (LIFO trim). 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[]; + /** + * Specific-wagon cancellation: the wagon_booking_allocation ids the customer + * picked in the Wagons tab. T2 releases exactly these (fallback to + * newest-first for any id that no longer exists, e.g. after a re-batch). + */ + allocationIds?: string[]; } /** diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 0670480b1..3b7218313 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -345,7 +345,15 @@ export function ReadonlyBookingView({ {showWagonsTab && ( - + )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx index 6ab192436..71d723c6a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WagonsTab.tsx @@ -1,5 +1,19 @@ -import { Box, Group, SimpleGrid, Skeleton, Table, Text, Tooltip } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; +import { + Alert, + Box, + Button, + Checkbox, + Group, + Modal, + SimpleGrid, + Skeleton, + Stack, + Table, + Text, + Textarea, + Tooltip, +} from "@mantine/core"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Container, Gauge, @@ -10,16 +24,28 @@ import { TrainFront, TrainTrack, } from "lucide-react"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; +import toast from "react-hot-toast"; import { bookingsService, type BookingWagonAllocation, + type WagonCancellationPreview, } from "@/services/bookings.service"; +import { api } from "@/services/api"; import { fmtDate, fmtWeight } from "../utils"; import { CardTitle, SectionCard } from "./layout"; +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; +}; + // Mirrors CargoTab's local Flag/StatTile look so the two tabs read as one page. const STATUS_TONES: Record< BookingWagonAllocation["status"], @@ -173,15 +199,39 @@ function LoadBar({ allocated, capacity }: { allocated: number; capacity: number const th = { color: "#9AA8B5", fontSize: 11 } as const; -function WagonCard({ wagon }: { wagon: BookingWagonAllocation }) { +function WagonCard({ + wagon, + selectable, + selected, + onToggle, +}: { + wagon: BookingWagonAllocation; + selectable?: boolean; + selected?: boolean; + onToggle?: () => void; +}) { const allocated = Number(wagon.allocatedWeightTons || 0); const capacity = Number(wagon.capacityTons || 0); const containers = wagon.containers ?? []; return ( - + + {selectable && ( + + )} void; +}) { + const queryClient = useQueryClient(); const { data: wagons, isLoading } = useQuery({ queryKey: ["booking-wagons", bookingId], queryFn: () => bookingsService.getWagons(bookingId), enabled: !!bookingId, }); + // Only needed to block a second request while one is awaiting its fee. + const { data: history } = useQuery({ + ...api.bookings.listWagonCancellations.queryOptions({ input: { bookingId } }), + enabled: !!bookingId && !!cancellable, + }); + const hasOpenCancellation = (history?.items ?? []).some( + (r) => r.bookingId === bookingId && r.status === "FEE_PENDING", + ); + + const [selected, setSelected] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); + const [reason, setReason] = useState(""); + const [preview, setPreview] = useState(null); + + const toggle = (allocationId: string) => + setSelected((prev) => { + const next = new Set(prev); + if (next.has(allocationId)) next.delete(allocationId); + else next.add(allocationId); + return next; + }); + + const previewMutation = useMutation({ + mutationFn: () => + bookingsService.previewWagonCancellation(bookingId, { + wagonAllocationIds: [...selected], + }), + onSuccess: setPreview, + onError: (e) => { + setPreview(null); + toast.error(apiErrorMessage(e, "Could not calculate the fee. Please try again.")); + }, + }); + + const requestMutation = useMutation({ + mutationFn: () => + bookingsService.requestWagonCancellation(bookingId, { + wagonAllocationIds: [...selected], + ...(reason.trim() ? { reason: reason.trim() } : {}), + }), + onSuccess: () => { + setConfirmOpen(false); + setSelected(new Set()); + setReason(""); + setPreview(null); + toast.success( + "Cancellation requested — pay the fee in the wagon cancellation card to release these wagons.", + { duration: 7000 }, + ); + void queryClient.invalidateQueries({ queryKey: ["booking-wagons", bookingId] }); + void queryClient.invalidateQueries({ + queryKey: api.bookings.listWagonCancellations.queryKey({ bookingId }), + }); + onCancellationRequested?.(); + }, + onError: (e) => + toast.error(apiErrorMessage(e, "Could not request the cancellation. Please try again.")), + }); + + const openConfirm = () => { + setPreview(null); + setConfirmOpen(true); + previewMutation.mutate(); + }; + if (isLoading) { return (
@@ -361,6 +486,7 @@ export function WagonsTab({ bookingId }: { bookingId: string }) { ); } + const canSelect = !!cancellable && !hasOpenCancellation; const first = wagons[0]; const totalAllocated = wagons.reduce( (s, w) => s + Number(w.allocatedWeightTons || 0), @@ -435,11 +561,122 @@ export function WagonsTab({ bookingId }: { bookingId: string }) { + {canSelect && ( + + + + + Cancel specific wagons + + + Tick the wagons you want to cancel. A per-wagon cancellation fee + applies; the wagons stay yours until the fee is paid, and the + freight you paid for them becomes a rebooking credit. + + + + + {selected.size >= wagons.length && selected.size > 0 && ( + + You cannot cancel every wagon here — to cancel the whole booking, + use the booking cancellation instead. + + )} + + )} + {cancellable && hasOpenCancellation && ( + + A wagon cancellation is already awaiting its fee — pay or withdraw it + in the wagon cancellation card before requesting another. + + )} + {wagons.map((w) => ( - + w.allocationId && toggle(w.allocationId)} + /> ))} + + setConfirmOpen(false)} + title="Cancel selected wagons" + centered + > + + + You are cancelling {selected.size} wagon(s). They stay + allocated to you until the cancellation fee is paid; after that the + paid freight for them becomes a credit you can rebook on another + day while your contract is valid. + + {previewMutation.isPending && } + {preview && ( + + + + Cancellation fee ({preview.wagons} × {Number(preview.feePerWagon).toLocaleString()}) + + + {Number(preview.feeAmount).toLocaleString()} {preview.feeCurrency} + + + + + Rebooking credit kept + + + {Number(preview.creditAmount).toLocaleString()} + + + + )} +