From 204bfd242190e92f7f0e3aa62f863d00e006b97b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 8 Aug 2026 12:56:10 +0000 Subject: [PATCH 1/2] fix: let trains dispatch with cargo still in the warehouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export-only assertAllocatedCargoLoaded guard threw a BadRequestException whenever a booking on the schedule had warehouse inventory in RECEIVED, STORED or READY_FOR_LOADING, making it impossible to dispatch a train whose cargo had not been inspected and loaded onto a wagon. Leaving cargo behind is an operational decision, not an error state. The dispatch confirm dialog already lists unassigned and unloaded bookings and offers Dispatch anyway, so the readiness signal is preserved — only the hard block is gone. Wagon/locomotive conflicts and the Djibouti gatepass check still block dispatch: those are physical and legal conflicts, not cargo readiness. --- .../train-scheduling.service.ts | 62 +--- .../src/pages/fleet/TrackingPage.tsx | 323 +++++++++++++----- .../new-booking-form/LocationPicker.tsx | 68 +++- 3 files changed, 300 insertions(+), 153 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9a03f665c..b5658e556 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -21,7 +21,6 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql'; import { DataSource, EntityManager, @@ -2474,63 +2473,6 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - /** - * EXPORT ONLY. An export train must not leave carrying nothing while its cargo - * sits in the shed: the goods are received into the origin warehouse, GRN'd and - * loaded onto the wagons allocated to the booking, so anything still in the - * warehouse at dispatch is being left behind. Blocks dispatch when an allocated - * booking has warehouse inventory that never made it onto a wagon (received / - * stored / ready but not LOADED) — either load it from the Load-to-Train queue, - * or drop the booking's wagon allocation so it rides a later train. - * - * Import/domestic are untouched: their cargo isn't loaded out of an origin - * warehouse, so warehouse inventory says nothing about what's aboard. - * - * Bookings with no warehouse inventory at all are NOT blocked — allocating a - * wagon before the goods arrive is normal planning; they simply aren't aboard. - */ - private async assertAllocatedCargoLoaded(scheduleId: string): Promise { - const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> = - await this.dataSource.query( - `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" - FROM freight.train_schedules ts - LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id - LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id - WHERE ts.id = $1 AND ts.deleted_at IS NULL`, - [scheduleId], - ); - if (!route) return; - const direction = deriveTradeDirection( - { country: route.originCountry }, - { country: route.destinationCountry }, - ); - if (direction !== 'EXPORT') return; - - // Only bookings boarding at the schedule's ORIGIN station gate dispatch — - // a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the - // train reaches its yard, so its warehouse state says nothing at departure. - const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query( - `WITH ${SCHEDULE_BOOKINGS_CTE} - SELECT DISTINCT b.reference AS "reference", inv.status AS "status" - FROM sched_bookings sb - JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL - JOIN freight.train_schedules ts ON ts.id = sb.schedule_id - JOIN freight.warehouse_inventory inv - ON inv.booking_id = b.id AND inv.deleted_at IS NULL - WHERE sb.schedule_id = $1 - AND b.origin_yard_id = ts.origin_station_id - AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`, - [scheduleId], - ); - if (rows.length) { - const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', '); - throw new BadRequestException( - `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` + - `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`, - ); - } - } - async dispatchSchedule(scheduleId: string) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -2540,8 +2482,8 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); - // Export only: don't leave received cargo behind in the warehouse. - await this.assertAllocatedCargoLoaded(scheduleId); + // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) + // never blocks departure — the dispatch confirm dialog warns and staff decide. // A locomotive may sit on many future schedules, but it can only pull one train // at a time — block dispatch while any set locomotive is out on a dispatched train. const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index b773e9c7c..cb64d3b22 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -30,15 +30,17 @@ import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { vehiclesService } from "@/services/vehicles.service"; -import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; +import { + gpsTrackingService, + type GpsDevice, +} from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; -// Same default key + env override the portal's LocationPicker uses. -// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set -// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key. -const GOOGLE_MAPS_API_KEY = - import.meta.env.VITE_GOOGLE_MAPS_API_KEY || - "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; +// Maps JavaScript API keys are public client-side keys — lock them down by +// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default +// used to live here and expired, turning a missing env var into a blank map +// that read as broken GPS rather than absent configuration. +const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim(); const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa const toNum = (v: number | string | null | undefined): number | null => @@ -57,8 +59,12 @@ const fmtTime = (iso?: string | null) => { const StatBox = ({ label, value }: { label: string; value: string }) => ( - {label} - {value} + + {label} + + + {value} + ); @@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null { if (typeof google === "undefined" || !google.maps?.Geocoder) return; setAddr(null); let cancelled = false; - new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => { - if (cancelled) return; - setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location"); - }); - return () => { cancelled = true; }; + new google.maps.Geocoder().geocode( + { location: { lat, lng } }, + (res, status) => { + if (cancelled) return; + setAddr( + status === "OK" && res?.[0] + ? res[0].formatted_address + : "Unknown location", + ); + }, + ); + return () => { + cancelled = true; + }; }, [lat, lng]); return addr; } @@ -120,16 +135,24 @@ function HoverInfo({ }) { const address = useAddress(lat, lng); return ( - +
-
{deviceLabel(device)}
+
+ {deviceLabel(device)} +
{lat.toFixed(5)}, {lng.toFixed(5)}
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
-
{address ?? "Locating…"}
+
+ {address ?? "Locating…"} +
); @@ -184,7 +207,8 @@ export function TrackingPage() { const { data: vehiclesData } = useQuery({ queryKey: ["vehicles", "all"], - queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [], + queryFn: async () => + (await vehiclesService.getAll({ limit: 1000 })).data ?? [], }); const vehicleOptions = useMemo( () => @@ -199,7 +223,10 @@ export function TrackingPage() { () => devices .map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) })) - .filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null), + .filter( + (x): x is { d: GpsDevice; lat: number; lng: number } => + x.lat != null && x.lng != null, + ), [devices], ); @@ -209,19 +236,31 @@ export function TrackingPage() { // Route history for the selected device's vehicle (chronological trail). const { data: history = [] } = useQuery({ queryKey: ["gps", "history", selected?.vehicleId], - queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [], + queryFn: async () => + (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [], enabled: Boolean(selected?.vehicleId), }); const trail = useMemo( - () => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })), + () => + [...history] + .reverse() + .map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })), [history], ); // Teardrop pin colored by state with a white truck glyph inside. - const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => { + const markerIcon = ( + d: GpsDevice, + selectedFlag: boolean, + ): google.maps.Icon | undefined => { // Maps API loads async — Size/Point classes may not exist yet at first render. - if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined; - const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6"; + if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) + return undefined; + const color = selectedFlag + ? freightBrand.primary + : d.online + ? "#2f80ed" + : "#95a5a6"; const svg = ` @@ -260,8 +299,13 @@ export function TrackingPage() { }, onError: (err: unknown) => { const description = - (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed"; - toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" }); + (err as { response?: { data?: { message?: string } } })?.response?.data + ?.message ?? "Failed"; + toast({ + title: editDevice ? "Update failed" : "Registration failed", + description, + variant: "destructive", + }); }, }); @@ -287,15 +331,25 @@ export function TrackingPage() { return ( - +
- Real-Time Vehicle Tracking - Live GPS positions from GT06 trackers + + Real-Time Vehicle Tracking + + + Live GPS positions from GT06 trackers +
{canManage && ( - )} @@ -314,36 +368,82 @@ export function TrackingPage() { - - - + {!GOOGLE_MAPS_API_KEY ? ( + // Name the missing variable rather than showing an empty map: the + // device list beside this still works, so a blank panel reads as + // "no GPS fixes" instead of "no map key". + - setMapsReady(true)} /> - {positioned.map(({ d, lat, lng }) => ( - setSelectedId(d.id)} - onMouseOver={() => setHoverId(d.id)} + + Map unavailable — VITE_GOOGLE_MAPS_API_KEY is + not set. Add a Google Maps key with the Maps JavaScript + API and Places API enabled to this app's{" "} + .env, then restart the dev server. Device + positions below are unaffected. + + + ) : ( + + + setMapsReady(true)} /> + {positioned.map(({ d, lat, lng }) => ( + setSelectedId(d.id)} + onMouseOver={() => setHoverId(d.id)} + /> + ))} + {(() => { + const h = positioned.find((p) => p.d.id === hoverId); + return h ? ( + setHoverId(null)} + /> + ) : null; + })()} + ({ + lat: p.lat, + lng: p.lng, + }))} /> - ))} - {(() => { - const h = positioned.find((p) => p.d.id === hoverId); - return h ? ( - setHoverId(null)} /> - ) : null; - })()} - ({ lat: p.lat, lng: p.lng }))} /> - {selected?.vehicleId && trail.length > 1 && } - - + {selected?.vehicleId && trail.length > 1 && ( + + )} + + + )} {positioned.length === 0 && ( @@ -363,11 +463,19 @@ export function TrackingPage() { {deviceLabel(selected)} - }> + } + > {selected.online ? "Live" : "Offline"} {canManage && ( - deleteMutation.mutate(selected.id)}> + deleteMutation.mutate(selected.id)} + > )} @@ -375,21 +483,55 @@ export function TrackingPage() { - - - - - - + + + + + +
- IMEI - {selected.imei} + + IMEI + + + {selected.imei} +
- Last fix - {fmtTime(selected.lastFixAt)} + + Last fix + + + {fmtTime(selected.lastFixAt)} +
{selected.vehicleId && ( @@ -402,7 +544,9 @@ export function TrackingPage() { placeholder="Unassigned" data={vehicleOptions} value={selected.vehicleId ?? null} - onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })} + onChange={(v) => + assignMutation.mutate({ id: selected.id, vehicleId: v }) + } disabled={!canManage} searchable clearable @@ -420,24 +564,43 @@ export function TrackingPage() { {devices.map((d) => ( setSelectedId(d.id)} > - {deviceLabel(d)} - {toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)} + + {deviceLabel(d)} + + + {toNum(d.lastSpeed) ?? 0} km/h ·{" "} + {fmtTime(d.lastFixAt)} + - {d.online ? "Live" : "Offline"} + + {d.online ? "Live" : "Offline"} + {canManage && ( { e.stopPropagation(); openEdit(d); }} + onClick={(e) => { + e.stopPropagation(); + openEdit(d); + }} > @@ -449,7 +612,9 @@ export function TrackingPage() { {devices.length === 0 && ( - No trackers registered yet. + + No trackers registered yet. + )} @@ -495,7 +660,9 @@ export function TrackingPage() { clearable /> - +