feat: replacement candidate review UI + approve/reject workflow
- 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
This commit is contained in:
+185
-1
@@ -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()
|
||||
|
||||
+147
-3
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -2349,6 +2373,42 @@ function renderBatchDiffDetail(el, batch) {
|
||||
html = '<div class="text-muted text-sm">No changes detected.</div>';
|
||||
}
|
||||
|
||||
// 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 += `<div class="cs-repl-section">
|
||||
<h4>🔄 Machine Replacements Detected (${replacements.length})${unresolved > 0 ? ` — <span style="color:var(--amber)">${unresolved} unresolved</span>` : ' — <span style="color:var(--green)">all resolved</span>'}</h4>
|
||||
<div>`;
|
||||
replacements.forEach(r => {
|
||||
const resolved = !!r.status;
|
||||
const statusLabel = r.status === 'approved' ? '✓ Approved' : r.status === 'rejected' ? '✗ Rejected' : '';
|
||||
html += `<div class="cs-repl-card${resolved ? ' resolved' : ''}" id="csReplCard_${batch.id}_${r.id}">
|
||||
<div class="cs-repl-info">
|
||||
<div class="cs-repl-old">🏷️ Old: <b>${esc(r.old_machine_id)}</b> — ${esc(r.old_asset_name || '—')}</div>
|
||||
<div class="cs-repl-new">🆕 New: <b>${esc(r.new_machine_id)}</b> — ${esc(r.new_asset_name || '—')}</div>
|
||||
<div class="cs-repl-addr">📍 ${esc(r.location_address || '—')} · ${esc(r.customer_name || '—')}</div>
|
||||
</div>
|
||||
<span class="cs-repl-conf ${r.confidence || 'medium'}">${(r.confidence || 'medium').toUpperCase()}</span>`;
|
||||
if (resolved) {
|
||||
html += `<span class="cs-repl-status ${r.status}">${statusLabel}</span>`;
|
||||
} else {
|
||||
html += `<div class="cs-repl-actions">
|
||||
<button class="btn btn-green btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'approve')">✓ Approve</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="resolveReplacement(${batch.id}, '${esc(r.new_machine_id)}', '${esc(r.old_machine_id)}', 'reject')">✗ Reject</button>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
if (unresolved > 0) {
|
||||
html += `<div class="cs-repl-resolve-all">
|
||||
<button class="btn btn-accent btn-sm" onclick="approveAllReplacements(${batch.id})">✓ Approve All</button>
|
||||
</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
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 ? `<span style="color:var(--amber)">⚠${replUnresolved}</span>` : ''}`);
|
||||
return `
|
||||
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
|
||||
<span class="csb-status">${csStatusBadge(b.status)}</span>
|
||||
@@ -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
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user