Inline route optimization into canteen server (remove proxy to daily-route on port 8912)

- Added extraction DB config and _get_extraction_db() helper
- Added _status_label, _haversine, _solve_tsp (nearest-neighbor + 2-opt)
- Added GET /api/workorders/search (native)
- Added POST /api/workorders/lookup (native)
- Added GET /api/workorders/today (native, reads bookableresourcebooking)
- Added GET /api/workorders/technicians (native)
- Added POST /api/route/optimize (native TSP solver)
- Removed httpx dependency, _proxy_route, _get_route_client
- Removed all proxy endpoints
This commit is contained in:
2026-05-27 18:18:24 -04:00
parent a1022b300e
commit da2a23f6da
3 changed files with 1433 additions and 1 deletions
+645 -1
View File
@@ -9,6 +9,7 @@ import csv
import hashlib
import io
import json as _json
import math
import os
import re
import secrets
@@ -17,6 +18,7 @@ import urllib.error
import urllib.request
import uuid
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
import pytesseract
@@ -515,7 +517,6 @@ app.add_middleware(
allow_headers=["*"],
)
# ─── Auth Middleware ──────────────────────────────────────────────────────────
@@ -2438,6 +2439,649 @@ def list_all_service_entrances():
return [dict(r) for r in rows]
# ═══════════════════════════════════════════════════════════════════════════
# WORK ORDER & ROUTE OPTIMIZATION — reads extraction DB directly
# ═══════════════════════════════════════════════════════════════════════════
# ── Extraction DB (MS Field Service sync) ────────────────────────────────
EXTRACTION_PROJECT = Path.home() / "projects" / "ms-field-service-extraction"
EXTRACTION_DB = (
EXTRACTION_PROJECT
/ "data-samples"
/ "msfs_backup"
/ "fieldservicecanteen.crm.dynamics.com_9fc6c50c-b097-f011-b4cb-7ced8d1b15a2_data.db"
)
STATUS_LABELS = {
"690970000": "Unscheduled",
"690970001": "Scheduled",
"690970002": "In Progress",
"690970003": "Completed",
"690970004": "Posted",
"690970005": "Cancelled",
"690959000": "Postponed",
}
def _status_label(code) -> str:
"""Map a status code integer to a human-readable label."""
return STATUS_LABELS.get(str(code), f"Unknown ({code})")
def _get_extraction_db() -> sqlite3.Connection | None:
"""Return read-only connection to the MS Field Service extraction DB."""
if not EXTRACTION_DB.exists():
return None
conn = sqlite3.connect(str(EXTRACTION_DB))
conn.row_factory = sqlite3.Row
return conn
# ── TSP Solver ───────────────────────────────────────────────────────────
def _haversine(lat1, lon1, lat2, lon2):
"""Haversine distance in km between two lat/lng points."""
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
)
return R * 2 * math.asin(math.sqrt(a))
def _solve_tsp(points, origin_idx=0):
"""Nearest-neighbor TSP with 2-opt improvement.
Args:
points: list of (lat, lng) tuples.
origin_idx: index of the starting point (default 0).
Returns list of indices in TSP-optimized order.
"""
n = len(points)
if n <= 2:
return list(range(n))
# Precompute distance matrix
dist = [
[_haversine(points[i][0], points[i][1], points[j][0], points[j][1]) for j in range(n)]
for i in range(n)
]
# Nearest-neighbor
unvisited = set(range(n))
unvisited.remove(origin_idx)
route = [origin_idx]
current = origin_idx
while unvisited:
nearest = min(unvisited, key=lambda j: dist[current][j])
route.append(nearest)
unvisited.remove(nearest)
current = nearest
# 2-opt improvement
improved = True
while improved:
improved = False
for i in range(n - 1):
for j in range(i + 2, n):
j1 = (j + 1) % n
old_dist = dist[route[i]][route[i + 1]] + dist[route[j]][route[j1]]
new_dist = dist[route[i]][route[j]] + dist[route[i + 1]][route[j1]]
if new_dist < old_dist - 0.001:
route[i + 1 : j + 1] = reversed(route[i + 1 : j + 1])
improved = True
return route
# ── Work Order Search ──────────────────────────────────────────────────────
@app.get("/api/workorders/search")
async def workorders_search(
q: str = Query("", description="Search term"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Search work orders by name, account, or city."""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
like = f"%{q}%"
cur.execute(
"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_name LIKE ?
OR a.name LIKE ?
OR a.address1_city LIKE ?
ORDER BY w.msdyn_name
LIMIT ? OFFSET ?
""",
(like, like, like, limit, offset),
)
rows = cur.fetchall()
cur.execute(
"""
SELECT COUNT(*) FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_name LIKE ?
OR a.name LIKE ?
OR a.address1_city LIKE ?
""",
(like, like, like),
)
total = cur.fetchone()[0]
return {
"results": [
{
"id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"priority": r["priority"],
"onsite_duration": r["hsl_onsiteduration"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": (
{
"lat": r["address1_latitude"],
"lng": r["address1_longitude"],
}
if r["address1_latitude"] and r["address1_longitude"]
else None
),
}
for r in rows
],
"total": total,
"offset": offset,
"limit": limit,
}
finally:
conn.close()
# ── Work Order Lookup (by ID) ──────────────────────────────────────────────
class WorkorderLookupRequest(BaseModel):
ids: list[str]
@app.post("/api/workorders/lookup")
async def workorders_lookup(body: WorkorderLookupRequest):
"""Lookup work orders by ID or name."""
ids = body.ids
if not ids:
raise HTTPException(400, "No work order IDs provided")
if len(ids) > 100:
raise HTTPException(400, "Max 100 work orders per request")
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
placeholders = ",".join(["?"] * len(ids))
cur.execute(
f"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w."msdyn_serviceaccount!id" AS account_id,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
WHERE w.msdyn_workorderid IN ({placeholders})
OR w.msdyn_name IN ({placeholders})
""",
ids + ids,
)
rows = cur.fetchall()
found = []
id_set = set(ids)
found_set = set()
for r in rows:
rid = r["msdyn_workorderid"]
rname = r["msdyn_name"]
matched = rid if rid in id_set else rname if rname in id_set else None
if matched:
found_set.add(matched)
lat = r["address1_latitude"]
lng = r["address1_longitude"]
gps = None
if lat and lng:
try:
gps = {"lat": float(lat), "lng": float(lng)}
except (ValueError, TypeError):
pass
found.append(
{
"id": rid,
"name": rname,
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"priority": r["priority"],
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"onsite_duration": r["hsl_onsiteduration"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
}
)
not_found = [uid for uid in ids if uid not in found_set and uid not in {f["id"] for f in found}]
return {
"found": found,
"not_found": not_found,
"total_found": len(found),
"total_not_found": len(not_found),
}
finally:
conn.close()
# ── Today's Active Work Orders ──────────────────────────────────────────────
@app.get("/api/workorders/today")
async def workorders_today(
tech: list[str] = Query([], description="Filter by technician names"),
limit: int = Query(100, ge=1, le=500),
):
"""Fetch today's active work orders via bookableresourcebooking.
Pass ?tech=Shawn+Canada&tech=John+Doe to filter by one or more technicians.
Omit tech entirely to return all technicians.
"""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
today = date.today().isoformat()
today_start = f"{today}T00:00:00Z"
today_end = f"{today}T23:59:59Z"
active_booking_statuses = ",".join(
f"'{s}'"
for s in ["Scheduled", "Unscheduled", "On Site", "Traveling", "On Break", "Hold", "Committed"]
)
params = [today_start, today_end]
tech_clause = ""
if tech:
placeholders_t = ",".join(["?" for _ in tech])
tech_clause = f'AND b."resource!name" IN ({placeholders_t})'
params.extend(tech)
params.append(limit)
cur.execute(
f"""
SELECT
w.msdyn_workorderid,
COALESCE(b."msdyn_workorder!name", w.msdyn_name) AS msdyn_name,
COALESCE(b."hsl_serviceaccountid!name", w."msdyn_serviceaccount!name") AS account_name,
COALESCE(b."hsl_serviceaccountid!id", w."msdyn_serviceaccount!id") AS account_id,
b."bookingstatus!name" AS booking_status,
w.msdyn_systemstatus,
w.msdyn_workordersummary,
b.starttime, b.endtime,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
b."resource!name" AS technician,
w."msdyn_serviceterritory!name" AS territory,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM bookableresourcebooking b
LEFT JOIN msdyn_workorder w ON b."msdyn_workorder!name" = w.msdyn_name
LEFT JOIN account a ON COALESCE(b."hsl_serviceaccountid!id", w."msdyn_serviceaccount!id") = a.accountid
WHERE b.starttime >= ?
AND b.starttime <= ?
AND b."bookingstatus!name" IN ({active_booking_statuses})
{tech_clause}
ORDER BY b.starttime ASC
LIMIT ?
""",
params,
)
rows = cur.fetchall()
workorders = []
for r in rows:
lat = r["address1_latitude"]
lng = r["address1_longitude"]
gps = None
if lat and lng:
try:
gps = {"lat": float(lat), "lng": float(lng)}
except (ValueError, TypeError):
pass
workorders.append(
{
"id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"booking_status": r["booking_status"],
"priority": r["priority"],
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"technician": r["technician"],
"territory": r["territory"],
"onsite_duration": r["hsl_onsiteduration"],
"starttime": r["starttime"],
"endtime": r["endtime"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
}
)
return {
"date": today,
"total": len(workorders),
"with_gps": sum(1 for w in workorders if w["gps"]),
"without_gps": sum(1 for w in workorders if not w["gps"]),
"workorders": workorders,
"booking_statuses": ["Scheduled", "Unscheduled", "On Site", "Traveling", "On Break", "Hold", "Committed"],
}
finally:
conn.close()
# ── List Technicians ──────────────────────────────────────────────────────
@app.get("/api/workorders/technicians")
async def workorders_technicians():
"""Return distinct technician names from bookings."""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
cur.execute(
"""
SELECT DISTINCT b."resource!name" AS technician
FROM bookableresourcebooking b
WHERE b."resource!name" IS NOT NULL
AND b."resource!name" != ''
ORDER BY technician
"""
)
techs = [r["technician"] for r in cur.fetchall()]
return {"technicians": techs, "total": len(techs)}
finally:
conn.close()
# ── Route Optimization ──────────────────────────────────────────────────────
def _lookup_asset_gps_by_machine_id(machine_id: str) -> dict | None:
"""Lookup GPS from the canteen-asset-tracker assets table by machine_id."""
try:
conn = get_db()
row = conn.execute(
"SELECT latitude, longitude FROM assets WHERE machine_id = ? "
"AND latitude IS NOT NULL AND latitude != 0 "
"AND longitude IS NOT NULL AND longitude != 0",
(machine_id,),
).fetchone()
conn.close()
if row:
return {"lat": float(row["latitude"]), "lng": float(row["longitude"])}
except Exception:
pass
return None
class RouteOptimizeRequest(BaseModel):
work_order_ids: list[str]
origin: dict | None = None # {"lat": 28.5, "lng": -81.3} or null
start_at: str | None = None # optional name/label for origin stop
@app.post("/api/route/optimize")
async def route_optimize(body: RouteOptimizeRequest):
"""Optimize a route for a set of work orders using TSP (nearest-neighbor + 2-opt)."""
wo_ids = body.work_order_ids
origin = body.origin
if not wo_ids:
raise HTTPException(400, "No work order IDs provided")
if len(wo_ids) > 50:
raise HTTPException(400, "Max 50 work orders per route")
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
placeholders = ",".join(["?"] * len(wo_ids))
cur.execute(
f"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
w."msdyn_serviceaccount!id" AS account_id,
w.msdyn_workordersummary,
w.msdyn_datewindowstart, w.msdyn_timewindowstart, w.msdyn_timewindowend,
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
w."msdyn_workordertype!name" AS work_type,
w."msdyn_primaryincidenttype!name" AS incident_type,
w."msdyn_serviceterritory!name" AS territory,
w."msdyn_customerasset!id" AS asset_id,
w."msdyn_customerasset!name" AS asset_name,
ca.msdyn_latitude AS asset_lat, ca.msdyn_longitude AS asset_lng, ca.hsl_lobassetnumber,
ca."msdyn_functionallocation!name" AS asset_location_in_facility,
ca."msdyn_account!name" AS asset_account_name,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude, a.name AS account_display_name
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
LEFT JOIN msdyn_customerasset ca ON w."msdyn_customerasset!id" = ca.msdyn_customerassetid
WHERE w.msdyn_name IN ({placeholders})
OR w.msdyn_workorderid IN ({placeholders})
""",
wo_ids + wo_ids,
)
rows = cur.fetchall()
finally:
conn.close()
if not rows:
raise HTTPException(404, "No matching work orders found in DB")
# Build stop list
stops = []
for r in rows:
addr_lat = r["address1_latitude"]
addr_lng = r["address1_longitude"]
address_gps = None
if addr_lat and addr_lng:
try:
address_gps = {"lat": float(addr_lat), "lng": float(addr_lng)}
except (ValueError, TypeError):
pass
# Machine GPS — prefer msdyn_customerasset GPS, fall back to canteen assets table
machine_gps = None
asset_lat = r["asset_lat"]
asset_lng = r["asset_lng"]
if asset_lat and asset_lng:
try:
machine_gps = {"lat": float(asset_lat), "lng": float(asset_lng)}
except (ValueError, TypeError):
pass
if not machine_gps:
lob = r["hsl_lobassetnumber"]
if lob:
machine_gps = _lookup_asset_gps_by_machine_id(str(lob).strip())
gps = machine_gps or address_gps
stops.append(
{
"work_order_id": r["msdyn_workorderid"],
"name": r["msdyn_name"],
"account_name": r["account_name"],
"account_display_name": r["account_display_name"],
"summary": r["msdyn_workordersummary"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
"state": r["address1_stateorprovince"],
"postal": r["address1_postalcode"],
},
"gps": gps,
"address_gps": address_gps,
"date_window": r["msdyn_datewindowstart"],
"time_window_start": r["msdyn_timewindowstart"],
"time_window_end": r["msdyn_timewindowend"],
"onsite_duration": r["hsl_onsiteduration"],
"priority": r["priority"],
"status_code": r["msdyn_systemstatus"],
"status": _status_label(r["msdyn_systemstatus"]),
"work_type": r["work_type"],
"incident_type": r["incident_type"],
"territory": r["territory"],
"asset_id": r["asset_id"],
"asset_name": r["asset_name"],
"asset_location_in_facility": r["asset_location_in_facility"],
"asset_account_name": r["asset_account_name"],
}
)
# Filter stops with GPS
stops_with_gps = [s for s in stops if s["gps"]]
stops_without_gps = [s for s in stops if not s["gps"]]
if not stops_with_gps:
return {
"route": [],
"error": "No stops have GPS coordinates",
"stops_without_gps": stops_without_gps,
"summary": {"total_stops": 0, "with_gps": 0, "without_gps": len(stops_without_gps)},
}
# TSP
points = [(s["gps"]["lat"], s["gps"]["lng"]) for s in stops_with_gps]
has_origin = origin and "lat" in origin and "lng" in origin
if has_origin:
points.insert(0, (origin["lat"], origin["lng"]))
route_indices = _solve_tsp(points, origin_idx=0)
route_stops = [stops_with_gps[i - 1] for i in route_indices if i > 0]
else:
route_indices = _solve_tsp(points, origin_idx=0)
route_stops = [stops_with_gps[i] for i in route_indices]
# Calculate distances and drive times
total_distance_km = 0
total_drive_min = 0
for i in range(len(route_stops) - 1):
p1 = (route_stops[i]["gps"]["lat"], route_stops[i]["gps"]["lng"])
p2 = (route_stops[i + 1]["gps"]["lat"], route_stops[i + 1]["gps"]["lng"])
d = _haversine(*p1, *p2)
drive_min = (d / 50) * 60 # avg 50 km/h
route_stops[i]["distance_to_next_km"] = round(d, 1)
route_stops[i]["drive_min_to_next"] = round(drive_min)
total_distance_km += d
total_drive_min += drive_min
if route_stops:
route_stops[-1]["distance_to_next_km"] = 0
route_stops[-1]["drive_min_to_next"] = 0
# Cumulative time from start
cumulative_min = 0
if has_origin and route_stops:
o_lat, o_lng = origin["lat"], origin["lng"]
f_lat, f_lng = route_stops[0]["gps"]["lat"], route_stops[0]["gps"]["lng"]
first_leg = _haversine(o_lat, o_lng, f_lat, f_lng)
cumulative_min = round((first_leg / 50) * 60)
for stop in route_stops:
stop["cumulative_min"] = cumulative_min
onsite = round((stop.get("onsite_duration") or 0) / 60)
cumulative_min += (stop.get("drive_min_to_next") or 0) + onsite
total_onsite_min = sum(round((s.get("onsite_duration") or 0) / 60) for s in route_stops)
result = {
"route": route_stops,
"stops_without_gps": stops_without_gps,
"origin": origin,
"summary": {
"total_stops": len(route_stops),
"with_gps": len(stops_with_gps),
"without_gps": len(stops_without_gps),
"total_distance_km": round(total_distance_km, 1),
"total_drive_min": round(total_drive_min),
"total_onsite_min": total_onsite_min,
"total_estimated_min": round(total_drive_min + total_onsite_min),
"origin": origin,
},
}
return result
# ─── Static Files (mounted last to not shadow routes) ──────────────────────
app.mount("/uploads", StaticFiles(directory=str(UPLOADS_DIR)), name="uploads")