Add work order details display and asset table WO column
- Backend: new /api/admin/workorders/by-asset endpoint for work orders linked to a specific asset name - Backend: enhanced WO search to also match by msdyn_customerasset!name and msdyn_serviceaccount!name - Frontend: added 🔧 column to asset table linking to work orders tab pre-filtered by asset name - Frontend: added showAssetWorkOrders() function - Frontend: made asset name in WO detail modal clickable (navigates to asset search) Closes #21
This commit is contained in:
+621
-1
@@ -11,6 +11,7 @@ Designed to run on a separate port from the main field-worker server.
|
||||
"""
|
||||
import csv
|
||||
import hashlib
|
||||
import httpx
|
||||
import io
|
||||
import json as _json
|
||||
import os
|
||||
@@ -406,7 +407,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
||||
return await call_next(request)
|
||||
|
||||
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
|
||||
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business") or path.startswith("/api/admin/reports/"):
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
@@ -2240,6 +2241,625 @@ def get_replacement_history():
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WORK ORDER REPORTS — reads extraction DB directly
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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_REPORT = {
|
||||
"690970000": "Unscheduled",
|
||||
"690970001": "Scheduled",
|
||||
"690970002": "In Progress",
|
||||
"690970003": "Completed",
|
||||
"690970004": "Posted",
|
||||
"690970005": "Cancelled",
|
||||
"690959000": "Postponed",
|
||||
}
|
||||
|
||||
|
||||
def _get_extraction_db_report() -> 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
|
||||
|
||||
|
||||
def _status_label_report(code) -> str:
|
||||
return STATUS_LABELS_REPORT.get(str(code), f"Unknown ({code})")
|
||||
|
||||
|
||||
@app.get("/api/admin/reports/workorder-kpis")
|
||||
async def reports_workorder_kpis(
|
||||
date_from: str = Query("", description="Start date ISO (YYYY-MM-DD)"),
|
||||
date_to: str = Query("", description="End date ISO (YYYY-MM-DD)"),
|
||||
technician: str = Query("", description="Filter by technician name"),
|
||||
status: str = Query("", description="Filter by status code (comma-separated)"),
|
||||
):
|
||||
"""
|
||||
Return aggregated KPI data for field tech work orders.
|
||||
Filters: date_from, date_to (on createdon), technician, status.
|
||||
"""
|
||||
conn = _get_extraction_db_report()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Extraction DB not available")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
# ── Build WHERE clause ──
|
||||
where_parts = []
|
||||
params = []
|
||||
|
||||
if date_from:
|
||||
where_parts.append("w.createdon >= ?")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where_parts.append("w.createdon < date(?, '+1 day')")
|
||||
params.append(date_to)
|
||||
if technician:
|
||||
tech_names = [t.strip() for t in technician.split(",") if t.strip()]
|
||||
if tech_names:
|
||||
tech_conditions = []
|
||||
for name in tech_names:
|
||||
like = f"%{name}%"
|
||||
tech_conditions.append(
|
||||
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
|
||||
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
|
||||
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
|
||||
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
|
||||
)
|
||||
params.extend([like, like, like, like])
|
||||
where_parts.append("(" + " OR ".join(tech_conditions) + ")")
|
||||
if status:
|
||||
codes = [s.strip() for s in status.split(",") if s.strip()]
|
||||
if codes:
|
||||
placeholders = ", ".join(["?"] * len(codes))
|
||||
where_parts.append(f"w.msdyn_systemstatus IN ({placeholders})")
|
||||
params.extend(codes)
|
||||
|
||||
where_sql = " AND ".join(where_parts) if where_parts else "1=1"
|
||||
|
||||
# ── KPI 1: Total WOs ──
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM msdyn_workorder w WHERE {where_sql}", params
|
||||
)
|
||||
total_workorders = cur.fetchone()[0]
|
||||
|
||||
# ── KPI 2: By Status ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT w.msdyn_systemstatus, COUNT(*) as cnt
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
GROUP BY w.msdyn_systemstatus
|
||||
ORDER BY cnt DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_status_raw = cur.fetchall()
|
||||
by_status = []
|
||||
for r in by_status_raw:
|
||||
code = str(r["msdyn_systemstatus"])
|
||||
by_status.append({
|
||||
"code": code,
|
||||
"label": _status_label_report(code),
|
||||
"count": r["cnt"],
|
||||
})
|
||||
|
||||
# ── KPI 3: WOs Over Time (daily) ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT date(w.createdon) as day, COUNT(*) as cnt
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
""",
|
||||
params,
|
||||
)
|
||||
over_time = [{"day": r["day"], "count": r["cnt"]} for r in cur.fetchall()]
|
||||
|
||||
# ── KPI 4: By Technician ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT t.technician, COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
||||
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
||||
) AS technician
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
) t
|
||||
WHERE t.technician IS NOT NULL AND t.technician != ''
|
||||
GROUP BY t.technician
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_technician = [{"name": r["technician"], "count": r["cnt"]} for r in cur.fetchall()]
|
||||
|
||||
# ── KPI 5: By Work Type ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT w.\"msdyn_workordertype!name\" AS work_type, COUNT(*) as cnt
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
GROUP BY work_type
|
||||
ORDER BY cnt DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_work_type = [{"name": r["work_type"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
|
||||
|
||||
# ── KPI 6: By Priority ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT w.\"msdyn_priority!name\" AS priority, COUNT(*) as cnt
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
GROUP BY priority
|
||||
ORDER BY cnt DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
by_priority = [{"name": r["priority"] or "Unspecified", "count": r["cnt"]} for r in cur.fetchall()]
|
||||
|
||||
# ── KPI 7: Technician Performance (detailed) — use simpler subquery ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
t.technician,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN t.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed,
|
||||
SUM(CASE WHEN t.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN t.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
||||
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
||||
) AS technician,
|
||||
w.msdyn_systemstatus
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
) t
|
||||
WHERE t.technician IS NOT NULL AND t.technician != ''
|
||||
GROUP BY t.technician
|
||||
ORDER BY total DESC
|
||||
LIMIT 30
|
||||
""",
|
||||
params,
|
||||
)
|
||||
tech_perf = []
|
||||
for r in cur.fetchall():
|
||||
tech_perf.append({
|
||||
"name": r["technician"],
|
||||
"total": r["total"],
|
||||
"completed": r["completed"],
|
||||
"in_progress": r["in_progress"],
|
||||
"scheduled": r["scheduled"],
|
||||
})
|
||||
|
||||
# ── KPI 8: By Status per Tech (stacked) ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
t.technician,
|
||||
t.status_code,
|
||||
COUNT(*) as cnt
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
||||
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
||||
) AS technician,
|
||||
w.msdyn_systemstatus AS status_code
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
) t
|
||||
WHERE t.technician IS NOT NULL AND t.technician != ''
|
||||
GROUP BY t.technician, t.status_code
|
||||
ORDER BY t.technician, t.status_code
|
||||
""",
|
||||
params,
|
||||
)
|
||||
status_by_tech = {}
|
||||
for r in cur.fetchall():
|
||||
tech = r["technician"]
|
||||
if tech not in status_by_tech:
|
||||
status_by_tech[tech] = {}
|
||||
code = str(r["status_code"])
|
||||
status_by_tech[tech][code] = r["cnt"]
|
||||
|
||||
# ── KPI 9: Summary stats ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN w.msdyn_systemstatus IN (690970003, 690970004) THEN 1 ELSE 0 END) as completed_posted,
|
||||
SUM(CASE WHEN w.msdyn_systemstatus = 690970002 THEN 1 ELSE 0 END) as in_progress,
|
||||
SUM(CASE WHEN w.msdyn_systemstatus = 690970001 THEN 1 ELSE 0 END) as scheduled,
|
||||
COUNT(DISTINCT COALESCE(w.\"hsl_bookedresource1!name\", w.\"hsl_bookedresource2!name\", w.\"hsl_bookedresource3!name\",
|
||||
(SELECT b2.\"resource!name\" FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" IS NOT NULL LIMIT 1)
|
||||
)) as unique_techs
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
summary = dict(cur.fetchone())
|
||||
|
||||
# ── KPI 10: Avg days to complete (for completed/posted) ──
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT AVG(julianday(w.msdyn_completedon) - julianday(w.createdon)) as avg_days
|
||||
FROM msdyn_workorder w
|
||||
WHERE {where_sql}
|
||||
AND w.msdyn_systemstatus IN (690970003, 690970004)
|
||||
AND w.msdyn_completedon IS NOT NULL
|
||||
""",
|
||||
params,
|
||||
)
|
||||
avg_days = cur.fetchone()[0]
|
||||
summary["avg_days_to_complete"] = round(avg_days, 1) if avg_days else None
|
||||
|
||||
return {
|
||||
"total_workorders": total_workorders,
|
||||
"summary": summary,
|
||||
"by_status": by_status,
|
||||
"by_technician": by_technician,
|
||||
"by_work_type": by_work_type,
|
||||
"by_priority": by_priority,
|
||||
"over_time": over_time,
|
||||
"technician_performance": tech_perf,
|
||||
"status_by_tech": status_by_tech,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# WORK ORDERS LIST — reads extraction DB directly (same DB as reports)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@app.get("/api/admin/workorders/list")
|
||||
async def admin_workorders_list(
|
||||
q: str = Query("", description="Search term (WO#, account, city)"),
|
||||
status: str = Query("", description="Comma-separated status codes"),
|
||||
tech: str = Query("", description="Technician name filter"),
|
||||
date_from: str = Query("", description="Date filter start (YYYY-MM-DD)"),
|
||||
date_to: str = Query("", description="Date filter end (YYYY-MM-DD)"),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Fetch filtered, paginated work orders from the extraction DB."""
|
||||
conn = _get_extraction_db_report()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Extraction DB not available")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
params: list = []
|
||||
where_clauses: list[str] = []
|
||||
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
where_clauses.append(
|
||||
"(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ? OR w.\"msdyn_customerasset!name\" LIKE ? OR w.\"msdyn_serviceaccount!name\" LIKE ?)"
|
||||
)
|
||||
params.extend([like, like, like, like, like])
|
||||
|
||||
if status:
|
||||
codes = [s.strip() for s in status.split(",") if s.strip()]
|
||||
if codes:
|
||||
placeholders = ",".join(["?" for _ in codes])
|
||||
where_clauses.append(f"w.msdyn_systemstatus IN ({placeholders})")
|
||||
params.extend(codes)
|
||||
|
||||
if tech:
|
||||
like = f"%{tech}%"
|
||||
where_clauses.append(
|
||||
"(COALESCE(w.\"hsl_bookedresource1!name\",'') LIKE ? "
|
||||
"OR COALESCE(w.\"hsl_bookedresource2!name\",'') LIKE ? "
|
||||
"OR COALESCE(w.\"hsl_bookedresource3!name\",'') LIKE ? "
|
||||
"OR EXISTS (SELECT 1 FROM bookableresourcebooking b2 WHERE b2.\"msdyn_workorder!name\" = w.msdyn_name AND b2.\"resource!name\" LIKE ?))"
|
||||
)
|
||||
params.extend([like, like, like, like])
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("w.createdon >= ?")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
where_clauses.append("w.createdon < date(?, '+1 day')")
|
||||
params.append(date_to)
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# Count
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM msdyn_workorder w LEFT JOIN account a ON w.\"msdyn_serviceaccount!id\" = a.accountid WHERE {where_sql}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Data
|
||||
data_sql = f"""
|
||||
SELECT
|
||||
w.msdyn_workorderid,
|
||||
w.msdyn_name,
|
||||
w."msdyn_serviceaccount!name" AS account_name,
|
||||
w.msdyn_workordersummary,
|
||||
w.createdon,
|
||||
w.msdyn_completedon,
|
||||
w.msdyn_systemstatus,
|
||||
w."msdyn_priority!name" AS priority,
|
||||
w."msdyn_workordertype!name" AS work_type,
|
||||
w."msdyn_primaryincidenttype!name" AS incident_type,
|
||||
w."msdyn_serviceterritory!name" AS territory,
|
||||
w."msdyn_customerasset!name" AS customer_asset_name,
|
||||
w.msdyn_city,
|
||||
w.msdyn_stateorprovince,
|
||||
(SELECT GROUP_CONCAT(DISTINCT t)
|
||||
FROM (
|
||||
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
|
||||
)) AS technicians,
|
||||
a.name AS account_display_name,
|
||||
a.address1_city,
|
||||
a.address1_stateorprovince
|
||||
FROM msdyn_workorder w
|
||||
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
||||
WHERE {where_sql}
|
||||
ORDER BY w.createdon DESC, w.msdyn_name
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
data_params = params + [limit, offset]
|
||||
cur.execute(data_sql, data_params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
workorders = []
|
||||
for r in rows:
|
||||
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": str(r["msdyn_systemstatus"]),
|
||||
"status": _status_label_report(r["msdyn_systemstatus"]),
|
||||
"priority": r["priority"],
|
||||
"work_type": r["work_type"],
|
||||
"incident_type": r["incident_type"],
|
||||
"territory": r["territory"],
|
||||
"customer_asset_name": r["customer_asset_name"],
|
||||
"technicians": r["technicians"],
|
||||
"created_on": r["createdon"],
|
||||
"completed_on": r["msdyn_completedon"],
|
||||
"city": r["msdyn_city"] or r["address1_city"],
|
||||
"state": r["msdyn_stateorprovince"] or r["address1_stateorprovince"],
|
||||
})
|
||||
|
||||
return {
|
||||
"results": workorders,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/admin/workorders/lookup")
|
||||
async def admin_workorders_lookup(name: str = Query("", description="Work order name/number")):
|
||||
"""Return full details for a single work order by name."""
|
||||
if not name:
|
||||
raise HTTPException(400, "Work order name is required")
|
||||
conn = _get_extraction_db_report()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Extraction DB not available")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
w.*,
|
||||
a.name AS account_display_name,
|
||||
a.address1_line1,
|
||||
a.address1_city,
|
||||
a.address1_stateorprovince,
|
||||
a.address1_postalcode,
|
||||
a.telephone1,
|
||||
a.websiteurl,
|
||||
i.title AS incident_title,
|
||||
i.msdyn_servicerequesttype AS incident_type_code,
|
||||
(SELECT GROUP_CONCAT(DISTINCT t)
|
||||
FROM (
|
||||
SELECT b2."resource!name" AS t FROM bookableresourcebooking b2 WHERE b2."msdyn_workorder!name" = w.msdyn_name
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource1!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource1!name" IS NOT NULL
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource2!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource2!name" IS NOT NULL
|
||||
UNION
|
||||
SELECT w2."hsl_bookedresource3!name" FROM msdyn_workorder w2 WHERE w2.msdyn_workorderid = w.msdyn_workorderid AND w2."hsl_bookedresource3!name" IS NOT NULL
|
||||
)) AS technicians
|
||||
FROM msdyn_workorder w
|
||||
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
||||
LEFT JOIN msdyn_workorderincident woi ON woi."msdyn_workorder!id" = w.msdyn_workorderid
|
||||
LEFT JOIN incident i ON i.incidentid = woi."msdyn_incident!id"
|
||||
WHERE w.msdyn_name = ?
|
||||
LIMIT 1
|
||||
""", [name])
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Work order '{name}' not found")
|
||||
|
||||
# Map row to dict (all columns)
|
||||
keys = [d[0] for d in cur.description]
|
||||
data = dict(zip(keys, row))
|
||||
|
||||
# Add readable status
|
||||
data["status_label"] = _status_label_report(data.get("msdyn_systemstatus"))
|
||||
|
||||
return {"found": True, "data": data}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/admin/workorders/technicians")
|
||||
async def admin_workorders_technicians():
|
||||
"""Return list of all technicians found in work orders."""
|
||||
conn = _get_extraction_db_report()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Extraction DB not available")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT t FROM (
|
||||
SELECT b."resource!name" AS t FROM bookableresourcebooking b WHERE b."resource!name" IS NOT NULL AND b."resource!name" != ''
|
||||
UNION
|
||||
SELECT w."hsl_bookedresource1!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource1!name" IS NOT NULL AND w."hsl_bookedresource1!name" != ''
|
||||
UNION
|
||||
SELECT w."hsl_bookedresource2!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource2!name" IS NOT NULL AND w."hsl_bookedresource2!name" != ''
|
||||
UNION
|
||||
SELECT w."hsl_bookedresource3!name" FROM msdyn_workorder w WHERE w."hsl_bookedresource3!name" IS NOT NULL AND w."hsl_bookedresource3!name" != ''
|
||||
) ORDER BY t
|
||||
""")
|
||||
techs = [r[0] for r in cur.fetchall()]
|
||||
return {"technicians": techs}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@app.get("/api/admin/workorders/by-asset")
|
||||
async def admin_workorders_by_asset(
|
||||
name: str = Query("", description="Asset name (msdyn_customerasset!name) to search work orders for"),
|
||||
):
|
||||
"""Return work orders linked to a specific customer asset by name."""
|
||||
if not name:
|
||||
raise HTTPException(400, "Asset name is required")
|
||||
conn = _get_extraction_db_report()
|
||||
if not conn:
|
||||
raise HTTPException(503, "Extraction DB not available")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT
|
||||
w.msdyn_name, w."msdyn_customerasset!name" AS asset_name,
|
||||
w."msdyn_serviceaccount!name" AS account_name,
|
||||
w.msdyn_workordersummary,
|
||||
w."msdyn_workordertype!name" AS work_type,
|
||||
w."msdyn_priority!name" AS priority,
|
||||
w.msdyn_systemstatus,
|
||||
w.createdon, w.msdyn_completedon,
|
||||
a.name AS account_display_name,
|
||||
a.address1_city, a.address1_stateorprovince
|
||||
FROM msdyn_workorder w
|
||||
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
|
||||
WHERE w."msdyn_customerasset!name" = ?
|
||||
ORDER BY w.createdon DESC
|
||||
""", [name])
|
||||
rows = cur.fetchall()
|
||||
workorders = []
|
||||
for r in rows:
|
||||
workorders.append({
|
||||
"name": r["msdyn_name"],
|
||||
"asset_name": r["asset_name"],
|
||||
"account_name": r["account_name"] or r["account_display_name"],
|
||||
"summary": r["msdyn_workordersummary"],
|
||||
"work_type": r["work_type"],
|
||||
"priority": r["priority"],
|
||||
"status_code": str(r["msdyn_systemstatus"]),
|
||||
"status": _status_label_report(r["msdyn_systemstatus"]),
|
||||
"created_on": r["createdon"],
|
||||
"completed_on": r["msdyn_completedon"],
|
||||
"city": r["address1_city"],
|
||||
"state": r["address1_stateorprovince"],
|
||||
})
|
||||
return {"results": workorders, "total": len(workorders)}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ADB MSFS SYNC — proxies to the extraction viewer's sync API on port 8910
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
EXTRACTION_SYNC_BASE = "http://localhost:8910"
|
||||
|
||||
|
||||
@app.get("/api/admin/msfs-sync/info")
|
||||
async def admin_msfs_sync_info():
|
||||
"""Return ADB connection status, last sync metadata, and DB info."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/info")
|
||||
return resp.json()
|
||||
except httpx.RequestError as e:
|
||||
return {
|
||||
"adb_connected": False,
|
||||
"extraction_server": False,
|
||||
"error": f"Cannot reach extraction server on port 8910: {e}",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/admin/msfs-sync/start")
|
||||
async def admin_msfs_sync_start():
|
||||
"""Start a background ADB sync task."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
|
||||
return resp.json()
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
||||
|
||||
|
||||
@app.get("/api/admin/msfs-sync/task/{task_id}")
|
||||
async def admin_msfs_sync_task(task_id: str):
|
||||
"""Poll status of a running sync task."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(f"{EXTRACTION_SYNC_BASE}/api/sync/task/{task_id}")
|
||||
if resp.status_code == 404:
|
||||
raise HTTPException(404, "Task not found")
|
||||
return resp.json()
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
||||
|
||||
|
||||
@app.post("/api/admin/msfs-sync/launch-app")
|
||||
async def admin_msfs_sync_launch():
|
||||
"""Launch the MS Field Service app on the connected phone."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/start")
|
||||
# The start endpoint already launches the app
|
||||
return resp.json()
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
||||
|
||||
|
||||
@app.post("/api/admin/msfs-sync/cancel/{task_id}")
|
||||
async def admin_msfs_sync_cancel(task_id: str):
|
||||
"""Cancel a running sync task."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/cancel/{task_id}")
|
||||
return resp.json()
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f"Extraction server unreachable: {e}")
|
||||
|
||||
|
||||
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
|
||||
|
||||
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
||||
|
||||
+1216
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user