From 90fef6856b79939388a86c96711d8d3b81ebb87e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Fri, 31 Jul 2026 09:13:13 +0000 Subject: [PATCH] Container return and Price per KM for Vehcles --- .../src/modules/bookings/bookings.service.ts | 80 ++++++++++++++++--- .../src/pages/fleet/config/vehicles.ts | 2 + .../pages/warehouses/ContainerReturnsPage.tsx | 65 +++++++++------ 3 files changed, 113 insertions(+), 34 deletions(-) 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 f9103ddbb..b8f73dfc8 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -87,6 +87,12 @@ interface CarriageAcceptanceWagonRow { sealNumbers: string | null; } +/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ +interface CarriageAcceptanceReceivedRow { + allocatedWeightTons: string | null; + containerNumbers: string | null; +} + const URGENT_PRIORITY_THRESHOLD = 1000; const NEEDS_ACTION_STATUSES = [ 'SUBMITTED', @@ -233,7 +239,7 @@ export class BookingsService { */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); - const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( + let wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query( `SELECT tsw.sequence_no AS "sequenceNo", COALESCE(wt.code, wt.name) AS "wagonType", w.wagon_number AS "wagonNumber", @@ -264,13 +270,57 @@ export class BookingsService { 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', - ); + // Export acceptance happens at the warehouse gate, not at marshalling: EDR + // takes custody of the cargo when it receives it, and the customer is handed + // this sheet then — before the booking is put on a train. So a received + // export booking gets its sheet off the received cargo, wagon columns blank + // until the consist exists. Import keeps the allocation gate: nothing is + // accepted from the customer before the wagons carry it. + // + // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork + // and never appears on this sheet — it is only the signal that EDR has taken + // the cargo, which is what the customer's sheet attests to. + const pendingWagons = wagons.length === 0; + if (pendingWagons) { + const receivedLines: CarriageAcceptanceReceivedRow[] = + booking.tradeDirection === 'EXPORT' + ? await this.dataSource.query( + `SELECT inv.weight AS "allocatedWeightTons", + c.container_number AS "containerNumbers" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.containers c + ON c.id = inv.container_id AND c.deleted_at IS NULL + WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL + AND COALESCE(NULLIF(TRIM(inv.grn_number), ''), '') <> '' + ORDER BY inv.created_at`, + [bookingId], + ) + : []; + if (receivedLines.length === 0) { + throw new BadRequestException( + booking.tradeDirection === 'EXPORT' + ? 'This export booking has no GRN yet — receive the cargo at the warehouse before issuing the carriage acceptance sheet' + : 'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation', + ); + } + wagons = receivedLines.map((row, index) => ({ + sequenceNo: index + 1, + wagonType: null, + wagonNumber: null, + tareWeightTons: null, + equatedLength: null, + loadCapacityTons: null, + allocatedWeightTons: row.allocatedWeightTons, + trainNumber: null, + departureAt: null, + marshalledAt: null, + arrivalAt: null, + containerNumbers: row.containerNumbers, + sealNumbers: null, + })); } - const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons); + const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons, { pendingWagons }); const buffer = await this.pdfRender.htmlToPdfBuffer(html, { label: 'carriage acceptance sheet', fallback: (prepared) => buildTabularFallbackPdf(prepared), @@ -299,6 +349,7 @@ export class BookingsService { private buildCarriageAcceptanceSheetHtml( booking: Booking, wagons: CarriageAcceptanceWagonRow[], + { pendingWagons }: { pendingWagons: boolean }, ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits); @@ -424,7 +475,11 @@ export class BookingsService { - Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons}) + ${ + pendingWagons + ? `Received lines: ${wagons.length} — wagons pending marshalling` + : `Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})` + } ${num(totals.tare, 2)} ${num(totals.length)} ${num(totals.capacity)} @@ -435,9 +490,14 @@ export class BookingsService {
- 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. + ${ + pendingWagons + ? `The cargo listed above is accepted for carriage under booking ${esc(booking.reference)}. + Wagon identity and seal numbers are filled in when the booking is marshalled onto a train.` + : `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.` + }
diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index c8e6667f4..0c91d3c15 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -141,6 +141,7 @@ export const vehiclesConfig: FleetResourceConfig = { required: true, description: "Pre-filled from the truck type — override only for a one-off", }, + { name: "pricePerKm", label: "Price per KM (ETB)", type: "number", description: "Haulage rate charged per kilometre" }, { name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS }, { name: "availability", label: "Availability", type: "select", required: true, options: VEHICLE_AVAILABILITY_OPTIONS }, { name: "description", label: "Description", type: "textarea" }, @@ -157,6 +158,7 @@ export const vehiclesConfig: FleetResourceConfig = { year: new Date().getFullYear(), fuelType: "DIESEL", capacity: 0, + pricePerKm: 0, status: "ACTIVE", availability: "FREE", description: "", 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 77cf89d30..36745db3b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -23,6 +23,7 @@ import { PageContainer, PageHeader } from "@/components/page"; import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { useListControls } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; +import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { api } from "@/services/api"; import { warehouseService } from "@/services/warehouse.service"; import { importOperationsService } from "@/services/importOperations.service"; @@ -561,12 +562,11 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]); const [warehouse, setWarehouse] = useState(null); - const [yard, setYard] = useState(""); - const [zone, setZone] = useState(""); + const [yardId, setYardId] = useState(null); + const [zoneId, setZoneId] = useState(null); 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 () => { @@ -575,17 +575,18 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon }); const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? []; - const selectedWarehouseData = warehouse && Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null; + + const { data: yards } = useWarehouseYards(warehouse ?? undefined); + const { data: zones } = useWarehouseZones(yardId ?? undefined); useEffect(() => { - if (selectedWarehouseData) { - setYard(selectedWarehouseData.yard || selectedWarehouseData.code || ""); - setZone(selectedWarehouseData.zone || ""); - } else { - setYard(""); - setZone(""); - } - }, [selectedWarehouseData]); + setYardId(null); + setZoneId(null); + }, [warehouse]); + + useEffect(() => { + setZoneId(null); + }, [yardId]); const warehouseOptions = Array.isArray(warehouses) ? warehouses.map((wh: any) => ({ @@ -594,10 +595,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon })) : []; + const yardOptions = (yards ?? []) + .filter((y) => y.status === "ACTIVE") + .map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })); + + const zoneOptions = (zones ?? []) + .filter((z) => z.status === "ACTIVE") + .map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })); + const handleSubmit = () => { if (!containerNumber || !warehouse || !returnedBy) return; const selectedWarehouse = Array.isArray(warehouses) ? warehouses.find((wh: any) => wh.id === warehouse) : null; + const selectedYard = yards?.find((y) => y.id === yardId); + const selectedZone = zones?.find((z) => z.id === zoneId); onSubmit({ trucks: [ @@ -610,6 +621,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon containerNumber, returnDate, warehouse: selectedWarehouse?.name || warehouse, + yard: selectedYard?.name, + zone: selectedZone?.name, condition: condition || undefined, handoverNote: handoverNote || undefined, }, @@ -622,8 +635,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon setReturnedBy(null); setReturnDate(new Date().toISOString().split("T")[0]); setWarehouse(null); - setYard(""); - setZone(""); + setYardId(null); + setZoneId(null); setCondition(""); setHandoverNote(""); onClose(); @@ -666,20 +679,24 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon searchable /> - -