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:
Leo
2026-05-27 20:26:48 -04:00
parent 6a37ea9f77
commit 2cafe6879a
2 changed files with 397 additions and 0 deletions
+219
View File
@@ -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"
+178
View File
@@ -824,6 +824,9 @@
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="replacements" onclick="switchPage('replacements')" title="Replacements — machine replacement history tracking old→new MID at same location">
<span class="ni-icon">🔄</span><span>Replacements</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')" title="Import — upload Excel files and review changes">
<span class="ni-icon">📥</span><span>Import</span>
</button>
@@ -1018,6 +1021,51 @@
</div>
</div>
<!-- ── REPLACEMENTS ── -->
<div id="pageReplacements" class="page hidden">
<div class="page-title">
<span>🔄 Machine Replacements</span>
<div class="pt-actions">
<button class="btn btn-sm btn-outline" onclick="loadReplacements()">🔄 Refresh</button>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
<div>
<span style="font-size:13px;color:var(--text2);" id="repCount"></span>
</div>
<div style="display:flex;gap:8px;">
<input type="text" id="repSearch" placeholder="🔍 Search location or MID..." style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;width:260px;" oninput="filterReplacements()">
</div>
</div>
<div id="repError" class="text-sm text-muted" style="display:none;padding:16px;"></div>
<div style="overflow-x:auto;">
<table class="data-table" id="repTable">
<thead>
<tr>
<th>Date</th>
<th>Location</th>
<th>Old MID</th>
<th>Old Name</th>
<th>Old Status</th>
<th></th>
<th>New MID</th>
<th>New Name</th>
<th>New Status</th>
<th>Confidence</th>
</tr>
</thead>
<tbody id="repBody"></tbody>
</table>
</div>
<div id="repEmpty" class="empty-state" style="display:none;padding:40px;">
<div class="es-icon">🔄</div>
<div>No replacement events found</div>
<div class="text-sm text-muted">Replacements appear when a machine is swapped at the same location.</div>
</div>
</div>
</div>
<!-- ── EXCEL IMPORT ── -->
<div id="pageCantaloupe" class="page hidden">
<div class="page-title">
@@ -1380,6 +1428,7 @@ function switchPage(page) {
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
else if (page === 'replacements') loadReplacements();
else if (page === 'exifscanner') loadExifScanner();
else if (page === 'route') initRoutePage();
}
@@ -4233,6 +4282,135 @@ function closeExifLightbox() {
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
}
// ═════════════════════════════════════════════════════════════════════════════
// REPLACEMENTS
// ═════════════════════════════════════════════════════════════════════════════
let cachedReplacements = [];
function escHtml(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
async function loadReplacements() {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
const error = document.getElementById('repError');
const count = document.getElementById('repCount');
body.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:32px;color:var(--text2);">Loading...</td></tr>';
empty.style.display = 'none';
error.style.display = 'none';
try {
const data = await api('/api/admin/replacements/history');
cachedReplacements = data.replacements || [];
renderReplacements(cachedReplacements);
const src = data.sources || {};
const srcParts = [];
if (src.sync_batch_inline) srcParts.push(`${src.sync_batch_inline} from sync`);
if (src.batch_crossref) srcParts.push(`${src.batch_crossref} from crossref`);
if (src.location_group) srcParts.push(`${src.location_group} from location data`);
const srcInfo = srcParts.length ? ` (${srcParts.join(', ')})` : '';
count.textContent = `${data.total} replacement event${data.total !== 1 ? 's' : ''}${srcInfo}`;
} catch (e) {
error.textContent = 'Error loading replacements: ' + (e.message || e);
error.style.display = 'block';
body.innerHTML = '';
}
}
function renderReplacements(reps) {
const body = document.getElementById('repBody');
const empty = document.getElementById('repEmpty');
if (!reps || reps.length === 0) {
body.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
body.innerHTML = reps.map(rep => {
const date = rep.batch_date ? new Date(rep.batch_date + 'Z').toLocaleDateString() : '—';
const oldStatus = rep.old_status || '';
const newStatus = rep.new_status || 'On';
const confidence = rep.confidence || 'low';
const oldStatusLabel = oldStatus ? statusBadge(oldStatus) : '';
const newStatusLabel = statusBadge(newStatus);
let confClass = 'badge badge-info';
if (confidence === 'high') confClass = 'badge badge-success';
else if (confidence === 'medium') confClass = 'badge badge-warning';
else if (confidence === 'low') confClass = 'badge badge-default';
const oldMid = rep.old_machine_id || '';
const newMid = rep.new_machine_id || '';
const loc = rep.location || '';
const oldName = rep.old_name || '';
const newName = rep.new_name || '';
return `<tr>
<td style="white-space:nowrap;">${date}</td>
<td><strong>${escHtml(loc)}</strong></td>
<td><a href="#" onclick="showAssetDetail('${escHtml(oldMid)}');return false;" style="font-family:monospace;">${escHtml(oldMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(oldName)}">${escHtml(oldName)}</td>
<td>${oldStatusLabel}</td>
<td style="text-align:center;font-size:18px;color:var(--accent);">→</td>
<td><a href="#" onclick="showAssetDetail('${escHtml(newMid)}');return false;" style="font-family:monospace;font-weight:600;color:var(--green);">${escHtml(newMid)}</a></td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(newName)}">${escHtml(newName)}</td>
<td>${newStatusLabel}</td>
<td><span class="${confClass}">${confidence}</span></td>
</tr>`;
}).join('');
}
function statusBadge(status) {
const colors = {
'On': { bg: '#1a3a2a', color: '#4ade80' },
'Incompatible': { bg: '#3a1a1a', color: '#f87171' },
'Action Required': { bg: '#3a2a1a', color: '#fbbf24' },
'Off': { bg: '#2a2a2a', color: '#9ca3af' },
'retired': { bg: '#2a1a2a', color: '#c084fc' },
};
const c = colors[status] || { bg: '#2a2a2a', color: '#ccc' };
return `<span style="background:${c.bg};color:${c.color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${escHtml(status)}</span>`;
}
function filterReplacements() {
const q = document.getElementById('repSearch').value.toLowerCase().trim();
if (!q) {
renderReplacements(cachedReplacements);
return;
}
const filtered = cachedReplacements.filter(rep =>
(rep.location || '').toLowerCase().includes(q) ||
(rep.old_machine_id || '').includes(q) ||
(rep.new_machine_id || '').includes(q) ||
(rep.old_name || '').toLowerCase().includes(q) ||
(rep.new_name || '').toLowerCase().includes(q)
);
renderReplacements(filtered);
document.getElementById('repCount').textContent = `${filtered.length} of ${cachedReplacements.length} replacement events`;
}
function showAssetDetail(machineId) {
// Navigate to assets page with search
document.getElementById('assetSearch').value = machineId;
switchPage('assets');
// Trigger search after render
setTimeout(() => {
const input = document.getElementById('assetSearch');
if (input) input.value = machineId;
if (typeof doAssetSearch === 'function') doAssetSearch();
}, 100);
}
// ═════════════════════════════════════════════════════════════════════════════
// ROUTE PLANNER
// ═════════════════════════════════════════════════════════════════════════════