diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 97dc7767f..fd1f530b8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -596,11 +596,40 @@ export class ContractBookingService { if (!booking || booking.contractId !== contract.id) { throw new NotFoundException(`Booking ${bookingId} not found on this contract`); } - if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) { + if ( + !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes( + booking.status, + ) + ) { throw new BadRequestException( 'Clearance must be finalized before the booking can be completed.', ); } + // An unpaid booking that expired at train dispatch keeps its finished + // per-booking clearance — GL rebooks it onto a new shipment day instead of + // forcing the customer through a new shipment request + clearance fee. + if (booking.status === 'EXPIRED') { + // Only a booking that completed once (it has a price, so its clearance + // finished and cargo is persisted) can be rebooked after expiry. + if (!(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + 'Only a previously completed booking can be rebooked after it expires.', + ); + } + // Expiry released the booking's contract-capacity hold; if the payload + // re-states the cargo, make sure the released share is still free. + if (dto.containers?.length || dto.bulkLines?.length) { + await this.assertWithinQuantityCap(contract, dto); + } + // Drop the departed train's link and fall into the day-only resubmit + // path below — same machinery as OPERATION_CHANGES_REQUESTED. + await this.bookingsRepository.update(booking.id, { + status: 'OPERATION_CHANGES_REQUESTED', + trainScheduleId: null, + } as never); + booking.status = 'OPERATION_CHANGES_REQUESTED'; + booking.trainScheduleId = null; + } // Path B: only GL Ethiopia completes a customs instance — the customer // never enters shipment data on a customs contract. if (contract.customsClearingEnabled) { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index ae72a5b28..97199afc3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -407,6 +407,9 @@ export default function GlCreateBookingForm() { useEffect(() => { if (!bookingRequest || prefilled) return; + // A rebook (?copyFrom=) seeds from the expired booking's real cargo — + // richer than the request's bare quantities. Let that seed win the race. + if (copyFromParam) return; setPrefilled(true); const lines = bookingRequest.requestedLines ?? {}; if (lines.containers?.length) { @@ -448,13 +451,27 @@ export default function GlCreateBookingForm() { setContainerLines( lines.map((c) => { const qty = Math.max(1, c.quantity); + // Carry the persisted per-unit details (numbers, seals, VGM, handling) + // when the source booking has them — a rebooked EXPIRED booking does, + // and its cargo is fixed server-side anyway. + const units: UnitDraft[] = + c.units?.length === qty + ? c.units.map((u) => ({ + containerNumber: u.containerNumber ?? "", + sealNumber: u.sealNumber ?? "", + vgmTons: u.vgmTons != null ? String(u.vgmTons) : "", + isHazardous: Boolean(u.isHazardous), + isReefer: Boolean(u.isReefer), + isReturn: Boolean(u.isReturn), + })) + : Array.from({ length: qty }, emptyUnit); return { containerSize: String(c.containerType?.sizeFt ?? ""), quantity: String(qty), - hazardousQuantity: "0", - reeferQuantity: "0", - returnQuantity: "0", - units: Array.from({ length: qty }, emptyUnit), + hazardousQuantity: String(units.filter((u) => u.isHazardous).length), + reeferQuantity: String(units.filter((u) => u.isReefer).length), + returnQuantity: String(units.filter((u) => u.isReturn).length), + units, }; }), ); diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 79d35aa7d..7be7871dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -23,6 +23,7 @@ import { Clock, PackageCheck, PackagePlus, + RotateCcw, ShieldCheck, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() { hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + // The completed booking expired unpaid at train dispatch. Its per-booking + // clearance is finished, so GL rebooks it onto a new day — the customer never + // re-requests the shipment or pays the clearance fee again. + const canRebookExpired = + booking?.status === "EXPIRED" && + Boolean(booking?.contractId) && + Number(booking?.totalAmount ?? 0) > 0 && + hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && + !isDjiboutiGl(user); + const docsPhaseComplete = clearance?.milestones?.some( (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", @@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() { > Create booking + ) : canRebookExpired ? ( + ) : undefined } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx index c18311ad2..03d4ef78a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx @@ -727,8 +727,12 @@ export default function ContractClearanceListPage() { ) } onRebook={(row) => + // Re-complete the SAME expired booking (new day, same finished + // per-booking clearance) — a fresh create-booking would spawn a + // new instance and force the customer through clearance + fee + // again. navigate( - `/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`, + `/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`, ) } onViewContract={(contractId) => diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 4b5b5257b..d0e92f652 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -79,6 +79,8 @@ export interface BookingContainerUnit { vgmTons: number; isHazardous?: boolean; isReefer?: boolean; + /** This container ships back empty after unloading (equipment return). */ + isReturn?: boolean; sortOrder?: number; }