Compare commits
13 Commits
a3328c765d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ff270697d5 | |||
| 25aec1490a | |||
| 4ab08ca7bd | |||
| 92e5170b47 | |||
| fb98de229d | |||
| 7a2d283379 | |||
| ed5e3ff9ec | |||
| 7024b5f820 | |||
| 4988fdaa21 | |||
| ae6685a401 | |||
| fb2db1e76a | |||
| d58834bc56 | |||
| 750cde68c9 |
+694
-3
@@ -11,6 +11,7 @@ Designed to run on a separate port from the main field-worker server.
|
|||||||
"""
|
"""
|
||||||
import csv
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import httpx
|
||||||
import io
|
import io
|
||||||
import json as _json
|
import json as _json
|
||||||
import os
|
import os
|
||||||
@@ -406,7 +407,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
||||||
return await call_next(request)
|
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)
|
return await call_next(request)
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
@@ -833,9 +834,11 @@ def login(body: dict):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
token = _generate_token()
|
token = _generate_token()
|
||||||
|
remember_me = body.get("remember_me", False)
|
||||||
|
expiry = "+30 days" if remember_me else "+1 day"
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', '+1 day'))",
|
"INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', ?))",
|
||||||
(row["id"], token),
|
(row["id"], token, expiry),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "login", "user", row["id"],
|
_log_activity(conn, "login", "user", row["id"],
|
||||||
@@ -1659,6 +1662,9 @@ def list_assets_admin(
|
|||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
category: Optional[str] = Query(None),
|
category: Optional[str] = Query(None),
|
||||||
customer_id: Optional[int] = 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),
|
limit: int = Query(500, ge=1, le=5000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
@@ -1680,6 +1686,15 @@ def list_assets_admin(
|
|||||||
if customer_id is not None:
|
if customer_id is not None:
|
||||||
conditions.append("customer_id = ?")
|
conditions.append("customer_id = ?")
|
||||||
params.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)
|
where = " AND ".join(conditions)
|
||||||
sql = "SELECT * FROM assets"
|
sql = "SELECT * FROM assets"
|
||||||
@@ -1852,6 +1867,60 @@ def batch_update_status(body: BatchStatusUpdate):
|
|||||||
return {"updated": len(body.ids), "status": body.status}
|
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 ────────────────────────────────────────────────────────
|
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -2240,6 +2309,628 @@ def get_replacement_history():
|
|||||||
conn.close()
|
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) ────────────────────────
|
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
|
||||||
|
|
||||||
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
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() != "":
|
if val is not None and str(val).strip() != "":
|
||||||
updates[db_col] = str(val).strip()
|
updates[db_col] = str(val).strip()
|
||||||
|
|
||||||
# Handle numeric fields
|
# Handle numeric fields (exclude GPS — comes only from real check-ins)
|
||||||
for num_field in ("latitude", "longitude", "geofence_radius_meters"):
|
for num_field in ("geofence_radius_meters",):
|
||||||
val = item.get(num_field)
|
val = item.get(num_field)
|
||||||
if val is not None and str(val).strip() != "":
|
if val is not None and str(val).strip() != "":
|
||||||
try:
|
try:
|
||||||
@@ -948,8 +948,8 @@ def approve_batch(
|
|||||||
cust_id,
|
cust_id,
|
||||||
loc_id,
|
loc_id,
|
||||||
1 if cust_name and cust_name.startswith("D-") else 0,
|
1 if cust_name and cust_name.startswith("D-") else 0,
|
||||||
_safe_float(item.get("latitude")),
|
None, # GPS only from real check-ins
|
||||||
_safe_float(item.get("longitude")),
|
None, # GPS only from real check-ins
|
||||||
_safe_int(item.get("geofence_radius_meters"), 50),
|
_safe_int(item.get("geofence_radius_meters"), 50),
|
||||||
str(item.get("dex_report_date", "")).strip() or "",
|
str(item.get("dex_report_date", "")).strip() or "",
|
||||||
str(item.get("deployed", "")).strip() or "",
|
str(item.get("deployed", "")).strip() or "",
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Run the admin server — uses same DB as main canteen-asset-tracker
|
# Run the admin server — MUST use same DB as main canteen-asset-tracker
|
||||||
# CANTEEN_DB_PATH defaults to ../canteen-asset-tracker/assets.db
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PORT="${PORT:-8090}"
|
PORT="${PORT:-8090}"
|
||||||
HOST="${HOST:-0.0.0.0}"
|
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 \
|
exec python -m uvicorn admin_server:app \
|
||||||
--host "$HOST" \
|
--host "$HOST" \
|
||||||
|
|||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 🚀 Dev instance — admin server on port 8092, points to dev DB
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PORT=8092
|
||||||
|
MAIN_DEV_DIR="$HOME/projects/canteen-asset-tracker-dev"
|
||||||
|
CANTEEN_DB_PATH="${MAIN_DEV_DIR}/assets.dev.db"
|
||||||
|
|
||||||
|
echo "🚀 Starting DEV admin server on http://0.0.0.0:${PORT}"
|
||||||
|
echo " DB: ${CANTEEN_DB_PATH}"
|
||||||
|
exec python -m uvicorn admin_server:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$PORT" \
|
||||||
|
--log-level info
|
||||||
+1696
-6
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user