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';