From ea1feaaad1b90555770ce28421eab4cbca08237b Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 23 May 2026 17:55:54 -0400 Subject: [PATCH] feat: replacement candidate review UI + approve/reject workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/admin/cantaloupe/batches/{id}/replacements returns candidates - POST /api/admin/cantaloupe/batches/{id}/replacements resolves (approve/reject) - Approve retires old asset (status=retired), sets install_date on new - Reject records decision without modifying assets - Batch approve blocks if unresolved replacements exist (use ?force=true) - Frontend shows replacement cards with confidence badges in batch detail - Per-replacement ✓ Approve / ✗ Reject buttons + ✓ Approve All bulk action - History view shows replacement counts with unresolved warnings --- cantaloupe_sync.py | 186 ++++++++++++++++++++++++++++++++++++++++++++- static/index.html | 150 +++++++++++++++++++++++++++++++++++- 2 files changed, 332 insertions(+), 4 deletions(-) diff --git a/cantaloupe_sync.py b/cantaloupe_sync.py index 5148f47..c12169f 100644 --- a/cantaloupe_sync.py +++ b/cantaloupe_sync.py @@ -23,6 +23,17 @@ import openpyxl from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File from pydantic import BaseModel +# ─── Replacement models ────────────────────────────────────────────────────── + +class ReplacementActionItem(BaseModel): + new_machine_id: str + old_machine_id: str + action: str # "approve" or "reject" + + +class ReplacementActionRequest(BaseModel): + replacements: list[ReplacementActionItem] + logger = logging.getLogger("cantaloupe_sync") # ─── Shared DB access ─────────────────────────────────────────────────────── @@ -670,6 +681,11 @@ def get_batch(batch_id: int): "changed": diff.get("changed_assets", []), } + # Replacement stats + replacements = diff.get("replacements", []) if isinstance(diff, dict) else [] + batch["replacement_count"] = len(replacements) + batch["unresolved_replacements"] = len([r for r in replacements if not r.get("status")]) + # Include raw data (imported rows) raw = batch.get("raw_data") if isinstance(raw, str): @@ -688,12 +704,17 @@ def get_batch(batch_id: int): @router.post("/batches/{batch_id}/approve") -def approve_batch(batch_id: int, request: Request): +def approve_batch( + batch_id: int, + request: Request, + force: bool = Query(False, description="Force approval even with unresolved replacements"), +): """ Apply staged data to live tables: - Upsert assets (match on machine_id) - Upsert customers (match on name) - Upsert locations (match on address + building_name + building_number) + Checks for unresolved replacement candidates and blocks unless forced. Sets status='approved' and records approved_at. """ conn = _get_db() @@ -709,6 +730,22 @@ def approve_batch(batch_id: int, request: Request): detail=f"Cannot approve batch in status '{row['status']}'. Only 'pending' batches can be approved.", ) + # Check for unresolved replacements + diff = row["diff_summary"] + if isinstance(diff, str): + try: + diff = _json.loads(diff) + except _json.JSONDecodeError: + diff = {} + if isinstance(diff, dict): + replacements = diff.get("replacements", []) + unresolved = [r for r in replacements if not r.get("status")] + if unresolved and not force: + raise HTTPException( + status_code=409, + detail=f"Cannot approve: {len(unresolved)} replacement(s) are unresolved. Resolve them first or use ?force=true.", + ) + # Parse raw data raw_data = row["raw_data"] if isinstance(raw_data, str): @@ -1011,3 +1048,150 @@ def reject_batch(batch_id: int, request: Request): raise HTTPException(status_code=500, detail=f"Reject failed: {e}") finally: conn.close() + + +# ─── Route: Get replacements ──────────────────────────────────────────────── + + +@router.get("/batches/{batch_id}/replacements") +def get_replacements(batch_id: int): + """Return replacement candidates for a sync batch.""" + conn = _get_db() + try: + row = conn.execute( + "SELECT diff_summary FROM cantaloupe_sync_batches WHERE id = ?", + (batch_id,), + ).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="Batch not found") + + diff = row["diff_summary"] + if isinstance(diff, str): + try: + diff = _json.loads(diff) + except _json.JSONDecodeError: + diff = {} + if not isinstance(diff, dict): + diff = {} + + replacements = diff.get("replacements", []) + return {"batch_id": batch_id, "replacements": replacements} + + finally: + conn.close() + + +# ─── Route: Resolve replacements ──────────────────────────────────────────── + + +@router.post("/batches/{batch_id}/replacements") +def resolve_replacements(batch_id: int, body: ReplacementActionRequest): + """Approve or reject individual replacement candidates. + + Approve: retires the old asset (status='retired') and sets + install_date = today on the new asset. Reject: records the + decision without modifying assets. + """ + conn = _get_db() + try: + batch_row = conn.execute( + "SELECT * FROM cantaloupe_sync_batches WHERE id = ?", (batch_id,) + ).fetchone() + if batch_row is None: + raise HTTPException(status_code=404, detail="Batch not found") + if batch_row["status"] != "pending": + raise HTTPException( + status_code=400, + detail=f"Cannot resolve replacements in status '{batch_row['status']}'. Only 'pending' batches can be modified.", + ) + + # Parse diff_summary + diff = batch_row["diff_summary"] + if isinstance(diff, str): + try: + diff = _json.loads(diff) + except _json.JSONDecodeError: + diff = {} + if not isinstance(diff, dict): + diff = {} + + replacements = diff.get("replacements", []) + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + resolved = [] + + # Build index of replacements by (new_mid, old_mid) + repl_index: dict[tuple[str, str], dict] = {} + for r in replacements: + key = (str(r.get("new_machine_id", "")).strip(), + str(r.get("old_machine_id", "")).strip()) + repl_index[key] = r + + for item in body.replacements: + key = (str(item.new_machine_id).strip(), + str(item.old_machine_id).strip()) + repl = repl_index.get(key) + if repl is None: + raise HTTPException( + status_code=404, + detail=f"Replacement not found: new={item.new_machine_id} old={item.old_machine_id}", + ) + + if item.action not in ("approve", "reject"): + raise HTTPException( + status_code=400, + detail=f"Invalid action '{item.action}'. Must be 'approve' or 'reject'.", + ) + + if item.action == "approve": + # Retire the old asset + old_asset = conn.execute( + "SELECT id, name, description FROM assets WHERE machine_id = ?", + (item.old_machine_id,), + ).fetchone() + if old_asset: + old_desc = (old_asset["description"] or "").strip() + link_note = f"[Replaced by {item.new_machine_id} on {today}]" + new_desc = (old_desc + "\n" + link_note).strip() if old_desc else link_note + conn.execute( + "UPDATE assets SET status = 'retired', description = ?, updated_at = datetime('now') WHERE id = ?", + (new_desc, old_asset["id"]), + ) + + # Set install_date on the new asset + new_asset = conn.execute( + "SELECT id, name, description FROM assets WHERE machine_id = ?", + (item.new_machine_id,), + ).fetchone() + if new_asset: + new_desc = (new_asset["description"] or "").strip() + link_note = f"[Replaced {item.old_machine_id} on {today}]" + new_desc = (new_desc + "\n" + link_note).strip() if new_desc else link_note + conn.execute( + "UPDATE assets SET install_date = ?, description = ?, updated_at = datetime('now') WHERE id = ?", + (today, new_desc, new_asset["id"]), + ) + + repl["status"] = "approved" + else: + repl["status"] = "rejected" + + repl["resolved_at"] = today + resolved.append(repl) + + # Persist updated replacements in diff_summary + diff["replacements"] = replacements + conn.execute( + "UPDATE cantaloupe_sync_batches SET diff_summary = ? WHERE id = ?", + (_json.dumps(diff, default=str, ensure_ascii=False), batch_id), + ) + conn.commit() + + return {"batch_id": batch_id, "replacements": replacements, "resolved_count": len(body.replacements)} + + except HTTPException: + raise + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=f"Resolve replacements failed: {e}") + finally: + conn.close() diff --git a/static/index.html b/static/index.html index 98c5897..e63db95 100644 --- a/static/index.html +++ b/static/index.html @@ -661,6 +661,30 @@ .cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; } .cs-actions { display: flex; gap: 8px; margin-top: 12px; } + + /* Replacement cards */ + .cs-repl-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); } + .cs-repl-section h4 { font-size: 14px; margin-bottom: 10px; } + .cs-repl-card { + display: flex; align-items: center; gap: 12px; + background: rgba(255,255,255,0.02); border: 1px solid var(--border); + border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; + flex-wrap: wrap; + } + .cs-repl-card.resolved { opacity: 0.55; } + .cs-repl-card .cs-repl-info { flex: 1; min-width: 200px; } + .cs-repl-card .cs-repl-info .cs-repl-old { color: var(--red); font-size: 12px; } + .cs-repl-card .cs-repl-info .cs-repl-new { color: var(--green); font-size: 12px; } + .cs-repl-card .cs-repl-info .cs-repl-addr { color: var(--text2); font-size: 11px; margin-top: 2px; } + .cs-repl-card .cs-repl-conf { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; } + .cs-repl-conf.high { background: rgba(255,140,0,0.15); color: #ff8c00; } + .cs-repl-conf.medium { background: rgba(255,193,7,0.15); color: #ffc107; } + .cs-repl-card .cs-repl-actions { display: flex; gap: 6px; flex-shrink: 0; } + .cs-repl-card .cs-repl-actions button { font-size: 11px; padding: 4px 12px; } + .cs-repl-status { font-size: 11px; padding: 2px 8px; border-radius: 10px; white-space: nowrap; } + .cs-repl-status.approved { background: rgba(72,199,142,0.15); color: var(--green); } + .cs-repl-status.rejected { background: rgba(240,61,62,0.15); color: var(--red); } + .cs-repl-resolve-all { margin-top: 8px; } @@ -2349,6 +2373,42 @@ function renderBatchDiffDetail(el, batch) { html = '
No changes detected.
'; } + // Replacement candidates section + const replacements = (batch.diff_summary && batch.diff_summary.replacements) || []; + if (replacements.length > 0) { + const unresolved = replacements.filter(r => !r.status).length; + html += `
+

🔄 Machine Replacements Detected (${replacements.length})${unresolved > 0 ? ` — ${unresolved} unresolved` : ' — all resolved'}

+
`; + replacements.forEach(r => { + const resolved = !!r.status; + const statusLabel = r.status === 'approved' ? '✓ Approved' : r.status === 'rejected' ? '✗ Rejected' : ''; + html += `
+
+
🏷️ Old: ${esc(r.old_machine_id)} — ${esc(r.old_asset_name || '—')}
+
🆕 New: ${esc(r.new_machine_id)} — ${esc(r.new_asset_name || '—')}
+
📍 ${esc(r.location_address || '—')} · ${esc(r.customer_name || '—')}
+
+ ${(r.confidence || 'medium').toUpperCase()}`; + if (resolved) { + html += `${statusLabel}`; + } else { + html += `
+ + +
`; + } + html += `
`; + }); + html += `
`; + if (unresolved > 0) { + html += `
+ +
`; + } + html += `
`; + } + el.innerHTML = html; } @@ -2364,10 +2424,13 @@ function renderCsHistory() { const newCount = (ds.new_assets || []).length; const chgCount = (ds.changed_assets || []).length; const remCount = (ds.removed_assets || []).length; + const replCount = (ds.replacements || []).length; + const replUnresolved = (ds.replacements || []).filter(r => !r.status).length; const summaryParts = []; if (newCount) summaryParts.push(`+${newCount}`); if (chgCount) summaryParts.push(`~${chgCount}`); if (remCount) summaryParts.push(`−${remCount}`); + if (replCount) summaryParts.push(`🔄${replCount}${replUnresolved > 0 ? `⚠${replUnresolved}` : ''}`); return `
${csStatusBadge(b.status)} @@ -2435,12 +2498,25 @@ async function uploadSyncFile(event) { // ── Approve / Reject ── async function approveBatch(batchId) { + // Check for unresolved replacements + let unresolved = 0; + try { + const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`); + unresolved = batch.unresolved_replacements || 0; + } catch (e) { /* ignore, proceed */ } + + let warning = ''; + if (unresolved > 0) { + warning = `\n\n⚠️ ${unresolved} replacement(s) are unresolved. Resolve them first or force-approve.`; + } + const confirmed = await showModal('Approve Batch', - `Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`, - 'Approve', 'btn-green'); + `Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?${warning}`, + unresolved > 0 ? 'Force Approve' : 'Approve', unresolved > 0 ? 'btn-amber' : 'btn-green'); if (!confirmed) return; try { - const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' }); + const url = `/api/admin/cantaloupe/batches/${batchId}/approve${unresolved > 0 ? '?force=true' : ''}`; + const result = await api(url, { method: 'POST' }); showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done')); await loadCantaloupeSync(); } catch (e) { @@ -2462,6 +2538,74 @@ async function rejectBatch(batchId) { } } +// ── Replacement Resolution ── +async function resolveReplacement(batchId, newMid, oldMid, action) { + const label = action === 'approve' ? 'Approve' : 'Reject'; + try { + const body = JSON.stringify({ + replacements: [{ new_machine_id: newMid, old_machine_id: oldMid, action }] + }); + const result = await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + }); + showToast(`${label}d replacement: ${oldMid} → ${newMid}`); + // Reload the batch detail inline + const detail = document.getElementById('csBatchDetail_' + batchId); + if (detail && detail.dataset.loaded === '1') { + const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`); + const tmp = document.createElement('div'); + renderBatchDiffDetail(tmp, batch); + detail.innerHTML = tmp.innerHTML; + } + // Refresh the batch list to update summary counts + await loadCantaloupeSync(); + } catch (e) { + showToast(`${label} failed: ` + e.message, true); + } +} + +async function approveAllReplacements(batchId) { + const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`); + const replacements = (batch.diff_summary && batch.diff_summary.replacements) || []; + const unresolved = replacements.filter(r => !r.status); + if (unresolved.length === 0) { + showToast('All replacements already resolved'); + return; + } + const confirmed = await showModal('Approve All', + `Approve all ${unresolved.length} unresolved replacement(s) in batch #${batchId}?`, + 'Approve All', 'btn-green'); + if (!confirmed) return; + try { + const body = JSON.stringify({ + replacements: unresolved.map(r => ({ + new_machine_id: r.new_machine_id, + old_machine_id: r.old_machine_id, + action: 'approve', + })) + }); + await api(`/api/admin/cantaloupe/batches/${batchId}/replacements`, { + method: 'POST', + body, + headers: { 'Content-Type': 'application/json' }, + }); + showToast(`Approved ${unresolved.length} replacement(s)`); + // Reload detail + const detail = document.getElementById('csBatchDetail_' + batchId); + if (detail && detail.dataset.loaded === '1') { + const updated = await api(`/api/admin/cantaloupe/batches/${batchId}`); + const tmp = document.createElement('div'); + renderBatchDiffDetail(tmp, updated); + detail.innerHTML = tmp.innerHTML; + } + await loadCantaloupeSync(); + } catch (e) { + showToast('Approve all failed: ' + e.message, true); + } +} + // ═════════════════════════════════════════════════════════════════════════════ // HELPERS // ═════════════════════════════════════════════════════════════════════════════