From 2cafe6879a5a981811e9d6cc9547950be1a4dbaa Mon Sep 17 00:00:00 2001 From: Leo Date: Wed, 27 May 2026 20:26:48 -0400 Subject: [PATCH] feat: add Machine Replacements history view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New /api/admin/replacements/history endpoint detects replacements via: - Sync batch diff_summary inline replacements + crossref (new vs removed) - Location-group detection (same building_name, active+inactive pair) - New Replacements page in admin SPA with: - Table showing old MID → new MID, location, statuses, confidence - Search/filter by location or MID - Clickable MID links navigating to asset detail - 146 historical replacement events detected from location data --- admin_server.py | 219 ++++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 178 +++++++++++++++++++++++++++++++++++++ 2 files changed, 397 insertions(+) diff --git a/admin_server.py b/admin_server.py index 9f5fa35..36a39c4 100644 --- a/admin_server.py +++ b/admin_server.py @@ -1926,6 +1926,225 @@ def lookup_business_batch(body: dict): return {"results": results, "total": total} +# ─── Machine Replacement History ─────────────────────────────────────────── + + +@app.get("/api/admin/replacements/history") +def get_replacement_history(): + """Return historical machine replacement events. + + Detects replacements by: + 1) Parsing all sync batch diff_summaries for new_assets & removed_assets + that share similar location names (same batch or adjacent batches). + 2) Cross-referencing assets grouped by location_id where one is active + ("On") and another is inactive ("Incompatible", "Action Required"), + suggesting a swap. + """ + conn = get_db() + try: + db = conn # alias for clarity + + # ── Method 1: Parse sync batch diff_summaries ── + batch_replacements = [] + batches = db.execute( + "SELECT id, created_at, diff_summary, status " + "FROM cantaloupe_sync_batches ORDER BY id" + ).fetchall() + + # All new_assets ever seen, keyed by batch id + all_new_by_batch = {} + all_removed_by_batch = {} + for b in batches: + diff = b["diff_summary"] + if isinstance(diff, str): + try: + diff = _json.loads(diff) + except _json.JSONDecodeError: + continue + if not isinstance(diff, dict): + continue + bid = b["id"] + new_list = diff.get("new_assets", []) or [] + rem_list = diff.get("removed_assets", []) or [] + if new_list: + all_new_by_batch[bid] = new_list + if rem_list: + all_removed_by_batch[bid] = rem_list + + # Also check inline replacements from diff + inline_reps = diff.get("replacements", []) or [] + for rep in inline_reps: + batch_replacements.append({ + "batch_id": bid, + "batch_date": b["created_at"], + "batch_status": b["status"], + "old_machine_id": rep.get("old_machine_id", ""), + "new_machine_id": rep.get("new_machine_id", ""), + "location": rep.get("location_address", ""), + "customer": rep.get("customer_name", ""), + "old_name": rep.get("old_asset_name", ""), + "new_name": rep.get("new_asset_name", ""), + "confidence": rep.get("confidence", "unknown"), + "source": "inline_sync_replacements", + }) + + # Cross-reference: new_assets in batch N vs removed_assets in batch N (or N-1) + batch_ids = sorted(set(list(all_new_by_batch.keys()) + list(all_removed_by_batch.keys()))) + for bid in batch_ids: + new_items = all_new_by_batch.get(bid, []) + rem_items = all_removed_by_batch.get(bid, []) + + # Also check previous batch for removals + prev_rem_items = all_removed_by_batch.get(bid - 1, []) + combined_rem = rem_items + prev_rem_items + + if not new_items or not combined_rem: + continue + + # Build location name -> machines mapping + new_by_loc = {} + for n in new_items: + loc = (n.get("location") or n.get("name", "")).strip().lower() + if loc: + new_by_loc.setdefault(loc, []).append(n) + + rem_by_loc = {} + for r in combined_rem: + loc = (r.get("location") or r.get("name", "")).strip().lower() + if loc: + rem_by_loc.setdefault(loc, []).append(r) + + # Try fuzzy match — share first 20+ chars + for nloc, new_list in new_by_loc.items(): + for rloc, rem_list in rem_by_loc.items(): + # Exact or prefix match + min_len = min(len(nloc), len(rloc)) + if min_len < 10: + continue + match_ratio = sum(1 for a, b in zip(nloc, rloc) if a == b) / min_len + if match_ratio < 0.7: + continue + + for new_item in new_list: + for rem_item in rem_list: + batch_replacements.append({ + "batch_id": bid, + "batch_date": next( + (b["created_at"] for b in batches if b["id"] == bid), ""), + "batch_status": next( + (b["status"] for b in batches if b["id"] == bid), ""), + "old_machine_id": rem_item.get("machine_id", ""), + "new_machine_id": new_item.get("machine_id", ""), + "location": new_item.get("location") or new_item.get("name", ""), + "customer": new_item.get("customer", ""), + "old_name": rem_item.get("name", ""), + "new_name": new_item.get("name", ""), + "confidence": "medium" if match_ratio >= 0.85 else "low", + "source": "batch_diff_crossref", + }) + + # ── Method 2: Location-group based (live assets at same location_id) ── + # Only for small locations (2-5 machines) where it's likely a replacement, + # not a multi-machine site like a large warehouse/hotel. + loc_replacements = [] + locations_with_multi = db.execute(""" + SELECT location_id, COUNT(*) as cnt + FROM assets + WHERE location_id IS NOT NULL + GROUP BY location_id + HAVING cnt BETWEEN 2 AND 5 + """).fetchall() + + # Also get location names + loc_names = {} + loc_rows = db.execute("SELECT id, name FROM locations").fetchall() + for lr in loc_rows: + loc_names[lr["id"]] = lr["name"] + + for loc_row in locations_with_multi: + lid = loc_row["location_id"] + assets_at_loc = db.execute(""" + SELECT id, machine_id, name, status, created_at, updated_at, + install_date, pulled_date, deployed, building_name, + building_number, floor, room + FROM assets + WHERE location_id = ? + ORDER BY machine_id + """, (lid,)).fetchall() + + # Find pairs where one is active ("On") and another is inactive + active = [a for a in assets_at_loc if a["status"] == "On"] + inactive = [a for a in assets_at_loc + if a["status"] in ("Incompatible", "Off", "Action Required", "retired")] + + if not active or not inactive: + continue + + # Only flag pairs where the building_name suggests the same physical spot + for act in active: + for inact in inactive: + # Check if they share building info (likely same spot) + same_building = ( + act["building_name"] and inact["building_name"] + and act["building_name"].strip().lower() == inact["building_name"].strip().lower() + ) + same_floor_room = ( + act["floor"] and inact["floor"] + and act["floor"].strip() == inact["floor"].strip() + and act["room"] and inact["room"] + and act["room"].strip() == inact["room"].strip() + ) + + if not same_building and not same_floor_room: + continue + + loc_name = loc_names.get(lid) or act["name"] + loc_replacements.append({ + "batch_id": None, + "batch_date": None, + "batch_status": None, + "old_machine_id": inact["machine_id"], + "new_machine_id": act["machine_id"], + "location": loc_name, + "customer": "", + "old_name": inact["name"], + "new_name": act["name"], + "confidence": "low", + "source": "location_group", + "old_status": inact["status"], + "new_status": act["status"], + }) + + # ── Merge & sort ── + seen = set() + all_reps = [] + + for entry in batch_replacements + loc_replacements: + key = (entry["old_machine_id"], entry["new_machine_id"]) + if key in seen: + continue + seen.add(key) + all_reps.append(entry) + + # Sort: by batch_date descending, then by location + all_reps.sort(key=lambda x: ( + x["batch_date"] or "9999-12-31", + x["location"] or "", + ), reverse=True) + + return { + "replacements": all_reps, + "total": len(all_reps), + "sources": { + "sync_batch_inline": len([r for r in all_reps if r["source"] == "inline_sync_replacements"]), + "batch_crossref": len([r for r in all_reps if r["source"] == "batch_diff_crossref"]), + "location_group": len([r for r in all_reps if r["source"] == "location_group"]), + }, + } + finally: + conn.close() + + # ─── SPA Frontend (catch-all — MUST be last route) ──────────────────────── FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html" diff --git a/static/index.html b/static/index.html index 9072be7..958060d 100644 --- a/static/index.html +++ b/static/index.html @@ -824,6 +824,9 @@ + @@ -1018,6 +1021,51 @@ + + +