feat(projects): "Merge into…" button to consolidate duplicate projects

Operator-facing tool for cleaning up duplicate projects.  Common after
the metadata-backfill parser auto-creates near-duplicates from operator
name variations ("SR81" vs "SR 81", "Swank-Karns Crossing" vs
"Swank-Karns Crossings", "Trumbull-Bryman Mont.Dam" vs
"Trumbull-Brayman-Mont Dam", etc.).

Workflow: visit the duplicate project's detail page, click "Merge into…"
in the header, search for the canonical target project from a typeahead,
review the preview (what assignments / locations / sessions will move,
any conflicts), confirm.  Source is soft-deleted; everything else
re-points to the target.  Smart consolidation: same-named locations in
both projects merge into one (source's assignments move to target's
existing location with the same name; source's empty location is then
deleted).  Different-named locations move as-is.

Backend:
- backend/services/project_merge.py (new): preview() and execute()
  functions.  Transaction-safe.  Per-assignment UnitHistory audit row
  with change_type='assignment_merged' so the deployment timeline shows
  the merge.  Source modules disabled; missing modules added to target.
  Handles edge cases: same project_id rejected, deleted projects rejected,
  orphan project-direct assignments (no location) re-pointed defensively.

- backend/routers/projects.py: new endpoints
    GET  /api/projects/{source_id}/merge_preview?target_id=...
    POST /api/projects/{source_id}/merge_into?target_id=...

Frontend (templates/partials/projects/project_header.html):
- "Merge into…" button in Project Actions area.
- Modal with typeahead (reuses /api/admin/metadata_backfill/projects_search)
  scoped to existing projects only (no create-new option).  Filters out
  the source project from candidates so operator can't accidentally pick
  it as target.
- Preview pane shows totals + per-location plan (consolidate vs move) +
  warnings (mismatched client names, location consolidation note).
- Red "Merge (permanent)" confirm button only enables after a target is
  picked and preview loads.
- On success, browser redirects to target project page.

Smoke verified: "Swank-Karns Crossing" (1 assignment) merged into
"Swank-Karns Crossings"; target now has 2 locations + 2 assignments,
source has 0 dangling rows, 1 project_merge audit entry written.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 20:18:42 +00:00
parent d3b5a3fd26
commit b1c2a1d778
3 changed files with 757 additions and 0 deletions
+66
View File
@@ -688,6 +688,72 @@ async def restore_project(project_id: str, db: Session = Depends(get_db)):
return {"success": True, "message": f"Project '{project.name}' restored."}
# ── Project merge ──────────────────────────────────────────────────────────────
# Consolidate a duplicate project into another. Common after the
# metadata-backfill parser creates near-duplicate projects from name
# variations operators typed on the BW device.
# See backend/services/project_merge.py for the merge logic.
@router.get("/{source_id}/merge_preview")
async def project_merge_preview(
source_id: str,
target_id: str,
db: Session = Depends(get_db),
):
"""Preview what the merge will do — used by the confirmation modal.
No writes."""
from backend.services import project_merge as pm
preview = pm.preview(db, source_id, target_id)
return {
"source_project_id": preview.source_project_id,
"source_project_name": preview.source_project_name,
"target_project_id": preview.target_project_id,
"target_project_name": preview.target_project_name,
"total_assignments_moving": preview.total_assignments_moving,
"total_sessions_moving": preview.total_sessions_moving,
"total_data_files_moving": preview.total_data_files_moving,
"modules_to_add": preview.modules_to_add,
"warnings": preview.warnings,
"location_plans": [
{
"source_id": p.source_id,
"source_name": p.source_name,
"target_id": p.target_id,
"target_name": p.target_name,
"action": p.action,
"assignments_moving": p.assignments_moving,
"sessions_moving": p.sessions_moving,
}
for p in preview.location_plans
],
}
@router.post("/{source_id}/merge_into")
async def project_merge_execute(
source_id: str,
target_id: str,
db: Session = Depends(get_db),
):
"""Execute the merge. Source project gets soft-deleted; all its
locations / assignments / sessions / data_files / modules move to
the target. Same-named locations consolidate."""
from backend.services import project_merge as pm
result = pm.execute(db, source_id, target_id, decided_by="operator")
return {
"success": True,
"source_project_id": result.source_project_id,
"target_project_id": result.target_project_id,
"assignments_moved": result.assignments_moved,
"locations_moved": result.locations_moved,
"locations_consolidated": result.locations_consolidated,
"sessions_moved": result.sessions_moved,
"data_files_moved": result.data_files_moved,
"modules_added": result.modules_added,
"audit_rows_written": result.audit_rows_written,
}
@router.get("/{project_id}")
async def get_project(project_id: str, db: Session = Depends(get_db)):
"""