From 74cf80106eb22ddd620a3ecf1dad12cfe71daebc Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 06:45:03 +0000 Subject: [PATCH 1/3] feat: add yard/zone fields and returned containers table Add Yard and Zone input fields to standalone return modal. Add table showing all returned containers with: container number, booking ref, returned date, facility, yard, condition, status. Table appears at top of Container Returns page when returns exist. Co-Authored-By: Claude Haiku 4.5 --- .../pages/warehouses/ContainerReturnsPage.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 01cfa423f..bc53742b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -69,6 +69,13 @@ export default function ContainerReturnsPage() { }, }); + const { data: returnedContainers = [] } = useQuery({ + queryKey: ["empty-container-returns"], + queryFn: async () => { + return await importOperationsService.listEmptyReturns().catch(() => []); + }, + }); + const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[]; const containerReturnsQuery = useQuery({ queryKey: ["container-returns", bookingIds], @@ -249,6 +256,42 @@ export default function ContainerReturnsPage() { + {returnedContainers.length > 0 && ( + <> + Returned Containers + + + + + Container Number + Booking Ref + Returned Date + Facility + Yard + Condition + Status + + + + {returnedContainers.map((ret: any) => ( + + {ret.containerNumber} + {ret.bookingId ? "Associated" : "—"} + {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} + {ret.facility || "—"} + {ret.yard || "—"} + {ret.condition || "—"} + + {ret.status} + + + ))} + +
+
+ + )} + {filteredGroups.length === 0 ? ( No {filterType !== "all" ? filterType : ""} container returns found. ) : ( @@ -517,6 +560,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon const [containerNumber, setContainerNumber] = useState(""); const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); const [warehouse, setWarehouse] = useState(null); + const [yard, setYard] = useState(""); + const [zone, setZone] = useState(""); const [condition, setCondition] = useState(""); const [handoverNote, setHandoverNote] = useState(""); @@ -562,6 +607,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon setContainerNumber(""); setReturnDate(new Date().toISOString().split("T")[0]); setWarehouse(null); + setYard(""); + setZone(""); setCondition(""); setHandoverNote(""); onClose(); @@ -592,6 +639,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon searchable /> + setYard(e.currentTarget.value)} + /> + + setZone(e.currentTarget.value)} + /> + Date: Fri, 31 Jul 2026 06:46:03 +0000 Subject: [PATCH 2/3] feat: add yard/zone fields and returned containers table --- .../modules/bookings/bookings.controller.ts | 20 ++ .../src/modules/bookings/bookings.service.ts | 241 ++++++++++++++++++ .../backoffice/src/constants/URLS.ts | 2 + .../pages/warehouses/ContainerReturnsPage.tsx | 22 ++ 4 files changed, 285 insertions(+) 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 3a17784f3..7b1f34f36 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -464,6 +464,26 @@ export class BookingsController { res.send(buffer); } + @Get(':id/carriage-acceptance-sheet') + @ApiOperation({ + summary: + 'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)', + }) + async carriageAcceptanceSheet( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @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.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index d54bd1190..2536f15c4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -70,6 +70,23 @@ export interface PaginatedBookings { }; } +/** One wagon line on the carriage acceptance sheet (raw SQL projection). */ +interface CarriageAcceptanceWagonRow { + sequenceNo: number; + wagonType: string | null; + wagonNumber: string | null; + tareWeightTons: string | null; + equatedLength: string | null; + loadCapacityTons: string | null; + allocatedWeightTons: string | null; + trainNumber: string | null; + departureAt: Date | null; + marshalledAt: string | null; + arrivalAt: string | null; + containerNumbers: string | null; + sealNumbers: string | null; +} + const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', @@ -208,6 +225,230 @@ export class BookingsService { }; } + /** + * Carriage acceptance sheet — one per booking, listing every wagon the booking + * occupies. Handed to the customer when EDR accepts the cargo (export) and when + * the wagons are allocated before marshalling (import), so it is only available + * once the booking has wagon allocations. + */ + async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( + `SELECT tsw.sequence_no AS "sequenceNo", + COALESCE(wt.code, wt.name) AS "wagonType", + w.wagon_number AS "wagonNumber", + wt.tare_weight_tons AS "tareWeightTons", + tsw.length_meters AS "equatedLength", + tsw.capacity_tons AS "loadCapacityTons", + a.allocated_weight_tons AS "allocatedWeightTons", + s.train_number AS "trainNumber", + s.scheduled_departure_date AS "departureAt", + so.label AS "marshalledAt", + sd.label AS "arrivalAt", + string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", + string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" + FROM freight.wagon_booking_allocations a + JOIN freight.train_set_wagons tsw + ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL + LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + LEFT JOIN freight.train_schedules s + ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + LEFT JOIN freight.wagon_allocation_container_items ci + ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL + WHERE a.booking_id = $1 AND a.deleted_at IS NULL + GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + s.train_number, s.scheduled_departure_date, so.label, sd.label + ORDER BY tsw.sequence_no`, + [bookingId], + ); + if (wagons.length === 0) { + throw new BadRequestException( + 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', + ); + } + + const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons); + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'carriage acceptance sheet', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); + return { + filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + + /** + * Split the booking amount across its wagons, proportional to allocated weight + * (equal shares when no weights are recorded). The last row absorbs the rounding + * remainder so the Price column always sums to the Total Amount on the sheet. + */ + private splitAmountAcrossWagons(total: number, weights: number[]): number[] { + const sum = weights.reduce((acc, w) => acc + w, 0); + const shares = weights.map((w) => + Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100, + ); + const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100; + shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100; + return shares; + } + + private buildCarriageAcceptanceSheetHtml( + booking: Booking, + wagons: CarriageAcceptanceWagonRow[], + ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); + const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); + const money = (v: number) => + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-'; + const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'; + const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-'; + const currency = booking.paymentCurrency ?? 'ETB'; + const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0; + const prices = this.splitAmountAcrossWagons( + totalAmount, + wagons.map((w) => Number(w.allocatedWeightTons) || 0), + ); + const header = wagons[0]; + const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); + + const totals = wagons.reduce( + (acc, w) => ({ + tare: acc.tare + (Number(w.tareWeightTons) || 0), + capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), + load: acc.load + (Number(w.allocatedWeightTons) || 0), + length: acc.length + (Number(w.equatedLength) || 0), + }), + { tare: 0, capacity: 0, load: 0, length: 0 }, + ); + // A wagon carrying no weight and no container is running empty under this booking. + const fullWagons = wagons.filter( + (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), + ).length; + + const rows = wagons + .map( + (w, i) => ` + ${i + 1} + ${esc(w.wagonType)} + ${esc(w.wagonNumber)} + ${num(w.tareWeightTons, 2)} + ${num(w.equatedLength)} + ${num(w.loadCapacityTons)} + ${esc(arrivalStation)} + ${esc(cargoName)} + ${esc(departureStation)} + ${esc(w.containerNumbers)} + ${esc(w.sealNumbers)} + ${money(prices[i])} + `, + ) + .join(''); + + return ` + + + + Carriage Acceptance Sheet + + + +
+
+
Ethio-Djibouti Railway S.C.
+

Carriage Acceptance Sheet

+
Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}
+
+
+ Sheet No. + CAS-${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+
+ +
+
Marshalled at${esc(header.marshalledAt ?? departureStation)}
+
Arrival at${esc(header.arrivalAt ?? arrivalStation)}
+
Date and time${esc(sheetDate.toLocaleString('en-GB'))}
+
Train No.${esc(header.trainNumber)}
+
Customer${esc(booking.company?.name)}
+
Cargo${esc(cargoName)}
+
+ + + + + + + + + + + + + + + + + + + + ${rows} + + + + + + + + + + + +
SNType of WagonWagon No.Tare WeightEquated LengthLoad CapacityArrival StationCargo NameDeparture StationContainer No.Seal No.Price (${esc(currency)})
Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})${num(totals.tare, 2)}${num(totals.length)}${num(totals.capacity)}Gross weight (tare + load): ${num(totals.tare + totals.load)} T${money(totalAmount)}
+ +
+ The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity, container and seal numbers must be verified against the physical consist + before the sheet is signed. +
+ +
+
Signed by — EDR operations / date
+
Signed by — customer or agent / date
+
Signed by — marshalling yard / date
+
+ +`; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ /** * An intercity corridor is valid when both yards are Ethiopian and at least diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 31b74488c..142e6c3e7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -132,6 +132,8 @@ export const URL_CONSTANTS = { CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/bookings/${id}/contract/sign`, CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`, + CARRIAGE_ACCEPTANCE_SHEET: (id: string) => + `/bookings/${id}/carriage-acceptance-sheet`, SUMMARY: (id: string) => `/bookings/${id}/summary`, CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`, MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index bc53742b0..aa8757d81 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -565,6 +565,28 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon const [condition, setCondition] = useState(""); const [handoverNote, setHandoverNote] = useState(""); + // Auto-populate yard and zone from selected warehouse + const { data: warehousesResponse } = useQuery({ + queryKey: ["warehouses-list"], + queryFn: async () => { + return await warehouseService.list({}); + }, + }); + + const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? []; + const selectedWarehouseData = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null; + + React.useEffect(() => { + if (selectedWarehouseData) { + setYard(selectedWarehouseData.yard || selectedWarehouseData.code || ""); + setZone(selectedWarehouseData.zone || ""); + } else { + setYard(""); + setZone(""); + } + }, [selectedWarehouseData]); + const [handoverNote, setHandoverNote] = useState(""); + const { data: warehousesResponse } = useQuery({ queryKey: ["warehouses-list"], queryFn: async () => { From 08e0fe2db2af9c3901273cbf79b4ec0918399e0b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 06:57:15 +0000 Subject: [PATCH 3/3] feat: auto-populate yard and zone from selected warehouse When warehouse is selected in standalone return modal, yard and zone fields are automatically populated from warehouse data (yard from warehouse.yard or warehouse.code, zone from warehouse.zone). Fields are read-only/disabled to prevent manual editing. Co-Authored-By: Claude Haiku 4.5 --- .../bookings/BookingRequestDetailPage.tsx | 30 +++++++++++++++++++ .../pages/warehouses/ContainerReturnsPage.tsx | 23 +++++--------- .../src/services/bookings.service.ts | 7 +++++ 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 66a8a5b48..e77b4c44b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -1,7 +1,9 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import toast from "react-hot-toast"; import { ArrowLeft, FileSignature, + FileText, FolderOpen, Layers, LayoutGrid, @@ -50,6 +52,7 @@ import { useBookingMutations, } from "@/hooks/bookings/useBookings"; import { useScrollToHash } from "@/hooks/useScrollToHash"; +import { bookingsService } from "@/services/bookings.service"; export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); @@ -269,6 +272,33 @@ export default function BookingRequestDetailPage() { View / sign contract )} + {booking.customsClearingEnabled && (