Ran a dedicated post-M7 release-readiness audit. Found and fixed the one real gap: mypy was a declared dev dependency but had never been run in any milestone's validation loop. Fixed all 43 pre-existing type errors it surfaced, including two genuine defensive- programming gaps (unguarded Optional vehicle/customer lookups that could have crashed with unhandled 500s instead of clean 404/401 responses) rather than suppressing them. make lint now runs ruff + mypy; mypy reports zero errors across 44 source files. Re-verified end to end against a genuinely wiped-volumes clean checkout: automatic migrations, deterministic seed, 66/66 backend tests, and the full user-journey matrix (login, dashboard, vehicle/booking detail, return workflow, invalid-mileage rejection, data-quality review, duplicate-customer merge, audit trail, Knowledge Assistant, n8n, MCP Hub) via curl and Playwright. Live-verified both external-dependency degraded modes, not just unit tests: stopped n8n mid-flow and confirmed a return still commits with the outbox event staying pending and retrying with backoff, then self-healing to succeeded with zero manual intervention once n8n came back; verified RAGcore's unavailable-degradation path against an unreachable host. Added frontend/e2e/interactive-elements.spec.ts (11 tests covering every nav item, filter, tab, and role boundary) alongside the existing demo script test — 12/12 e2e tests passing. Verified no secrets are committed (.env never tracked, clean git history scan) and .env.example covers every operator-configurable setting. Confirmed no placeholders, TODOs, fake responses, hardcoded metrics, or dead routes anywhere in the codebase. Updated README.md with an honest integration-status section and PROJECT_STATE.md with the full audit findings. Added artifacts/final-acceptance/summary.md as the authoritative final evidence document (commands, results, URLs, demo access, integration status per external dependency, known limitations, deployment instructions, five-minute demo flow).
292 lines
10 KiB
Python
292 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import csv
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
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
|
|
|
|
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",
|
|
},
|
|
]
|
|
|
|
|
|
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]
|
|
|
|
|
|
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] = {}
|
|
|
|
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"]),
|
|
"ends_at": _parse_dt(row["ends_at"]),
|
|
"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"]),
|
|
"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"]),
|
|
"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"]),
|
|
"delivery_status": row["status"],
|
|
"attempts": int(row["attempts"]),
|
|
"next_attempt_at": None,
|
|
"last_error": row["last_error"] or None,
|
|
"external_run_id": None,
|
|
}
|
|
)
|
|
db.execute(insert(OutboxEvent), outbox_rows)
|
|
counts["workflow_runs"] = len(outbox_rows)
|
|
|
|
return SeedResult(counts=counts)
|
|
|
|
|
|
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
|