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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user