[31b88f42] Finalize staff locations and travel time logic

This commit is contained in:
2026-03-09 14:44:10 +00:00
parent 9afe4148ba
commit 2c90da3ba5
4 changed files with 185 additions and 28 deletions

View File

@@ -1,7 +1,9 @@
import requests
import os
import io
import pandas as pd
from fastapi import FastAPI, File, UploadFile, HTTPException from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import pandas as pd
import io
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, List from typing import Dict, List
@@ -21,6 +23,12 @@ df_storage = None
plz_column_name = None plz_column_name = None
plz_geocoord_df = None plz_geocoord_df = None
# OpenRouteService API Key (should be in .env)
ORS_API_KEY = os.getenv("ORS_API_KEY")
if ORS_API_KEY:
# Strip quotes if they were passed literally from .env
ORS_API_KEY = ORS_API_KEY.strip("'\"")
STAFF_DATA = { STAFF_DATA = {
"sales": [ "sales": [
{"name": "Alexander Kiss", "plz": "86911"}, {"name": "Alexander Kiss", "plz": "86911"},
@@ -37,6 +45,43 @@ STAFF_DATA = {
] ]
} }
# --- Pydantic Models ---
class IsochroneRequest(BaseModel):
locations: List[Dict[str, float]] # List of {"lat": ..., "lon": ...}
range_minutes: int
@app.get("/api/isochrones")
async def get_isochrones(lat: float, lon: float, minutes: int):
if not ORS_API_KEY:
# Return a warning or a simple "simulated" isochrone if no key is provided
return {"error": "ORS_API_KEY missing", "simulated": True}
url = "https://api.openrouteservice.org/v2/isochrones/driving-car"
headers = {
'Accept': 'application/json, application/geo+json, application/gpx+xml, img/png; charset=utf-8',
'Authorization': ORS_API_KEY,
'Content-Type': 'application/json; charset=utf-8'
}
# ORS expects [lon, lat]
body = {
"locations": [[lon, lat]],
"range": [minutes * 60], # range is in seconds
"range_type": "time"
}
try:
response = requests.post(url, json=body, headers=headers)
if response.status_code == 200:
return response.json()
else:
print(f"ORS Error: {response.status_code} - {response.text}")
raise HTTPException(status_code=response.status_code, detail=f"ORS API error: {response.text}")
except Exception as e:
print(f"Request failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.on_event("startup") @app.on_event("startup")
def load_plz_data(): def load_plz_data():
global plz_geocoord_df global plz_geocoord_df
@@ -75,10 +120,14 @@ class PlzColumnRequest(BaseModel):
def read_root(): def read_root():
return {"message": "Heatmap Tool Backend"} return {"message": "Heatmap Tool Backend"}
@app.get("/health")
def health_check():
return {"status": "ok", "geocoding_loaded": not plz_geocoord_df.empty if plz_geocoord_df is not None else False}
@app.get("/api/staff-locations") @app.get("/api/staff-locations")
async def get_staff_locations(): async def get_staff_locations():
global plz_geocoord_df global plz_geocoord_df
if plz_geocoord_df.empty: if plz_geocoord_df is None or plz_geocoord_df.empty:
raise HTTPException(status_code=500, detail="Geocoding data is not available on the server.") raise HTTPException(status_code=500, detail="Geocoding data is not available on the server.")
result = {"sales": [], "technicians": []} result = {"sales": [], "technicians": []}
@@ -88,6 +137,10 @@ async def get_staff_locations():
plz = person["plz"] plz = person["plz"]
if plz in plz_geocoord_df.index: if plz in plz_geocoord_df.index:
coords = plz_geocoord_df.loc[plz] coords = plz_geocoord_df.loc[plz]
# If there are duplicates, loc returns a DataFrame. Take the first row.
if isinstance(coords, pd.DataFrame):
coords = coords.iloc[0]
result[category].append({ result[category].append({
"name": person["name"], "name": person["name"],
"plz": plz, "plz": plz,

View File

@@ -3,3 +3,4 @@ uvicorn[standard]
pandas pandas
openpyxl openpyxl
python-multipart python-multipart
requests

View File

@@ -57,7 +57,13 @@ function App() {
const [staffData, setStaffData] = useState<StaffData>({ sales: [], technicians: [] }); const [staffData, setStaffData] = useState<StaffData>({ sales: [], technicians: [] });
const [showSales, setShowSales] = useState(false); const [showSales, setShowSales] = useState(false);
const [showTechnicians, setShowTechnicians] = useState(false); const [showTechnicians, setShowTechnicians] = useState(false);
const [travelRadius, setTravelRadius] = useState(50); // In km const [travelTime, setTravelTime] = useState(60); // In minutes
const [averageSpeed, setAverageSpeed] = useState(80); // In km/h
const [isochrones, setIsochrones] = useState<Record<string, any>>({});
const [orsError, setOrsError] = useState<string | null>(null);
// Derived travel radius in km (fallback)
const travelRadius = (travelTime / 60) * averageSpeed;
useEffect(() => { useEffect(() => {
const fetchStaffLocations = async () => { const fetchStaffLocations = async () => {
@@ -71,6 +77,56 @@ function App() {
fetchStaffLocations(); fetchStaffLocations();
}, []); }, []);
// Fetch isochrones when settings change
useEffect(() => {
if (!showSales && !showTechnicians) return;
const fetchAllIsochrones = async () => {
const activeStaff = [
...(showSales ? staffData.sales : []),
...(showTechnicians ? staffData.technicians : [])
];
if (activeStaff.length === 0) return;
const newIsochrones: Record<string, any> = { ...isochrones };
let anyOrsError = false;
for (const person of activeStaff) {
// Skip if already fetched for this person and time
if (newIsochrones[person.name] && newIsochrones[person.name].minutes === travelTime) {
continue;
}
try {
const response = await axios.get('/heatmap/api/isochrones', {
params: { lat: person.lat, lon: person.lon, minutes: travelTime }
});
if (response.data.simulated || response.data.error) {
setOrsError("OpenRouteService API Key missing or invalid. Showing circular fallback.");
anyOrsError = true;
break;
}
newIsochrones[person.name] = {
data: response.data,
minutes: travelTime
};
} catch (err) {
console.error(`Failed to fetch isochrone for ${person.name}:`, err);
// Don't set anyOrsError here, just let this one person use the fallback
}
}
setIsochrones(newIsochrones);
if (!anyOrsError) setOrsError(null);
};
const timer = setTimeout(fetchAllIsochrones, 800); // Debounce
return () => clearTimeout(timer);
}, [travelTime, showSales, showTechnicians, staffData]);
const handleUploadSuccess = (response: any) => { const handleUploadSuccess = (response: any) => {
setError(null); setError(null);
if (response.plz_column_needed) { if (response.plz_column_needed) {
@@ -194,18 +250,44 @@ function App() {
</div> </div>
{(showSales || showTechnicians) && ( {(showSales || showTechnicians) && (
<div className="radius-control" style={{ marginBottom: '20px' }}> <div className="radius-control" style={{ marginBottom: '20px', padding: '10px', backgroundColor: '#444', borderRadius: '4px' }}>
<label htmlFor="travel-radius">Travel Radius: {travelRadius} km</label> <div style={{ marginBottom: '10px' }}>
<input <label htmlFor="travel-time">Travel Time: {travelTime} min (max 60)</label>
type="range" <input
id="travel-radius" type="range"
min="10" id="travel-time"
max="200" min="15"
step="5" max="60"
value={travelRadius} step="5"
onChange={(e) => setTravelRadius(parseInt(e.target.value))} value={travelTime}
style={{ width: '100%' }} </div>
/> {orsError ? (
<>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="avg-speed">Avg. Speed: {averageSpeed} km/h</label>
<input
type="range"
id="avg-speed"
min="30"
max="120"
step="5"
value={averageSpeed}
onChange={(e) => setAverageSpeed(parseInt(e.target.value))}
style={{ width: '100%' }}
/>
</div>
<div style={{ fontSize: '0.8em', color: '#ffcc00', marginBottom: '5px' }}>
{orsError}
</div>
<div style={{ fontSize: '0.9em', color: '#ccc', textAlign: 'center', borderTop: '1px solid #666', paddingTop: '5px' }}>
Resulting Radius: <strong>{travelRadius.toFixed(1)} km</strong>
</div>
</>
) : (
<div style={{ fontSize: '0.8em', color: '#4CAF50' }}>
Using real road data (OpenRouteService)
</div>
)}
</div> </div>
)} )}
@@ -240,6 +322,7 @@ function App() {
showSales={showSales} showSales={showSales}
showTechnicians={showTechnicians} showTechnicians={showTechnicians}
travelRadius={travelRadius} travelRadius={travelRadius}
isochrones={isochrones}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,6 @@
// src/components/MapDisplay.tsx // src/components/MapDisplay.tsx
import React from 'react'; import React from 'react';
import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle } from 'react-leaflet'; import { MapContainer, TileLayer, CircleMarker, Tooltip, Marker, Circle, GeoJSON } from 'react-leaflet';
import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3'; import { HeatmapLayer } from 'react-leaflet-heatmap-layer-v3';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import 'leaflet.heat'; import 'leaflet.heat';
@@ -25,6 +25,7 @@ interface MapDisplayProps {
showSales: boolean; showSales: boolean;
showTechnicians: boolean; showTechnicians: boolean;
travelRadius: number; travelRadius: number;
isochrones?: Record<string, any>;
} }
const MapDisplay: React.FC<MapDisplayProps> = ({ const MapDisplay: React.FC<MapDisplayProps> = ({
@@ -35,7 +36,8 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
staffData, staffData,
showSales, showSales,
showTechnicians, showTechnicians,
travelRadius travelRadius,
isochrones = {}
}) => { }) => {
const germanyCenter: [number, number] = [51.1657, 10.4515]; const germanyCenter: [number, number] = [51.1657, 10.4515];
const maxCount = heatmapData.length > 0 ? Math.max(...heatmapData.map(p => p.count), 1) : 1; const maxCount = heatmapData.length > 0 ? Math.max(...heatmapData.map(p => p.count), 1) : 1;
@@ -120,16 +122,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showSales) { if (showSales) {
staffData.sales.forEach((person, idx) => { staffData.sales.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push( elements.push(
<React.Fragment key={`sales-${idx}`}> <React.Fragment key={`sales-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={salesIcon}> <Marker position={[person.lat, person.lon]} icon={salesIcon}>
<Tooltip permanent={false}>Sales: {person.name} ({person.plz})</Tooltip> <Tooltip permanent={false}>Sales: {person.name} ({person.plz})</Tooltip>
</Marker> </Marker>
<Circle {iso ? (
center={[person.lat, person.lon]} <GeoJSON
radius={travelRadius * 1000} key={`iso-sales-${person.name}-${iso.minutes}`}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }} data={iso.data}
/> pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'blue', fillColor: 'blue', fillOpacity: 0.1 }}
/>
)}
</React.Fragment> </React.Fragment>
); );
}); });
@@ -137,16 +148,25 @@ const MapDisplay: React.FC<MapDisplayProps> = ({
if (showTechnicians) { if (showTechnicians) {
staffData.technicians.forEach((person, idx) => { staffData.technicians.forEach((person, idx) => {
const iso = isochrones[person.name];
elements.push( elements.push(
<React.Fragment key={`tech-${idx}`}> <React.Fragment key={`tech-${idx}`}>
<Marker position={[person.lat, person.lon]} icon={technicianIcon}> <Marker position={[person.lat, person.lon]} icon={technicianIcon}>
<Tooltip permanent={false}>Tech: {person.name} ({person.plz})</Tooltip> <Tooltip permanent={false}>Tech: {person.name} ({person.plz})</Tooltip>
</Marker> </Marker>
<Circle {iso ? (
center={[person.lat, person.lon]} <GeoJSON
radius={travelRadius * 1000} key={`iso-tech-${person.name}-${iso.minutes}`}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }} data={iso.data}
/> pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.2, weight: 1 }}
/>
) : (
<Circle
center={[person.lat, person.lon]}
radius={travelRadius * 1000}
pathOptions={{ color: 'orange', fillColor: 'orange', fillOpacity: 0.1 }}
/>
)}
</React.Fragment> </React.Fragment>
); );
}); });