Files
MobilityOps/backend/app/seed_loader.py
T
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00

325 lines
12 KiB
Python

from __future__ import annotations
import csv
import uuid
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
from sqlalchemy import delete, insert, update
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event
settings = get_settings()
DEMO_USERS = [
{
"public_ref": "USR-OPS",
"display_name": "Amelie De Ridder",
"role": "operations_manager",
},
{
"public_ref": "USR-EMP",
"display_name": "Karim Boujaddaine",
"role": "rental_employee",
},
]
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
# right now" scenarios stay true to the actual reset moment instead of decaying as real
# time passes between resets -- a fixed anchor with no shift goes stale within days.
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
def _seed_anchor_shift(today: date) -> timedelta:
return today - SEED_AUTHORED_ANCHOR
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _parse_bool(value: str) -> bool:
return value.strip().lower() == "true"
def _parse_optional_int(value: str) -> int | None:
value = value.strip()
return int(value) if value else None
@dataclass
class SeedResult:
counts: dict[str, int]
anchor_date: date
seeded_at: datetime
def _seed_dir() -> Path:
return Path(settings.seed_dir)
def _read_csv(name: str) -> list[dict[str, str]]:
path = _seed_dir() / name
with path.open(newline="", encoding="utf-8") as handle:
return list(csv.DictReader(handle))
def clear_all(db: Session) -> None:
for model in (
AuditEvent,
OutboxEvent,
IdempotencyRecord,
DataQualityIssue,
Inspection,
MaintenanceRecord,
Booking,
Vehicle,
Customer,
User,
):
db.execute(delete(model))
def load_seed(db: Session) -> SeedResult:
counts: dict[str, int] = {}
today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today)
user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows)
counts["users"] = len(user_rows)
customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = []
for row in _read_csv("customers.csv"):
cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append(
{
"id": cid,
"public_ref": row["public_ref"],
"first_name": row["first_name"],
"last_name": row["last_name"],
"email": row["email"] or None,
"phone": row["phone"] or None,
"postal_code": row["postal_code"] or None,
"city": row["city"] or None,
}
)
db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first.
for row in _read_csv("customers.csv"):
merged_ref = row.get("merged_into") or ""
if merged_ref:
db.execute(
update(Customer)
.where(Customer.id == customer_id_by_ref[row["public_ref"]])
.values(merged_into_customer_id=customer_id_by_ref[merged_ref])
)
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = []
for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append(
{
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
"model": row["model"],
"model_year": int(row["model_year"]),
"registration_number": row["registration_number"],
"location": row["location"],
"operational_status": row["operational_status"],
"odometer_km": int(row["odometer_km"]),
"next_service_km": int(row["next_service_km"]),
"active": _parse_bool(row["active"]),
"version": 1,
}
)
db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = []
for row in _read_csv("bookings.csv"):
bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append(
{
"id": bid,
"public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"starts_at": _parse_dt(row["starts_at"]) + shift,
"ends_at": _parse_dt(row["ends_at"]) + shift,
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
)
db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows)
inspection_rows = []
for row in _read_csv("inspections.csv"):
inspection_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"booking_id": booking_id_by_ref[row["booking_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"type": row["type"],
"fuel_level_percent": int(row["fuel_level_percent"]),
"cleanliness_ok": _parse_bool(row["cleanliness_ok"]),
"damage_reported": _parse_bool(row["damage_reported"]),
"technical_warning": _parse_bool(row["technical_warning"]),
"odometer_km": int(row["odometer_km"]),
"completed_at": _parse_dt(row["completed_at"]) + shift,
"completed_by": None,
}
)
db.execute(insert(Inspection), inspection_rows)
counts["inspections"] = len(inspection_rows)
maintenance_rows = []
for row in _read_csv("maintenance.csv"):
maintenance_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
"odometer_km": int(row["odometer_km"]),
"category": row["category"],
"summary": row["summary"],
}
)
db.execute(insert(MaintenanceRecord), maintenance_rows)
counts["maintenance"] = len(maintenance_rows)
def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]:
if entity_ref.startswith("CUS-"):
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
dq_rows = []
now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
dq_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"rule_type": row["rule_type"],
"entity_type": entity_type,
"entity_id": entity_id,
"severity": row["severity"],
"status": row["status"],
"evidence_json": {
"summary": row["evidence"],
"entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [],
},
"proposed_action_json": {},
"detected_at": now,
"resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
}
)
db.execute(insert(DataQualityIssue), dq_rows)
counts["data_quality_issues"] = len(dq_rows)
vehicle_ref_by_booking_ref = {
row["public_ref"]: row["vehicle_ref"] for row in _read_csv("bookings.csv")
}
outbox_rows = []
for row in _read_csv("workflow_runs.csv"):
booking_id = booking_id_by_ref.get(row["aggregate_ref"])
# Build the same schema-complete envelope the live return workflow (M2) produces,
# so a seeded/historical event is redeliverable (e.g. via manual retry) without the
# dispatcher crashing on a missing key. See PROJECT_STATE.md M4 notes.
outbox_rows.append(
{
"event_id": uuid.UUID(row["event_id"]),
"event_type": row["event_type"],
"aggregate_type": "booking",
"aggregate_id": booking_id or uuid.uuid4(),
"payload_json": {
"correlation_id": str(uuid.uuid4()),
"aggregate": {
"type": "booking",
"id": str(booking_id or uuid.uuid4()),
"public_ref": row["aggregate_ref"],
},
"data": {
"vehicle_ref": vehicle_ref_by_booking_ref.get(row["aggregate_ref"], ""),
"inspection_ref": "",
"resulting_vehicle_status": "cleaning",
"attention_reasons": [],
},
"aggregate_ref": row["aggregate_ref"],
},
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
"delivery_status": row["status"],
"attempts": int(row["attempts"]),
"next_attempt_at": None,
"last_error": row["last_error"] or None,
# The seed dataset's one synthetic failure (BK-H-0020) models a
# connection-timeout-style delivery failure -- see workflow_runs.csv.
"last_error_code": "connectionError" if row["last_error"] else None,
"external_run_id": None,
}
)
db.execute(insert(OutboxEvent), outbox_rows)
counts["workflow_runs"] = len(outbox_rows)
seeded_at = datetime.now(UTC)
record_audit_event(
db,
actor_type="system",
actor_label="seed loader",
action="demo_data_seeded",
entity_type="system",
metadata={
"anchor_date": today.isoformat(),
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
"counts": counts,
},
)
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(db: Session) -> SeedResult:
from app.services.data_quality import run_scan
clear_all(db)
result = load_seed(db)
db.commit()
scan = run_scan(db)
result.counts["data_quality_issues"] += sum(scan.created.values())
return result