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"
|
||||
|
||||
@@ -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
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user