Compare commits
7 Commits
7024b5f820
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff270697d5 | |||
| 25aec1490a | |||
| 4ab08ca7bd | |||
| 92e5170b47 | |||
| fb98de229d | |||
| 7a2d283379 | |||
| ed5e3ff9ec |
+694
-3
@@ -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", "")
|
||||
@@ -833,9 +834,11 @@ def login(body: dict):
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
token = _generate_token()
|
||||
remember_me = body.get("remember_me", False)
|
||||
expiry = "+30 days" if remember_me else "+1 day"
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', '+1 day'))",
|
||||
(row["id"], token),
|
||||
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', ?))",
|
||||
(row["id"], token, expiry),
|
||||
)
|
||||
conn.commit()
|
||||
_log_activity(conn, "login", "user", row["id"],
|
||||
@@ -1659,6 +1662,9 @@ def list_assets_admin(
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
customer_id: Optional[int] = Query(None),
|
||||
company: Optional[str] = Query(None),
|
||||
place: Optional[str] = Query(None),
|
||||
make: Optional[str] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
@@ -1680,6 +1686,15 @@ def list_assets_admin(
|
||||
if customer_id is not None:
|
||||
conditions.append("customer_id = ?")
|
||||
params.append(customer_id)
|
||||
if company:
|
||||
conditions.append("company = ?")
|
||||
params.append(company)
|
||||
if place:
|
||||
conditions.append("place = ?")
|
||||
params.append(place)
|
||||
if make:
|
||||
conditions.append("make = ?")
|
||||
params.append(make)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
sql = "SELECT * FROM assets"
|
||||
@@ -1852,6 +1867,60 @@ def batch_update_status(body: BatchStatusUpdate):
|
||||
return {"updated": len(body.ids), "status": body.status}
|
||||
|
||||
|
||||
class BatchAssetUpdate(BaseModel):
|
||||
ids: list[int]
|
||||
fields: dict # { "column_name": value, ... }
|
||||
|
||||
|
||||
@app.post("/api/assets/batch-update")
|
||||
def batch_update_assets(body: BatchAssetUpdate):
|
||||
"""Update the same fields on multiple assets at once. Used by Cleanup tool.
|
||||
|
||||
Body: { ids: [1,2,3], fields: { make: "Crane", building_name: "B7", floor: "FL 1" } }
|
||||
Only updates non-null field values.
|
||||
"""
|
||||
if not body.ids:
|
||||
raise HTTPException(status_code=422, detail="No asset IDs provided")
|
||||
if not body.fields:
|
||||
raise HTTPException(status_code=422, detail="No fields to update")
|
||||
|
||||
ALLOWED_FIELDS = {
|
||||
"make", "model", "name", "company", "place", "building_name",
|
||||
"building_number", "floor", "room", "location_area",
|
||||
"address", "category", "status", "customer_name",
|
||||
}
|
||||
# Filter to allowed fields with non-null/empty values
|
||||
updates = {k: v for k, v in body.fields.items()
|
||||
if k in ALLOWED_FIELDS and v is not None and v != ""}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=422, detail="No valid fields to update")
|
||||
|
||||
conn = get_db()
|
||||
placeholders = ",".join("?" * len(body.ids))
|
||||
|
||||
# Verify all IDs exist
|
||||
existing = conn.execute(
|
||||
f"SELECT COUNT(*) FROM assets WHERE id IN ({placeholders})",
|
||||
body.ids,
|
||||
).fetchone()[0]
|
||||
if existing != len(body.ids):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="One or more asset IDs not found")
|
||||
|
||||
set_clause = ", ".join(f"{col} = ?" for col in updates)
|
||||
values = list(updates.values())
|
||||
conn.execute(
|
||||
f"UPDATE assets SET {set_clause}, updated_at = datetime('now') "
|
||||
f"WHERE id IN ({placeholders})",
|
||||
values + body.ids,
|
||||
)
|
||||
_log_activity(conn, "updated", "asset", 0,
|
||||
f"Batch update: {len(body.ids)} assets — {', '.join(updates.keys())}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"updated": len(body.ids), "fields": list(updates.keys())}
|
||||
|
||||
|
||||
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2240,6 +2309,628 @@ 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 MS Field Service app, navigate to Check for Updates via UI nav.
|
||||
|
||||
Taps globe icon in top-right, waits 15s, taps Check for Updates button,
|
||||
then waits for the phone's offline DB to stabilize.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(f"{EXTRACTION_SYNC_BASE}/api/sync/check-for-updates")
|
||||
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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+4
-4
@@ -869,8 +869,8 @@ def approve_batch(
|
||||
if val is not None and str(val).strip() != "":
|
||||
updates[db_col] = str(val).strip()
|
||||
|
||||
# Handle numeric fields
|
||||
for num_field in ("latitude", "longitude", "geofence_radius_meters"):
|
||||
# Handle numeric fields (exclude GPS — comes only from real check-ins)
|
||||
for num_field in ("geofence_radius_meters",):
|
||||
val = item.get(num_field)
|
||||
if val is not None and str(val).strip() != "":
|
||||
try:
|
||||
@@ -948,8 +948,8 @@ def approve_batch(
|
||||
cust_id,
|
||||
loc_id,
|
||||
1 if cust_name and cust_name.startswith("D-") else 0,
|
||||
_safe_float(item.get("latitude")),
|
||||
_safe_float(item.get("longitude")),
|
||||
None, # GPS only from real check-ins
|
||||
None, # GPS only from real check-ins
|
||||
_safe_int(item.get("geofence_radius_meters"), 50),
|
||||
str(item.get("dex_report_date", "")).strip() or "",
|
||||
str(item.get("deployed", "")).strip() or "",
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the admin server — uses same DB as main canteen-asset-tracker
|
||||
# CANTEEN_DB_PATH defaults to ../canteen-asset-tracker/assets.db
|
||||
# Run the admin server — MUST use same DB as main canteen-asset-tracker
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-8090}"
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
CANTEEN_DB_PATH="${CANTEEN_DB_PATH:-${APP_DIR}/../canteen-asset-tracker/assets.db}"
|
||||
|
||||
export CANTEEN_DB_PATH
|
||||
|
||||
exec python -m uvicorn admin_server:app \
|
||||
--host "$HOST" \
|
||||
|
||||
+1696
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user