feat(locations): soft-remove monitoring locations without destroying history

When a client drops a location from scope mid-project (e.g. the office
half of a museum+office monitoring job), operators couldn't previously
mark it as no-longer-active without either deleting it (which would
orphan historical events) or leaving it in the active list looking
deployable.  Now there's a proper middle ground.

Data model
- MonitoringLocation gets two new nullable columns:
  - removed_at      — NULL means active; set means soft-removed
  - removal_reason  — optional operator note
  Migration: backend/migrate_add_location_removed.py (idempotent)

Endpoints
- POST /api/projects/{p}/locations/{l}/remove
    Body: { effective_date?: ISO-datetime, reason?: str }
    Side effects (cascade):
      1. Closes active UnitAssignment rows at this location
         (assigned_until = effective_date, status = "completed")
      2. Cancels pending ScheduledActions at this location
      3. Marks location.removed_at = effective_date
    Returns counts of assignments closed + actions cancelled.
- POST /api/projects/{p}/locations/{l}/restore
    Clears removed_at + removal_reason.  Does NOT auto-reopen
    assignments — operator creates new ones if resuming monitoring.

Active-surface filters
- locations-json defaults to active-only; pass include_removed=true
  for historical / reporting views.  Schedule modal dropdowns now
  exclude removed locations automatically.
- Metadata-backfill fuzzy matcher excludes removed locations from
  proposed targets (don't want backfill creating new assignments at
  decommissioned locations).
- Vibration-summary per_location rollup includes removed locations
  (so historical event totals stay accurate) but tags each with
  removed_at so the UI can show a badge.

UI
- Project detail page's Monitoring Locations section now splits into:
    Active locations (full card with Assign / Edit / Remove / Delete)
    Removed locations (collapsed <details>, greyed cards, Restore button,
                       shows removal date + reason)
- New per-card "Remove" button → opens confirmation modal explaining
  the cascade, with optional effective-date (defaults to now,
  backdateable) and reason fields.
- Unit detail's SFM Events attribution cell shows a small "removed"
  badge next to historical attributions whose location is no longer
  active.  Same pattern in vibration_summary's top-locations list.
- Soft-removal indicator surfaced through the events_for_unit
  attribution payload as location_removed_at.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:22:40 +00:00
parent fd37425f1c
commit d5a0163852
9 changed files with 531 additions and 38 deletions
+16 -7
View File
@@ -318,13 +318,18 @@ async def events_for_unit(
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
return {
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
# Soft-removal indicator so the UI can render a "(removed)"
# badge next to historical attributions whose location is no
# longer actively monitored.
"location_removed_at": (loc.removed_at.isoformat()
if loc and loc.removed_at else None),
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
}
# 2. Fetch all events for this serial in one shot.
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
"event_count": ec,
"peak_pvs": ev_peak,
"last_event": ev_last,
# Soft-removal state — UI can show a "(removed)" badge in the
# per-location list so operators see at a glance that a row's
# numbers are historical-only.
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
})
per_location.sort(key=lambda r: r["event_count"], reverse=True)