feat: add Machine Replacements history view
- 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
This commit is contained in:
+219
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user