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:
Leo
2026-05-23 17:55:54 -04:00
parent 78f4b74675
commit ea1feaaad1
2 changed files with 332 additions and 4 deletions
+185 -1
View File
@@ -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()