From 1bae13aec412735bb0e7be01fb749e11f666fcf5 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 06:46:03 +0000 Subject: [PATCH] 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 () => {