diff --git a/assets.db.20260630_142444.pre-categorization b/assets.db.20260630_142444.pre-categorization
new file mode 100644
index 0000000..7f84599
Binary files /dev/null and b/assets.db.20260630_142444.pre-categorization differ
diff --git a/assets.db.bak b/assets.db.bak
index be6b179..ccca42c 100644
Binary files a/assets.db.bak and b/assets.db.bak differ
diff --git a/import_msfs.py b/import_msfs.py
index 280ccad..aa3b726 100644
--- a/import_msfs.py
+++ b/import_msfs.py
@@ -311,6 +311,61 @@ def get_machine_id(rec):
return str(mid).strip()
return ""
+def _get_category_for_manufacturer(manufacturer: str) -> str:
+ """Map manufacturer to asset category for new imports."""
+ if not manufacturer:
+ return "Other"
+ m = manufacturer.strip().upper()
+
+ # Vending Machine
+ for prefix in ["DIXIE NARCO", "NATIONAL", "ROYAL", "VENDO", "AUTOMATIC PRODUCTS",
+ "CRANE", "USI", "WITTERN", "AMS", "ROWE", "SEAGA", "LEKTRO-VEND",
+ "ROYAL VENDORS"]:
+ if m.startswith(prefix):
+ return "Vending Machine"
+
+ # Coffee Brewer
+ for prefix in ["FLAVIA", "KEURIG", "NEWCO", "BUNN", "CAFECTION", "NESPRESSO",
+ "CURTIS", "BRAVILOR", "SMUCKER", "BLOOMFIELD", "STARBUCKS",
+ "AQUACAFE", "SEGAFREDO", "SIMONELLI", "WITTENBORG", "KREA",
+ "TOTAL ONE",
+ "DEJONG", "DE JONG", "DE JOHN"]:
+ if m.startswith(prefix):
+ return "Coffee Brewer"
+
+ # Cooler / Refrigerator
+ for prefix in ["MINUS FORTY", "IMBERA", "ALPINE", "COOLSERV", "VITRIFRIGO",
+ "KOOLTEK", "MTN", "CELSIUS", "NESTLE", "COLD SNAP", "MONSTER",
+ "COKE", "PEPSI", "INTERPURE", "EVERPURE"]:
+ if m.startswith(prefix):
+ return "Cooler"
+
+ # Water Dispenser
+ for prefix in ["WATERLOGIC", "OASIS", "ECOSTREAM", "ECHOSTREAM", "ION",
+ "PURLOGIX", "PURELOGIX", "AVALON", "WELLSYS", "MARCO",
+ "SC WATER"]:
+ if m.startswith(prefix):
+ return "Water Dispenser"
+
+ # Smart Market Kiosk
+ for prefix in ["365 RETAIL MARKETS", "PANOPTYC", "INTUITIVO", "SMART MARKET",
+ "MICRO-MARKET", "OPTCONNECT", "INSTANT SYSTEMS"]:
+ if m.startswith(prefix):
+ return "Smart Market Kiosk"
+
+ # Ice Machine
+ for prefix in ["FOLLETT", "HOSHIZAKI", "EVERSYS"]:
+ if m.startswith(prefix):
+ return "Ice Machine"
+
+ # Beverage Dispenser
+ for prefix in ["BEVI"]:
+ if m.startswith(prefix):
+ return "Beverage Dispenser"
+
+ return "Other"
+
+
def map_record_to_asset(rec):
"""
Map a merged record's fields to the assets table columns.
@@ -321,7 +376,7 @@ def map_record_to_asset(rec):
name = rec.get("msfs_name") or rec.get("canteen_name") or ""
# Category / status
- category = rec.get("canteen_category") or "Other"
+ category = rec.get("canteen_category") or _get_category_for_manufacturer(rec.get("msfs_manufacturer"))
status = rec.get("canteen_status") or "active"
# Make / model / serial
diff --git a/server.py b/server.py
index 50ccd5f..b56c624 100644
--- a/server.py
+++ b/server.py
@@ -924,10 +924,10 @@ def list_categories():
def list_makes():
conn = get_db()
rows = conn.execute(
- "SELECT DISTINCT make FROM assets WHERE make IS NOT NULL AND make != '' ORDER BY make"
+ "SELECT DISTINCT manufacturer FROM assets WHERE manufacturer IS NOT NULL AND manufacturer != '' ORDER BY manufacturer"
).fetchall()
conn.close()
- return [r["make"] for r in rows]
+ return [r["manufacturer"] for r in rows]
# ─── Task 4: POST /api/assets ──────────────────────────────────────────────
@@ -1062,7 +1062,7 @@ def list_assets(
model: Optional[str] = Query(None),
customer_id: Optional[int] = Query(None),
location_id: Optional[int] = Query(None),
- assigned_to: Optional[int] = Query(None),
+ assigned_to: Optional[str] = Query(None, description="Technician username to filter by"),
disney_park: Optional[str] = Query(None),
disney_filter: Optional[str] = Query(None),
disney_group: Optional[str] = Query(None, description="Grouped park categories: park, resort, office, springs, other"),
@@ -1084,7 +1084,7 @@ def list_assets(
conditions.append("status = ?")
params.append(status)
if make:
- conditions.append("make = ?")
+ conditions.append("manufacturer = ?")
params.append(make)
if disney_park:
conditions.append("disney_park = ?")
@@ -1116,12 +1116,12 @@ def list_assets(
conditions.append("location_id = ?")
params.append(location_id)
if assigned_to is not None:
- conditions.append("assigned_to = ?")
+ conditions.append("u.username = ?")
params.append(assigned_to)
if q:
- conditions.append("(a.name LIKE ? OR a.machine_id LIKE ? OR a.serial_number LIKE ? OR a.description LIKE ? OR a.company LIKE ?)")
+ conditions.append("(a.name LIKE ? OR a.machine_id LIKE ? OR a.serial_number LIKE ? OR a.description LIKE ? OR a.company LIKE ? OR a.place LIKE ? OR a.customer_name LIKE ? OR a.address LIKE ?)")
like = f"%{q}%"
- params.extend([like, like, like, like, like])
+ params.extend([like, like, like, like, like, like, like, like])
if no_dex_days is not None:
conditions.append("(dex_report_date IS NULL OR REPLACE(dex_report_date, 'T', ' ') < datetime('now', '-' || ? || ' days'))")
params.append(no_dex_days)
@@ -1147,7 +1147,7 @@ def list_assets(
conditions.append("1=0")
where = " AND ".join(conditions)
- from_clause = "FROM assets a LEFT JOIN customers c ON a.customer_id = c.id"
+ from_clause = "FROM assets a LEFT JOIN customers c ON a.customer_id = c.id LEFT JOIN users u ON a.assigned_to = u.id"
sql = f"SELECT a.*, COALESCE(c.name, a.customer_name) AS customer_name {from_clause}"
if where:
sql += f" WHERE {where}"
@@ -1170,10 +1170,23 @@ def list_assets(
@app.get("/api/assets/search")
def search_by_machine_id(machine_id: str = Query(...)):
conn = get_db()
+ # Exact match first
row = conn.execute(
"SELECT * FROM assets WHERE machine_id = ? OR connect_id = ? OR serial_number = ?",
(machine_id, machine_id, machine_id),
).fetchone()
+
+ # If not found and value is all-numeric >= 5 chars, try LIKE suffix match
+ # on connect_id and equipment_id (for partial OCR reads of Connect IDs)
+ if row is None and len(machine_id) >= 5 and machine_id.isdigit():
+ row = conn.execute(
+ """SELECT * FROM assets
+ WHERE replace(replace(replace(replace(upper(connect_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
+ OR replace(replace(replace(replace(upper(equipment_id), '-', ''), '.', ''), ' ', ''), '/', '') LIKE ?
+ """,
+ (f"%{machine_id}", f"%{machine_id}"),
+ ).fetchone()
+
conn.close()
if row is None:
raise HTTPException(status_code=404, detail="Asset not found")
@@ -1452,8 +1465,12 @@ def update_asset(asset_id: int, body: AssetUpdate):
updates["assigned_to"] = body.assigned_to
if body.latitude is not None:
updates["latitude"] = body.latitude
+ elif body.latitude is None and "latitude" in body.model_fields_set:
+ updates["latitude"] = None
if body.longitude is not None:
updates["longitude"] = body.longitude
+ elif body.longitude is None and "longitude" in body.model_fields_set:
+ updates["longitude"] = None
if body.geofence_radius_meters is not None:
updates["geofence_radius_meters"] = body.geofence_radius_meters
if body.is_disney is not None:
@@ -3066,18 +3083,47 @@ def _solve_tsp(points, origin_idx=0):
@app.get("/api/workorders/search")
async def workorders_search(
q: str = Query("", description="Search term"),
+ status: str = Query("", description="Filter by status label (e.g. 'Posted', 'Completed', 'In Progress', 'Scheduled', 'On Hold')"),
+ date_from: str = Query("", description="Filter by created date >= (ISO format, e.g. 2026-01-01)"),
+ date_to: str = Query("", description="Filter by created date <= (ISO format, e.g. 2026-06-30)"),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
):
- """Search work orders by name, account, or city."""
+ """Search work orders by name, account, or city, with optional status and date filters."""
conn = _get_extraction_db()
if not conn:
raise HTTPException(503, "Database not available — sync extraction DB missing")
try:
cur = conn.cursor()
- like = f"%{q}%"
+ params: list = []
+ where_clauses: list = []
+
+ # Search term
+ like = f"%{q}%" if q else None
+ if like:
+ where_clauses.append("(w.msdyn_name LIKE ? OR a.name LIKE ? OR a.address1_city LIKE ?)")
+ params.extend([like, like, like])
+
+ # Status filter — map label back to code
+ if status:
+ status_label_map = {v: k for k, v in STATUS_LABELS.items()}
+ code = status_label_map.get(status)
+ if code:
+ where_clauses.append("w.msdyn_systemstatus = ?")
+ params.append(int(code))
+
+ # Date range
+ if date_from:
+ where_clauses.append("w.createdon >= ?")
+ params.append(date_from)
+ if date_to:
+ where_clauses.append("w.createdon <= ?")
+ params.append(date_to + "T23:59:59Z" if "T" not in date_to else date_to)
+
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
+
cur.execute(
- """
+ f"""
SELECT
w.msdyn_workorderid, w.msdyn_name,
w."msdyn_serviceaccount!name" AS account_name,
@@ -3086,29 +3132,26 @@ async def workorders_search(
w.hsl_onsiteduration,
w."msdyn_priority!name" AS priority,
w.msdyn_systemstatus,
+ w.createdon,
a.address1_line1, a.address1_city, a.address1_stateorprovince, a.address1_postalcode,
a.address1_latitude, a.address1_longitude
FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
- WHERE w.msdyn_name LIKE ?
- OR a.name LIKE ?
- OR a.address1_city LIKE ?
- ORDER BY w.msdyn_name
+ WHERE {where_sql}
+ ORDER BY w.createdon DESC
LIMIT ? OFFSET ?
""",
- (like, like, like, limit, offset),
+ (*params, limit, offset),
)
rows = cur.fetchall()
cur.execute(
- """
+ f"""
SELECT COUNT(*) FROM msdyn_workorder w
LEFT JOIN account a ON w."msdyn_serviceaccount!id" = a.accountid
- WHERE w.msdyn_name LIKE ?
- OR a.name LIKE ?
- OR a.address1_city LIKE ?
+ WHERE {where_sql}
""",
- (like, like, like),
+ params,
)
total = cur.fetchone()[0]
@@ -3123,6 +3166,7 @@ async def workorders_search(
"status": _status_label(r["msdyn_systemstatus"]),
"priority": r["priority"],
"onsite_duration": r["hsl_onsiteduration"],
+ "created_on": r["createdon"],
"address": {
"line1": r["address1_line1"],
"city": r["address1_city"],
diff --git a/static/index.html b/static/index.html
index 66d093f..7e1901b 100644
--- a/static/index.html
+++ b/static/index.html
@@ -3897,6 +3897,13 @@
'Markets': { icon: '', color: '#7c3aed' },
'Coffee': { icon: '', color: '#a16207' },
'Other': { icon: '', color: '#6b7280' },
+ 'Vending Machine': { icon: '', color: '#7c3aed' },
+ 'Coffee Brewer': { icon: '', color: '#a16207' },
+ 'Smart Market Kiosk': { icon: '', color: '#0891b2' },
+ 'Cooler': { icon: '', color: '#0284c7' },
+ 'Water Dispenser': { icon: '', color: '#2563eb' },
+ 'Beverage Dispenser': { icon: '', color: '#0ea5e9' },
+ 'Ice Machine': { icon: '', color: '#0891b2' },
};
const CATEGORY_UNMAPPED_ICON = '❓';
const CATEGORY_UNMAPPED_COLOR = '#b91c1c';