M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+58
View File
@@ -1,5 +1,63 @@
# Project state
## M54 — full logic, resilience and recruiter upgrade (2026-08-23, local candidate)
- Replaced the static Engineering architecture row with an interactive, keyboard-operable
five-step system flow. It exposes the user-intent, local-transaction and recoverable-edge
boundaries, marks the post-commit outbox hand-off and explains persisted evidence for
each selected step. Dashboard recovery is abortable/retryable and mobile actions retain
a full-width 44 px target.
- Tightened operational invariants around bookings, returns and data quality. New bookings
cannot pre-confirm requirements; customer tombstones are rejected; employee vehicle views
never load manager-only quality evidence. DQ-03 now uses actual inspection chronology,
appends concurrent evidence, supports partial correction, synchronizes booking/inspection/
vehicle readings and suppresses only the exact source fingerprint explicitly retained by
a manager. Resolver/return lock order is covered by a real two-session deadlock regression.
- Split the oversized data-quality service into focused common/odometer/duplicate-scan
modules while preserving its public API. The seed generator now reproduces all seven
committed CSV files byte-for-byte and authored inspection/maintenance evidence matches
the structured runtime facts.
- Made demo reset atomic across requests and replicas: all SQLAlchemy transactions take the
shared side of a PostgreSQL advisory barrier, reset takes the exclusive side, a separate
non-blocking replica guard rejects competing resets and the database audit timestamp is
the authoritative cooldown. An integrity failure rolls the complete reset back.
- Hardened external contracts. n8n callbacks bind event plus correlation IDs under row locks;
dispatcher success requires an exact event acknowledgement and execution ID; a per-claim
lease token prevents late workers overwriting a reclaim/callback. RAGcore responses are
accepted as grounded only with valid response UUIDs, managed provenance, local excerpt
validation and claim-to-citation bindings. Unconfigured/unverified integrations no longer
inherit a historical green state.
- Closed frontend lifecycle and timing races: guide target searches cancel on close and
restore focus, final progress is completable, reset clears progress only after commit,
guided entry waits for a retryable manifest, session/status loads do not block each other,
latest-request-wins guards stale filters, Brussels DST gaps are rejected and maintenance
dates are browser-timezone independent. Ctrl/Cmd+K registers before paint in capture phase.
All new error codes and states have NL/EN/FR parity.
- Validation evidence:
- isolated PostgreSQL backend suite: **331 passed in 94.19 s**;
- DQ-03 edge/concurrency suite: **44/44 passed**; combined RAG/dispatcher/DQ suite:
**121/121 passed**;
- Ruff clean; mypy clean across **62 source files**;
- frontend TypeScript/ESLint, production build, source and bundle budgets passed;
- OpenAPI, event, MCP and n8n contracts synchronized; production npm audit reports
**0 vulnerabilities**;
- guarded source sizes: data-quality service **799/900 lines**, main CSS
**76,342/78,000 bytes**, architecture CSS **8,057/9,000 bytes**;
- the previously intermittent global-search shortcut passed **10/10** consecutive
release-container repetitions after its lifecycle fix;
- final uninterrupted Playwright release run: **171/171 passed in 6.9 min**;
- in-app inspection of login, Dashboard and Engineering at desktop and 390×844 found
zero horizontal overflow, no alert/console errors and the intended one-column mobile flow.
- Final local reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 DQ issues / 20 workflow runs** with all five
synthetic scenarios ready. API/database readiness is green and the final API/web log scan
contains no traceback, critical, unhandled, panic, fatal or emergency hit.
- Local Compose `db`, `api` and rebuilt `web` services run the candidate at
`http://localhost:1228`. Production and its data were not changed during validation.
Explicit release authority was granted on 2026-08-24.
- Exact next action: create and push the coherent M54 commit, take and verify a production
backup, then promote that exact revision through the established start-first deployer.
## M52 — activate verified ITWorx OneDrive backup (2026-08-21)
- Authorized the `onedrive` rclone remote against the owner's ITWorx Microsoft 365
+55
View File
@@ -72,3 +72,58 @@ Current evidence images are in `artifacts/evidence/screenshots/`:
## Deliberate boundary
This is a completed, production-shaped public demo—not claimed customer adoption and not a general ERP. Accounting, payments, public reservations, CRM, inventory, HR, a second RAG stack, a separate MCP server and autonomous write agents remain intentionally excluded. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery governance.
## Local M54 upgrade candidate — 2026-08-23 (not released)
This evidence supplements, but does not replace, the production hand-off above. The local
working tree contains an intentionally uncommitted recruiter UX and resilience upgrade;
production, its database and the production screenshots were not changed.
Implemented evidence includes the interactive five-step system flow, hardened booking/return/
data-quality invariants, atomic cross-replica demo reset, exact n8n callback and dispatcher
leases, citation-bound RAGcore answers, deterministic seed regeneration and abortable,
race-safe frontend data loading. Keyboard, forced-colours, reduced-motion and responsive
behaviour remain part of the established Fleet Ops design system.
Exact local validation commands and results:
```text
docker compose -p mobilityops-m54-final -f compose.yaml -f compose.test.yaml run --build --rm api pytest
331 passed in 94.19s
focused DQ-03 edge/concurrency suite
44 passed
combined RAGcore, dispatcher and data-quality suite
121 passed
docker compose -p mobilityops-lint -f compose.yaml -f compose.test.yaml run --build --rm --no-deps api ruff check app tests
All checks passed
docker compose -p mobilityops-lint -f compose.yaml -f compose.test.yaml run --build --rm --no-deps api mypy app
Success: no issues found in 62 source files
cd frontend && npm run lint
passed
cd frontend && npm run build && npm run budget
passed
python scripts/check-source-budgets.py
passed; data-quality service 799/900 lines; main CSS 76342/78000 bytes;
architecture CSS 8057/9000 bytes; data-quality CSS 6525/8000 bytes
cd frontend && npx playwright test
171 passed (6.9m) in one uninterrupted run against the rebuilt release container
global-search shortcut regression, repeated against the release container
10 passed in 10 consecutive repetitions
docker compose -p mobilityops-contracts -f compose.yaml -f compose.test.yaml run --build --rm --no-deps --volume "C:\Projects\MobilityOps:/repo" --workdir /repo/backend api python ../scripts/check-contracts.py
OpenAPI, event, MCP and n8n contracts are synchronized
cd frontend && npm audit --omit=dev
0 vulnerabilities
```
The final local reset restored `2 users / 180 customers / 50 vehicles / 254 bookings / 75
inspections / 40 maintenance records / 33 data-quality issues / 20 workflow runs`, with all
five synthetic scenarios ready. API readiness is `ready`, PostgreSQL is `up`, the rebuilt
API/web stack remains available, and the final 20-minute log scan contains no traceback,
critical, unhandled, panic, fatal or emergency hit. `git diff --check` is clean.
In-app browser inspection covered the login, Dashboard and Engineering surfaces at desktop
and 390×844. It confirmed zero horizontal overflow, no alert or console errors, the intended
one-column mobile flow, readable mobile cards and consistent selected-step feedback. The local
Compose candidate is healthy at `http://localhost:1228`. A release commit/tag, production
backup and deployment remain deferred until explicit authorization; production and its data
were not touched by M54.
+51 -3
View File
@@ -30,6 +30,7 @@ from app.schemas import (
ReturnPreviewResult,
)
from app.services.audit import record_audit_event
from app.services.data_quality import open_odometer_regression_issue
from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -146,8 +147,14 @@ def create_booking(
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref))
if customer is None or customer.merged_into_customer_id is not None:
customer = db.scalar(
select(Customer).where(Customer.public_ref == body.customer_ref).with_for_update()
)
if (
customer is None
or customer.merged_into_customer_id is not None
or customer.anonymized_at is not None
):
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
# Serialise booking creation per vehicle. The overlap check must run after
# acquiring this lock, otherwise two concurrent requests can both pass it.
@@ -179,7 +186,9 @@ def create_booking(
status="reserved",
start_odometer_km=None,
end_odometer_km=None,
requirements_complete=body.requirements_complete,
# Requirements are intentionally confirmed in a separate, audited action.
# Never allow booking creation to bypass that operational checkpoint.
requirements_complete=False,
)
db.add(booking)
db.flush()
@@ -225,6 +234,15 @@ def checkout_booking(
if active_conflict is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
correlation_id = uuid.uuid4()
before_booking = {
"status": booking.status,
"start_odometer_km": booking.start_odometer_km,
}
before_vehicle = {
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
}
attention_reasons: list[str] = []
if body.start_odometer_km < vehicle.odometer_km:
attention_reasons.append("odometer_regression")
@@ -250,6 +268,18 @@ def checkout_booking(
completed_by=user.display_name,
)
db.add(inspection)
if "odometer_regression" in attention_reasons:
open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=inspection.public_ref,
reading_km=body.start_odometer_km,
canonical_km=vehicle.odometer_km,
source_type="checkout",
related_refs=[booking.public_ref, inspection.public_ref],
actor_label=user.display_name,
correlation_id=correlation_id,
)
if attention_reasons:
booking.status = "blocked"
vehicle.operational_status = (
@@ -269,13 +299,31 @@ def checkout_booking(
action="booking_checkout_recorded",
entity_type="booking",
entity_id=booking.id,
correlation_id=correlation_id,
before=before_booking,
after={
"inspection_ref": inspection.public_ref,
"booking_status": booking.status,
"start_odometer_km": booking.start_odometer_km,
"vehicle_status": vehicle.operational_status,
"attention_reasons": attention_reasons,
},
)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="vehicle_status_changed",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before_vehicle,
after={
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
},
metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref},
)
db.commit()
return CheckoutBookingResult(
booking_ref=booking.public_ref,
+1
View File
@@ -21,6 +21,7 @@ def search_customers(
select(Customer)
.where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
or_(
Customer.public_ref.ilike(term),
Customer.first_name.ilike(term),
+14
View File
@@ -11,6 +11,7 @@ from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.user import User
from app.models.vehicle import Vehicle
from app.schemas import (
@@ -224,6 +225,8 @@ _PREFIX_TO_TYPE = {
"MO-": "vehicle",
"BK-": "booking",
"INSP-": "inspection",
"MAINT-": "maintenance",
"MNT-": "maintenance",
}
@@ -292,6 +295,17 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
"completed_at": inspection.completed_at.isoformat(),
"booking_ref": booking.public_ref if booking else None,
}
if entity_type == "maintenance":
record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref))
if record is None:
return None
return {
"entity_type": "maintenance",
"public_ref": record.public_ref,
"odometer_km": record.odometer_km,
"occurred_at": record.occurred_at.isoformat(),
"category": record.category,
}
return None
+33 -9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import threading
import time
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import func, select
@@ -10,7 +11,9 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_operations_manager
from app.core.config import get_settings
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine
from app.core.security import SessionPayload, create_session_token, read_session_token
from app.models.audit import AuditEvent
from app.models.user import User
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed
@@ -21,7 +24,6 @@ from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
_reset_guard = threading.Lock()
_last_reset_monotonic = 0.0
_RESET_ADVISORY_LOCK_ID = 706_533_149
@@ -110,7 +112,6 @@ def demo_reset(
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
global _last_reset_monotonic
if not settings.mobilityops_demo_mode:
# Outside demo mode the reset endpoint must not exist at all: it wipes
# operational data and replaces it with synthetic records.
@@ -122,19 +123,35 @@ def demo_reset(
)
if not _reset_guard.acquire(blocking=False):
raise HTTPException(status_code=409, detail="A demo reset is already running.")
replica_lock_connection = None
try:
elapsed = time.monotonic() - _last_reset_monotonic
if _last_reset_monotonic and elapsed < settings.demo_reset_cooldown_seconds:
# A session-level lock on its own connection rejects another replica immediately;
# the main DB session can then safely end its auth read transaction and wait on
# the normal shared/exclusive data barrier without releasing this replica guard.
replica_lock_connection = engine.connect()
locked = replica_lock_connection.scalar(
select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID))
)
if not locked:
raise HTTPException(status_code=409, detail="A demo reset is already running.")
begin_exclusive_demo_reset(db)
# The audit timestamp is shared by every replica. A process-local monotonic
# timestamp cannot protect a multi-replica deployment.
last_reset_at = db.scalar(
select(AuditEvent.occurred_at)
.where(AuditEvent.action == "demo_reset")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
)
elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None
if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds:
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
raise HTTPException(
status_code=429,
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
if not locked:
raise HTTPException(status_code=409, detail="A demo reset is already running.")
result = reset_and_seed(db, preserve_integration_telemetry=True)
result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False)
integrity = scenario_integrity_report(db)
record_audit_event(
db,
@@ -149,8 +166,15 @@ def demo_reset(
},
)
db.commit()
_last_reset_monotonic = time.monotonic()
end_exclusive_demo_reset(db)
finally:
if replica_lock_connection is not None:
try:
replica_lock_connection.scalar(
select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID))
)
finally:
replica_lock_connection.close()
_reset_guard.release()
response.delete_cookie(settings.session_cookie_name)
return {
+35 -10
View File
@@ -1,12 +1,13 @@
from __future__ import annotations
import hashlib
import hmac
import uuid
from datetime import UTC, datetime
from pathlib import Path
from fastapi import APIRouter, Depends, Header
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_db
@@ -51,6 +52,13 @@ def _require_service_token(service_token: str) -> None:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
"""Serialize callback check+insert by a stable, transaction-scoped key."""
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
def workflow_heartbeat(
body: N8nHeartbeatIn,
@@ -61,6 +69,7 @@ def workflow_heartbeat(
_require_service_token(service_token)
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
@@ -109,9 +118,29 @@ def return_callback(
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
) from exc
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
if event is None:
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
if body.event_id != event_id:
raise AppError(
"CALLBACK_EVENT_MISMATCH",
"Callback event_id does not match Idempotency-Key.",
status_code=409,
)
try:
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
except (KeyError, TypeError, ValueError) as exc:
raise AppError(
"INVALID_EVENT_CORRELATION",
"The stored outbox event has no valid correlation ID.",
status_code=409,
) from exc
if body.correlation_id != expected_correlation_id:
raise AppError(
"CALLBACK_CORRELATION_MISMATCH",
"Callback correlation_id does not match the outbox event.",
status_code=409,
)
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
# (e.g. a lost response after a timeout), so this callback must not double-record.
@@ -131,7 +160,7 @@ def return_callback(
actor_label="n8n",
action="n8n_return_followup_recorded",
entity_type="booking",
correlation_id=body.correlation_id,
correlation_id=expected_correlation_id,
after={"follow_up": body.follow_up, "summary": body.summary},
metadata={"event_id": str(event_id)},
)
@@ -170,6 +199,7 @@ def workflow_error(
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
error report (e.g. after a timed-out response), so this must not double-record."""
_require_service_token(service_token)
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
already_recorded = (
db.scalar(
@@ -181,19 +211,13 @@ def workflow_error(
is not None
)
if not already_recorded:
correlation_id: uuid.UUID | None = None
if body.correlation_id:
try:
correlation_id = uuid.UUID(body.correlation_id)
except ValueError:
correlation_id = None
record_audit_event(
db,
actor_type="service",
actor_label="n8n error handler",
action="n8n_workflow_failure_registered",
entity_type="automation",
correlation_id=correlation_id,
correlation_id=body.correlation_id,
after={
"workflow_id": body.workflow_id,
"workflow_name": body.workflow_name,
@@ -248,6 +272,7 @@ def procedures_sync_result(
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
_require_service_token(service_token)
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
already_recorded = (
db.scalar(
+3 -1
View File
@@ -53,7 +53,9 @@ def ask_question(
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
else request.client.host
if request.client
else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
+39 -4
View File
@@ -27,6 +27,7 @@ from app.schemas import (
VehiclePageOut,
)
from app.services.audit import record_audit_event
from app.services.data_quality import open_odometer_regression_issue
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -143,7 +144,7 @@ def list_vehicles(
def get_vehicle(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
user: CurrentUser = Depends(get_current_user),
) -> VehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
if vehicle is None:
@@ -163,11 +164,21 @@ def get_vehicle(
.where(MaintenanceRecord.vehicle_id == vehicle.id)
.order_by(MaintenanceRecord.occurred_at.desc())
).all()
issues = db.scalars(
# Detailed data-quality evidence is an operations-manager surface. Employees still
# get the operational vehicle record they need, but never receive hidden evidence in
# the payload merely because the frontend omits the Quality tab.
issues = (
db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
.where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
)
.order_by(DataQualityIssue.detected_at.desc())
).all()
if user.role == "operations_manager"
else []
)
booking_by_id = {b.id: b.public_ref for b in bookings}
next_booking = next(
@@ -276,6 +287,12 @@ def create_maintenance_record(
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
correlation_id = uuid.uuid4()
before_vehicle = {
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
"next_service_km": vehicle.next_service_km,
}
record = MaintenanceRecord(
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
vehicle_id=vehicle.id,
@@ -285,6 +302,17 @@ def create_maintenance_record(
summary=body.summary.strip(),
)
db.add(record)
open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=record.public_ref,
reading_km=body.odometer_km,
canonical_km=vehicle.odometer_km,
source_type="maintenance",
related_refs=[record.public_ref],
actor_label=user.display_name,
correlation_id=correlation_id,
)
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
if body.next_service_km is not None:
if body.next_service_km < vehicle.odometer_km:
@@ -301,7 +329,14 @@ def create_maintenance_record(
action="maintenance_record_created",
entity_type="vehicle",
entity_id=vehicle.id,
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
correlation_id=correlation_id,
before=before_vehicle,
after={
"maintenance_ref": record.public_ref,
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
"next_service_km": vehicle.next_service_km,
},
)
db.commit()
return MaintenanceOut(
+5 -1
View File
@@ -33,7 +33,11 @@ class Settings(BaseSettings):
n8n_callback_token: str = "replace-me-n8n-callback-token"
n8n_dispatch_enabled: bool = True
n8n_dispatch_interval_seconds: float = 3.0
n8n_http_timeout_seconds: float = 5.0
# The synchronous n8n workflow performs two bounded, retried callbacks before it
# acknowledges an event. Keep this above that complete workflow budget, while the
# delivery lease remains the wider crash-recovery boundary (enforced by the contract
# check in scripts/check-contracts.py).
n8n_http_timeout_seconds: float = 15.0
n8n_max_attempts: int = 5
n8n_delivery_lease_seconds: float = 120.0
app_secret: str = "replace-in-production"
+32 -1
View File
@@ -1,6 +1,6 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event, func, select
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.core.config import get_settings
@@ -10,6 +10,37 @@ settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
# Every SQLAlchemy transaction participates in a shared database-wide barrier. Normal
# reads/writes coexist; the short demo reset takes the exclusive form so it can never
# interleave deletes/inserts with an API request, scanner, callback, or dispatcher cycle.
DEMO_DATA_BARRIER_LOCK_ID = 5_344_725_149_212_793_901
_EXCLUSIVE_RESET_INFO_KEY = "mobilityops_demo_reset_exclusive"
@event.listens_for(Session, "after_begin")
def _acquire_demo_data_barrier(session: Session, _transaction, connection) -> None:
if connection.dialect.name != "postgresql":
return
lock = (
func.pg_advisory_xact_lock(DEMO_DATA_BARRIER_LOCK_ID)
if session.info.get(_EXCLUSIVE_RESET_INFO_KEY)
else func.pg_advisory_xact_lock_shared(DEMO_DATA_BARRIER_LOCK_ID)
)
connection.execute(select(lock))
def begin_exclusive_demo_reset(db: Session) -> None:
"""Make the session's next transaction the exclusive side of the reset barrier."""
if db.in_transaction():
# Auth normally already read the user under a shared barrier. End that read-only
# transaction before requesting exclusive; in-place lock upgrades can deadlock.
db.rollback()
db.info[_EXCLUSIVE_RESET_INFO_KEY] = True
def end_exclusive_demo_reset(db: Session) -> None:
db.info.pop(_EXCLUSIVE_RESET_INFO_KEY, None)
class Base(DeclarativeBase):
pass
+12 -8
View File
@@ -4,9 +4,13 @@ import uuid
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
Role = Literal["operations_manager", "rental_employee"]
NonBlankOperationalReason = Annotated[
str,
StringConstraints(strip_whitespace=True, min_length=3, max_length=500),
]
class DemoLoginRequest(BaseModel):
@@ -93,17 +97,16 @@ class CreateBookingRequest(BaseModel):
vehicle_ref: str = Field(min_length=3, max_length=20)
starts_at: datetime
ends_at: datetime
requirements_complete: bool = False
class CompleteBookingRequirementsRequest(BaseModel):
confirmation: str = Field(min_length=3, max_length=500)
confirmation: NonBlankOperationalReason
class RescheduleBookingRequest(BaseModel):
starts_at: datetime
ends_at: datetime
reason: str = Field(min_length=3, max_length=500)
reason: NonBlankOperationalReason
class CustomerOptionOut(BaseModel):
@@ -122,7 +125,7 @@ class AvailableVehicleOut(BaseModel):
class CancelBookingRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
reason: NonBlankOperationalReason
class CheckoutBookingRequest(BaseModel):
@@ -225,7 +228,7 @@ class CreateMaintenanceRequest(BaseModel):
class ReleaseVehicleRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
reason: NonBlankOperationalReason
class DataQualityIssueOut(BaseModel):
@@ -295,7 +298,7 @@ class WorkflowErrorReportIn(BaseModel):
]
error_summary: str = Field(max_length=500)
trigger_context: str | None = Field(default=None, max_length=200)
correlation_id: str | None = None
correlation_id: uuid.UUID | None = None
attempt: int = Field(default=1, ge=1, le=1000)
retry_action: str | None = Field(default=None, max_length=200)
@@ -433,7 +436,8 @@ class ReturnCallbackIn(BaseModel):
model_config = ConfigDict(extra="ignore")
correlation_id: uuid.UUID | None = None
event_id: uuid.UUID
correlation_id: uuid.UUID
follow_up: str | None = Field(default=None, max_length=200)
summary: str | None = Field(default=None, max_length=2000)
+51 -11
View File
@@ -11,6 +11,7 @@ from sqlalchemy import delete, insert, update
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
@@ -201,7 +202,11 @@ def load_seed(db: Session) -> SeedResult:
counts["bookings"] = len(booking_rows)
inspection_rows = []
for row in _read_csv("inspections.csv"):
inspection_source_rows = _read_csv("inspections.csv")
return_inspection_row_by_booking_ref = {
row["booking_ref"]: row for row in inspection_source_rows if row["type"] == "return"
}
for row in inspection_source_rows:
inspection_rows.append(
{
"id": uuid.uuid4(),
@@ -259,16 +264,18 @@ def load_seed(db: Session) -> SeedResult:
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
later = return_inspection_row_by_booking_ref[later_ref]
earlier = return_inspection_row_by_booking_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"source_type": "return",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
"later_ref": later["public_ref"],
"later_km": int(later["odometer_km"]),
"earlier_ref": earlier["public_ref"],
"earlier_km": int(earlier["odometer_km"]),
"booking_ref": later_ref,
},
}
]
@@ -354,6 +361,27 @@ def load_seed(db: Session) -> SeedResult:
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
signals = _seed_signals(row["public_ref"], row["entity_ref"], related_refs)
evidence_extra: dict[str, object] = {}
if row["rule_type"] == "odometer_regression" and signals:
params = signals[0].get("params", {})
later_ref = params.get("later_ref")
earlier_ref = params.get("earlier_ref")
booking_ref = params.get("booking_ref")
if (
isinstance(earlier_ref, str)
and isinstance(later_ref, str)
and isinstance(booking_ref, str)
):
# The authored CSV prose predates structured references. Preserve the
# two real source bookings so the detail page can show evidence, while
# only the later (regressing) reading is eligible for correction.
related_refs = [earlier_ref, booking_ref, later_ref]
evidence_extra = {
"source_type": signals[0].get("source_type", "return"),
"source_types": [signals[0].get("source_type", "return")],
"correctable_booking_refs": [booking_ref],
}
severity_due_delta = {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
@@ -372,7 +400,8 @@ def load_seed(db: Session) -> SeedResult:
"summary": row["evidence"],
"entity_ref": row["entity_ref"],
"related_refs": related_refs,
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
"signals": signals,
**evidence_extra,
},
"proposed_action_json": {},
"detected_at": now,
@@ -449,12 +478,23 @@ def load_seed(db: Session) -> SeedResult:
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
def reset_and_seed(
db: Session,
*,
preserve_integration_telemetry: bool = False,
commit: bool = True,
) -> SeedResult:
from app.services.data_quality import run_scan
if not db.info.get("mobilityops_demo_reset_exclusive"):
begin_exclusive_demo_reset(db)
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
result = load_seed(db)
db.commit()
scan = run_scan(db)
scan = run_scan(db, commit=False)
result.counts["data_quality_issues"] += sum(scan.created.values())
if commit:
db.commit()
end_exclusive_demo_reset(db)
else:
db.flush()
return result
+124 -204
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
@@ -11,10 +11,22 @@ from app.core.errors import AppError
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.schemas import CurrentUser
from app.services.audit import record_audit_event
from app.services.data_quality_common import has_open_issue as _has_open_issue
from app.services.data_quality_common import issue_due_at
from app.services.data_quality_common import load_open_issue as _load_open_issue
from app.services.data_quality_common import new_scan_ref as _new_scan_ref
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
from app.services.data_quality_odometer import (
open_odometer_regression_issue as open_odometer_regression_issue,
)
from app.services.data_quality_odometer import (
resolve_odometer_regression as resolve_odometer_regression,
)
from app.services.vehicle_status import (
RECOMMENDATION_CODE_NO_CONFLICT,
VehicleStatusRecommendation,
@@ -28,15 +40,6 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
"""Return the local operational SLA deadline for a newly detected issue."""
return detected_at + {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(severity, timedelta(days=1))
@dataclass
class ScanResult:
created: dict[str, int] = field(default_factory=dict)
@@ -45,25 +48,6 @@ class ScanResult:
self.created[rule_type] = self.created.get(rule_type, 0) + 1
def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool:
return (
db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.rule_type == rule_type,
DataQualityIssue.entity_type == entity_type,
DataQualityIssue.entity_id == entity_id,
DataQualityIssue.status == "open",
)
)
is not None
)
def _new_scan_ref(prefix: str) -> str:
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
def _open_issue(
db: Session,
scan: ScanResult,
@@ -76,6 +60,7 @@ def _open_issue(
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
evidence_extra: dict | None = None,
) -> None:
if _has_open_issue(db, rule_type, entity_type, entity_id):
return
@@ -103,6 +88,7 @@ def _open_issue(
"related_refs": related_refs,
"signals": signals or [],
}
evidence.update(evidence_extra or {})
if previous is not None:
evidence["reopened_from"] = previous.public_ref
evidence["previous_decision"] = previous.status
@@ -235,57 +221,110 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
)
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
# The seed dataset's vehicle.odometer_km is generated independently of booking
# history, so comparing every historical booking against it produces near-universal
# false positives. Instead check the booking sequence's own internal consistency:
# each vehicle's completed bookings should show a non-decreasing odometer reading.
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
).all():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
for vehicle_id, bookings in bookings_by_vehicle.items():
bookings.sort(key=lambda b: b.ends_at)
for earlier, later in zip(bookings, bookings[1:], strict=False):
# The query above filters end_odometer_km IS NOT NULL, so both are ints here.
assert earlier.end_odometer_km is not None
assert later.end_odometer_km is not None
if later.end_odometer_km < earlier.end_odometer_km:
vehicle = vehicles[vehicle_id]
_open_issue(
db,
scan,
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle_id,
severity="medium",
summary=(
f"Booking {later.public_ref} recorded {later.end_odometer_km} km, "
f"below the {earlier.end_odometer_km} km recorded by earlier "
f"booking {earlier.public_ref}."
),
entity_ref=vehicle.public_ref,
related_refs=[earlier.public_ref, later.public_ref],
signals=[
{
"code": "odometer.regression",
"params": {
"later_ref": later.public_ref,
"later_km": later.end_odometer_km,
"earlier_ref": earlier.public_ref,
"earlier_km": earlier.end_odometer_km,
},
def _scan_odometer_regressions(
db: Session,
scan: ScanResult,
*,
actor_label: str | None,
actor_type: str,
) -> None:
# Compare the chronological history with itself rather than every historical reading
# to today's canonical value. That detects imported checkout/return/maintenance
# regressions without flagging every legitimate older reading.
# Runtime checkout/return/maintenance mutations take the vehicle lock before they
# inspect or append DQ-03 evidence. Taking the same lock here makes scan-vs-command
# check/merge atomic and prevents a partial-unique race for the open issue.
vehicles = {
v.id: v for v in db.scalars(select(Vehicle).order_by(Vehicle.id).with_for_update()).all()
}
],
readings_by_vehicle: dict[uuid.UUID, list[tuple[datetime, str, int, str, str | None]]] = {}
inspections = db.scalars(select(Inspection)).all()
return_inspection_booking_ids = {
inspection.booking_id for inspection in inspections if inspection.type == "return"
}
returned_bookings = db.scalars(
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
).all()
booking_ref_by_id = {booking.id: booking.public_ref for booking in returned_bookings}
for booking in returned_bookings:
# A real return inspection owns the actual reading timestamp. Using the booking's
# planned ends_at as a duplicate second reading can make an early return appear to
# go backwards after a newer real inspection. Keep booking data only as the legacy
# import fallback when no return inspection exists.
if booking.id in return_inspection_booking_ids:
continue
assert booking.end_odometer_km is not None
readings_by_vehicle.setdefault(booking.vehicle_id, []).append(
(
booking.ends_at,
booking.public_ref,
booking.end_odometer_km,
"booking",
booking.public_ref,
)
break
)
for inspection in inspections:
readings_by_vehicle.setdefault(inspection.vehicle_id, []).append(
(
inspection.completed_at,
inspection.public_ref,
inspection.odometer_km,
inspection.type,
(
booking_ref_by_id.get(inspection.booking_id)
if inspection.type == "return"
else None
),
)
)
for record in db.scalars(select(MaintenanceRecord)).all():
readings_by_vehicle.setdefault(record.vehicle_id, []).append(
(record.occurred_at, record.public_ref, record.odometer_km, "maintenance", None)
)
for vehicle_id, readings in readings_by_vehicle.items():
readings.sort(key=lambda reading: (reading[0], reading[1]))
highest = readings[0] if readings else None
for later in readings[1:]:
assert highest is not None
if later[2] < highest[2]:
vehicle = vehicles[vehicle_id]
had_open_issue = _has_open_issue(
db,
"odometer_regression",
"vehicle",
vehicle_id,
)
issue = open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=later[1],
reading_km=later[2],
canonical_km=highest[2],
canonical_ref=highest[1],
source_type=later[3],
related_refs=list(
dict.fromkeys(
[highest[1], later[1], *([later[4]] if later[4] is not None else [])]
)
),
correctable_booking_refs=[later[4]] if later[4] is not None else [],
public_ref=_new_scan_ref("DQ-SCAN"),
actor_label=actor_label,
actor_type=actor_type,
)
if issue is not None and not had_open_issue:
scan.bump("odometer_regression")
if later[2] > highest[2]:
highest = later
def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
db: Session,
*,
actor_label: str | None = None,
actor_type: str = "user",
commit: bool = True,
) -> ScanResult:
# The check-then-insert work below spans several rules. Serialise whole scans at the
# database boundary so API and n8n triggers cannot both observe an empty condition.
@@ -293,7 +332,12 @@ def run_scan(
scan = ScanResult()
scan_duplicate_customers(db, scan, _open_issue)
_scan_missing_required_fields(db, scan)
_scan_odometer_regressions(db, scan)
_scan_odometer_regressions(
db,
scan,
actor_label=actor_label,
actor_type=actor_type,
)
_scan_booking_overlaps(db, scan)
_scan_vehicle_status_conflicts(db, scan)
if actor_label is not None:
@@ -305,28 +349,13 @@ def run_scan(
entity_type="system",
metadata={"created": scan.created},
)
if commit:
db.commit()
else:
db.flush()
return scan
def _load_open_issue(
db: Session, public_ref: str, *, lock: bool = True
) -> DataQualityIssue:
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update()
issue = db.scalar(statement)
if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open":
raise AppError(
"ISSUE_NOT_OPEN",
f"Issue is '{issue.status}', not 'open'.",
status_code=409,
)
return issue
def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
issue.status = "deferred"
@@ -456,115 +485,6 @@ def provide_missing_fields(
return issue
def resolve_odometer_regression(
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "odometer_regression":
raise AppError(
"NOT_AN_ODOMETER_ISSUE",
"This issue is not an odometer_regression issue.",
status_code=409,
)
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
booking: Booking | None = None
if body.decision != "retain_canonical":
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
correlation_id = uuid.uuid4()
if body.decision == "retain_canonical":
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_retained",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
)
else:
assert booking is not None and body.corrected_odometer_km is not None
# Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km:
raise AppError(
"CORRECTION_BELOW_CANONICAL",
(
f"Corrected value {body.corrected_odometer_km} km is still below the "
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
),
status_code=422,
)
before = {
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
}
booking.end_odometer_km = body.corrected_odometer_km
vehicle.odometer_km = body.corrected_odometer_km
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_corrected",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before,
after={
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
},
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
)
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
metadata={"decision": body.decision, "note": body.note},
)
db.commit()
return issue
def resolve_booking_overlap(
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
) -> DataQualityIssue:
@@ -0,0 +1,59 @@
from __future__ import annotations
import uuid
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.data_quality import DataQualityIssue
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
"""Return the local operational SLA deadline for a newly detected issue."""
return detected_at + {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(severity, timedelta(days=1))
def has_open_issue(
db: Session,
rule_type: str,
entity_type: str,
entity_id: uuid.UUID,
) -> bool:
return (
db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.rule_type == rule_type,
DataQualityIssue.entity_type == entity_type,
DataQualityIssue.entity_id == entity_id,
DataQualityIssue.status == "open",
)
)
is not None
)
def new_scan_ref(prefix: str) -> str:
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
def load_open_issue(db: Session, public_ref: str, *, lock: bool = True) -> DataQualityIssue:
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update().execution_options(populate_existing=True)
issue = db.scalar(statement)
if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open":
raise AppError(
"ISSUE_NOT_OPEN",
f"Issue is '{issue.status}', not 'open'.",
status_code=409,
)
return issue
@@ -90,9 +90,7 @@ def scan_duplicate_customers[ScanType: ScanAccumulator](
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
@@ -0,0 +1,507 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event
from app.services.data_quality_common import issue_due_at, load_open_issue, new_scan_ref
def _odometer_fingerprint(
*,
source_type: str,
later_ref: str,
later_km: int,
) -> dict[str, str | int]:
"""Stable identity for one reviewed regression, independent of issue IDs."""
return {
"source_type": source_type,
"later_ref": later_ref,
"later_km": later_km,
}
def _was_odometer_regression_retained(
db: Session,
*,
vehicle_id: uuid.UUID,
fingerprint: dict[str, str | int],
) -> bool:
reviewed = db.scalars(
select(DataQualityIssue).where(
DataQualityIssue.rule_type == "odometer_regression",
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle_id,
DataQualityIssue.status == "resolved",
)
).all()
return any(
issue.evidence_json.get("resolution_decision") == "retain_canonical"
and fingerprint in issue.evidence_json.get("retained_odometer_fingerprints", [])
for issue in reviewed
)
def open_odometer_regression_issue(
db: Session,
*,
vehicle: Vehicle,
reading_ref: str,
reading_km: int,
canonical_km: int,
source_type: str,
related_refs: list[str],
correctable_booking_refs: list[str] | None = None,
canonical_ref: str | None = None,
detected_at: datetime | None = None,
public_ref: str | None = None,
actor_label: str | None = None,
actor_type: str = "user",
correlation_id: uuid.UUID | None = None,
) -> DataQualityIssue | None:
"""Open one explainable DQ-03 issue while the caller holds the vehicle lock."""
if reading_km >= canonical_km:
return None
earlier_ref = canonical_ref or vehicle.public_ref
fingerprint = _odometer_fingerprint(
source_type=source_type,
later_ref=reading_ref,
later_km=reading_km,
)
# A manager's explicit "retain canonical" decision acknowledges this exact source
# fact. Reopening it on every scheduled scan would create churn; only changed or new
# evidence (and therefore a different fingerprint) is actionable again.
if _was_odometer_regression_retained(
db,
vehicle_id=vehicle.id,
fingerprint=fingerprint,
):
return None
existing = db.scalar(
select(DataQualityIssue)
.where(
DataQualityIssue.rule_type == "odometer_regression",
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
)
.with_for_update()
.execution_options(populate_existing=True)
)
if existing is not None:
before_related_refs = list(existing.evidence_json.get("related_refs", []))
before_correctable_refs = list(existing.evidence_json.get("correctable_booking_refs", []))
new_signal = {
"code": "odometer.regression",
"source_type": source_type,
"params": {
"later_ref": reading_ref,
"later_km": reading_km,
"earlier_ref": earlier_ref,
"earlier_km": canonical_km,
},
}
existing_signals = list(existing.evidence_json.get("signals", []))
signal_was_new = not any(
isinstance(signal, dict)
and isinstance(signal.get("params"), dict)
and _odometer_fingerprint(
source_type=str(
signal.get("source_type", existing.evidence_json.get("source_type"))
),
later_ref=str(signal["params"].get("later_ref")),
later_km=signal["params"].get("later_km"),
)
== fingerprint
for signal in existing_signals
if isinstance(signal, dict)
and isinstance(signal.get("params"), dict)
and isinstance(signal["params"].get("later_km"), int)
)
if signal_was_new:
existing_signals.append(new_signal)
merged_related_refs = list(
dict.fromkeys([*before_related_refs, *(related_refs if signal_was_new else [])])
)
merged_correctable_refs = list(
dict.fromkeys([*before_correctable_refs, *(correctable_booking_refs or [])])
)
if (
not signal_was_new
and merged_related_refs == before_related_refs
and merged_correctable_refs == before_correctable_refs
):
return existing
source_types = list(existing.evidence_json.get("source_types", []))
previous_source_type = existing.evidence_json.get("source_type")
if (
isinstance(previous_source_type, str)
and previous_source_type != "multiple"
and previous_source_type not in source_types
):
source_types.append(previous_source_type)
if source_type not in source_types:
source_types.append(source_type)
existing.evidence_json = {
**existing.evidence_json,
"summary": (
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
f"the canonical {canonical_km} km for {vehicle.public_ref}."
),
"related_refs": merged_related_refs,
"correctable_booking_refs": merged_correctable_refs,
"source_type": source_type if len(source_types) == 1 else "multiple",
"source_types": source_types,
"signals": existing_signals,
}
if actor_label is not None and (
merged_related_refs != before_related_refs
or merged_correctable_refs != before_correctable_refs
or signal_was_new
):
record_audit_event(
db,
actor_type=actor_type,
actor_label=actor_label,
action="data_quality_issue_evidence_updated",
entity_type="data_quality_issue",
entity_id=existing.id,
correlation_id=correlation_id,
before={
"related_refs": before_related_refs,
"correctable_booking_refs": before_correctable_refs,
},
after={
"related_refs": merged_related_refs,
"correctable_booking_refs": merged_correctable_refs,
},
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
)
return existing
now = detected_at or datetime.now(UTC)
previous = db.scalar(
select(DataQualityIssue)
.where(
DataQualityIssue.rule_type == "odometer_regression",
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status != "open",
)
.order_by(DataQualityIssue.detected_at.desc())
)
evidence = {
"summary": (
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
f"the canonical {canonical_km} km for {vehicle.public_ref}."
),
"entity_ref": vehicle.public_ref,
"related_refs": related_refs,
"correctable_booking_refs": correctable_booking_refs or [],
"source_type": source_type,
"source_types": [source_type],
"signals": [
{
"code": "odometer.regression",
"source_type": source_type,
"params": {
"later_ref": reading_ref,
"later_km": reading_km,
"earlier_ref": earlier_ref,
"earlier_km": canonical_km,
},
}
],
}
if previous is not None:
evidence["reopened_from"] = previous.public_ref
evidence["previous_decision"] = previous.status
issue = DataQualityIssue(
public_ref=public_ref or new_scan_ref("DQ-ODO"),
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle.id,
severity="medium",
status="open",
evidence_json=evidence,
proposed_action_json={},
detected_at=now,
due_at=issue_due_at(now, "medium"),
)
db.add(issue)
db.flush()
if actor_label is not None:
record_audit_event(
db,
actor_type=actor_type,
actor_label=actor_label,
action="data_quality_issue_created",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
after={"status": "open", "rule_type": "odometer_regression"},
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
)
return issue
def resolve_odometer_regression(
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
) -> DataQualityIssue:
# Read the routing data without a row lock first. The canonical mutation order is
# booking -> vehicle -> issue -> inspection everywhere, matching checkout/return.
# Locking the issue before the booking creates a resolver-vs-return deadlock.
issue_snapshot = load_open_issue(db, public_ref, lock=False)
if issue_snapshot.rule_type != "odometer_regression":
raise AppError(
"NOT_AN_ODOMETER_ISSUE",
"This issue is not an odometer_regression issue.",
status_code=409,
)
def correctable_refs(issue: DataQualityIssue) -> list[str]:
if "correctable_booking_refs" in issue.evidence_json:
refs = issue.evidence_json.get("correctable_booking_refs", [])
else:
# Backward compatibility for issues created before source-aware evidence.
refs = [
ref
for ref in issue.evidence_json.get("related_refs", [])
if isinstance(ref, str) and ref.startswith("BK-")
]
return [ref for ref in refs if isinstance(ref, str)]
booking: Booking | None = None
if body.decision != "retain_canonical":
if body.booking_ref not in correctable_refs(issue_snapshot):
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
vehicle = db.scalar(
select(Vehicle).where(Vehicle.id == issue_snapshot.entity_id).with_for_update()
)
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
# A return or scan may have appended evidence while we waited for the domain locks.
# Lock and refresh the issue only now, then revalidate every decision against that
# current evidence instead of resolving a stale snapshot.
issue = load_open_issue(db, public_ref)
if issue.rule_type != "odometer_regression" or issue.entity_id != vehicle.id:
raise AppError(
"ISSUE_CHANGED",
"The issue changed while the correction was being prepared. Review it again.",
status_code=409,
)
if booking is not None:
if booking.vehicle_id != vehicle.id or booking.public_ref not in correctable_refs(issue):
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
correlation_id = uuid.uuid4()
if body.decision == "retain_canonical":
retained_fingerprints: list[dict[str, str | int]] = []
fallback_source_type = str(issue.evidence_json.get("source_type", "unknown"))
for signal in issue.evidence_json.get("signals", []):
if not isinstance(signal, dict) or signal.get("code") != "odometer.regression":
continue
params = signal.get("params")
if not isinstance(params, dict):
continue
source_type = signal.get("source_type", fallback_source_type)
later_ref = params.get("later_ref")
later_km = params.get("later_km")
earlier_ref = params.get("earlier_ref")
earlier_km = params.get("earlier_km")
if (
isinstance(source_type, str)
and isinstance(later_ref, str)
and isinstance(later_km, int)
and isinstance(earlier_ref, str)
and isinstance(earlier_km, int)
):
retained_fingerprints.append(
_odometer_fingerprint(
source_type=source_type,
later_ref=later_ref,
later_km=later_km,
)
)
issue.evidence_json = {
**issue.evidence_json,
"resolution_decision": "retain_canonical",
"retained_odometer_fingerprints": retained_fingerprints,
}
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_retained",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
metadata={
"issue_ref": issue.public_ref,
"canonical_odometer_km": vehicle.odometer_km,
"retained_fingerprint_count": len(retained_fingerprints),
},
)
else:
assert booking is not None and body.corrected_odometer_km is not None
# Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km:
raise AppError(
"CORRECTION_BELOW_CANONICAL",
(
f"Corrected value {body.corrected_odometer_km} km is still below the "
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
),
status_code=422,
)
# A returned booking has two persisted representations of the same reading.
# Correct every return inspection for the selected booking, including historical
# seed rows whose old issue evidence did not yet cite the inspection explicitly.
related_inspections = db.scalars(
select(Inspection)
.where(Inspection.booking_id == booking.id, Inspection.type == "return")
.with_for_update()
).all()
before = {
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
"inspection_odometer_km": {
inspection.public_ref: inspection.odometer_km for inspection in related_inspections
},
}
booking.end_odometer_km = body.corrected_odometer_km
for inspection in related_inspections:
inspection.odometer_km = body.corrected_odometer_km
vehicle.odometer_km = body.corrected_odometer_km
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_odometer_corrected",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before,
after={
"booking_end_odometer_km": booking.end_odometer_km,
"vehicle_odometer_km": vehicle.odometer_km,
"inspection_odometer_km": {
inspection.public_ref: inspection.odometer_km
for inspection in related_inspections
},
},
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
)
has_remaining_evidence = False
if booking is not None:
target_refs = {booking.public_ref, *(item.public_ref for item in related_inspections)}
before_related = [
ref for ref in issue.evidence_json.get("related_refs", []) if isinstance(ref, str)
]
before_correctable = correctable_refs(issue)
before_signals = [
signal for signal in issue.evidence_json.get("signals", []) if isinstance(signal, dict)
]
remaining_signals = [
signal
for signal in before_signals
if not (
isinstance(signal.get("params"), dict)
and signal["params"].get("later_ref") in target_refs
)
]
remaining_related = [ref for ref in before_related if ref not in target_refs]
remaining_correctable = [ref for ref in before_correctable if ref != booking.public_ref]
has_remaining_evidence = bool(remaining_signals or remaining_correctable)
issue.evidence_json = {
**issue.evidence_json,
"summary": (
"Additional odometer regression evidence remains for review."
if has_remaining_evidence
else issue.evidence_json.get("summary", "Odometer reading corrected.")
),
"related_refs": remaining_related,
"correctable_booking_refs": remaining_correctable,
"signals": remaining_signals,
}
if has_remaining_evidence:
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_partially_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={
"related_refs": before_related,
"correctable_booking_refs": before_correctable,
"signal_count": len(before_signals),
},
after={
"related_refs": remaining_related,
"correctable_booking_refs": remaining_correctable,
"signal_count": len(remaining_signals),
},
metadata={
"decision": body.decision,
"booking_ref": booking.public_ref,
"note": body.note,
},
)
if not has_remaining_evidence:
issue.status = "resolved"
issue.resolved_at = datetime.now(UTC)
issue.resolved_by = actor.display_name
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="data_quality_issue_resolved",
entity_type="data_quality_issue",
entity_id=issue.id,
correlation_id=correlation_id,
before={"status": "open"},
after={"status": "resolved"},
metadata={"decision": body.decision, "note": body.note},
)
db.commit()
return issue
+23 -10
View File
@@ -9,16 +9,28 @@ from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
from app.services.knowledge import get_knowledge_provider
from app.services.knowledge import KnowledgeHealth, get_knowledge_provider
settings = get_settings()
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
def _knowledge_scenario_ready(health: KnowledgeHealth) -> bool:
"""Require a reachable provider and independently verified indexed documents.
``source_document_count`` describes local Markdown files, while a sync report only
describes an upload attempt. Neither proves that the active provider can retrieve a
corpus, so an unavailable verification count remains honestly not ready.
"""
return bool(
health.available and health.document_count is not None and health.document_count > 0
)
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
marker = db.scalar(
select(AuditEvent)
@@ -49,7 +61,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
automation_ready = bool(failed_run and is_demo_scenario_failure(failed_run))
knowledge_ready = _knowledge_scenario_ready(knowledge_health)
return [
DemoScenarioOut(
@@ -119,8 +132,8 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
estimated_minutes=2,
required_roles=["rental_employee", "operations_manager"],
start_path="/knowledge",
ready=knowledge_health.available,
blocked_reason_code=None if knowledge_health.available else "knowledgeUnavailable",
ready=knowledge_ready,
blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable",
),
]
@@ -145,9 +158,11 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
key="ragcore",
status_code=(
"operational"
if knowledge_health.provider == "ragcore" and knowledge_health.available
if knowledge_health.provider == "ragcore"
and _knowledge_scenario_ready(knowledge_health)
else "demoMode"
if knowledge_health.provider == "demo" and knowledge_health.available
if knowledge_health.provider == "demo"
and _knowledge_scenario_ready(knowledge_health)
else "unavailable"
),
detail_code=(
@@ -169,9 +184,7 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
# "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=(
"mcpDetailOperational"
if mcp_hub.state == "operational"
else "mcpDetailPrepared"
"mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared"
),
detail_params={},
),
+59 -5
View File
@@ -77,6 +77,14 @@ def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
for row in rows:
row.delivery_status = "delivering"
row.next_attempt_at = lease_deadline
# The token is stored inside the internal payload (the wire envelope below
# explicitly selects only contract fields). It lets the outcome transaction
# prove that this is still the same lease after network I/O. A stale worker
# must never overwrite a later reclaim/retry or an idempotent callback.
row.payload_json = {
**row.payload_json,
"_delivery_claim_token": str(uuid.uuid4()),
}
db.commit()
return claimed_ids
finally:
@@ -109,6 +117,9 @@ def _deliver_one(event_id: uuid.UUID) -> None:
wire_event = None
payload_error = f"Malformed outbox payload, missing key {exc}"
attempts = event.attempts
claim_token = event.payload_json.get("_delivery_claim_token")
if event.delivery_status != "delivering" or not isinstance(claim_token, str):
return
finally:
db.close()
@@ -130,9 +141,33 @@ def _deliver_one(event_id: uuid.UUID) -> None:
except ValueError:
body = None
if isinstance(body, dict):
success = bool(body.get("ok", True))
error = None if success else f"n8n reported failure: {body}"
error_code = None if success else "remoteReportedFailure"
acknowledged = body.get("ok") is True
response_event_id = body.get("event_id")
event_id_matches = response_event_id == str(event_id)
result = body.get("result")
execution_id = result.get("execution_id") if isinstance(result, dict) else None
execution_id_valid = isinstance(execution_id, str) and bool(execution_id.strip())
success = acknowledged and event_id_matches and execution_id_valid
if success:
error = None
error_code = None
elif not acknowledged:
error = (
"n8n response did not explicitly acknowledge the event with ok=true: "
f"{body}"
)
error_code = (
"remoteReportedFailure" if body.get("ok") is False else "malformedResponse"
)
elif not event_id_matches:
error = (
"n8n acknowledged a different event ID "
f"(expected {event_id}, received {response_event_id!r})"
)
error_code = "mismatchedEventId"
else:
error = "n8n response omitted a valid result.execution_id"
error_code = "malformedResponse"
else:
# A 2xx status with a non-object (or unparsable) body means the workflow
# itself errored before its "Respond to Webhook" node ran -- n8n's default
@@ -152,16 +187,35 @@ def _deliver_one(event_id: uuid.UUID) -> None:
db = SessionLocal()
try:
event = db.get(OutboxEvent, event_id)
event = db.scalar(
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
)
if event is None:
return
if (
event.delivery_status != "delivering"
or event.attempts != attempts
or event.payload_json.get("_delivery_claim_token") != claim_token
):
logger.info(
"Ignoring stale delivery outcome for event %s because lease ownership changed",
event_id,
)
return
event.attempts = attempts + 1
event.payload_json = {
key: value
for key, value in event.payload_json.items()
if key != "_delivery_claim_token"
}
if success:
event.delivery_status = "succeeded"
event.last_error = None
event.last_error_code = None
event.next_attempt_at = None
event.external_run_id = str((body or {}).get("event_id", event_id))
result = (body or {}).get("result")
execution_id = result.get("execution_id") if isinstance(result, dict) else None
event.external_run_id = execution_id if isinstance(execution_id, str) else None
else:
event.last_error = (error or "delivery failed")[:2000]
event.last_error_code = error_code or "unknownError"
+44 -7
View File
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any, Literal
import httpx
from sqlalchemy import Row, func, select
from sqlalchemy import Row, func, or_, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
@@ -93,7 +93,10 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
or_(
OutboxEvent.last_error_code.is_(None),
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
),
)
)
latest_demo_scenario_at = db.scalar(
@@ -106,11 +109,17 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled:
state = "disabled"
elif not settings.n8n_webhook_url:
# Persisted history does not make a currently unconfigured dispatcher green.
state = "unavailable"
elif unexpected_failed > 0 and succeeded == 0:
state = "unavailable"
elif unexpected_failed > 0:
state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0:
# Queued/in-flight work proves only that MobilityOps has work for the dispatcher;
# it does not prove that n8n has ever accepted a delivery. A green state requires
# at least one persisted successful round trip.
elif succeeded > 0:
state = "operational"
else:
state = "no_evidence"
@@ -128,8 +137,14 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
# procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
latest_procedure_sync_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == "n8n_procedures_synced")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
latest_procedure_sync_at = (
latest_procedure_sync_row[0] if latest_procedure_sync_row is not None else None
)
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
@@ -198,6 +213,24 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
seen_at = failure_signal[0]
last_status = "failed"
execution_id = failure_signal[1]
if name == "Fleet Ops — RAGcore Procedure Sync" and latest_procedure_sync_row:
sync_at, sync_result, sync_metadata = latest_procedure_sync_row
synced = (sync_result or {}).get("synced", 0)
failed_syncs = (sync_result or {}).get("failed", 0)
# The result callback is authoritative for corpus delivery. A generic
# succeeded heartbeat cannot turn a zero/partial upload green.
sync_result_failed = (
not isinstance(synced, int)
or not isinstance(failed_syncs, int)
or synced <= 0
or failed_syncs > 0
)
if sync_result_failed:
last_status = "failed"
if seen_at is None or sync_at > seen_at:
seen_at = sync_at
if sync_result_failed:
execution_id = (sync_metadata or {}).get("execution_id")
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
if seen_at is None:
workflow_state = "no_evidence"
@@ -265,16 +298,20 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
last_client = latest_call_row[1] if latest_call_row else None
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
hub_reachable = _check_hub_reachable()
state: Literal["not_configured", "no_evidence", "operational"]
if not settings.mcp_hub_registration_enabled:
state = "not_configured"
elif total_calls > 0:
elif total_calls > 0 and hub_reachable is not False:
state = "operational"
else:
# Historical tool calls remain useful telemetry, but cannot support a current
# operational claim when the configured Hub health endpoint is unreachable.
# ``hub_reachable`` stays available separately so consumers can distinguish
# this from a provider that simply has no call evidence yet.
state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
+166 -44
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import hashlib
import re
import uuid
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Lock
@@ -13,6 +16,10 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | {
"not_answerable",
"conflicting_evidence",
}
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
@@ -47,6 +54,11 @@ _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
}
_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"}
def _normalize_evidence_text(value: str) -> str:
return " ".join(re.findall(r"\w+", value.casefold()))
def _question_concepts(question: str) -> set[str]:
@@ -93,7 +105,9 @@ def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) ->
)
def _retrieval_score(result: dict) -> float | None:
def _retrieval_score(result: object) -> float | None:
if not isinstance(result, dict):
return None
scores = result.get("scores")
if not isinstance(scores, dict):
return None
@@ -124,23 +138,81 @@ class RAGcoreKnowledgeProvider:
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
chunk's `language` payload field as `"en"` regardless of actual document language (the
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
Filtering search/answer requests by requested UI language would therefore silently
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for
the requested UI language. Returned internal document/version UUIDs are deliberately
not treated as Fleet Ops identifiers: every citation must instead prove the complete
managed-source chain (source id, URI, locator, checksum and extractive local text).
"""
name = "ragcore"
def __init__(self) -> None:
self._settings = get_settings()
self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {}
for document in iter_procedure_documents(Path(self._settings.knowledge_dir)):
self._managed_documents_by_source_id[document.source_id] = document
self._verification_cache: dict[str, tuple[float, int]] = {}
self._verification_lock = Lock()
self._answers_circuit_lock = Lock()
self._answers_circuit_open_until = 0.0
def _managed_source(self, citation: object, language: str) -> SourceCard | None:
if not isinstance(citation, dict):
return None
source_id = citation.get("source_id")
if not isinstance(source_id, str):
return None
document = self._managed_documents_by_source_id.get(str(source_id))
if document is None or document.language != language:
return None
if citation.get("locator") != f"{document.document_id}.md":
return None
if citation.get("source_uri") != f"ragcore://source/{document.source_id}":
return None
for field_name in ("id", "document_id", "document_version_id"):
value = citation.get(field_name)
if not isinstance(value, str):
return None
try:
parsed = uuid.UUID(value)
except (ValueError, TypeError, AttributeError):
return None
if parsed.int == 0:
return None
if not isinstance(citation.get("title"), str):
return None
section = citation.get("section")
if section is not None and not isinstance(section, str):
return None
excerpt = citation.get("excerpt")
if not isinstance(excerpt, str) or not excerpt.strip():
return None
expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest()
if citation.get("excerpt_sha256") != expected_hash:
return None
# RAGcore owns chunking, but the cited excerpt must still be extractive evidence
# from the authoritative local procedure. Token normalization tolerates Markdown
# punctuation/whitespace while rejecting provider text that was never uploaded.
normalized_excerpt = _normalize_evidence_text(excerpt)
if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text(
document.content
):
return None
return SourceCard(
document_id=document.document_id,
title=document.title,
version=document.version,
section=section or "",
excerpt=excerpt,
)
def _managed_source_ids(self, language: str) -> list[str]:
return [
document.source_id
for document in self._managed_documents_by_source_id.values()
if document.language == language
]
def _answers_circuit_is_open(self) -> bool:
with self._answers_circuit_lock:
return monotonic() < self._answers_circuit_open_until
@@ -284,7 +356,7 @@ class RAGcoreKnowledgeProvider:
if self._answers_circuit_is_open():
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
else:
answered = self._ask_via_answers(question, correlation_id)
answered = self._ask_via_answers(question, correlation_id, language)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
@@ -295,7 +367,9 @@ class RAGcoreKnowledgeProvider:
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
def _ask_via_answers(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
@@ -307,6 +381,7 @@ class RAGcoreKnowledgeProvider:
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"filters": {"source_ids": self._managed_source_ids(language)},
},
)
if response.status_code != 200:
@@ -320,25 +395,73 @@ class RAGcoreKnowledgeProvider:
return None
try:
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
document_id=str(citation["document_id"]),
title=citation["title"],
version=str(citation["document_version_id"]),
section=citation.get("section") or "",
excerpt=citation["excerpt"],
)
for citation in citations.values()
if not isinstance(body, dict):
raise TypeError("answer response must be an object")
# Validate the provider's complete AnswerResponse contract before trusting
# generated prose. These IDs and claim bindings are the evidence that RAGcore
# ran its deterministic claim/citation validator; one unrelated but otherwise
# valid citation must never make arbitrary answer text appear grounded.
uuid.UUID(str(body["answer_id"]))
uuid.UUID(str(body["retrieval_run_id"]))
raw_citations = body.get("citations", [])
if not isinstance(raw_citations, list):
raise TypeError("citations must be a list")
citation_ids: set[uuid.UUID] = set()
for citation in raw_citations:
if not isinstance(citation, dict):
raise TypeError("citation must be an object")
citation_ids.add(uuid.UUID(str(citation["id"])))
raw_claims = body["claims"]
if not isinstance(raw_claims, list):
raise TypeError("claims must be a list")
claims_are_bound = bool(raw_claims)
answer_text = body.get("answer")
for claim in raw_claims:
if not isinstance(claim, dict):
raise TypeError("claim must be an object")
claim_text = claim.get("text")
claim_citation_ids = claim.get("citation_ids")
if (
not isinstance(claim_text, str)
or not claim_text.strip()
or not isinstance(answer_text, str)
or claim_text.strip() not in answer_text
or not isinstance(claim_citation_ids, list)
or not claim_citation_ids
):
claims_are_bound = False
continue
try:
bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids}
except (TypeError, ValueError):
claims_are_bound = False
continue
if not bound_ids.issubset(citation_ids):
claims_are_bound = False
mapped_sources = [
source
for citation in raw_citations
if (source := self._managed_source(citation, language)) is not None
]
sources = _deduplicate_sources(sources)
citations_are_managed = len(mapped_sources) == len(raw_citations)
sources = _deduplicate_sources(mapped_sources)
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
if answerability not in _ANSWERABILITY_STATES:
raise TypeError("answerability is invalid")
if not isinstance(answer_text, str):
raise TypeError("answer must be a string")
is_grounded = (
answerability in _GROUNDED_ANSWERABILITY
and bool(sources)
and citations_are_managed
and claims_are_bound
and bool(answer_text.strip())
)
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
self._close_answers_circuit()
return GroundedAnswer(
answer=body.get("answer", "") if evidence_state == "grounded" else "",
answer=answer_text if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
@@ -366,6 +489,7 @@ class RAGcoreKnowledgeProvider:
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"filters": {"source_ids": self._managed_source_ids(language)},
"max_results": 5,
},
)
@@ -378,24 +502,24 @@ class RAGcoreKnowledgeProvider:
return unavailable
try:
if not isinstance(body, dict):
raise TypeError("search response must be an object")
results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
source
for result in results
if isinstance(result, dict)
and (source := self._managed_source(result.get("citation"), language)) is not None
]
except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
return unavailable
for result in results:
if not isinstance(result, dict):
continue
score = _retrieval_score(result)
if score is not None:
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
@@ -403,15 +527,11 @@ class RAGcoreKnowledgeProvider:
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
qualified_sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
source
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
if isinstance(result, dict)
and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
and (source := self._managed_source(result.get("citation"), language)) is not None
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
@@ -424,15 +544,17 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id,
)
damage_evidence = any(
term
in (
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
evidence_text = " ".join(
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
).casefold()
covered_concepts = {
concept
for concept in required_concepts
if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept])
}
if not required_concepts or not sources or covered_concepts != required_concepts:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
+15 -22
View File
@@ -4,7 +4,7 @@ import hashlib
import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -12,13 +12,13 @@ from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, RegisterReturnRequest
from app.services.audit import record_audit_event
from app.services.data_quality import open_odometer_regression_issue
def _new_inspection_ref() -> str:
@@ -261,28 +261,21 @@ def register_vehicle_return(
quality_issue_ref: str | None = None
if evaluation.odometer_regression:
issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle.id,
severity="medium",
status="open",
evidence_json={
"summary": (
f"Return submitted {body.end_odometer_km} km, below canonical "
f"{evaluation.canonical_odometer_km} km."
),
"entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref],
},
proposed_action_json={},
issue = open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=inspection.public_ref,
reading_km=body.end_odometer_km,
canonical_km=evaluation.canonical_odometer_km,
source_type="return",
related_refs=[booking.public_ref, inspection.public_ref],
correctable_booking_refs=[booking.public_ref],
detected_at=now,
due_at=now + timedelta(days=1),
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
actor_label=actor.display_name,
correlation_id=correlation_id,
)
db.add(issue)
db.flush()
quality_issue_ref = issue.public_ref
quality_issue_ref = issue.public_ref if issue is not None else None
resulting_status = evaluation.resulting_vehicle_status
vehicle.odometer_km = evaluation.resulting_odometer_km
+105 -2
View File
@@ -1,3 +1,15 @@
import threading
import pytest
from sqlalchemy import delete, func, select
from app.core.db import SessionLocal, engine
from app.models.audit import AuditEvent
from app.models.vehicle import Vehicle
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
def test_unauthenticated_dashboard_is_rejected(client):
response = client.get("/api/v1/dashboard")
assert response.status_code == 401
@@ -101,15 +113,106 @@ def test_demo_reset_rejects_concurrent_rebuild(ops_client):
assert response.status_code == 409
def test_demo_reset_database_lock_blocks_another_api_replica(ops_client):
import app.api.routers.demo as demo_router
# Use a raw connection: an ordinary Session intentionally participates in the
# shared mutation barrier and would make an exclusive reset wait before it can test
# this separate non-blocking replica lock.
with engine.connect() as lock_connection:
lock_connection.scalar(select(func.pg_advisory_lock(demo_router._RESET_ADVISORY_LOCK_ID)))
try:
response = ops_client.post("/api/v1/demo/reset")
finally:
lock_connection.scalar(
select(func.pg_advisory_unlock(demo_router._RESET_ADVISORY_LOCK_ID))
)
assert response.status_code == 409
def test_demo_reset_waits_for_active_mutation_and_then_replaces_it_atomically():
started = threading.Event()
completed = threading.Event()
errors: list[BaseException] = []
def reset_in_other_session() -> None:
started.set()
try:
with SessionLocal() as reset_db:
reset_and_seed(reset_db)
except BaseException as exc: # pragma: no cover - assertion reports thread failures
errors.append(exc)
finally:
completed.set()
with SessionLocal() as mutation_db:
vehicle = mutation_db.scalar(
select(Vehicle).where(Vehicle.public_ref == "MO-001").with_for_update()
)
vehicle.location = "Concurrent mutation marker"
mutation_db.flush()
worker = threading.Thread(target=reset_in_other_session, daemon=True)
worker.start()
assert started.wait(timeout=1)
assert not completed.wait(timeout=0.2)
mutation_db.commit()
worker.join(timeout=10)
assert not worker.is_alive(), "reset deadlocked behind the active mutation"
assert errors == []
with SessionLocal() as db:
restored = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-001"))
assert restored.location != "Concurrent mutation marker"
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
monkeypatch.setattr(demo_router, "_last_reset_monotonic", demo_router.time.monotonic())
with SessionLocal() as db:
cooldown_event = record_audit_event(
db,
actor_type="user",
actor_label="Cooldown test",
action="demo_reset",
entity_type="system",
)
db.commit()
cooldown_event_id = cooldown_event.id
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 429
assert int(response.headers["retry-after"]) >= 1
monkeypatch.setattr(demo_router, "_last_reset_monotonic", 0.0)
with SessionLocal() as db:
db.execute(delete(AuditEvent).where(AuditEvent.id == cooldown_event_id))
db.commit()
def test_demo_reset_rolls_back_every_change_when_integrity_check_fails(ops_client, monkeypatch):
import app.api.routers.demo as demo_router
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 0)
with SessionLocal() as db:
probe = record_audit_event(
db,
actor_type="system",
actor_label="Atomic reset test",
action="reset_atomicity_probe",
entity_type="system",
)
db.commit()
probe_id = probe.id
def fail_integrity_check(_db):
raise RuntimeError("Injected integrity-check failure")
monkeypatch.setattr(demo_router, "scenario_integrity_report", fail_integrity_check)
with pytest.raises(RuntimeError, match="Injected integrity-check failure"):
ops_client.post("/api/v1/demo/reset")
with SessionLocal() as db:
assert db.scalar(select(AuditEvent).where(AuditEvent.id == probe_id)) is not None
db.execute(delete(AuditEvent).where(AuditEvent.id == probe_id))
db.commit()
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
+127 -1
View File
@@ -95,6 +95,16 @@ def test_booking_requirements_are_explicit_and_audited(ops_client):
)
assert checkout.status_code == 409
whitespace_only = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": " "},
)
assert whitespace_only.status_code == 422
assert (
ops_client.get(f"/api/v1/bookings/{booking['public_ref']}").json()["requirements_complete"]
is False
)
confirmed = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": "Licence and rental conditions checked"},
@@ -106,6 +116,29 @@ def test_booking_requirements_are_explicit_and_audited(ops_client):
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
def test_booking_creation_cannot_preconfirm_requirements(ops_client):
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
created = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle["public_ref"],
"requirements_complete": True,
**window,
},
)
assert created.status_code == 201
assert created.json()["requirements_complete"] is False
audit = ops_client.get(
"/api/v1/audit",
params={"action": "booking_requirements_completed"},
)
assert all(item["entity_ref"] != created.json()["public_ref"] for item in audit.json())
def test_customer_search_returns_canonical_customers(ops_client):
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
assert response.status_code == 200
@@ -221,10 +254,14 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle["public_ref"],
"requirements_complete": True,
**window,
},
).json()
confirmed = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": "Licence and rental conditions checked"},
)
assert confirmed.status_code == 200
response = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
@@ -241,6 +278,95 @@ def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert updated_vehicle["operational_status"] == "rented"
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
checkout_audit = ops_client.get(
"/api/v1/audit",
params={"action": "booking_checkout_recorded"},
).json()
booking_event = next(
item for item in checkout_audit if item["entity_ref"] == booking["public_ref"]
)
vehicle_audit = ops_client.get(
"/api/v1/audit",
params={
"action": "vehicle_status_changed",
"correlation_id": booking_event["correlation_id"],
},
).json()
assert len(vehicle_audit) == 1
assert vehicle_audit[0]["entity_ref"] == vehicle["public_ref"]
assert vehicle_audit[0]["before"]["operational_status"] == "available"
assert vehicle_audit[0]["after"]["operational_status"] == "rented"
def test_checkout_odometer_regression_keeps_canonical_and_opens_bounded_quality_issue(
ops_client,
):
window = {"starts_at": "2052-09-01T10:00:00Z", "ends_at": "2052-09-02T12:00:00Z"}
existing_issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
vehicle_option = next(
item
for item in available
if item["operational_status"] == "available" and item["public_ref"] not in unavailable_refs
)
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
assert vehicle["odometer_km"] > 0
booking = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0003",
"vehicle_ref": vehicle["public_ref"],
**window,
},
).json()
ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": "Licence and rental conditions checked"},
)
checkout = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
"start_odometer_km": vehicle["odometer_km"] - 1,
"fuel_level_percent": 90,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
)
assert checkout.status_code == 200
assert checkout.json()["booking_status"] == "blocked"
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert updated_vehicle["odometer_km"] == vehicle["odometer_km"]
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
assert issue["evidence"]["source_type"] == "checkout"
assert issue["evidence"]["correctable_booking_refs"] == []
detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
assert any(snapshot["entity_type"] == "inspection" for snapshot in detail["related_snapshots"])
invalid_correction = ops_client.post(
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": booking["public_ref"],
"corrected_odometer_km": vehicle["odometer_km"],
},
)
assert invalid_correction.status_code == 422
retained = ops_client.post(
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
json={"decision": "retain_canonical"},
)
assert retained.status_code == 200
assert retained.json()["status"] == "resolved"
def test_get_booking_detail(ops_client):
+43
View File
@@ -1,3 +1,9 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.vehicle import Vehicle
def test_dashboard_metrics_are_persisted_counts(ops_client):
response = ops_client.get("/api/v1/dashboard")
assert response.status_code == 200
@@ -15,6 +21,43 @@ def test_dashboard_metrics_are_persisted_counts(ops_client):
assert metrics["pending_or_failed_workflows"] >= 1
def test_dashboard_metrics_change_when_persisted_fleet_data_changes(ops_client):
before = ops_client.get("/api/v1/dashboard").json()["metrics"]
db = SessionLocal()
probe = Vehicle(
public_ref="MO-METRIC-PROBE",
make="Synthetic",
model="Metric probe",
model_year=2026,
registration_number="TEST-METRIC-PROBE",
location="Test fixture",
operational_status="blocked",
odometer_km=0,
next_service_km=1,
active=True,
version=1,
)
try:
db.add(probe)
db.commit()
after_response = ops_client.get("/api/v1/dashboard")
assert after_response.status_code == 200
after = after_response.json()["metrics"]
assert after["blocked"] == before["blocked"] + 1
fleet_statuses = ("available", "rented", "cleaning", "maintenance", "blocked")
assert (
sum(after[status] for status in fleet_statuses)
== sum(before[status] for status in fleet_statuses) + 1
)
finally:
persisted_probe = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-METRIC-PROBE"))
if persisted_probe is not None:
db.delete(persisted_probe)
db.commit()
db.close()
def test_dashboard_attention_items_link_to_records(ops_client):
response = ops_client.get("/api/v1/dashboard")
body = response.json()
+818 -4
View File
@@ -1,8 +1,25 @@
from sqlalchemy import select
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from threading import Barrier
from sqlalchemy import delete, select, text
from app.core.db import SessionLocal
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.vehicle import Vehicle
from app.schemas import CurrentUser, RegisterReturnRequest, ResolveOdometerRegressionRequest
from app.services.data_quality import (
open_odometer_regression_issue,
resolve_odometer_regression,
)
from app.services.returns import register_vehicle_return
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
@@ -21,6 +38,30 @@ def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
db.close()
def _cleanup_odometer_scenario(vehicle_id, customer_id) -> None:
"""Remove one isolated DQ-03 scenario in foreign-key-safe order."""
with SessionLocal() as db:
booking_ids = db.scalars(select(Booking.id).where(Booking.vehicle_id == vehicle_id)).all()
issue_ids = db.scalars(
select(DataQualityIssue.id).where(DataQualityIssue.entity_id == vehicle_id)
).all()
audit_entity_ids = [vehicle_id, *booking_ids, *issue_ids]
if audit_entity_ids:
db.execute(delete(AuditEvent).where(AuditEvent.entity_id.in_(audit_entity_ids)))
if booking_ids:
db.execute(
delete(IdempotencyRecord).where(IdempotencyRecord.booking_id.in_(booking_ids))
)
db.execute(delete(OutboxEvent).where(OutboxEvent.aggregate_id.in_(booking_ids)))
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
def test_list_includes_all_five_rule_types(ops_client):
response = ops_client.get("/api/v1/data-quality/issues")
assert response.status_code == 200
@@ -43,6 +84,84 @@ def test_scan_is_idempotent_once_seeded(ops_client):
assert response.json()["created"] == {}
def test_scan_detects_imported_maintenance_odometer_regression(ops_client):
with SessionLocal() as db:
vehicle = Vehicle(
public_ref="MO-DQ-SCAN",
make="Synthetic",
model="Scanner",
model_year=2026,
registration_number="DQ-SCAN-01",
location="Brussels",
operational_status="available",
odometer_km=20_000,
next_service_km=30_000,
active=True,
version=1,
)
db.add(vehicle)
db.flush()
vehicle_id = vehicle.id
db.add_all(
[
MaintenanceRecord(
public_ref="MAINT-DQ-SCAN-A",
vehicle_id=vehicle.id,
occurred_at=datetime(2045, 1, 1, tzinfo=UTC),
odometer_km=20_000,
category="inspection",
summary="Synthetic scan baseline",
),
MaintenanceRecord(
public_ref="MAINT-DQ-SCAN-B",
vehicle_id=vehicle.id,
occurred_at=datetime(2045, 2, 1, tzinfo=UTC),
odometer_km=19_000,
category="inspection",
summary="Synthetic imported regression",
),
]
)
db.commit()
try:
response = ops_client.post("/api/v1/data-quality/scan")
assert response.status_code == 200
assert response.json()["created"]["odometer_regression"] >= 1
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
issue = next(item for item in issues if item["entity_ref"] == "MO-DQ-SCAN")
assert issue["evidence"]["source_type"] == "maintenance"
assert issue["evidence"]["correctable_booking_refs"] == []
# A later imported regression must enrich the existing open issue instead of
# being silently dropped by the generic check-then-return scanner path.
with SessionLocal() as db:
db.add(
MaintenanceRecord(
public_ref="MAINT-DQ-SCAN-C",
vehicle_id=vehicle_id,
occurred_at=datetime(2045, 3, 1, tzinfo=UTC),
odometer_km=18_000,
category="inspection",
summary="Second synthetic imported regression",
)
)
db.commit()
rescanned = ops_client.post("/api/v1/data-quality/scan")
assert rescanned.status_code == 200
enriched = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
later_refs = {signal["params"]["later_ref"] for signal in enriched["evidence"]["signals"]}
assert {"MAINT-DQ-SCAN-B", "MAINT-DQ-SCAN-C"}.issubset(later_refs)
finally:
with SessionLocal() as db:
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
db.commit()
def test_scan_requires_operations_manager(employee_client):
response = employee_client.post("/api/v1/data-quality/scan")
assert response.status_code == 403
@@ -440,9 +559,8 @@ def test_resolve_odometer_regression_correction_below_canonical_is_rejected_then
def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_client):
# The seeded odometer_regression issues carry no related booking (CSV-only rows).
# Create a fresh one with a real related booking via a live regression return, so
# the "correct_reading" path has an actual booking_ref to target.
# Create a live return regression to prove the complete correction chain updates
# both persisted representations of that reading (booking + return inspection).
booking_ref = _activate_booking("MO-018", start_odometer_km=12000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-018").json()
low_reading = vehicle_before["odometer_km"] - 200
@@ -459,6 +577,7 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
)
assert returned.status_code == 201
issue_ref = returned.json()["quality_issue_ref"]
inspection_ref = returned.json()["inspection_ref"]
assert issue_ref is not None
corrected = vehicle_before["odometer_km"] + 500
@@ -477,6 +596,701 @@ def test_resolve_odometer_regression_correct_reading_updates_canonical(ops_clien
assert vehicle["odometer_km"] == corrected
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
assert booking["end_odometer_km"] == corrected
with SessionLocal() as db:
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == inspection_ref))
assert inspection is not None
assert inspection.odometer_km == corrected
def test_correcting_one_of_two_regressions_keeps_the_other_open(ops_client):
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-DQ-MULTI",
first_name="Synthetic",
last_name="Multi",
email="dq-multi@example.test",
)
vehicle = Vehicle(
public_ref="MO-DQ-MULTI",
make="Synthetic",
model="Multi",
model_year=2026,
registration_number="DQ-MULTI",
location="Brussels",
operational_status="available",
odometer_km=1_000,
next_service_km=2_000,
active=True,
version=1,
)
db.add_all([customer, vehicle])
db.flush()
bookings: list[Booking] = []
inspections: list[Inspection] = []
for index, reading in enumerate((900, 800), start=1):
booking = Booking(
public_ref=f"BK-DQ-MULTI-{index}",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=datetime(2046, index, 1, tzinfo=UTC),
ends_at=datetime(2046, index, 2, tzinfo=UTC),
status="returned",
start_odometer_km=reading - 10,
end_odometer_km=reading,
requirements_complete=True,
)
db.add(booking)
db.flush()
inspection = Inspection(
public_ref=f"INSP-DQ-M-{index}",
booking_id=booking.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=reading,
completed_at=booking.ends_at,
)
db.add(inspection)
db.flush()
open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=inspection.public_ref,
reading_km=reading,
canonical_km=vehicle.odometer_km,
source_type="return",
related_refs=[booking.public_ref, inspection.public_ref],
correctable_booking_refs=[booking.public_ref],
public_ref="DQ-MULTI-SOURCE" if index == 1 else None,
)
bookings.append(booking)
inspections.append(inspection)
booking_refs = [booking.public_ref for booking in bookings]
vehicle_id = vehicle.id
customer_id = customer.id
db.commit()
try:
first = ops_client.post(
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": booking_refs[0],
"corrected_odometer_km": 1_100,
},
)
assert first.status_code == 200, first.text
assert first.json()["status"] == "open"
assert first.json()["evidence"]["correctable_booking_refs"] == [booking_refs[1]]
second = ops_client.post(
"/api/v1/data-quality/issues/DQ-MULTI-SOURCE/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": booking_refs[1],
"corrected_odometer_km": 1_200,
},
)
assert second.status_code == 200, second.text
assert second.json()["status"] == "resolved"
with SessionLocal() as db:
persisted = db.scalars(
select(Inspection)
.where(Inspection.vehicle_id == vehicle_id)
.order_by(Inspection.public_ref)
).all()
assert [item.odometer_km for item in persisted] == [1_100, 1_200]
finally:
with SessionLocal() as db:
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
def test_retained_regression_is_suppressed_until_source_reading_changes(ops_client):
with SessionLocal() as db:
vehicle = Vehicle(
public_ref="MO-DQ-RETAIN",
make="Synthetic",
model="Retain",
model_year=2026,
registration_number="DQ-RETAIN",
location="Brussels",
operational_status="available",
odometer_km=20_000,
next_service_km=30_000,
active=True,
version=1,
)
db.add(vehicle)
db.flush()
vehicle_id = vehicle.id
db.add_all(
[
MaintenanceRecord(
public_ref="MAINT-DQ-RET-A",
vehicle_id=vehicle.id,
occurred_at=datetime(2047, 1, 1, tzinfo=UTC),
odometer_km=20_000,
category="inspection",
summary="Retain baseline",
),
MaintenanceRecord(
public_ref="MAINT-DQ-RET-B",
vehicle_id=vehicle.id,
occurred_at=datetime(2047, 2, 1, tzinfo=UTC),
odometer_km=19_000,
category="inspection",
summary="Retained source reading",
),
]
)
db.commit()
try:
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
issue = next(
item
for item in ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
if item["entity_ref"] == "MO-DQ-RETAIN"
)
retained = ops_client.post(
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
json={"decision": "retain_canonical", "note": "Verified source entry"},
)
assert retained.status_code == 200
assert retained.json()["status"] == "resolved"
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
with SessionLocal() as db:
assert (
db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_id == vehicle_id,
DataQualityIssue.status == "open",
)
)
is None
)
changed = db.scalar(
select(MaintenanceRecord).where(MaintenanceRecord.public_ref == "MAINT-DQ-RET-B")
)
changed.odometer_km = 18_999
db.commit()
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
reopened = next(
item
for item in ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
if item["entity_ref"] == "MO-DQ-RETAIN"
)
assert reopened["evidence"]["reopened_from"] == issue["public_ref"]
finally:
with SessionLocal() as db:
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
db.execute(delete(MaintenanceRecord).where(MaintenanceRecord.vehicle_id == vehicle_id))
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
db.commit()
def test_live_return_retained_as_canonical_is_not_reopened_by_scan(ops_client):
now = datetime.now(UTC)
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-DQ-LIVE-RET",
first_name="Synthetic",
last_name="Retained return",
email="dq-live-retain@example.test",
)
vehicle = Vehicle(
public_ref="MO-DQ-LIVE-RET",
make="Synthetic",
model="Retained return",
model_year=2026,
registration_number="DQ-LIVE-RET",
location="Brussels",
operational_status="rented",
odometer_km=20_000,
next_service_km=30_000,
active=True,
version=1,
)
db.add_all([customer, vehicle])
db.flush()
booking = Booking(
public_ref="BK-DQ-LIVE-RET",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=now - timedelta(days=2),
ends_at=now + timedelta(days=1),
status="active",
start_odometer_km=19_500,
end_odometer_km=None,
requirements_complete=True,
)
db.add_all(
[
booking,
MaintenanceRecord(
public_ref="MNT-DQ-LIVE-RET",
vehicle_id=vehicle.id,
occurred_at=now - timedelta(days=3),
odometer_km=20_000,
category="inspection",
summary="Synthetic canonical baseline",
),
]
)
vehicle_id = vehicle.id
customer_id = customer.id
db.commit()
try:
returned = ops_client.post(
"/api/v1/bookings/BK-DQ-LIVE-RET/return",
json={
"end_odometer_km": 19_000,
"fuel_level_percent": 50,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
},
headers={"Idempotency-Key": "test-dq-live-retain-001"},
)
assert returned.status_code == 201, returned.text
issue_ref = returned.json()["quality_issue_ref"]
inspection_ref = returned.json()["inspection_ref"]
assert issue_ref is not None
retained = ops_client.post(
f"/api/v1/data-quality/issues/{issue_ref}/resolve-odometer-regression",
json={"decision": "retain_canonical", "note": "Source reading verified as wrong."},
)
assert retained.status_code == 200, retained.text
assert retained.json()["status"] == "resolved"
assert retained.json()["evidence"]["retained_odometer_fingerprints"] == [
{
"source_type": "return",
"later_ref": inspection_ref,
"later_km": 19_000,
}
]
scanned = ops_client.post("/api/v1/data-quality/scan")
assert scanned.status_code == 200, scanned.text
with SessionLocal() as db:
issues = db.scalars(
select(DataQualityIssue)
.where(
DataQualityIssue.entity_id == vehicle_id,
DataQualityIssue.rule_type == "odometer_regression",
)
.order_by(DataQualityIssue.detected_at)
).all()
assert [(issue.public_ref, issue.status) for issue in issues] == [
(issue_ref, "resolved")
]
finally:
_cleanup_odometer_scenario(vehicle_id, customer_id)
def test_scanned_return_regression_can_correct_booking_all_inspections_and_vehicle(ops_client):
now = datetime.now(UTC)
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-DQ-SCAN-RET",
first_name="Synthetic",
last_name="Scanned return",
email="dq-scan-return@example.test",
)
vehicle = Vehicle(
public_ref="MO-DQ-SCAN-RET",
make="Synthetic",
model="Scanned return",
model_year=2026,
registration_number="DQ-SCAN-RET",
location="Brussels",
operational_status="available",
odometer_km=20_000,
next_service_km=30_000,
active=True,
version=1,
)
db.add_all([customer, vehicle])
db.flush()
booking = Booking(
public_ref="BK-DQ-SCAN-RET",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=now - timedelta(days=3),
ends_at=now - timedelta(hours=12),
status="returned",
start_odometer_km=19_500,
end_odometer_km=18_500,
requirements_complete=True,
)
db.add(booking)
db.flush()
db.add_all(
[
MaintenanceRecord(
public_ref="MNT-DQ-SCAN-RET",
vehicle_id=vehicle.id,
occurred_at=now - timedelta(days=4),
odometer_km=20_000,
category="inspection",
summary="Synthetic canonical baseline",
),
Inspection(
public_ref="INSP-DQ-SCAN-R1",
booking_id=booking.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=19_000,
completed_at=now - timedelta(days=1),
),
Inspection(
public_ref="INSP-DQ-SCAN-R2",
booking_id=booking.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=18_500,
completed_at=now - timedelta(hours=12),
),
]
)
vehicle_id = vehicle.id
customer_id = customer.id
db.commit()
try:
scanned = ops_client.post("/api/v1/data-quality/scan")
assert scanned.status_code == 200, scanned.text
issue = next(
item
for item in ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
if item["entity_ref"] == "MO-DQ-SCAN-RET"
)
assert issue["evidence"]["correctable_booking_refs"] == ["BK-DQ-SCAN-RET"]
later_refs = {signal["params"]["later_ref"] for signal in issue["evidence"]["signals"]}
assert later_refs == {"INSP-DQ-SCAN-R1", "INSP-DQ-SCAN-R2"}
corrected = ops_client.post(
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
json={
"decision": "correct_reading",
"booking_ref": "BK-DQ-SCAN-RET",
"corrected_odometer_km": 20_500,
},
)
assert corrected.status_code == 200, corrected.text
assert corrected.json()["status"] == "resolved"
with SessionLocal() as db:
persisted_vehicle = db.get(Vehicle, vehicle_id)
persisted_booking = db.scalar(
select(Booking).where(Booking.public_ref == "BK-DQ-SCAN-RET")
)
assert persisted_booking is not None
persisted_inspections = db.scalars(
select(Inspection)
.where(Inspection.booking_id == persisted_booking.id, Inspection.type == "return")
.order_by(Inspection.public_ref)
).all()
assert persisted_vehicle is not None
assert persisted_vehicle.odometer_km == 20_500
assert persisted_booking.end_odometer_km == 20_500
assert [inspection.odometer_km for inspection in persisted_inspections] == [
20_500,
20_500,
]
finally:
_cleanup_odometer_scenario(vehicle_id, customer_id)
def test_resolver_and_concurrent_return_finish_without_deadlock_or_lost_evidence():
now = datetime.now(UTC)
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-DQ-RACE",
first_name="Synthetic",
last_name="Race",
email="dq-race@example.test",
)
vehicle = Vehicle(
public_ref="MO-DQ-RACE",
make="Synthetic",
model="Race",
model_year=2026,
registration_number="DQ-RACE",
location="Brussels",
operational_status="rented",
odometer_km=20_000,
next_service_km=30_000,
active=True,
version=1,
)
db.add_all([customer, vehicle])
db.flush()
corrected_booking = Booking(
public_ref="BK-DQ-RACE-OLD",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=now - timedelta(days=4),
ends_at=now - timedelta(days=3),
status="returned",
start_odometer_km=19_500,
end_odometer_km=19_000,
requirements_complete=True,
)
concurrent_booking = Booking(
public_ref="BK-DQ-RACE-NEW",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=now - timedelta(days=1),
ends_at=now + timedelta(days=1),
status="active",
start_odometer_km=19_000,
end_odometer_km=None,
requirements_complete=True,
)
db.add_all([corrected_booking, concurrent_booking])
db.flush()
old_inspection = Inspection(
public_ref="INSP-DQ-RACE-OLD",
booking_id=corrected_booking.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=19_000,
completed_at=corrected_booking.ends_at,
)
db.add(old_inspection)
db.flush()
issue = open_odometer_regression_issue(
db,
vehicle=vehicle,
reading_ref=old_inspection.public_ref,
reading_km=19_000,
canonical_km=20_000,
source_type="return",
related_refs=[corrected_booking.public_ref, old_inspection.public_ref],
correctable_booking_refs=[corrected_booking.public_ref],
public_ref="DQ-ODO-RACE",
)
assert issue is not None
vehicle_id = vehicle.id
customer_id = customer.id
db.commit()
actor = CurrentUser(
public_ref="USR-DQ-RACE",
display_name="DQ Race Manager",
role="operations_manager",
)
start = Barrier(2)
def correct_existing_reading() -> str:
with SessionLocal() as db:
db.execute(text("SET LOCAL lock_timeout = '5s'"))
start.wait(timeout=5)
resolved = resolve_odometer_regression(
db,
"DQ-ODO-RACE",
ResolveOdometerRegressionRequest(
decision="correct_reading",
booking_ref="BK-DQ-RACE-OLD",
corrected_odometer_km=20_500,
),
actor,
)
return resolved.status
def return_other_booking() -> dict:
with SessionLocal() as db:
db.execute(text("SET LOCAL lock_timeout = '5s'"))
start.wait(timeout=5)
_status, response = register_vehicle_return(
db,
"BK-DQ-RACE-NEW",
RegisterReturnRequest(
end_odometer_km=18_000,
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
),
"test-dq-resolve-return-race-001",
actor,
)
return response
try:
with ThreadPoolExecutor(max_workers=2) as executor:
correction_future = executor.submit(correct_existing_reading)
return_future = executor.submit(return_other_booking)
correction_status = correction_future.result(timeout=15)
return_result = return_future.result(timeout=15)
assert correction_status in {"open", "resolved"}
assert return_result["quality_issue_ref"] is not None
with SessionLocal() as db:
persisted_vehicle = db.get(Vehicle, vehicle_id)
bookings = {
booking.public_ref: booking
for booking in db.scalars(
select(Booking).where(Booking.vehicle_id == vehicle_id)
).all()
}
old_inspections = db.scalars(
select(Inspection).where(Inspection.booking_id == bookings["BK-DQ-RACE-OLD"].id)
).all()
open_issues = db.scalars(
select(DataQualityIssue).where(
DataQualityIssue.entity_id == vehicle_id,
DataQualityIssue.rule_type == "odometer_regression",
DataQualityIssue.status == "open",
)
).all()
assert persisted_vehicle is not None
assert persisted_vehicle.odometer_km == 20_500
assert bookings["BK-DQ-RACE-OLD"].end_odometer_km == 20_500
assert [inspection.odometer_km for inspection in old_inspections] == [20_500]
assert bookings["BK-DQ-RACE-NEW"].status == "returned"
assert bookings["BK-DQ-RACE-NEW"].end_odometer_km == 18_000
assert len(open_issues) == 1
assert open_issues[0].evidence_json["correctable_booking_refs"] == ["BK-DQ-RACE-NEW"]
later_refs = {
signal["params"]["later_ref"] for signal in open_issues[0].evidence_json["signals"]
}
assert later_refs == {return_result["inspection_ref"]}
finally:
_cleanup_odometer_scenario(vehicle_id, customer_id)
def test_early_return_uses_inspection_time_without_duplicate_booking_regression(ops_client):
with SessionLocal() as db:
customer = Customer(
public_ref="CUS-DQ-EARLY",
first_name="Synthetic",
last_name="Early",
email="dq-early@example.test",
)
vehicle = Vehicle(
public_ref="MO-DQ-EARLY",
make="Synthetic",
model="Early",
model_year=2026,
registration_number="DQ-EARLY",
location="Brussels",
operational_status="available",
odometer_km=200,
next_service_km=10_000,
active=True,
version=1,
)
db.add_all([customer, vehicle])
db.flush()
early = Booking(
public_ref="BK-DQ-EARLY-A",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=datetime(2048, 1, 1, tzinfo=UTC),
ends_at=datetime(2048, 3, 1, tzinfo=UTC),
status="returned",
start_odometer_km=90,
end_odometer_km=100,
requirements_complete=True,
)
later = Booking(
public_ref="BK-DQ-EARLY-B",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=datetime(2048, 1, 10, tzinfo=UTC),
ends_at=datetime(2048, 2, 1, tzinfo=UTC),
status="returned",
start_odometer_km=100,
end_odometer_km=200,
requirements_complete=True,
)
db.add_all([early, later])
db.flush()
db.add_all(
[
Inspection(
public_ref="INSP-DQ-EARLY-A",
booking_id=early.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=100,
completed_at=datetime(2048, 1, 2, tzinfo=UTC),
),
Inspection(
public_ref="INSP-DQ-EARLY-B",
booking_id=later.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=50,
cleanliness_ok=True,
damage_reported=False,
technical_warning=False,
odometer_km=200,
completed_at=datetime(2048, 2, 1, tzinfo=UTC),
),
]
)
vehicle_id = vehicle.id
customer_id = customer.id
db.commit()
try:
assert ops_client.post("/api/v1/data-quality/scan").status_code == 200
with SessionLocal() as db:
assert (
db.scalar(
select(DataQualityIssue).where(
DataQualityIssue.entity_id == vehicle_id,
DataQualityIssue.rule_type == "odometer_regression",
)
)
is None
)
finally:
with SessionLocal() as db:
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == vehicle_id))
db.execute(delete(Inspection).where(Inspection.vehicle_id == vehicle_id))
db.execute(delete(Booking).where(Booking.vehicle_id == vehicle_id))
db.execute(delete(Vehicle).where(Vehicle.id == vehicle_id))
db.execute(delete(Customer).where(Customer.id == customer_id))
db.commit()
def test_manual_scan_records_audit_event(ops_client):
+104
View File
@@ -1,5 +1,10 @@
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
from app.seed_loader import reset_and_seed
from app.services import demo_manifest
from app.services.knowledge import KnowledgeHealth
def test_demo_manifest_is_public(client):
@@ -52,3 +57,102 @@ def test_demo_manifest_ragcore_labelled_as_demo_mode_not_live(client):
body = client.get("/api/v1/demo/manifest").json()
ragcore = next(i for i in body["integrations"] if i["key"] == "ragcore")
assert ragcore["status_code"] == "demoMode"
def test_automation_scenario_requires_the_exact_prepared_failure():
db = SessionLocal()
try:
reset_and_seed(db)
event = db.scalar(
select(OutboxEvent).where(OutboxEvent.event_id == demo_manifest._FAILED_DEMO_EVENT_ID)
)
assert event is not None
event.last_error_code = "connectionError"
db.flush()
scenario = next(
item for item in demo_manifest._scenarios(db) if item.id == "automation-retry"
)
assert scenario.ready is False
finally:
db.rollback()
db.close()
def test_knowledge_scenario_rejects_available_provider_with_verified_empty_corpus(
client, monkeypatch
):
class EmptyKnowledgeProvider:
def health(self, language="en-GB"):
return KnowledgeHealth(
provider="ragcore",
available=True,
detail="Ready, but no indexed documents.",
tenant="fleet-ops",
workspace="mobilityops",
collection="procedures",
document_count=0,
source_document_count=4,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
)
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", EmptyKnowledgeProvider)
body = client.get("/api/v1/demo/manifest").json()
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
assert scenario["ready"] is False
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
assert ragcore["status_code"] == "unavailable"
def test_knowledge_scenario_rejects_local_sources_when_index_count_is_unverified(
client, monkeypatch
):
class SourceAwareKnowledgeProvider:
def health(self, language="en-GB"):
return KnowledgeHealth(
provider="ragcore",
available=True,
detail="Ready; index count endpoint is unavailable.",
tenant="fleet-ops",
workspace="mobilityops",
collection="procedures",
document_count=None,
source_document_count=4,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="not_reported",
)
monkeypatch.setattr(demo_manifest, "get_knowledge_provider", SourceAwareKnowledgeProvider)
body = client.get("/api/v1/demo/manifest").json()
scenario = next(item for item in body["scenarios"] if item["id"] == "knowledge-question")
assert scenario["ready"] is False
assert scenario["blocked_reason_code"] == "knowledgeUnavailable"
ragcore = next(item for item in body["integrations"] if item["key"] == "ragcore")
assert ragcore["status_code"] == "unavailable"
def test_knowledge_scenario_requires_provider_availability_even_with_documents():
health = KnowledgeHealth(
provider="ragcore",
available=False,
detail="Provider is unreachable.",
tenant="fleet-ops",
workspace="mobilityops",
collection="procedures",
document_count=4,
source_document_count=4,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
)
assert demo_manifest._knowledge_scenario_ready(health) is False
+120 -5
View File
@@ -4,6 +4,7 @@ import uuid
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.core.config import get_settings
@@ -65,12 +66,17 @@ def test_claim_marks_events_delivering():
def test_deliver_one_success(monkeypatch):
event_id = _make_pending_event("MO-002")
execution_id = "n8n-execution-123"
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
json=lambda: {
"ok": True,
"event_id": str(event_id),
"result": {"execution_id": execution_id},
},
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -79,7 +85,7 @@ def test_deliver_one_success(monkeypatch):
event = _get_event(event_id)
assert event.delivery_status == "succeeded"
assert event.attempts == 1
assert event.external_run_id == str(event_id)
assert event.external_run_id == execution_id
assert event.last_error is None
assert event.last_error_code is None
@@ -129,6 +135,106 @@ def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
assert event.last_error_code == "malformedResponse"
def test_deliver_one_rejects_json_object_without_explicit_success_ack(monkeypatch):
event_id = _make_pending_event("MO-010")
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {},
status_code=200,
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.attempts == 1
assert event.last_error_code == "malformedResponse"
assert event.external_run_id is None
@pytest.mark.parametrize("result", [{}, {"execution_id": 123}, {"execution_id": " "}])
def test_deliver_one_rejects_ack_without_valid_execution_id(monkeypatch, result):
event_id = _make_pending_event("MO-EXEC-ID")
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": result},
status_code=200,
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.last_error_code == "malformedResponse"
assert event.external_run_id is None
def test_deliver_one_rejects_success_ack_for_a_different_event(monkeypatch):
event_id = _make_pending_event("MO-011")
dispatcher._claim_due_events()
other_event_id = uuid.uuid4()
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(other_event_id)},
status_code=200,
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.attempts == 1
assert event.last_error_code == "mismatchedEventId"
assert event.external_run_id is None
def test_late_delivery_outcome_cannot_overwrite_a_newer_lease(monkeypatch):
event_id = _make_pending_event("MO-LEASE-RACE")
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
# Simulate lease expiry/reclaim while the original worker is still in network
# I/O. The later claimant owns a different token and its state must win.
with SessionLocal() as db:
event = db.scalar(
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
)
event.payload_json = {
**event.payload_json,
"_delivery_claim_token": "newer-worker-token",
}
event.next_attempt_at = datetime.now(UTC) + timedelta(minutes=1)
db.commit()
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"ok": True,
"event_id": str(event_id),
"result": {"execution_id": "stale-worker-execution"},
},
status_code=200,
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "delivering"
assert event.attempts == 0
assert event.external_run_id is None
assert event.payload_json["_delivery_claim_token"] == "newer-worker-token"
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
event_id = _make_pending_event("MO-004")
settings = get_settings()
@@ -139,7 +245,6 @@ def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
for _ in range(settings.n8n_max_attempts):
dispatcher._claim_due_events()
db = SessionLocal()
try:
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id))
@@ -147,6 +252,8 @@ def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
db.commit()
finally:
db.close()
claimed = dispatcher._claim_due_events(batch_size=1000)
assert event_id in claimed
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
@@ -259,7 +366,11 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
json=lambda: {
"ok": True,
"event_id": str(event_id),
"result": {"execution_id": "n8n-recovered-execution"},
},
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -275,7 +386,11 @@ def test_run_dispatch_cycle_end_to_end(monkeypatch):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
json=lambda: {
"ok": True,
"event_id": str(event_id),
"result": {"execution_id": "n8n-cycle-execution"},
},
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
+8 -3
View File
@@ -174,11 +174,12 @@ def test_return_idempotency_key_rejects_different_body(ops_client):
def test_return_callback_rejects_malformed_correlation_id(client):
event_id = str(uuid.uuid4())
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning", "correlation_id": "nope"},
json={"event_id": event_id, "follow_up": "cleaning", "correlation_id": "nope"},
headers={
"Idempotency-Key": str(uuid.uuid4()),
"Idempotency-Key": event_id,
"X-Service-Token": get_settings().n8n_callback_token,
},
)
@@ -195,10 +196,14 @@ def test_blocked_checkout_booking_can_be_cancelled(ops_client):
json={
"customer_ref": "CUS-0002",
"vehicle_ref": vehicle["public_ref"],
"requirements_complete": True,
**window,
},
).json()
confirmed = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
json={"confirmation": "Licence and rental conditions checked"},
)
assert confirmed.status_code == 200
checkout = ops_client.post(
f"/api/v1/bookings/{booking['public_ref']}/checkout",
json={
+102 -1
View File
@@ -1,8 +1,14 @@
from sqlalchemy import select
import uuid
from datetime import UTC, datetime
import pytest
from sqlalchemy import delete, select
from app.core.db import SessionLocal
from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent
from app.seed_loader import reset_and_seed
from app.services import integration_status
def _reseed() -> None:
@@ -108,6 +114,101 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
assert body["state"] == "operational"
@pytest.mark.parametrize("delivery_status", ["pending", "delivering"])
def test_queued_work_without_any_success_is_not_reported_operational(delivery_status):
_reseed()
db = SessionLocal()
try:
for event in db.scalars(select(OutboxEvent)).all():
event.delivery_status = delivery_status
event.last_error = None
event.last_error_code = None
db.execute(
delete(AuditEvent).where(
AuditEvent.action.in_(
{
"data_quality_scan_run",
"n8n_procedures_synced",
"n8n_workflow_failure_registered",
"n8n_workflow_heartbeat",
}
)
)
)
db.flush()
status = integration_status.derive_n8n_status(db)
assert status.succeeded == 0
assert getattr(status, delivery_status) > 0
assert status.state == "no_evidence"
finally:
db.rollback()
db.close()
def test_historical_success_is_not_green_when_webhook_is_unconfigured(monkeypatch):
_reseed()
with SessionLocal() as db:
monkeypatch.setattr(integration_status.settings, "n8n_dispatch_enabled", True)
monkeypatch.setattr(integration_status.settings, "n8n_webhook_url", "")
status = integration_status.derive_n8n_status(db)
assert status.configured is False
assert status.succeeded > 0
assert status.state == "unavailable"
def test_null_coded_real_failure_sets_latest_failure_timestamp():
_reseed()
with SessionLocal() as db:
event = db.scalar(
select(OutboxEvent).where(OutboxEvent.delivery_status == "succeeded").limit(1)
)
event.delivery_status = "failed"
event.last_error_code = None
db.flush()
status = integration_status.derive_n8n_status(db)
assert status.unexpected_failed == 1
assert status.latest_failure_at is not None
db.rollback()
def test_unreachable_hub_invalidates_historical_operational_claim(monkeypatch):
db = SessionLocal()
try:
db.add(
AuditEvent(
actor_type="service",
actor_id=None,
actor_label="itworx-mcp-hub",
action="mcp_tool_request",
entity_type="integration",
entity_id=None,
correlation_id=uuid.uuid4(),
before_json=None,
after_json=None,
metadata_json={"tool": "operations_summary"},
occurred_at=datetime.now(UTC),
)
)
db.flush()
monkeypatch.setattr(integration_status.settings, "mcp_hub_registration_enabled", True)
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: False)
unreachable = integration_status.derive_mcp_hub_status(db)
assert unreachable.total_calls > 0
assert unreachable.hub_reachable is False
assert unreachable.state == "no_evidence"
monkeypatch.setattr(integration_status, "_check_hub_reachable", lambda: True)
reachable = integration_status.derive_mcp_hub_status(db)
assert reachable.state == "operational"
finally:
db.rollback()
db.close()
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert body["expected_workflow_count"] == 4
+158 -7
View File
@@ -1,4 +1,5 @@
import uuid
from concurrent.futures import ThreadPoolExecutor
from app.core.config import get_settings
@@ -12,19 +13,29 @@ def _callback_headers(event_id: str, token: str | None = None):
def test_callback_rejects_wrong_service_token(client):
event_id = str(uuid.uuid4())
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning"},
headers=_callback_headers(str(uuid.uuid4()), token="wrong-token"),
json={
"event_id": event_id,
"correlation_id": str(uuid.uuid4()),
"follow_up": "cleaning",
},
headers=_callback_headers(event_id, token="wrong-token"),
)
assert response.status_code == 401
def test_callback_unknown_event_returns_404(client):
event_id = str(uuid.uuid4())
response = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning"},
headers=_callback_headers(str(uuid.uuid4())),
json={
"event_id": event_id,
"correlation_id": str(uuid.uuid4()),
"follow_up": "cleaning",
},
headers=_callback_headers(event_id),
)
assert response.status_code == 404
@@ -39,13 +50,14 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
db = SessionLocal()
try:
booking = db.scalar(select(Booking).limit(1))
correlation_id = uuid.uuid4()
event = OutboxEvent(
event_id=uuid.uuid4(),
event_type="vehicle.returned.v1",
aggregate_type="booking",
aggregate_id=booking.id,
payload_json={
"correlation_id": str(uuid.uuid4()),
"correlation_id": str(correlation_id),
"aggregate": {
"type": "booking",
"id": str(booking.id),
@@ -66,12 +78,22 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
first = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning", "summary": "test"},
json={
"event_id": event_id,
"correlation_id": str(correlation_id),
"follow_up": "cleaning",
"summary": "test",
},
headers=_callback_headers(event_id),
)
second = client.post(
"/api/v1/integrations/n8n/return-callback",
json={"follow_up": "cleaning", "summary": "test"},
json={
"event_id": event_id,
"correlation_id": str(correlation_id),
"follow_up": "cleaning",
"summary": "test",
},
headers=_callback_headers(event_id),
)
assert first.status_code == 200
@@ -82,6 +104,79 @@ def test_callback_is_idempotent_by_event_id(client, ops_client):
).json()
matching = [e for e in audit_events if e["metadata"]["event_id"] == event_id]
assert len(matching) == 1
assert matching[0]["correlation_id"] == str(correlation_id)
def test_callback_rejects_crossed_event_or_correlation(client):
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
with SessionLocal() as db:
event = db.scalar(select(OutboxEvent).limit(1))
event_id = str(event.event_id)
correlation_id = event.payload_json["correlation_id"]
crossed_event = client.post(
"/api/v1/integrations/n8n/return-callback",
json={
"event_id": str(uuid.uuid4()),
"correlation_id": correlation_id,
"follow_up": "cleaning",
},
headers=_callback_headers(event_id),
)
assert crossed_event.status_code == 409
assert crossed_event.json()["error"]["code"] == "CALLBACK_EVENT_MISMATCH"
crossed_correlation = client.post(
"/api/v1/integrations/n8n/return-callback",
json={
"event_id": event_id,
"correlation_id": str(uuid.uuid4()),
"follow_up": "cleaning",
},
headers=_callback_headers(event_id),
)
assert crossed_correlation.status_code == 409
assert crossed_correlation.json()["error"]["code"] == "CALLBACK_CORRELATION_MISMATCH"
def test_callback_concurrent_retries_record_one_audit(client, ops_client):
from sqlalchemy import select
from app.core.db import SessionLocal
from app.models.outbox import OutboxEvent
with SessionLocal() as db:
event = db.scalar(select(OutboxEvent).limit(1))
event_id = str(event.event_id)
correlation_id = event.payload_json["correlation_id"]
body = {
"event_id": event_id,
"correlation_id": correlation_id,
"follow_up": "cleaning",
}
def post_callback(_index: int):
return client.post(
"/api/v1/integrations/n8n/return-callback",
json=body,
headers=_callback_headers(event_id),
)
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(post_callback, range(2)))
assert [response.status_code for response in responses] == [200, 200]
matching = [
event
for event in ops_client.get(
"/api/v1/audit", params={"action": "n8n_return_followup_recorded"}
).json()
if event["metadata"]["event_id"] == event_id
]
assert len(matching) == 1
def test_scheduled_scan_rejects_wrong_service_token(client):
@@ -183,6 +278,42 @@ def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
def test_workflow_error_preserves_correlation_and_rejects_malformed(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
execution_id = str(uuid.uuid4())
correlation_id = str(uuid.uuid4())
accepted = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(execution_id, correlation_id=correlation_id),
headers=headers,
)
assert accepted.status_code == 200
matching = [
event
for event in ops_client.get(
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
).json()
if event["metadata"]["execution_id"] == execution_id
]
assert len(matching) == 1
assert matching[0]["correlation_id"] == correlation_id
malformed_execution = str(uuid.uuid4())
malformed = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(malformed_execution, correlation_id="not-a-uuid"),
headers=headers,
)
assert malformed.status_code == 422
assert all(
event["metadata"]["execution_id"] != malformed_execution
for event in ops_client.get(
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
).json()
)
def test_workflow_error_bounds_summary_length(client):
settings = get_settings()
response = client.post(
@@ -266,6 +397,26 @@ def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
assert ragcore_sync["last_seen_at"] is not None
def test_failed_or_partial_procedure_sync_is_never_reported_healthy(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
for synced, failed in ((0, 33), (32, 1)):
execution_id = str(uuid.uuid4())
response = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": execution_id, "synced": synced, "failed": failed},
headers=headers,
)
assert response.status_code == 200
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
workflow = next(w for w in status["workflows"] if "RAGcore" in w["name"])
assert workflow["state"] == "failed"
assert workflow["last_status"] == "failed"
assert workflow["last_execution_id"] == execution_id
assert status["state"] == "degraded"
def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client):
settings = get_settings()
execution_id = str(uuid.uuid4())
+410 -71
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import hashlib
import re
import uuid
from pathlib import Path
import httpx
import pytest
from app.api.routers import knowledge as knowledge_router
from app.core.config import get_settings
@@ -244,11 +247,11 @@ def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_c
class _FakeResponse:
def __init__(self, status_code: int, body: dict):
def __init__(self, status_code: int, body: object):
self.status_code = status_code
self._body = body
def json(self) -> dict:
def json(self) -> object:
return self._body
@@ -260,6 +263,7 @@ class _FakeClient:
post_responses=None,
raise_on=None,
get_handler=None,
requests=None,
):
self._get_response = get_response
self._post_response = post_response
@@ -270,6 +274,7 @@ class _FakeClient:
self._post_responses = post_responses or {}
self._raise_on = raise_on
self._get_handler = get_handler
self.requests = requests if requests is not None else []
def __enter__(self):
return self
@@ -289,6 +294,7 @@ class _FakeClient:
def post(self, path, json=None):
if self._raise_on == "post":
raise httpx.ConnectError("no ragcore in this environment")
self.requests.append((path, json))
return self._post_responses.get(path, self._post_response)
@@ -393,22 +399,107 @@ def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
assert "unavailable" in health.detail.lower()
def _answers_body(**overrides) -> dict:
body = {
"answer": "Report damage and route the vehicle to maintenance.",
"answerability": "answerable",
"citations": [
{
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Damage handling procedure",
"section": "Detection",
"excerpt": "Inspect the vehicle for visible damage.",
_EXCERPTS = {
("en-GB", "damage-procedure"): (
"When a vehicle returns with visible or reported damage, mark damage in the "
"return inspection, add a concise factual description and keep the vehicle "
"blocked."
),
("en-GB", "vehicle-return-procedure"): (
"Open the active booking and record the ending odometer, fuel level, cleanliness, "
"visible damage, technical warnings and relevant notes."
),
("en-GB", "cleaning-checklist"): (
"Cleaning completion alone does not make a blocked or maintenance vehicle "
"available. Fleet Ops derives availability from all active restrictions."
),
("en-GB", "maintenance-escalation"): (
"Escalate when a technical warning is reported, the service threshold is reached, "
"a safety-related defect is observed or an existing maintenance block remains "
"unresolved."
),
("nl-BE", "damage-procedure"): (
"Wanneer een voertuig terugkomt met zichtbare of gemelde schade, markeer de schade "
"in de retourinspectie, voeg een beknopte feitelijke beschrijving toe en houd het "
"voertuig geblokkeerd."
),
("nl-BE", "vehicle-return-procedure"): (
"Open de actieve boeking en registreer de eindkilometerstand, het brandstofniveau, "
"de netheid, zichtbare schade, technische waarschuwingen en relevante notities."
),
}
],
def _managed_document(provider: RAGcoreKnowledgeProvider, document_id: str, language: str):
return next(
document
for document in provider._managed_documents_by_source_id.values()
if document.document_id == document_id and document.language == language
)
def _citation(
provider: RAGcoreKnowledgeProvider,
document_id: str,
*,
language: str = "en-GB",
section: str = "Procedure",
seed: str = "1",
) -> dict:
document = _managed_document(provider, document_id, language)
excerpt = _EXCERPTS[(language, document_id)]
return {
"id": str(uuid.uuid5(uuid.NAMESPACE_URL, f"citation:{language}:{document_id}:{seed}")),
# These are deliberately opaque RAGcore-owned UUIDs, not Fleet Ops' stable
# human-readable procedure id.
"document_id": str(
uuid.uuid5(uuid.NAMESPACE_URL, f"ragcore-document:{language}:{document_id}")
),
"document_version_id": str(
uuid.uuid5(
uuid.NAMESPACE_URL,
f"ragcore-document-version:{language}:{document_id}:{seed}",
)
),
"title": "untrusted-provider-title.md",
"section": section,
"excerpt": excerpt,
"excerpt_sha256": hashlib.sha256(excerpt.encode("utf-8")).hexdigest(),
"source_uri": f"ragcore://source/{document.source_id}",
"source_id": document.source_id,
"locator": f"{document.document_id}.md",
}
def _answers_body(provider: RAGcoreKnowledgeProvider, **overrides) -> dict:
citation = _citation(provider, "damage-procedure", section="Detection")
answer = "Report damage and route the vehicle to maintenance."
body = {
"answer_id": str(uuid.uuid4()),
"retrieval_run_id": str(uuid.uuid4()),
"answer": answer,
"answerability": "answerable",
"citations": [citation],
"claims": [{"text": answer, "citation_ids": [citation["id"]]}],
}
body.update(overrides)
if "claims" not in overrides:
effective_answer = body.get("answer")
effective_citations = body.get("citations")
body["claims"] = (
[
{
"text": effective_answer,
"citation_ids": [item["id"] for item in effective_citations],
}
]
if isinstance(effective_answer, str)
and effective_answer
and isinstance(effective_citations, list)
and effective_citations
and all(isinstance(item, dict) and "id" in item for item in effective_citations)
else []
)
return body
@@ -418,47 +509,178 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(provider))),
)
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
assert answer.evidence_state == "grounded"
assert answer.answer
assert len(answer.sources) == 1
source = answer.sources[0]
assert source.document_id == "doc-1"
assert source.document_id == "damage-procedure"
assert source.title == "Damage handling procedure"
assert source.version == "version-1"
assert source.version == "1.3"
assert source.section == "Detection"
assert source.excerpt
@pytest.mark.parametrize(
"mutate",
[
lambda body: body.pop("claims"),
lambda body: body.update(claims=[]),
lambda body: body["claims"][0].update(citation_ids=[str(uuid.uuid4())]),
lambda body: body.pop("answer_id"),
lambda body: body.update(retrieval_run_id="not-a-uuid"),
],
)
def test_ragcore_answer_requires_valid_claim_citation_contract(monkeypatch, mutate):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
body = _answers_body(provider)
mutate(body)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, body)),
)
answer = provider.ask("What must I do about damage?", "test-invalid-claim-contract")
assert answer.evidence_state != "grounded"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_requests_are_scoped_to_managed_sources_for_requested_language(
monkeypatch,
):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
requests = []
fake_client = _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")),
},
requests=requests,
)
monkeypatch.setattr(provider, "_client", lambda: fake_client)
answer = provider.ask(
"Wat is de procedure voor een voertuigretour?",
"language-filter",
"nl-BE",
)
assert answer.evidence_state == "grounded"
assert [path for path, _payload in requests] == ["/v1/answers", "/v1/search"]
expected_source_ids = provider._managed_source_ids("nl-BE")
assert len(expected_source_ids) == 11
assert all(uuid.UUID(source_id) for source_id in expected_source_ids)
for _path, payload in requests:
assert payload["filters"] == {"source_ids": expected_source_ids}
assert payload["requested_space_ids"] == ["space-1"]
def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
citations = []
for index in range(5):
document_ids = [
"damage-procedure",
"damage-procedure",
"vehicle-return-procedure",
"cleaning-checklist",
"maintenance-escalation",
]
for index, document_id in enumerate(document_ids):
citations.append(
{
"id": f"cite-{index}",
"document_id": f"doc-{index}",
"document_version_id": f"version-{index}",
"title": "Damage procedure" if index < 2 else f"Procedure {index}",
"section": "Return",
"excerpt": (
f"Record visible damage before release, chunk {index}."
if index < 2
else f"Unique procedure evidence {index}."
),
}
_citation(
provider,
document_id,
section="Return",
seed=str(index),
)
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))),
lambda: _FakeClient(
post_response=_FakeResponse(200, _answers_body(provider, citations=citations))
),
)
answer = provider.ask("What must I do about vehicle damage?", "dedupe-test")
assert len(answer.sources) == 3
assert sum(source.title == "Damage procedure" for source in answer.sources) == 1
assert sum(source.title == "Damage handling procedure" for source in answer.sources) == 1
def test_ragcore_answer_rejects_unknown_document_citation(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200,
_answers_body(
provider,
answer="A plausible-looking but unmanaged answer.",
citations=[
{
**_citation(provider, "damage-procedure"),
"source_id": str(uuid.uuid4()),
}
],
),
)
),
)
answer = provider.ask("What must I do about damage?", "unknown-answer-citation")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
@pytest.mark.parametrize(
("field_name", "bad_value"),
[
("id", "not-a-uuid"),
("document_id", "not-a-uuid"),
("document_version_id", "00000000-0000-0000-0000-000000000000"),
("source_id", "00000000-0000-4000-8000-000000000099"),
("source_uri", "ragcore://source/00000000-0000-4000-8000-000000000099"),
("locator", "another-procedure.md"),
("excerpt_sha256", "0" * 64),
("title", None),
("section", {"not": "a string"}),
],
)
def test_ragcore_rejects_broken_managed_source_provenance(field_name, bad_value):
provider = RAGcoreKnowledgeProvider()
citation = _citation(provider, "damage-procedure")
citation[field_name] = bad_value
assert provider._managed_source(citation, "en-GB") is None
def test_ragcore_rejects_fabricated_excerpt_even_with_matching_hash():
provider = RAGcoreKnowledgeProvider()
citation = _citation(provider, "damage-procedure")
fabricated = "Invent a repair price and promise it to the customer."
citation["excerpt"] = fabricated
citation["excerpt_sha256"] = hashlib.sha256(fabricated.encode("utf-8")).hexdigest()
assert provider._managed_source(citation, "en-GB") is None
def test_ragcore_never_relabels_an_english_source_as_dutch():
provider = RAGcoreKnowledgeProvider()
citation = _citation(provider, "damage-procedure", language="en-GB")
assert provider._managed_source(citation, "nl-BE") is None
def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client):
@@ -503,6 +725,7 @@ def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(mo
post_response=_FakeResponse(
200,
_answers_body(
provider,
answer="This should never be shown.",
answerability="not_answerable",
citations=[],
@@ -524,7 +747,8 @@ def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypat
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200, _answers_body(answerability="answerable", citations=[])
200,
_answers_body(provider, answerability="answerable", citations=[]),
)
),
)
@@ -565,19 +789,66 @@ def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
assert answer.evidence_state == "unavailable"
def _search_body(**overrides) -> dict:
def test_ragcore_provider_malformed_top_level_bodies_do_not_escape(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(200, ["not", "an", "object"]),
"/v1/search": _FakeResponse(200, None),
}
),
)
answer = provider.ask("Anything?", "test-correlation-malformed-top-level")
assert answer.evidence_state == "unavailable"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_answer_with_malformed_citation_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200,
_answers_body(provider, citations=[None]),
)
),
)
answer = provider.ask("What must I do about damage?", "malformed-answer-citation")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
def _search_body(
provider: RAGcoreKnowledgeProvider,
language: str = "en-GB",
**overrides,
) -> dict:
body = {
"retrieval_run_id": str(uuid.uuid4()),
"effective_space_ids": ["00000000-0000-4000-8000-000000000001"],
"results": [
{
"chunk_id": "chunk-1",
"citation": {
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Vehicle return procedure",
"section": "Return",
"excerpt": "Register the return odometer reading before releasing the vehicle.",
},
"chunk_id": str(uuid.uuid4()),
"text": _EXCERPTS[(language, "vehicle-return-procedure")],
"citation": _citation(
provider,
"vehicle-return-procedure",
language=language,
section="Return",
),
"rank": 1,
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
}
@@ -588,6 +859,31 @@ def _search_body(**overrides) -> dict:
return body
def test_ragcore_search_ignores_malformed_result_and_citation_bodies(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
malformed_search = _search_body(
provider,
results=[None, {"citation": ["not-an-object"], "scores": "not-an-object"}],
)
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, malformed_search),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "malformed-search-items")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
not a real 'insufficient evidence' classification) must not silently degrade
@@ -601,26 +897,24 @@ def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypa
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
"/v1/search": _FakeResponse(200, _search_body(provider)),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
assert answer.evidence_state == "grounded"
assert "Register the return odometer reading" in answer.answer
assert "Open the active booking and record the ending odometer" in answer.answer
assert "Vehicle return procedure" in answer.answer
assert len(answer.sources) == 1
assert answer.sources[0].title == "Vehicle return procedure"
assert answer.sources[0].excerpt == (
"Register the return odometer reading before releasing the vehicle."
)
assert answer.sources[0].excerpt == _EXCERPTS[("en-GB", "vehicle-return-procedure")]
def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0)
search_response = _FakeResponse(200, _search_body())
search_response = _FakeResponse(200, _search_body(provider))
monkeypatch.setattr(
provider,
"_client",
@@ -632,8 +926,7 @@ def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
),
)
assert (
provider.ask("What is the vehicle return procedure?", "first").evidence_state
== "grounded"
provider.ask("What is the vehicle return procedure?", "first").evidence_state == "grounded"
)
monkeypatch.setattr(
@@ -654,7 +947,7 @@ def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
"/v1/search": _FakeResponse(200, _search_body(provider, "nl-BE")),
}
),
)
@@ -676,7 +969,7 @@ def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkey
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body(results=[])),
"/v1/search": _FakeResponse(200, _search_body(provider, results=[])),
}
),
)
@@ -695,7 +988,7 @@ def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, _search_body()),
"/v1/search": _FakeResponse(200, _search_body(provider)),
}
),
)
@@ -707,19 +1000,66 @@ def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
assert answer.answer == ""
def test_ragcore_search_fallback_rejects_unknown_document_citation(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body(provider)
search["results"][0]["citation"]["source_id"] = str(uuid.uuid4())
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, search),
}
),
)
answer = provider.ask(
"What is the vehicle return procedure?",
"unknown-search-citation",
)
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_search_fallback_rejects_vehicle_colour_despite_high_score(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body(provider)
search["results"][0]["scores"]["fused"] = 1.0
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {}),
"/v1/search": _FakeResponse(200, search),
}
),
)
answer = provider.ask("What colour is this vehicle?", "vehicle-colour")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body()
search = _search_body(provider, "nl-BE", results=[])
search["results"].append(
{
"citation": {
"document_id": "damage-procedure",
"document_version_id": "version-2",
"title": "damage-procedure.md",
"section": "Damage",
"excerpt": "Record damage and keep the vehicle blocked.",
},
"citation": _citation(
provider,
"damage-procedure",
language="nl-BE",
section="Damage",
),
"rank": 2,
"scores": {"fused": 0.01},
}
@@ -743,16 +1083,15 @@ def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
def test_ragcore_search_fallback_accepts_top_ranked_ragcore_damage_evidence(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
search = _search_body()
search = _search_body(provider, "nl-BE", results=[])
search["results"].append(
{
"citation": {
"document_id": "damage-procedure",
"document_version_id": "version-2",
"title": "damage-procedure.md",
"section": "Damage",
"excerpt": "Record damage and keep the vehicle blocked.",
},
"citation": _citation(
provider,
"damage-procedure",
language="nl-BE",
section="Damage",
),
"rank": 1,
# RAGcore uses reciprocal-rank fusion; a genuine rank-one result is about
# 1 / (60 + 1), not a normalized 0..1 relevance score.
+31
View File
@@ -1,4 +1,12 @@
from app.core.config import get_settings
from app.main import app
MCP_ROUTE_ALLOWLIST = {
("GET", "/api/v1/integrations/mcp/operations-summary"),
("GET", "/api/v1/integrations/mcp/attention-vehicles"),
("GET", "/api/v1/integrations/mcp/vehicles/{vehicle_ref}"),
("POST", "/api/v1/integrations/mcp/search-knowledge"),
}
def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
@@ -154,3 +162,26 @@ def test_no_write_endpoints_exist_under_mcp_namespace(client):
]:
response = getattr(client, method)(path, headers=_headers())
assert response.status_code in (404, 405)
def test_mcp_namespace_matches_exact_route_allowlist_and_rejects_lookalikes(client):
implemented = {
(method.upper(), path)
for path, operations in app.openapi()["paths"].items()
if path.startswith("/api/v1/integrations/mcp/")
for method in operations
if method in {"get", "post", "put", "patch", "delete"}
}
assert implemented == MCP_ROUTE_ALLOWLIST
lookalikes = [
("get", "/api/v1/integrations/mcp-extra/operations-summary"),
("get", "/api/v1/integrations/mcp/operations-summary-extra"),
("get", "/api/v1/integrations/mcp/prefix/attention-vehicles"),
("get", "/api/v1/integrations/mcp/attention-vehicles/suffix"),
("post", "/api/v1/integrations/mcp/search-knowledge-extra"),
("post", "/api/v1/integrations/mcp/prefix/search-knowledge"),
]
for method, path in lookalikes:
response = client.request(method, path, headers=_headers(), json={})
assert response.status_code == 404, path
+18
View File
@@ -63,6 +63,24 @@ def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_c
)
assert response.status_code == 200
assert response.json()["status"] == "anonymized"
search = ops_client.get("/api/v1/customers", params={"query": "CUS-PRIVACY"})
assert search.status_code == 200
assert search.json() == []
window = {
"starts_at": "2090-01-01T10:00:00Z",
"ends_at": "2090-01-02T10:00:00Z",
}
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
assert available
booking = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-PRIVACY",
"vehicle_ref": available[0]["public_ref"],
**window,
},
)
assert booking.status_code == 422
with SessionLocal() as db:
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
assert customer is not None
+17
View File
@@ -128,6 +128,23 @@ def test_seed_scenario_s4_booking_overlap():
db.close()
def test_seed_odometer_issues_expose_only_the_regressing_booking_as_correctable():
db = SessionLocal()
try:
reset_and_seed(db)
issue = _by_ref(db, DataQualityIssue, "DQ-0007")
assert issue is not None
assert issue.evidence_json["source_type"] == "return"
assert issue.evidence_json["related_refs"] == [
"INSP-0057",
"BK-H-0007",
"INSP-0007",
]
assert issue.evidence_json["correctable_booking_refs"] == ["BK-H-0007"]
finally:
db.close()
def test_seed_scenario_s5_failed_workflow_run():
"""S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely
pending, so the background dispatcher never silently auto-heals it away."""
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import csv
import subprocess
import sys
from pathlib import Path
REPOSITORY_ROOT = next(
parent
for parent in Path(__file__).resolve().parents
if (parent / "seed" / "generate_seed.py").is_file()
)
SEED_DIRECTORY = REPOSITORY_ROOT / "seed"
def _rows_by_ref(path: Path) -> dict[str, dict[str, str]]:
with path.open(newline="", encoding="utf-8") as handle:
rows = list(csv.DictReader(handle))
assert all(None not in row for row in rows), f"malformed CSV row in {path.name}"
return {row["public_ref"]: row for row in rows}
def test_generator_reproduces_committed_seed_snapshot_byte_for_byte(tmp_path: Path):
subprocess.run(
[
sys.executable,
str(SEED_DIRECTORY / "generate_seed.py"),
"--anchor",
"2026-08-01",
"--seed",
"20260801",
"--out",
str(tmp_path),
],
cwd=REPOSITORY_ROOT,
check=True,
capture_output=True,
text=True,
)
committed_files = sorted(SEED_DIRECTORY.glob("*.csv"))
generated_files = sorted(tmp_path.glob("*.csv"))
assert [path.name for path in generated_files] == [path.name for path in committed_files]
for committed in committed_files:
generated = tmp_path / committed.name
assert generated.read_bytes() == committed.read_bytes(), (
f"{committed.name} differs from the deterministic generated snapshot"
)
bookings = _rows_by_ref(tmp_path / "bookings.csv")
inspections = _rows_by_ref(tmp_path / "inspections.csv")
issues = _rows_by_ref(tmp_path / "data_quality_issues.csv")
assert int(bookings["BK-H-0007"]["end_odometer_km"]) < int(
bookings["BK-H-0057"]["end_odometer_km"]
)
assert int(bookings["BK-H-0010"]["end_odometer_km"]) < int(
bookings["BK-H-0060"]["end_odometer_km"]
)
assert inspections["INSP-0007"]["odometer_km"] == bookings["BK-H-0007"]["end_odometer_km"]
assert inspections["INSP-0010"]["odometer_km"] == bookings["BK-H-0010"]["end_odometer_km"]
assert inspections["INSP-0001"]["completed_at"] == bookings["BK-H-0001"]["ends_at"]
expected_issue_refs = {f"DQ-{index:04d}" for index in range(5, 22)}
assert expected_issue_refs <= issues.keys()
assert all(
issues[public_ref]["evidence"] != "Synthetic deterministic seed issue"
for public_ref in expected_issue_refs
)
assert "INSP-0007" in issues["DQ-0007"]["evidence"]
assert "INSP-0057" in issues["DQ-0007"]["evidence"]
assert "INSP-0010" in issues["DQ-0010"]["evidence"]
assert "INSP-0060" in issues["DQ-0010"]["evidence"]
+19
View File
@@ -23,3 +23,22 @@ def test_manager_can_create_and_update_user(ops_client):
def test_employee_cannot_manage_users(employee_client):
assert employee_client.get("/api/v1/users").status_code == 403
assert (
employee_client.post(
"/api/v1/users",
json={
"email": "unauthorised@example.test",
"display_name": "Unauthorised User",
"role": "rental_employee",
"password": "a-secure-demo-password",
},
).status_code
== 403
)
assert (
employee_client.patch(
"/api/v1/users/USR-OPS",
json={"display_name": "Unauthorised Change"},
).status_code
== 403
)
+53
View File
@@ -46,6 +46,12 @@ def test_vehicle_detail_includes_related_records(ops_client):
assert len(body["quality_issues"]) >= 1
def test_employee_vehicle_detail_never_exposes_quality_evidence(employee_client):
response = employee_client.get("/api/v1/vehicles/MO-016")
assert response.status_code == 200
assert response.json()["quality_issues"] == []
def test_vehicle_detail_404_for_unknown_ref(ops_client):
response = ops_client.get("/api/v1/vehicles/MO-999")
assert response.status_code == 404
@@ -78,6 +84,53 @@ def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
assert released.json()["operational_status"] == "available"
def test_low_maintenance_reading_keeps_canonical_and_opens_quality_issue(ops_client):
existing_issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
vehicles = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()
vehicle = next(
candidate
for candidate in vehicles
if candidate["odometer_km"] > 0 and candidate["public_ref"] not in unavailable_refs
)
response = ops_client.post(
f"/api/v1/vehicles/{vehicle['public_ref']}/maintenance",
json={
"occurred_at": "2053-01-15T12:00:00Z",
"odometer_km": vehicle["odometer_km"] - 1,
"category": "inspection",
"summary": "Imported workshop reading requires verification.",
"mark_maintenance": True,
},
)
assert response.status_code == 201
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert detail["odometer_km"] == vehicle["odometer_km"]
issues = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "rule_type": "odometer_regression"},
).json()
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
assert issue["evidence"]["source_type"] == "maintenance"
assert issue["evidence"]["correctable_booking_refs"] == []
issue_detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
assert any(
snapshot["entity_type"] == "maintenance"
and snapshot["public_ref"] == response.json()["public_ref"]
for snapshot in issue_detail["related_snapshots"]
)
retained = ops_client.post(
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
json={"decision": "retain_canonical"},
)
assert retained.status_code == 200
def test_employee_cannot_record_maintenance(employee_client):
response = employee_client.post(
"/api/v1/vehicles/MO-001/maintenance",
+9 -7
View File
@@ -2757,10 +2757,6 @@ components:
type: string
format: date-time
title: Ends At
requirements_complete:
type: boolean
title: Requirements Complete
default: false
type: object
required:
- customer_ref
@@ -4189,11 +4185,13 @@ components:
title: ResolveOverlapRequest
ReturnCallbackIn:
properties:
event_id:
type: string
format: uuid
title: Event Id
correlation_id:
anyOf:
- type: string
type: string
format: uuid
- type: 'null'
title: Correlation Id
follow_up:
anyOf:
@@ -4208,6 +4206,9 @@ components:
- type: 'null'
title: Summary
type: object
required:
- event_id
- correlation_id
title: ReturnCallbackIn
description: 'Body of the n8n return follow-up callback.
@@ -4777,6 +4778,7 @@ components:
correlation_id:
anyOf:
- type: string
format: uuid
- type: 'null'
title: Correlation Id
attempt:
+25 -11
View File
@@ -32,13 +32,26 @@ updates the record and its evidence but leaves the issue open.
## DQ-03 Odometer regression
Flag an inspection or maintenance reading below the canonical odometer. Never lower the canonical value automatically.
Flag a newly recorded checkout, return-inspection or maintenance reading below the
canonical odometer. The deterministic import scan compares booking, inspection and
maintenance readings in chronological order. A return inspection's actual completion
time is authoritative over the booking's planned end, so an early return cannot be
mistaken for a later regression. Never lower the canonical value automatically.
When another regression is recorded while the vehicle already has an open DQ-03 issue,
append the new source reference and signal to that issue. Do not silently discard the
new evidence and do not open parallel issues for the same vehicle.
Resolution: `POST /resolve-odometer-regression` offers exactly two bounded decisions —
`retain_canonical` (the submitted reading is treated as erroneous; canonical is
untouched) or `correct_reading` (updates a named related booking's reading and the
vehicle's canonical odometer together). A `correct_reading` value below the current
canonical is rejected, since it would not resolve the regression, not silently applied.
untouched) or `correct_reading`. The correction option is offered only for an explicitly
named, correctable booking reading; checkout-only and maintenance-only issues can only
retain the canonical value. A corrected return updates the booking, its related return
inspection and the vehicle's canonical odometer in one transaction. A
`correct_reading` value below the current canonical is rejected, since it would not
resolve the regression, not silently applied. When one open issue contains several
source readings, correcting one removes only that booking/inspection evidence and keeps
the issue open until every remaining signal has received a bounded decision.
## DQ-04 Booking overlap
@@ -63,10 +76,11 @@ function to confirm the conflict is actually gone before resolving.
## Lifecycle
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open — the CSV
seed rows don't carry a stable evidence fingerprint, so the literal
`(..., evidence fingerprint)` scheme from an earlier draft of this rule was dropped as
unworkable for seeded data; re-implementing it would need to reconcile with that. Resolved
issues remain historical. Reintroduced evidence creates a new issue whose evidence carries
`reopened_from` (the prior issue's reference) and `previous_decision` (its resolved
status), so a repeat problem is never presented as if no decision was ever made.
Detection is idempotent by `(rule_type, entity_type, entity_id)` while open. Source-aware
rules additionally store stable evidence identity. For DQ-03 this is the tuple
`(source_type, later_ref, later_km)`: an explicit `retain_canonical` decision suppresses
only that exact fact on later scans. A changed reading or a different source is actionable
again. Resolved issues remain historical. Reintroduced evidence creates a new issue whose
evidence carries `reopened_from` (the prior issue's reference) and `previous_decision`
(its resolved status), so a repeat problem is never presented as if no decision was ever
made.
+250
View File
@@ -0,0 +1,250 @@
import { expect, test, type Page } from "@playwright/test";
import type { AutomationRun } from "../src/api/types";
const apiErrorBody = JSON.stringify({
error: { code: "TEST_UNAVAILABLE", message: "Injected test failure", correlation_id: "test-correlation" },
});
async function loginAsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function failFirstManifestRequest(page: Page) {
let requestCount = 0;
await page.route("**/api/v1/demo/manifest", async (route) => {
requestCount += 1;
if (requestCount === 1) {
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
return;
}
await route.continue();
});
}
test("session validation does not wait for a stalled system-status request", async ({ page }) => {
let releaseStatus = () => undefined;
const statusGate = new Promise<void>((resolve) => {
releaseStatus = resolve;
});
let sessionRequested = false;
await page.route("**/api/v1/system/status", async (route) => {
await statusGate;
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
});
await page.route("**/api/v1/auth/session", async (route) => {
sessionRequested = true;
await route.fulfill({ status: 401, contentType: "application/json", body: apiErrorBody });
});
try {
await page.goto("/login");
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeEnabled({
timeout: 2_000,
});
expect(sessionRequested).toBe(true);
} finally {
releaseStatus();
}
});
test("guided demo stays gated while its manifest loads, then exposes an explicit retry", async ({ page }) => {
let releaseManifest = () => undefined;
const manifestGate = new Promise<void>((resolve) => {
releaseManifest = resolve;
});
let markManifestRequested = () => undefined;
const manifestRequested = new Promise<void>((resolve) => {
markManifestRequested = resolve;
});
let manifestAttempts = 0;
await page.route("**/api/v1/demo/manifest", async (route) => {
manifestAttempts += 1;
if (manifestAttempts === 1) {
markManifestRequested();
await manifestGate;
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
return;
}
await route.continue();
});
try {
await page.goto("/login");
await manifestRequested;
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
await expect(guidedDemo).toBeDisabled();
await expect(page.getByText("Begeleide demo voorbereiden…")).toBeVisible();
releaseManifest();
await expect(page.getByText("De begeleide demo-informatie kon niet geladen worden.")).toBeVisible();
await expect(guidedDemo).toBeDisabled();
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
await expect(guidedDemo).toBeEnabled();
expect(manifestAttempts).toBeGreaterThanOrEqual(2);
} finally {
releaseManifest();
}
});
test("guided demo does not continue until the manifest reports it ready", async ({ page }) => {
let returnNotReady = true;
await page.route("**/api/v1/demo/manifest", async (route) => {
if (!returnNotReady) {
await route.continue();
return;
}
const response = await route.fetch();
const manifest = await response.json() as Record<string, unknown>;
await route.fulfill({ response, json: { ...manifest, guide_available: false } });
});
await page.goto("/login");
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
await expect(guidedDemo).toBeDisabled();
await expect(page.getByText("De begeleide demo is momenteel niet klaar om te starten.")).toBeVisible();
await guidedDemo.evaluate((button) => {
button.removeAttribute("disabled");
(button as HTMLButtonElement).click();
});
await expect(page).toHaveURL(/\/login$/);
returnNotReady = false;
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
await expect(guidedDemo).toBeEnabled();
});
test("blocked sessionStorage cannot break login or logout", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperties(Storage.prototype, {
setItem: {
configurable: true,
value: () => {
throw new DOMException("Storage is blocked", "SecurityError");
},
},
removeItem: {
configurable: true,
value: () => {
throw new DOMException("Storage is blocked", "SecurityError");
},
},
});
});
await loginAsManager(page);
await expect(page.getByText("Amelie De Ridder").first()).toBeVisible();
await page.locator(".operator-menu > summary").click();
await page.getByRole("button", { name: "Wissel van rol" }).click();
await expect(page).toHaveURL(/\/login$/);
});
test("scenario manifest failure is visible and retryable", async ({ page }) => {
await failFirstManifestRequest(page);
await loginAsManager(page);
await page.locator('a[href="/scenarios"]').first().click();
await expect(page).toHaveURL(/\/scenarios$/);
await expect(page.getByText("De demonstratiescenario's konden niet geladen worden.")).toBeVisible();
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
await expect(page.locator(".scenario-card")).toHaveCount(5);
});
test("engineering manifest failure is visible and retryable", async ({ page }) => {
await failFirstManifestRequest(page);
await loginAsManager(page);
await page.locator('a[href="/about"]').first().click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByText("De demo-informatie kon niet geladen worden.")).toBeVisible();
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" })).toBeVisible();
});
test("the latest automation filter wins when an older response completes last", async ({ page }) => {
test.setTimeout(45_000);
let releaseOldResponse = () => undefined;
const oldResponseGate = new Promise<void>((resolve) => {
releaseOldResponse = resolve;
});
let markOldRequestStarted = () => undefined;
const oldRequestStarted = new Promise<void>((resolve) => {
markOldRequestStarted = resolve;
});
let markOldResponseAttempted = () => undefined;
const oldResponseAttempted = new Promise<void>((resolve) => {
markOldResponseAttempted = resolve;
});
const run = (status: "failed" | "pending", aggregateRef: string): AutomationRun => ({
event_id: status === "failed"
? "11111111-1111-4111-8111-111111111111"
: "22222222-2222-4222-8222-222222222222",
event_type: "vehicle.returned.v1",
aggregate_ref: aggregateRef,
status,
attempts: status === "failed" ? 2 : 0,
last_error: null,
last_error_code: null,
is_demo_scenario: false,
occurred_at: "2026-08-23T10:00:00Z",
});
await page.route(/\/api\/v1\/workflows(?:\?.*)?$/, async (route) => {
const requestedStatus = new URL(route.request().url()).searchParams.get("status");
if (requestedStatus === "failed") {
markOldRequestStarted();
await oldResponseGate;
try {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([run("failed", "BK-RACE-OLD")]),
});
} catch {
// Chromium cancels the superseded fetch. The delayed route can therefore be
// closed before Playwright gets to fulfil it; either outcome proves the same
// contract as long as it cannot overwrite the newer response.
} finally {
markOldResponseAttempted();
}
return;
}
if (requestedStatus === "pending") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([run("pending", "BK-RACE-NEW")]),
});
return;
}
await route.continue();
});
try {
await loginAsManager(page);
await page.goto("/automation");
await expect(page.getByRole("heading", { name: "Automatiseringsopdrachten" })).toBeVisible();
const statusFilter = page.getByRole("combobox", { name: "Status", exact: true });
await statusFilter.selectOption("failed");
await oldRequestStarted;
await statusFilter.selectOption("pending");
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
releaseOldResponse();
await oldResponseAttempted;
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
await expect(page.getByText("BK-RACE-OLD", { exact: true })).toHaveCount(0);
} finally {
releaseOldResponse();
}
});
+97
View File
@@ -74,6 +74,7 @@ test("the collapsed chip has its own close control, independent of reopening it"
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(chip).toBeHidden();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
});
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
@@ -106,6 +107,41 @@ test("restarting the demo from the guide resets data and returns to login", asyn
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
});
test("sidebar reset preserves guide progress on failure and clears it only after success", async ({ page }) => {
let resetShouldFail = true;
await page.route("**/api/v1/demo/reset", async (route) => {
if (resetShouldFail) {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({
error: { code: "TEST_UNAVAILABLE", message: "Injected reset failure", correlation_id: "reset-test" },
}),
});
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "reset" }) });
});
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.locator(".demo-guide-trigger").click();
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
await page.getByRole("button", { name: "Ja, herstellen" }).click();
await expect(page.getByText("Demogegevens konden niet hersteld worden.")).toBeVisible();
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
resetShouldFail = false;
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
await page.getByRole("button", { name: "Ja, herstellen" }).click();
await expect(page).toHaveURL(/\/login$/);
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.locator(".demo-guide-trigger")).toContainText("0/8");
});
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
@@ -147,6 +183,52 @@ test("Escape collapses the standard-tier panel, then closes it", async ({ page }
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
});
test("closing the guide cancels a delayed target lookup and restores trigger focus", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
let releaseKnowledgeChunk = () => undefined;
const knowledgeChunkGate = new Promise<void>((resolve) => {
releaseKnowledgeChunk = resolve;
});
let markKnowledgeChunkRequested = () => undefined;
const knowledgeChunkRequested = new Promise<void>((resolve) => {
markKnowledgeChunkRequested = resolve;
});
await page.route(
/\/(?:assets\/Knowledge-[^/?]+\.js|src\/pages\/Knowledge\.tsx)(?:\?.*)?$/,
async (route) => {
markKnowledgeChunkRequested();
await knowledgeChunkGate;
await route.continue();
},
);
try {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await knowledgeChunkRequested;
await page
.locator(".demo-guide-panel.is-wide")
.getByRole("button", { name: "Sluiten" })
.click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
releaseKnowledgeChunk();
const target = page.locator("#ask-heading");
await expect(target).toBeVisible();
await page.waitForTimeout(150);
await expect(target).not.toBeFocused();
await expect(target).not.toHaveClass(/demo-guide-highlight/);
} finally {
releaseKnowledgeChunk();
}
});
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
@@ -161,3 +243,18 @@ test("going to a step scrolls, focuses and highlights the on-page target", async
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
test("going to a guide target on the already-active route still focuses and highlights it", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /8\. Bekijk wat echt is/ }).click();
await page.goto("/about");
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
const target = page.locator(".engineering-hero");
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
+6 -1
View File
@@ -49,7 +49,12 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count();
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
// The URL-driven filter update can replace the controlled checkbox immediately after
// its click. Assert against the newly rendered control instead of asking `check()` to
// verify the detached pre-navigation node.
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).click();
await expect(page).toHaveURL(/demo=true/);
await expect(page.getByRole("checkbox", { name: "Enkel demoscenario's" })).toBeChecked();
const filteredRows = await page.locator(".data-table tbody tr").count();
expect(filteredRows).toBeGreaterThan(0);
expect(filteredRows).toBeLessThanOrEqual(allRows);
+62 -2
View File
@@ -1,4 +1,10 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import type {
AuditEvent,
AutomationRun,
DataQualityIssueDetail,
RegisterReturnResult,
} from "../src/api/types";
async function resetDemoData(request: APIRequestContext) {
const login = await request.post("/api/v1/demo/login", {
@@ -12,6 +18,8 @@ async function resetDemoData(request: APIRequestContext) {
test.describe.configure({ mode: "serial" });
test("five-minute demo script end to end", async ({ page, request }) => {
const proof: { returned?: RegisterReturnResult } = {};
// Reset via a throwaway API session so the UI test starts from the deterministic seed
// regardless of what earlier test runs mutated (S1 return, S2 merge, etc.).
await resetDemoData(request);
@@ -50,14 +58,66 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await page.getByLabel("Brandstofniveau (%)").fill("55");
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
const returnResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().endsWith("/api/v1/bookings/BK-DEMO-RETURN/return"),
);
await page.getByRole("button", { name: "Retour bevestigen" }).click();
const returnResponse = await returnResponsePromise;
expect(returnResponse.status()).toBe(201);
proof.returned = (await returnResponse.json()) as RegisterReturnResult;
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
});
await test.step("5. verify quality issue and queued automation event", async () => {
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).toBeVisible();
await test.step("5. verify the persisted quality issue, outbox event and audit trace", async () => {
const returned = proof.returned;
if (!returned) throw new Error("The return API response was not captured");
expect(returned.odometer_regression).toBe(true);
expect(returned.quality_issue_ref).toMatch(/^DQ-RET-/);
const qualityIssueRef = returned.quality_issue_ref;
if (!qualityIssueRef) throw new Error("The regression return did not create a quality issue");
await expect(page.getByRole("link", { name: qualityIssueRef })).toBeVisible();
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
// Use the page-bound request context: the throwaway reset session above is
// deliberately invalidated by reset, while this context shares the manager login
// cookie established in step 1.
const issueResponse = await page.request.get(`/api/v1/data-quality/issues/${qualityIssueRef}`);
expect(issueResponse.ok()).toBeTruthy();
const issue = (await issueResponse.json()) as DataQualityIssueDetail;
expect(issue.public_ref).toBe(qualityIssueRef);
expect(issue.rule_type).toBe("odometer_regression");
expect(issue.status).toBe("open");
expect(issue.entity_ref).toBe(returned.vehicle_ref);
const workflowsResponse = await page.request.get("/api/v1/workflows");
expect(workflowsResponse.ok()).toBeTruthy();
const workflows = (await workflowsResponse.json()) as AutomationRun[];
const workflow = workflows.find((run) => run.event_id === returned.workflow_event_id);
expect(workflow).toBeDefined();
expect(workflow).toMatchObject({
event_type: "vehicle.returned.v1",
aggregate_ref: returned.booking_ref,
});
const auditResponse = await page.request.get(
`/api/v1/audit?correlation_id=${encodeURIComponent(returned.correlation_id)}`,
);
expect(auditResponse.ok()).toBeTruthy();
const trace = (await auditResponse.json()) as AuditEvent[];
expect(trace.map((event) => event.action)).toEqual(
expect.arrayContaining([
"data_quality_issue_created",
"return_registered",
"vehicle_status_changed",
]),
);
expect(trace.every((event) => event.correlation_id === returned.correlation_id)).toBe(true);
});
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
+15
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test";
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
import { brusselsLocalToIso, toBrusselsDate, tryBrusselsLocalToIso } from "../src/i18n/brusselsDateTime";
// Pure Node-context boundary tests for the central, clock-injectable greeting function
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
@@ -66,3 +67,17 @@ test("default argument uses the real current time when no clock is injected", ()
const period = getGreetingPeriod();
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
});
test("Brussels wall-clock conversion is DST-aware and rejects impossible local times", () => {
expect(brusselsLocalToIso("2026-01-15T12:00")).toBe("2026-01-15T11:00:00.000Z");
expect(brusselsLocalToIso("2026-07-15T12:00")).toBe("2026-07-15T10:00:00.000Z");
expect(tryBrusselsLocalToIso("2026-03-29T02:30")).toBeNull();
expect(tryBrusselsLocalToIso("2026-02-30T12:00")).toBeNull();
// The repeated autumn hour resolves deterministically to the post-transition CET instant.
expect(brusselsLocalToIso("2026-10-25T02:30")).toBe("2026-10-25T01:30:00.000Z");
});
test("Brussels calendar dates do not depend on the browser or runner timezone", () => {
expect(toBrusselsDate(new Date("2026-01-01T23:30:00.000Z"))).toBe("2026-01-02");
expect(toBrusselsDate(new Date("2026-07-01T22:30:00.000Z"))).toBe("2026-07-02");
});
+7
View File
@@ -106,6 +106,13 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
await page.getByRole("button", { name: "Demo afronden" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const guideTrigger = page.locator(".demo-guide-trigger");
await expect(guideTrigger).toContainText("8/8");
await expect(guideTrigger).toBeFocused();
await page.reload();
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("8/8");
});
await test.step("restore the environment", async () => {
+26
View File
@@ -400,9 +400,35 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
await page.goto("/privacy");
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
await page.goto("/users");
await expect(page.getByText("User administration is available to Operations Managers only.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Add user" })).toHaveCount(0);
await page.goto("/vehicles/MO-024");
await expect(page.getByRole("tab", { name: "Quality" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
});
test("rental employee return result exposes operational follow-up but no manager-only evidence links", async ({
page,
}) => {
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("54000");
await page.getByLabel("Fuel level (%)").fill("75");
await page.getByRole("button", { name: "Review return" }).click();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await expect(page.getByRole("link", { name: /View vehicle MO-024/ })).toBeVisible();
await expect(page.getByRole("link", { name: "View automation status" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Trace the complete operation" })).toHaveCount(0);
});
test("operations manager can reset demo data and is returned to login", async ({ page }) => {
await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible();
await page.getByRole("button", { name: "Reset demo data" }).click();
@@ -14,6 +14,20 @@ async function login(page: Page) {
test.describe.configure({ mode: "serial" });
test("booking creation explains when the end does not follow the start", async ({ page }) => {
await login(page);
await page.goto("/bookings/new");
const start = page.getByLabel("Start");
const end = page.getByLabel("Einde");
const startValue = await start.inputValue();
await end.fill(startValue);
await expect(page.getByRole("alert")).toHaveText("Het einde moet na de start van de boeking vallen.");
await expect(end).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("button", { name: "Boeking aanmaken" })).toBeDisabled();
});
test("operator can create and cancel a booking through the UI", async ({ page, request }) => {
await reset(request);
await login(page);
+59 -1
View File
@@ -26,11 +26,69 @@ test("Engineering Story exposes architecture, reliability and honest integration
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible();
await expect(page.getByText("AI moet bewijs tonen")).toBeVisible();
await expect(page.getByText("PostgreSQL + audittrail")).toBeVisible();
await expect(page.locator(".architecture-step", { hasText: "PostgreSQL + audittrail" }).getByRole("button")).toBeVisible();
const outboxStep = page.locator(".architecture-step", { hasText: "Betrouwbare outbox" }).getByRole("button");
await outboxStep.click();
await expect(outboxStep).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#architecture-active-detail")).toContainText("Post-commit aflevering");
const architectureTypeMinimums = [
[".architecture-zones > span", 12],
[".architecture-step button > small", 13],
[".architecture-zone-tag", 12],
[".architecture-commit-note", 12],
[".architecture-interaction-hint", 13],
[".architecture-active-copy p", 13],
[".architecture-active-evidence dt", 12],
[".architecture-active-evidence dd", 13],
] as const;
for (const [selector, minimumPixels] of architectureTypeMinimums) {
const fontSize = await page.locator(selector).first().evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).fontSize));
expect(fontSize, `${selector} font-size`).toBeGreaterThanOrEqual(minimumPixels);
}
await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0);
await expect(page.getByText(/unknown procedures/)).toHaveCount(0);
});
test("dashboard load failure offers an in-place retry and recovers", async ({ page }) => {
await loginAsOperationsManager(page);
let dashboardAttempts = 0;
let allowDashboardRecovery = false;
await page.route("**/api/v1/dashboard", async (route) => {
dashboardAttempts += 1;
if (!allowDashboardRecovery) {
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
return;
}
await route.continue();
});
await page.reload();
const retryButton = page.getByRole("button", { name: "Opnieuw proberen" });
await expect(retryButton).toBeVisible();
allowDashboardRecovery = true;
await retryButton.click();
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
expect(dashboardAttempts).toBeGreaterThanOrEqual(2);
});
test("dashboard header stacks a full-width touch target on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAsOperationsManager(page);
const header = page.locator(".page-header");
const action = page.locator(".page-actions .button");
const [headerBox, actionBox] = await Promise.all([header.boundingBox(), action.boundingBox()]);
expect(headerBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(actionBox!.width).toBeGreaterThanOrEqual(headerBox!.width - 1);
expect(Math.round(actionBox!.height)).toBeGreaterThanOrEqual(44);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth + 1),
);
});
test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
+4
View File
@@ -24,6 +24,7 @@ export const KNOWN_CODES = new Set([
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"ISSUE_CHANGED",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
@@ -36,6 +37,9 @@ export const KNOWN_CODES = new Set([
"INVALID_SURVIVOR",
"INVALID_BOOKING_REFERENCE",
"INVALID_EVENT_ID",
"INVALID_EVENT_CORRELATION",
"CALLBACK_EVENT_MISMATCH",
"CALLBACK_CORRELATION_MISMATCH",
"INVALID_IDEMPOTENCY_KEY",
"IDEMPOTENCY_KEY_REUSED",
"CORRECTED_VALUE_REQUIRED",
+1 -1
View File
@@ -245,7 +245,7 @@ export interface ReturnPreviewResult {
}
export interface EntitySnapshot {
entity_type: "customer" | "vehicle" | "booking" | "inspection";
entity_type: "customer" | "vehicle" | "booking" | "inspection" | "maintenance";
public_ref: string;
[key: string]: unknown;
}
@@ -0,0 +1,104 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Icon, type IconName } from "./Icons";
import "../styles-architecture-flow.css";
type ArchitectureStepId = "Frontend" | "Api" | "Database" | "Outbox" | "External";
type ArchitectureZone = "intent" | "transaction" | "edge";
const ARCHITECTURE_STEPS: Array<{
id: ArchitectureStepId;
icon: IconName;
zone: ArchitectureZone;
}> = [
{ id: "Frontend", icon: "user", zone: "intent" },
{ id: "Api", icon: "shield", zone: "transaction" },
{ id: "Database", icon: "audit", zone: "transaction" },
{ id: "Outbox", icon: "activity", zone: "transaction" },
{ id: "External", icon: "integrations", zone: "edge" },
];
const ZONE_TRANSLATION_KEYS: Record<ArchitectureZone, string> = {
intent: "architectureZoneIntent",
transaction: "architectureZoneTransaction",
edge: "architectureZoneEdge",
};
export function ArchitectureFlow() {
const { t } = useTranslation("demo");
const [activeStepId, setActiveStepId] = useState<ArchitectureStepId>("Database");
const activeIndex = ARCHITECTURE_STEPS.findIndex((step) => step.id === activeStepId);
const activeStep = ARCHITECTURE_STEPS[activeIndex];
return (
<div className="architecture-explorer">
<div className="architecture-zones" aria-label={t("about.architectureZonesLabel")}>
<span className="zone-intent"><Icon name="user" /> {t("about.architectureZoneIntent")}</span>
<span className="zone-transaction"><Icon name="shield" /> {t("about.architectureZoneTransaction")}</span>
<span className="zone-edge"><Icon name="integrations" /> {t("about.architectureZoneEdge")}</span>
</div>
<ol className="architecture-flow" aria-label={t("about.architectureFlowLabel")}>
{ARCHITECTURE_STEPS.map((step, index) => {
const isActive = step.id === activeStepId;
const isCommitBoundary = step.id === "Outbox";
return (
<li
className={`architecture-step architecture-step-${step.zone}${isActive ? " is-active" : ""}`}
key={step.id}
>
<button
type="button"
aria-pressed={isActive}
aria-controls="architecture-active-detail"
onClick={() => setActiveStepId(step.id)}
>
<span className="architecture-step-top">
<span className="architecture-step-number">0{index + 1}</span>
<span className="architecture-step-icon"><Icon name={step.icon} /></span>
</span>
<strong>{t(`about.architecture${step.id}`)}</strong>
<small>{t(`about.architectureDetails.${step.id}.summary`)}</small>
<span className="architecture-step-meta">
<span className="architecture-zone-tag">{t(`about.${ZONE_TRANSLATION_KEYS[step.zone]}`)}</span>
{isCommitBoundary ? <span className="architecture-commit-note"> {t("about.architectureCommitBoundary")}</span> : null}
</span>
</button>
{index < ARCHITECTURE_STEPS.length - 1 ? (
<span className={`architecture-connector${isCommitBoundary ? " is-commit-boundary" : ""}`} aria-hidden="true">
<span className="architecture-signal" />
</span>
) : null}
</li>
);
})}
</ol>
<p className="architecture-interaction-hint">{t("about.architectureInteractionHint")}</p>
<section
className={`architecture-active-detail architecture-active-detail-${activeStep.zone}`}
id="architecture-active-detail"
aria-live="polite"
>
<div className="architecture-active-copy">
<span>{t("about.architectureSelectedStep", { current: activeIndex + 1, total: ARCHITECTURE_STEPS.length })}</span>
<h3>{t(`about.architecture${activeStep.id}`)}</h3>
<p>{t(`about.architectureDetails.${activeStep.id}.body`)}</p>
</div>
<dl className="architecture-active-evidence">
<div>
<dt>{t("about.architectureBoundaryLabel")}</dt>
<dd>{t(`about.${ZONE_TRANSLATION_KEYS[activeStep.zone]}`)}</dd>
</div>
<div>
<dt>{t("about.architectureEvidenceLabel")}</dt>
<dd>
<span>{t(`about.architectureDetails.${activeStep.id}.proofOne`)}</span>
<span>{t(`about.architectureDetails.${activeStep.id}.proofTwo`)}</span>
</dd>
</div>
</dl>
</section>
</div>
);
}
+63 -18
View File
@@ -1,5 +1,5 @@
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
@@ -12,6 +12,8 @@ import { Icon } from "./Icons";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product";
const GUIDE_TRIGGER_ID = "demo-guide-trigger";
export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
const { user } = useAuth();
@@ -21,6 +23,7 @@ export function DemoGuideTrigger() {
return (
<button
id={GUIDE_TRIGGER_ID}
type="button"
className="demo-guide-trigger"
aria-expanded={open}
@@ -35,25 +38,28 @@ export function DemoGuideTrigger() {
);
}
function highlightTarget(selector: string | undefined) {
if (!selector) return;
function highlightTarget(selector: string): (() => void) | null {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return;
if (!el) return null;
el.scrollIntoView({ behavior: "smooth", block: "center" });
const previousTabIndex = el.getAttribute("tabindex");
if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "-1");
el.focus({ preventScroll: true });
el.classList.add("demo-guide-highlight");
window.setTimeout(() => {
const timeout = window.setTimeout(() => {
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
}, 2200);
return () => {
window.clearTimeout(timeout);
el.classList.remove("demo-guide-highlight");
if (previousTabIndex === null) el.removeAttribute("tabindex");
};
}
export function DemoGuide() {
const { t } = useTranslation("demo");
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth();
const { manifest, refresh } = useDemoManifest();
const tier = useViewportTier();
@@ -73,10 +79,19 @@ export function DemoGuide() {
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
const [targetRequest, setTargetRequest] = useState(0);
const step = DEMO_GUIDE_STEPS[currentIndex];
const isLastStep = currentIndex === totalSteps - 1;
const closeAndRestoreFocus = useCallback(() => {
pendingTarget.current = null;
closeGuide();
window.requestAnimationFrame(() => {
document.getElementById(GUIDE_TRIGGER_ID)?.focus();
});
}, [closeGuide]);
useEffect(() => {
if (!open) return;
function handleKeydown(event: KeyboardEvent) {
@@ -86,30 +101,60 @@ export function DemoGuide() {
} else if (tier === "mobile" && mobileSheetState !== "collapsed") {
setMobileSheetState("collapsed");
} else {
closeGuide();
closeAndRestoreFocus();
}
}
document.addEventListener("keydown", handleKeydown);
return () => document.removeEventListener("keydown", handleKeydown);
}, [open, tier, collapsedToChip, mobileSheetState, closeGuide, setCollapsedToChip]);
}, [open, tier, collapsedToChip, mobileSheetState, closeAndRestoreFocus, setCollapsedToChip]);
useEffect(() => {
if (!pendingTarget.current) return;
if (!open) pendingTarget.current = null;
}, [open]);
useEffect(() => {
if (!open || !pendingTarget.current) return;
const target = pendingTarget.current;
let attempts = 0;
let timer = 0;
let cancelled = false;
let removeHighlight: (() => void) | null = null;
const findAndHighlight = () => {
if (cancelled) return;
removeHighlight = highlightTarget(target);
if (removeHighlight) {
pendingTarget.current = null;
const raf = requestAnimationFrame(() => highlightTarget(target));
return () => cancelAnimationFrame(raf);
}, [location.pathname]);
return;
}
attempts += 1;
if (attempts < 40) timer = window.setTimeout(findAndHighlight, 50);
else pendingTarget.current = null;
};
timer = window.setTimeout(findAndHighlight, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
removeHighlight?.();
};
}, [open, targetRequest]);
if (!open) return null;
function goToStepRoute() {
pendingTarget.current = step.target ?? null;
navigate(step.route(manifest));
// A navigation to the already-active route does not necessarily change pathname.
// This request counter guarantees the bounded target lookup still runs.
setTargetRequest((request) => request + 1);
if (tier === "standard") setCollapsedToChip(true);
if (tier === "mobile") setMobileSheetState("collapsed");
}
function handlePrimaryAction() {
completeAndAdvance();
if (isLastStep) closeAndRestoreFocus();
}
async function handleRestartDemo() {
setResetError(null);
setResetting(true);
@@ -117,7 +162,7 @@ export function DemoGuide() {
await api.post("/api/v1/demo/reset");
restart();
refresh();
closeGuide();
closeAndRestoreFocus();
await logout();
navigate("/login");
} catch (err) {
@@ -140,7 +185,7 @@ export function DemoGuide() {
{t("guide.progressChip", { current: currentIndex + 1, total: totalSteps })}
<Icon name="chevron" />
</button>
<button type="button" className="demo-guide-chip-close" onClick={closeGuide} aria-label={t("guide.close")}>
<button type="button" className="demo-guide-chip-close" onClick={closeAndRestoreFocus} aria-label={t("guide.close")}>
<Icon name="x" />
</button>
</div>
@@ -183,7 +228,7 @@ export function DemoGuide() {
<button
type="button"
className="icon-button"
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeGuide}
onClick={tier === "standard" ? () => setCollapsedToChip(true) : closeAndRestoreFocus}
aria-label={tier === "standard" ? t("guide.collapse") : t("guide.close")}
>
<Icon name="x" />
@@ -235,8 +280,8 @@ export function DemoGuide() {
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
{t("guide.goToStep")}
</button>
<button type="button" className="button button-primary" onClick={completeAndAdvance} disabled={isLastStep}>
{t("guide.next")}
<button type="button" className="button button-primary" onClick={handlePrimaryAction}>
{t(isLastStep ? "guide.finish" : "guide.next")}
</button>
{tier !== "mobile" && (
<button type="button" className="demo-guide-restart" onClick={handleRestartDemo} disabled={resetting}>
+10 -5
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -80,7 +80,7 @@ export function Layout() {
const { t } = useTranslation(["navigation", "common", "auth"]);
const { user, logout, demoMode } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen, collapsedToChip: guideCollapsed } = useDemoGuide();
const { open: guideOpen, collapsedToChip: guideCollapsed, restart: restartGuide } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -105,7 +105,7 @@ export function Layout() {
);
const mobileItems = useMemo(() => navGroups.flatMap((group) => group.items).slice(0, 5), [navGroups]);
useEffect(() => {
useLayoutEffect(() => {
function focusGlobalSearch(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
@@ -113,8 +113,10 @@ export function Layout() {
}
}
window.addEventListener("keydown", focusGlobalSearch);
return () => window.removeEventListener("keydown", focusGlobalSearch);
// Capture the shortcut before a nested control can consume it, and register before
// paint so Ctrl/Cmd+K is available as soon as the visible shell is interactive.
document.addEventListener("keydown", focusGlobalSearch, true);
return () => document.removeEventListener("keydown", focusGlobalSearch, true);
}, []);
useEffect(() => {
@@ -175,6 +177,9 @@ export function Layout() {
setResetting(true);
try {
await api.post("/api/v1/demo/reset");
// Reset guide progress only after the server has confirmed that the data reset
// committed. A failed reset must leave the visitor's current walkthrough intact.
restartGuide();
// The server invalidates the acting session as part of reset; drop local state the
// same way an explicit logout would and return to the login screen.
await logout();
+14 -2
View File
@@ -57,12 +57,24 @@ export function LoadingState({ label }: { label?: string }) {
);
}
export function ErrorState({ message }: { message: string }) {
export function ErrorState({
message,
onRetry,
retryLabel,
}: {
message: string;
onRetry?: () => void;
retryLabel?: string;
}) {
const { t } = useTranslation("common");
return (
<div className="state-panel state-error" role="alert">
<Icon name="alert" />
<div><strong>{t("states.errorTitle")}</strong><p>{message}</p></div>
<div>
<strong>{t("states.errorTitle")}</strong>
<p>{message}</p>
{onRetry ? <button type="button" className="button button-secondary state-retry" onClick={onRetry}>{retryLabel ?? t("actions.retry")}</button> : null}
</div>
</div>
);
}
+4 -4
View File
@@ -25,7 +25,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
const navigate = useNavigate();
const { manifest } = useDemoManifest();
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
const canSeeQualityIssue = user?.role === "operations_manager";
const canSeeManagerEvidence = user?.role === "operations_manager";
function continueDemo() {
completeAndAdvance();
@@ -43,7 +43,7 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
<dt>{t("result.qualityIssue")}</dt>
<dd>
{result.quality_issue_ref ? (
canSeeQualityIssue ? (
canSeeManagerEvidence ? (
<Link to={`/data-quality/${result.quality_issue_ref}`}>{result.quality_issue_ref}</Link>
) : (
result.quality_issue_ref
@@ -74,8 +74,8 @@ export function ReturnResultPanel({ result }: { result: RegisterReturnResult })
)}
<div className="result-links">
<Link className="button button-secondary" to={`/vehicles/${result.vehicle_ref}`}>{t("result.viewVehicle", { ref: result.vehicle_ref })}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>
<Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>
{canSeeManagerEvidence && <Link className="button button-secondary" to="/automation">{t("result.viewAutomation")}<Icon name="chevron" /></Link>}
{canSeeManagerEvidence && <Link className="button button-secondary" to={`/audit?correlation_id=${result.correlation_id}`}>{t("result.viewTrace")}<Icon name="chevron" /></Link>}
{guideOpen && (
<button type="button" className="button button-primary" onClick={continueDemo}>
{t("result.continueDemo")} <Icon name="chevron" />
@@ -4,11 +4,12 @@ import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { MaintenanceRecord, VehicleDetail } from "../api/types";
import { ApiErrorNotice } from "./PageChrome";
import { brusselsLocalToIso, toBrusselsDate } from "../i18n/brusselsDateTime";
export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: VehicleDetail; onSaved: () => void }) {
const { t } = useTranslation(["fleet", "errors"]);
const [showRecord, setShowRecord] = useState(false);
const [occurredAt, setOccurredAt] = useState(() => new Date().toISOString().slice(0, 10));
const [occurredAt, setOccurredAt] = useState(() => toBrusselsDate(new Date()));
const [odometer, setOdometer] = useState(vehicle.odometer_km);
const [category, setCategory] = useState("periodic_service");
const [summary, setSummary] = useState("");
@@ -21,7 +22,7 @@ export function VehicleMaintenanceActions({ vehicle, onSaved }: { vehicle: Vehic
event.preventDefault(); setSaving(true); setError(null);
try {
await api.post<MaintenanceRecord>(`/api/v1/vehicles/${vehicle.public_ref}/maintenance`, {
occurred_at: new Date(`${occurredAt}T12:00:00`).toISOString(), odometer_km: odometer,
occurred_at: brusselsLocalToIso(`${occurredAt}T12:00`), odometer_km: odometer,
category, summary, next_service_km: nextService, mark_maintenance: true,
});
setShowRecord(false); setSummary(""); onSaved();
+25 -12
View File
@@ -29,11 +29,17 @@ function readCachedUser(): CurrentUser | null {
}
function cacheUser(user: CurrentUser | null) {
try {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch {
// Session storage is only a paint optimisation. Browser privacy settings or a full
// storage quota must never turn a successful server-side login/logout into a client
// failure; the HttpOnly session cookie remains authoritative.
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
@@ -47,32 +53,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api.get<SystemStatus>("/api/v1/system/status")
const controller = new AbortController();
// System capabilities and session identity are independent reads. In particular, a
// slow public status endpoint must not hold an already valid session (or the login
// controls) behind its timeout.
void api.get<SystemStatus>("/api/v1/system/status", { signal: controller.signal })
.then((system) => {
if (controller.signal.aborted) return;
setDemoMode(system.demo_mode);
setOidcEnabled(system.oidc_enabled);
setOidcProviderName(system.oidc_provider_name);
})
.catch(() => setDemoMode(true))
.finally(() => api
.get<CurrentUser>("/api/v1/auth/session")
.catch(() => {
if (!controller.signal.aborted) setDemoMode(true);
});
void api
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
.then((confirmed) => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
}));
return () => {
cancelled = true;
};
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
useEffect(
+15 -2
View File
@@ -12,15 +12,28 @@ function readProgress(): StoredProgress {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { currentIndex: 0, completed: [] };
const parsed = JSON.parse(raw) as StoredProgress;
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
const parsed = JSON.parse(raw) as Partial<StoredProgress>;
const validStepIds = new Set(DEMO_GUIDE_STEPS.map((step) => step.id));
const currentIndex = Number.isInteger(parsed.currentIndex)
? Math.max(0, Math.min(parsed.currentIndex as number, DEMO_GUIDE_STEPS.length - 1))
: 0;
const completed = Array.isArray(parsed.completed)
? parsed.completed.filter(
(stepId): stepId is string => typeof stepId === "string" && validStepIds.has(stepId),
)
: [];
return { currentIndex, completed: Array.from(new Set(completed)) };
} catch {
return { currentIndex: 0, completed: [] };
}
}
function writeProgress(progress: StoredProgress) {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Progress is a convenience. Blocked or full storage must never break the guide.
}
}
interface DemoGuideState {
+12 -9
View File
@@ -5,6 +5,7 @@ import type { DemoManifest } from "../api/types";
interface DemoManifestState {
manifest: DemoManifest | null;
loading: boolean;
error: boolean;
refresh: () => void;
}
@@ -13,32 +14,34 @@ const DemoManifestContext = createContext<DemoManifestState | undefined>(undefin
export function DemoManifestProvider({ children }: { children: ReactNode }) {
const [manifest, setManifest] = useState<DemoManifest | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setLoading(true);
setError(false);
// Public endpoint by design: the demo-entry screen needs this before any session
// exists, so it is never gated behind auth.
api
.get<DemoManifest>("/api/v1/demo/manifest")
.get<DemoManifest>("/api/v1/demo/manifest", { signal: controller.signal })
.then((result) => {
if (!cancelled) setManifest(result);
if (!controller.signal.aborted) setManifest(result);
})
.catch(() => {
if (!cancelled) setManifest(null);
if (controller.signal.aborted) return;
setManifest(null);
setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!controller.signal.aborted) setLoading(false);
});
return () => {
cancelled = true;
};
return () => controller.abort();
}, [version]);
return (
<DemoManifestContext.Provider
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
value={{ manifest, loading, error, refresh: () => setVersion((v) => v + 1) }}
>
{children}
</DemoManifestContext.Provider>
+22 -2
View File
@@ -21,7 +21,13 @@ function partsAt(value: Date): Record<string, string> {
export function toBrusselsDateTimeLocal(value: Date): string {
const parts = partsAt(value);
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
return `${toBrusselsDate(value)}T${parts.hour}:${parts.minute}`;
}
/** Render an instant as the Fleet Ops Europe/Brussels calendar date (YYYY-MM-DD). */
export function toBrusselsDate(value: Date): string {
const parts = partsAt(value);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function brusselsDateTimeFromNow(hours: number): string {
@@ -42,7 +48,21 @@ export function brusselsLocalToIso(value: string): string {
const represented = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute);
candidate += wallClockUtc - represented;
}
return new Date(candidate).toISOString();
const result = new Date(candidate);
// Date.UTC normalises impossible calendar values and Brussels' spring transition
// contains a wall-clock hour that does not exist. Never silently shift either one.
if (toBrusselsDateTimeLocal(result) !== value) {
throw new Error("Invalid or non-existent Brussels date-time");
}
return result.toISOString();
}
export function tryBrusselsLocalToIso(value: string): string | null {
try {
return brusselsLocalToIso(value);
} catch {
return null;
}
}
/** Start of the given Brussels calendar day (YYYY-MM-DD) as a UTC ISO instant. */
@@ -9,6 +9,10 @@
"accessHeading": "Choose how to start",
"accessIntro": "No password needed. Each role opens a scoped synthetic environment — all workflows and controls are really implemented.",
"startGuidedDemo": "Start guided demo",
"guidedDemoChecking": "Preparing the guided demo…",
"guidedDemoManifestUnavailable": "The guided-demo information could not be loaded.",
"guidedDemoNotReady": "The guided demo is not ready to start right now.",
"guidedDemoRetry": "Reload demo information",
"startRecruiterTour": "See the highlights in 90 seconds",
"exploreAsOperationsManager": "Explore as Operations Manager",
"exploreAsOperationsManagerDetail": "Full overview, quality resolution and retries",
@@ -59,7 +59,8 @@
"loadingVehicles": "Checking availability…",
"chooseVehicle": "Select a vehicle",
"noVehicles": "No vehicle available for this window",
"requirementsComplete": "Driving licence and rental requirements have been checked",
"invalidLocalTime": "Choose a valid Europe/Brussels time. The skipped hour during the spring clock change does not exist.",
"endAfterStart": "The booking end must be after its start.",
"cancel": "Cancel",
"save": "Create booking",
"saving": "Saving booking…",
@@ -118,6 +119,7 @@
"confirmReschedule": "Save new window",
"rescheduling": "Saving window…",
"rescheduleFailed": "The reservation could not be rescheduled.",
"invalidScheduleWindow": "Choose valid Europe/Brussels times and make sure the end is after the start.",
"yes": "Yes",
"no": "No",
"cancelAction": "Cancel booking",
+45
View File
@@ -20,6 +20,7 @@
"expectedOutcome": "Expected outcome",
"goToStep": "Go to this step",
"next": "Next",
"finish": "Finish demo",
"close": "Close",
"restart": "Prepare demo again",
"restarting": "Restarting…",
@@ -107,6 +108,7 @@
"title": "Try a demonstration scenario",
"description": "Five focused scenarios that always use the same fixed bookings, customers and vehicles — always re-findable after a reset.",
"loading": "Loading scenarios…",
"unavailable": "The demo scenarios could not be loaded. Please try again.",
"ready": "Ready for demo",
"notReady": "Not available",
"duration": "Duration",
@@ -164,6 +166,7 @@
"title": "What {{productName}} is and isn't",
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
"loading": "Loading demo information…",
"unavailable": "The demo information could not be loaded. Please try again.",
"ctaTitle": "Choose how much time you have",
"ctaBody": "See the three strongest engineering moments in 90 seconds, or take the complete operational tour.",
"ctaButton": "Start full demo",
@@ -185,6 +188,48 @@
"architectureDatabase": "PostgreSQL + audit trail",
"architectureOutbox": "Reliable outbox",
"architectureExternal": "External services: n8n, RAGcore and MCP Hub",
"architectureZonesLabel": "Architecture responsibility boundaries",
"architectureZoneIntent": "User intent",
"architectureZoneTransaction": "Local transaction",
"architectureZoneEdge": "Recoverable system edge",
"architectureFlowLabel": "Interactive system flow from interface to external services",
"architectureCommitBoundary": "After commit",
"architectureInteractionHint": "Select a step to inspect its operational guarantee and control points.",
"architectureSelectedStep": "Step {{current}} of {{total}}",
"architectureBoundaryLabel": "Responsibility",
"architectureEvidenceLabel": "Control points",
"architectureDetails": {
"Frontend": {
"summary": "Makes role and intent explicit",
"body": "Accessible forms collect validated input and show the impact before confirmation. The interface never decides a domain status on its own.",
"proofOne": "Role-based route guard",
"proofTwo": "Preview before confirmation"
},
"Api": {
"summary": "Validates every operational rule",
"body": "FastAPI enforces statuses, invariants and resolution rules at the API boundary, independently of what the browser submits.",
"proofOne": "Pydantic validation",
"proofTwo": "Server-side domain decision"
},
"Database": {
"summary": "Commits data and audit atomically",
"body": "PostgreSQL stores the operational change and its audit evidence in the same transaction, preventing a partial state change.",
"proofOne": "UUID + public reference",
"proofTwo": "UTC + unbroken audit trail"
},
"Outbox": {
"summary": "Records follow-up work durably",
"body": "The outbox record commits with the operation. A worker only delivers afterwards, idempotently and with bounded retries, to the orchestration layer.",
"proofOne": "Post-commit delivery",
"proofTwo": "Idempotency + bounded retries"
},
"External": {
"summary": "Degrades without local data loss",
"body": "n8n, RAGcore and MCP Hub have timeouts and visible health state. Failure remains recoverable and AI never answers without sufficient source evidence.",
"proofOne": "Health state + timeouts",
"proofTwo": "No answer without evidence"
}
},
"verificationTitle": "Built to be verified",
"verificationBody": "Domain rules, API contracts, degraded modes and the complete demo are tested automatically. The repository contains the exact acceptance commands and evidence bundle.",
"problemTitle": "The fictional problem",
@@ -38,6 +38,11 @@
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"ISSUE_CHANGED": {
"title": "Issue evidence has changed",
"explanation": "New evidence was recorded while this correction was being prepared.",
"nextStep": "Refresh the issue and review the current evidence before deciding again."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
@@ -88,6 +93,18 @@
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_EVENT_CORRELATION": {
"title": "Invalid correlation reference",
"explanation": "The automation callback does not contain a valid correlation reference."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Callback event does not match",
"explanation": "The automation callback refers to a different event than the event being updated."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "Callback trace does not match",
"explanation": "The automation callback does not match the correlation reference stored for this event."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"users": { "eyebrow": "Administration / Access", "title": "Users", "description": "Manage operational access and roles. Every change is audited.", "managerOnly": "User administration is available to Operations Managers only.", "addTitle": "Add user", "name": "Name", "email": "Email", "role": "Role", "password": "Temporary password", "status": "Status", "action": "Action", "add": "Add user", "saving": "Saving…", "loading": "Loading users…", "active": "active", "inactive": "inactive", "activate": "Activate", "deactivate": "Deactivate", "edit": "Edit", "editTitle": "Edit user {{ref}}", "newPassword": "New password", "passwordUnchanged": "Leave empty to keep unchanged", "saveChanges": "Save changes", "cancel": "Cancel", "createFailed": "The user could not be created.", "updateFailed": "The user could not be updated." },
"roles": { "operations_manager": "Operations Manager", "rental_employee": "Rental employee" }
}
@@ -30,6 +30,7 @@
"selected": "{{count}} selected",
"assignTo": "Assign to",
"dueAt": "Due date",
"invalidDueAt": "Choose a valid Europe/Brussels date and time.",
"bulkApply": "Update work queue",
"bulkSaving": "Updating…",
"bulkFailed": "The work queue could not be updated.",
@@ -9,6 +9,10 @@
"accessHeading": "Choisissez comment démarrer",
"accessIntro": "Aucun mot de passe requis. Chaque rôle ouvre un environnement synthétique délimité — tous les workflows et contrôles sont réellement implémentés.",
"startGuidedDemo": "Démarrer la démo guidée",
"guidedDemoChecking": "Préparation de la démo guidée…",
"guidedDemoManifestUnavailable": "Les informations de la démo guidée nont pas pu être chargées.",
"guidedDemoNotReady": "La démo guidée nest pas prête à démarrer pour le moment.",
"guidedDemoRetry": "Recharger les informations de démo",
"startRecruiterTour": "Voir les points forts en 90 secondes",
"exploreAsOperationsManager": "Explorer en tant que Responsable des opérations",
"exploreAsOperationsManagerDetail": "Vue d'ensemble complète, résolution qualité et nouvelles tentatives",
@@ -59,7 +59,8 @@
"loadingVehicles": "Vérification des disponibilités…",
"chooseVehicle": "Sélectionnez un véhicule",
"noVehicles": "Aucun véhicule disponible pour cette période",
"requirementsComplete": "Le permis de conduire et les exigences de location ont été vérifiés",
"invalidLocalTime": "Choisissez une heure Europe/Brussels valide. Lheure sautée lors du passage à lheure d’été nexiste pas.",
"endAfterStart": "La fin de la réservation doit être postérieure à son début.",
"cancel": "Annuler",
"save": "Créer la réservation",
"saving": "Enregistrement…",
@@ -118,6 +119,7 @@
"confirmReschedule": "Enregistrer la nouvelle période",
"rescheduling": "Enregistrement…",
"rescheduleFailed": "La réservation na pas pu être replanifiée.",
"invalidScheduleWindow": "Choisissez des heures Europe/Brussels valides et assurez-vous que la fin suit le début.",
"yes": "Oui",
"no": "Non",
"cancelAction": "Annuler la réservation",
+45
View File
@@ -20,6 +20,7 @@
"expectedOutcome": "Résultat attendu",
"goToStep": "Aller à cette étape",
"next": "Suivant",
"finish": "Terminer la démo",
"close": "Fermer",
"restart": "Préparer à nouveau la démo",
"restarting": "Réinitialisation…",
@@ -107,6 +108,7 @@
"title": "Essayer un scénario de démonstration",
"description": "Cinq scénarios ciblés qui utilisent toujours les mêmes réservations, clients et véhicules fixes — toujours retrouvables après une réinitialisation.",
"loading": "Chargement des scénarios…",
"unavailable": "Les scénarios de démonstration n'ont pas pu être chargés. Veuillez réessayer.",
"ready": "Prêt pour la démo",
"notReady": "Non disponible",
"duration": "Durée",
@@ -164,6 +166,7 @@
"title": "Ce que {{productName}} est et n'est pas",
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
"loading": "Chargement des informations de démo…",
"unavailable": "Les informations de démonstration n'ont pas pu être chargées. Veuillez réessayer.",
"ctaTitle": "Choisissez le temps dont vous disposez",
"ctaBody": "Découvrez les trois moments d'ingénierie les plus forts en 90 secondes, ou suivez la démo opérationnelle complète.",
"ctaButton": "Démarrer la démo complète",
@@ -185,6 +188,48 @@
"architectureDatabase": "PostgreSQL + piste d'audit",
"architectureOutbox": "Outbox fiable",
"architectureExternal": "Services externes : n8n, RAGcore et MCP Hub",
"architectureZonesLabel": "Limites de responsabilité de l'architecture",
"architectureZoneIntent": "Intention utilisateur",
"architectureZoneTransaction": "Transaction locale",
"architectureZoneEdge": "Périphérie système récupérable",
"architectureFlowLabel": "Flux système interactif de l'interface aux services externes",
"architectureCommitBoundary": "Après validation",
"architectureInteractionHint": "Sélectionnez une étape pour examiner sa garantie opérationnelle et ses points de contrôle.",
"architectureSelectedStep": "Étape {{current}} sur {{total}}",
"architectureBoundaryLabel": "Responsabilité",
"architectureEvidenceLabel": "Points de contrôle",
"architectureDetails": {
"Frontend": {
"summary": "Rend le rôle et l'intention explicites",
"body": "Des formulaires accessibles recueillent les données validées et montrent l'impact avant confirmation. L'interface ne décide jamais seule d'un statut métier.",
"proofOne": "Garde de route par rôle",
"proofTwo": "Aperçu avant confirmation"
},
"Api": {
"summary": "Valide chaque règle opérationnelle",
"body": "FastAPI applique les statuts, invariants et règles de résolution à la frontière de l'API, indépendamment de ce que le navigateur envoie.",
"proofOne": "Validation Pydantic",
"proofTwo": "Décision métier côté serveur"
},
"Database": {
"summary": "Valide les données et l'audit atomiquement",
"body": "PostgreSQL enregistre la modification opérationnelle et sa preuve d'audit dans la même transaction, empêchant tout changement d'état partiel.",
"proofOne": "UUID + référence publique",
"proofTwo": "UTC + piste d'audit continue"
},
"Outbox": {
"summary": "Enregistre durablement le suivi",
"body": "L'enregistrement outbox est validé avec l'opération. Un worker ne livre qu'ensuite, de façon idempotente et avec des tentatives limitées, vers l'orchestration.",
"proofOne": "Livraison après validation",
"proofTwo": "Idempotence + tentatives limitées"
},
"External": {
"summary": "Se dégrade sans perte locale",
"body": "n8n, RAGcore et MCP Hub disposent de délais d'attente et d'un état de santé visible. Une panne reste récupérable et l'IA ne répond jamais sans preuves suffisantes.",
"proofOne": "État de santé + délais",
"proofTwo": "Aucune réponse sans preuve"
}
},
"verificationTitle": "Construit pour être vérifiable",
"verificationBody": "Les règles métier, contrats d'API, modes dégradés et la démo complète sont testés automatiquement. Le dépôt contient les commandes d'acceptation exactes et le dossier de preuves.",
"problemTitle": "Le problème fictif",
@@ -38,6 +38,11 @@
"explanation": "Ce problème a déjà été résolu, reporté ou rejeté.",
"nextStep": "Actualisez la page pour voir son état actuel."
},
"ISSUE_CHANGED": {
"title": "Les éléments de preuve ont changé",
"explanation": "De nouveaux éléments ont été enregistrés pendant la préparation de cette correction.",
"nextStep": "Actualisez le problème et réexaminez les éléments actuels avant de décider."
},
"BOOKING_NOT_ACTIVE": {
"title": "La réservation n'est pas active",
"explanation": "Seule une réservation réservée ou active peut être utilisée pour cette action."
@@ -88,6 +93,18 @@
"title": "Référence d'événement non valide",
"explanation": "Cette référence d'événement d'automatisation n'est pas valide."
},
"INVALID_EVENT_CORRELATION": {
"title": "Référence de corrélation non valide",
"explanation": "Le rappel d'automatisation ne contient pas de référence de corrélation valide."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Le rappel concerne un autre événement",
"explanation": "Le rappel d'automatisation fait référence à un événement différent de celui qui est mis à jour."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "La trace du rappel ne correspond pas",
"explanation": "Le rappel d'automatisation ne correspond pas à la référence de corrélation enregistrée pour cet événement."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "La demande n'a pas pu être répétée en toute sécurité",
"explanation": "La clé de suivi de cette demande n'est pas valide.",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier lutilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"users": { "eyebrow": "Administration / Accès", "title": "Utilisateurs", "description": "Gérez les accès opérationnels et les rôles. Chaque modification est auditée.", "managerOnly": "Ladministration des utilisateurs est réservée aux Responsables des opérations.", "addTitle": "Ajouter un utilisateur", "name": "Nom", "email": "E-mail", "role": "Rôle", "password": "Mot de passe temporaire", "status": "Statut", "action": "Action", "add": "Ajouter", "saving": "Enregistrement…", "loading": "Chargement des utilisateurs…", "active": "actif", "inactive": "inactif", "activate": "Activer", "deactivate": "Désactiver", "edit": "Modifier", "editTitle": "Modifier lutilisateur {{ref}}", "newPassword": "Nouveau mot de passe", "passwordUnchanged": "Laisser vide pour ne pas modifier", "saveChanges": "Enregistrer les modifications", "cancel": "Annuler", "createFailed": "L'utilisateur n'a pas pu être créé.", "updateFailed": "L'utilisateur n'a pas pu être mis à jour." },
"roles": { "operations_manager": "Responsable des opérations", "rental_employee": "Employé de location" }
}
@@ -30,6 +30,7 @@
"selected": "{{count}} sélectionné(s)",
"assignTo": "Attribuer à",
"dueAt": "Échéance",
"invalidDueAt": "Choisissez une date et une heure Europe/Brussels valides.",
"bulkApply": "Mettre à jour la file",
"bulkSaving": "Mise à jour…",
"bulkFailed": "La file de travail na pas pu être mise à jour.",
@@ -9,6 +9,10 @@
"accessHeading": "Kies hoe je wil starten",
"accessIntro": "Geen wachtwoord nodig. Elke rol opent een afgebakende, synthetische omgeving — alle workflows en controles zijn echt geïmplementeerd.",
"startGuidedDemo": "Start begeleide demo",
"guidedDemoChecking": "Begeleide demo voorbereiden…",
"guidedDemoManifestUnavailable": "De begeleide demo-informatie kon niet geladen worden.",
"guidedDemoNotReady": "De begeleide demo is momenteel niet klaar om te starten.",
"guidedDemoRetry": "Demo-informatie opnieuw laden",
"startRecruiterTour": "Bekijk de highlights in 90 seconden",
"exploreAsOperationsManager": "Verken als Operationsmanager",
"exploreAsOperationsManagerDetail": "Volledig overzicht, kwaliteitsoplossing en herpogingen",
@@ -59,7 +59,8 @@
"loadingVehicles": "Beschikbaarheid controleren…",
"chooseVehicle": "Selecteer een voertuig",
"noVehicles": "Geen beschikbaar voertuig in deze periode",
"requirementsComplete": "Rijbewijs- en huurvereisten zijn gecontroleerd",
"invalidLocalTime": "Kies een geldig tijdstip in Europe/Brussels. Het overgeslagen uur bij de omschakeling naar zomertijd bestaat niet.",
"endAfterStart": "Het einde moet na de start van de boeking vallen.",
"cancel": "Annuleren",
"save": "Boeking aanmaken",
"saving": "Boeking opslaan…",
@@ -118,6 +119,7 @@
"confirmReschedule": "Nieuwe periode opslaan",
"rescheduling": "Periode opslaan…",
"rescheduleFailed": "De reservatie kon niet worden verplaatst.",
"invalidScheduleWindow": "Kies geldige tijdstippen in Europe/Brussels en zorg dat het einde na de start valt.",
"yes": "Ja",
"no": "Nee",
"cancelAction": "Boeking annuleren",
+45
View File
@@ -20,6 +20,7 @@
"expectedOutcome": "Verwacht resultaat",
"goToStep": "Ga naar deze stap",
"next": "Volgende",
"finish": "Demo afronden",
"close": "Sluiten",
"restart": "Demo opnieuw voorbereiden",
"restarting": "Bezig met herstellen…",
@@ -107,6 +108,7 @@
"title": "Probeer een demonstratiescenario",
"description": "Vijf afgebakende scenario's die telkens dezelfde vaste boekingen, klanten en voertuigen gebruiken — na een reset zijn ze altijd opnieuw te vinden.",
"loading": "Scenario's laden…",
"unavailable": "De demonstratiescenario's konden niet geladen worden. Probeer het opnieuw.",
"ready": "Klaar voor demo",
"notReady": "Niet beschikbaar",
"duration": "Duur",
@@ -164,6 +166,7 @@
"title": "Wat {{productName}} wel en niet is",
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
"loading": "Demo-informatie laden…",
"unavailable": "De demo-informatie kon niet geladen worden. Probeer het opnieuw.",
"ctaTitle": "Kies hoeveel tijd je hebt",
"ctaBody": "Bekijk de drie sterkste engineeringmomenten in 90 seconden, of doorloop de volledige operationele demo.",
"ctaButton": "Start volledige demo",
@@ -185,6 +188,48 @@
"architectureDatabase": "PostgreSQL + audittrail",
"architectureOutbox": "Betrouwbare outbox",
"architectureExternal": "Externe diensten: n8n, RAGcore en MCP Hub",
"architectureZonesLabel": "Verantwoordelijkheidsgrenzen in de architectuur",
"architectureZoneIntent": "Gebruikersintentie",
"architectureZoneTransaction": "Lokale transactie",
"architectureZoneEdge": "Herstelbare systeemrand",
"architectureFlowLabel": "Interactieve systeemflow van interface naar externe diensten",
"architectureCommitBoundary": "Na commit",
"architectureInteractionHint": "Selecteer een stap om de operationele garantie en controlepunten te bekijken.",
"architectureSelectedStep": "Stap {{current}} van {{total}}",
"architectureBoundaryLabel": "Verantwoordelijkheid",
"architectureEvidenceLabel": "Controlepunten",
"architectureDetails": {
"Frontend": {
"summary": "Maakt rol en intentie expliciet",
"body": "Toegankelijke formulieren verzamelen gevalideerde invoer en tonen de impact vóór bevestiging. De interface beslist nooit zelfstandig over een domeinstatus.",
"proofOne": "Routeguard per rol",
"proofTwo": "Preview vóór bevestiging"
},
"Api": {
"summary": "Valideert elke operationele regel",
"body": "FastAPI bewaakt statussen, invarianten en oplossingsregels aan de API-grens, onafhankelijk van wat de browser aanlevert.",
"proofOne": "Pydantic-validatie",
"proofTwo": "Domeinbeslissing op de server"
},
"Database": {
"summary": "Commit data en audit atomair",
"body": "PostgreSQL bewaart de operationele wijziging en het auditbewijs binnen dezelfde transactie, zodat een gedeeltelijke statuswijziging niet kan ontstaan.",
"proofOne": "UUID + publieke referentie",
"proofTwo": "UTC + onverbreekbare audittrail"
},
"Outbox": {
"summary": "Legt vervolgwerk duurzaam vast",
"body": "Het outboxrecord wordt samen met de operatie gecommit. Een worker levert pas daarna, idempotent en met begrensde herpogingen, aan de orkestratielaag.",
"proofOne": "Post-commit aflevering",
"proofTwo": "Idempotency + begrensde retries"
},
"External": {
"summary": "Degradeert zonder lokaal dataverlies",
"body": "n8n, RAGcore en MCP Hub hebben time-outs en zichtbare healthstatus. Een storing blijft herstelbaar en AI antwoordt nooit zonder voldoende bronbewijs.",
"proofOne": "Healthstatus + time-outs",
"proofTwo": "Geen antwoord zonder bewijs"
}
},
"verificationTitle": "Verifieerbaar gebouwd",
"verificationBody": "Domeinregels, API-contracten, degradatiemodi en de volledige demonstratie worden automatisch getest. De repository bevat de exacte acceptatiecommando's en bewijsbundel.",
"problemTitle": "Het fictieve probleem",
@@ -38,6 +38,11 @@
"explanation": "Dit probleem is al opgelost, uitgesteld of verworpen.",
"nextStep": "Vernieuw de pagina om de huidige status te zien."
},
"ISSUE_CHANGED": {
"title": "De bewijslast is gewijzigd",
"explanation": "Er werd nieuwe bewijslast geregistreerd terwijl deze correctie werd voorbereid.",
"nextStep": "Vernieuw het probleem en beoordeel de actuele bewijslast opnieuw."
},
"BOOKING_NOT_ACTIVE": {
"title": "Boeking is niet actief",
"explanation": "Enkel een gereserveerde of actieve boeking kan voor deze actie gebruikt worden."
@@ -88,6 +93,18 @@
"title": "Ongeldige opdrachtreferentie",
"explanation": "Deze referentie naar een automatiseringsopdracht is niet geldig."
},
"INVALID_EVENT_CORRELATION": {
"title": "Ongeldige correlatiereferentie",
"explanation": "De automatiseringscallback bevat geen geldige correlatiereferentie."
},
"CALLBACK_EVENT_MISMATCH": {
"title": "Callback hoort bij een ander event",
"explanation": "De automatiseringscallback verwijst naar een ander event dan het event dat wordt bijgewerkt."
},
"CALLBACK_CORRELATION_MISMATCH": {
"title": "Callbacktrace komt niet overeen",
"explanation": "De automatiseringscallback komt niet overeen met de opgeslagen correlatiereferentie van dit event."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Aanvraag kon niet veilig herhaald worden",
"explanation": "De trackingsleutel van deze aanvraag is niet geldig.",
@@ -1,4 +1,4 @@
{
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"users": { "eyebrow": "Beheer / Toegang", "title": "Gebruikers", "description": "Beheer operationele toegang en rollen. Elke wijziging wordt geaudit.", "managerOnly": "Gebruikersbeheer is uitsluitend beschikbaar voor Operationsmanagers.", "addTitle": "Gebruiker toevoegen", "name": "Naam", "email": "E-mail", "role": "Rol", "password": "Tijdelijk wachtwoord", "status": "Status", "action": "Actie", "add": "Gebruiker toevoegen", "saving": "Opslaan…", "loading": "Gebruikers laden…", "active": "actief", "inactive": "inactief", "activate": "Activeren", "deactivate": "Deactiveren", "edit": "Bewerken", "editTitle": "Gebruiker {{ref}} bewerken", "newPassword": "Nieuw wachtwoord", "passwordUnchanged": "Leeg laten om niet te wijzigen", "saveChanges": "Wijzigingen opslaan", "cancel": "Annuleren", "createFailed": "De gebruiker kon niet worden aangemaakt.", "updateFailed": "De gebruiker kon niet worden bijgewerkt." },
"roles": { "operations_manager": "Operationeel beheerder", "rental_employee": "Verhuurmedewerker" }
}
@@ -30,6 +30,7 @@
"selected": "{{count}} geselecteerd",
"assignTo": "Toewijzen aan",
"dueAt": "Uiterste datum",
"invalidDueAt": "Kies een geldige datum en tijd in Europe/Brussels.",
"bulkApply": "Werkvoorraad bijwerken",
"bulkSaving": "Bijwerken…",
"bulkFailed": "De werkvoorraad kon niet bijgewerkt worden.",
+6 -6
View File
@@ -4,8 +4,9 @@ import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useLocaleFormat } from "../i18n/format";
import { ArchitectureFlow } from "../components/ArchitectureFlow";
import { Icon, type IconName } from "../components/Icons";
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
import { PRODUCT_NAME } from "../product";
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = { n8n: "n8n", ragcore: "rag", mcp_hub: "mcp" };
@@ -19,7 +20,7 @@ const PROOFS: Array<{ id: "Rules" | "Reliability" | "Ai"; icon: IconName }> = [
export function AboutDemo() {
const { t } = useTranslation(["demo", "integrations"]);
const { formatDateTime } = useLocaleFormat();
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
const { openGuide } = useDemoGuide();
const statusLabelKey = (key: string, statusCode: string) => key === "n8n" ? N8N_STATUS_LABEL_KEY[statusCode] ?? statusCode : statusCode;
@@ -28,6 +29,7 @@ export function AboutDemo() {
<div className="page engineering-story">
<PageHeader eyebrow={t("about.eyebrow")} title={t("about.title", { productName: PRODUCT_NAME })} description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined} />
{loading && <LoadingState label={t("about.loading")} />}
{!loading && error && <ErrorState message={t("about.unavailable")} onRetry={refresh} />}
{manifest && <>
<section className="panel engineering-hero">
<div>
@@ -49,10 +51,8 @@ export function AboutDemo() {
</section>
<section className="panel architecture-story" aria-labelledby="architecture-heading">
<SectionHeading title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<div className="architecture-flow" id="architecture-heading">
{["Frontend", "Api", "Database", "Outbox", "External"].map((step, index) => <div className="architecture-step" key={step}><span>{index + 1}</span><strong>{t(`about.architecture${step}`)}</strong>{index < 4 && <Icon name="chevron" />}</div>)}
</div>
<SectionHeading headingId="architecture-heading" title={t("about.architectureFlowTitle")} description={t("about.architectureFlowDescription")} />
<ArchitectureFlow />
</section>
<div className="about-grid">
+37 -27
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
@@ -32,53 +32,63 @@ export function Automation() {
const [knowledgeSettled, setKnowledgeSettled] = useState(false);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [integrationSettled, setIntegrationSettled] = useState(false);
const [runsVersion, setRunsVersion] = useState(0);
const [integrationVersion, setIntegrationVersion] = useState(0);
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
setRuns(null);
setError(null);
const params = new URLSearchParams();
if (status) params.set("status", status);
const controller = new AbortController();
api
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`)
.get<AutomationRun[]>(`/api/v1/workflows?${params.toString()}`, { signal: controller.signal })
.then(setRuns)
.catch(() =>
setError(user?.role === "operations_manager" ? t("ledger.unavailable") : t("ledger.managerOnly")),
);
}, [status, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("ledger.unavailable"));
});
return () => controller.abort();
}, [status, user?.role, t, runsVersion]);
useEffect(() => {
setKnowledgeSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then(setKnowledge)
.catch(() => setKnowledge(null))
.finally(() => setKnowledgeSettled(true));
}, []);
const loadIntegrationStatus = useCallback(() => {
setIntegrationSettled(false);
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null))
.finally(() => setIntegrationSettled(true));
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
})
.finally(() => {
if (!controller.signal.aborted) setKnowledgeSettled(true);
});
return () => controller.abort();
}, []);
useEffect(() => {
loadIntegrationStatus();
}, [loadIntegrationStatus]);
if (user?.role !== "operations_manager") return;
setIntegrationSettled(false);
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then(setIntegrationStatus)
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setIntegrationSettled(true);
});
return () => controller.abort();
}, [integrationVersion, user?.role]);
async function handleRetry(eventId: string) {
setRetryError(null);
setRetrying(eventId);
try {
await api.post(`/api/v1/workflows/${eventId}/retry`);
load();
loadIntegrationStatus();
setRunsVersion((version) => version + 1);
setIntegrationVersion((version) => version + 1);
} catch (err) {
setRetryError(describeApiError(t, err, "ledger.retryFailed"));
} finally {
+17 -14
View File
@@ -6,7 +6,7 @@ import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { AvailableVehicle, Booking, CustomerOption } from "../api/types";
import { ApiErrorNotice, PageHeader } from "../components/PageChrome";
import { Icon } from "../components/Icons";
import { brusselsDateTimeFromNow, brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { brusselsDateTimeFromNow, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingCreate() {
const { t } = useTranslation(["bookings", "errors"]);
@@ -18,16 +18,18 @@ export function BookingCreate() {
const [endsAt, setEndsAt] = useState(() => brusselsDateTimeFromNow(26));
const [vehicles, setVehicles] = useState<AvailableVehicle[]>([]);
const [vehicleRef, setVehicleRef] = useState("");
const [requirementsComplete, setRequirementsComplete] = useState(false);
const [vehicleQuery, setVehicleQuery] = useState("");
const [loadingVehicles, setLoadingVehicles] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const windowValid = useMemo(
() => Boolean(startsAt && endsAt && brusselsLocalToIso(endsAt) > brusselsLocalToIso(startsAt)),
[endsAt, startsAt],
const startsAtIso = useMemo(() => startsAt ? tryBrusselsLocalToIso(startsAt) : null, [startsAt]);
const endsAtIso = useMemo(() => endsAt ? tryBrusselsLocalToIso(endsAt) : null, [endsAt]);
const windowValid = Boolean(startsAtIso && endsAtIso && endsAtIso > startsAtIso);
const localTimeInvalid = Boolean(
(startsAt && !startsAtIso) || (endsAt && !endsAtIso),
);
const windowOrderInvalid = Boolean(startsAtIso && endsAtIso && endsAtIso <= startsAtIso);
useEffect(() => {
if (customerQuery.trim().length < 2) {
@@ -61,8 +63,8 @@ export function BookingCreate() {
const timeout = window.setTimeout(() => {
setLoadingVehicles(true);
const params = new URLSearchParams({
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
starts_at: startsAtIso as string,
ends_at: endsAtIso as string,
});
if (vehicleQuery.trim()) params.set("query", vehicleQuery.trim());
params.set("limit", "50");
@@ -82,19 +84,19 @@ export function BookingCreate() {
window.clearTimeout(timeout);
controller.abort();
};
}, [endsAt, startsAt, t, vehicleQuery, windowValid]);
}, [endsAtIso, startsAtIso, t, vehicleQuery, windowValid]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!startsAtIso || !endsAtIso || endsAtIso <= startsAtIso) return;
setError(null);
setSubmitting(true);
try {
const booking = await api.post<Booking>("/api/v1/bookings", {
customer_ref: customerRef,
vehicle_ref: vehicleRef,
starts_at: brusselsLocalToIso(startsAt),
ends_at: brusselsLocalToIso(endsAt),
requirements_complete: requirementsComplete,
starts_at: startsAtIso,
ends_at: endsAtIso,
});
navigate(`/bookings/${booking.public_ref}`);
} catch (err) {
@@ -111,14 +113,15 @@ export function BookingCreate() {
<ApiErrorNotice error={error} />
<form className="record-surface booking-create-form" onSubmit={submit}>
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(startsAt && !startsAtIso)} value={startsAt} onChange={(event) => setStartsAt(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean((endsAt && !endsAtIso) || windowOrderInvalid)} min={startsAt} value={endsAt} onChange={(event) => setEndsAt(event.target.value)} /></label>
<label>{t("create.customerSearch")}<input type="search" value={customerQuery} onChange={(event) => setCustomerQuery(event.target.value)} placeholder={t("create.customerPlaceholder")} /></label>
<label>{t("create.customer")}<select required value={customerRef} onChange={(event) => setCustomerRef(event.target.value)} disabled={customers.length === 0}><option value="">{t(customers.length ? "create.chooseCustomer" : "create.searchFirst")}</option>{customers.map((customer) => <option key={customer.public_ref} value={customer.public_ref}>{customer.display_name} · {customer.public_ref}{customer.email ? ` · ${customer.email}` : ""}</option>)}</select></label>
<label>{t("create.vehicleSearch")}<input type="search" value={vehicleQuery} onChange={(event) => setVehicleQuery(event.target.value)} placeholder={t("create.vehiclePlaceholder")} /></label>
<label>{t("create.vehicle")}<select required value={vehicleRef} onChange={(event) => setVehicleRef(event.target.value)} disabled={!windowValid || loadingVehicles}><option value="">{t(loadingVehicles ? "create.loadingVehicles" : vehicles.length ? "create.chooseVehicle" : "create.noVehicles")}</option>{vehicles.map((vehicle) => <option key={vehicle.public_ref} value={vehicle.public_ref}>{vehicle.public_ref} · {vehicle.make} {vehicle.model} · {vehicle.location || t("create.locationUnknown")}</option>)}</select></label>
</div>
<label className="check-card"><input type="checkbox" checked={requirementsComplete} onChange={(event) => setRequirementsComplete(event.target.checked)} /> <span>{t("create.requirementsComplete")}</span></label>
{localTimeInvalid && <p className="error" role="alert">{t("create.invalidLocalTime")}</p>}
{!localTimeInvalid && windowOrderInvalid && <p className="error" role="alert">{t("create.endAfterStart")}</p>}
<div className="form-actions"><Link className="button button-secondary" to="/bookings">{t("create.cancel")}</Link><button className="button button-primary" type="submit" disabled={submitting || !windowValid || !customerRef || !vehicleRef}>{submitting ? t("create.saving") : t("create.save")}</button></div>
</form>
</div>
+20 -8
View File
@@ -1,4 +1,4 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -13,7 +13,7 @@ import { PRODUCT_NAME } from "../product";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { ApiErrorNotice } from "../components/PageChrome";
import { CheckoutForm } from "../components/CheckoutForm";
import { brusselsLocalToIso, toBrusselsDateTimeLocal } from "../i18n/brusselsDateTime";
import { toBrusselsDateTimeLocal, tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
export function BookingDetail() {
const { t } = useTranslation(["bookings", "returns", "errors"]);
@@ -35,6 +35,17 @@ export function BookingDetail() {
const [scheduleEnd, setScheduleEnd] = useState("");
const [scheduleReason, setScheduleReason] = useState("");
const [rescheduling, setRescheduling] = useState(false);
const scheduleStartIso = useMemo(
() => scheduleStart ? tryBrusselsLocalToIso(scheduleStart) : null,
[scheduleStart],
);
const scheduleEndIso = useMemo(
() => scheduleEnd ? tryBrusselsLocalToIso(scheduleEnd) : null,
[scheduleEnd],
);
const scheduleWindowValid = Boolean(
scheduleStartIso && scheduleEndIso && scheduleEndIso > scheduleStartIso,
);
const load = useCallback(() => {
if (!publicRef) return;
@@ -120,13 +131,13 @@ export function BookingDetail() {
async function reschedule(event: FormEvent) {
event.preventDefault();
if (!publicRef) return;
if (!publicRef || !scheduleStartIso || !scheduleEndIso || !scheduleWindowValid) return;
setRescheduling(true);
setActionError(null);
try {
const updated = await api.patch<Booking>(`/api/v1/bookings/${publicRef}/schedule`, {
starts_at: brusselsLocalToIso(scheduleStart),
ends_at: brusselsLocalToIso(scheduleEnd),
starts_at: scheduleStartIso,
ends_at: scheduleEndIso,
reason: scheduleReason,
});
setBooking(updated);
@@ -175,11 +186,12 @@ export function BookingDetail() {
<p>{t("detail.rescheduleDetail")}</p>
<ApiErrorNotice error={actionErrorSource === "reschedule" ? actionError : null} />
<div className="form-grid">
<label>{t("create.startsAt")}<input type="datetime-local" required value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
<label>{t("create.startsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleStart && !scheduleStartIso)} value={scheduleStart} onChange={(event) => setScheduleStart(event.target.value)} /></label>
<label>{t("create.endsAt")}<input type="datetime-local" required aria-invalid={Boolean(scheduleEnd && !scheduleEndIso)} min={scheduleStart} value={scheduleEnd} onChange={(event) => setScheduleEnd(event.target.value)} /></label>
</div>
{scheduleStart && scheduleEnd && !scheduleWindowValid && <p className="error" role="alert">{t("detail.invalidScheduleWindow")}</p>}
<label>{t("detail.rescheduleReason")}<textarea required minLength={3} maxLength={500} value={scheduleReason} onChange={(event) => setScheduleReason(event.target.value)} /></label>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || scheduleEnd <= scheduleStart}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
<div className="form-actions"><button className="button button-secondary" type="submit" disabled={rescheduling || scheduleReason.trim().length < 3 || !scheduleWindowValid}>{rescheduling ? t("detail.rescheduling") : t("detail.confirmReschedule")}</button></div>
</form>
</details>
) : null}
+50 -9
View File
@@ -67,21 +67,50 @@ export function Dashboard() {
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [dashboardRequest, setDashboardRequest] = useState(0);
const [severity, setSeverity] = useState("all");
const [query, setQuery] = useState("");
useEffect(() => {
api.get<DashboardData>("/api/v1/dashboard").then(setData).catch(() => setError(t("common:status.error")));
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null));
}, [t]);
const controller = new AbortController();
setError(null);
api
.get<DashboardData>("/api/v1/dashboard", { signal: controller.signal })
.then((nextData) => {
if (!controller.signal.aborted) setData(nextData);
})
.catch(() => {
if (!controller.signal.aborted) setError(t("common:status.error"));
});
api
.get<KnowledgeHealth>("/api/v1/knowledge/status", { signal: controller.signal })
.then((nextKnowledge) => {
if (!controller.signal.aborted) setKnowledge(nextKnowledge);
})
.catch(() => {
if (!controller.signal.aborted) setKnowledge(null);
});
return () => controller.abort();
}, [dashboardRequest, t]);
useEffect(() => {
if (!canSeeAutomation) return;
if (!canSeeAutomation) {
setIntegrationStatus(null);
return;
}
const controller = new AbortController();
api
.get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null));
}, [canSeeAutomation]);
.get<IntegrationStatus>("/api/v1/integrations/status", { signal: controller.signal })
.then((nextStatus) => {
if (!controller.signal.aborted) setIntegrationStatus(nextStatus);
})
.catch(() => {
if (!controller.signal.aborted) setIntegrationStatus(null);
});
return () => controller.abort();
}, [canSeeAutomation, dashboardRequest]);
const attention = useMemo(() => data?.attention_items.filter((item) => {
const matchesSeverity = severity === "all" || item.severity === severity;
@@ -104,7 +133,19 @@ export function Dashboard() {
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
if (error) return <ErrorState message={error} />;
if (error) {
return (
<ErrorState
message={error}
onRetry={() => {
setData(null);
setKnowledge(null);
setIntegrationStatus(null);
setDashboardRequest((requestNumber) => requestNumber + 1);
}}
/>
);
}
if (!data) return <LoadingState label={t("common:status.loading")} />;
const latestRun = data.recent_automation[0];
+39 -21
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api } from "../api/client";
@@ -9,7 +9,7 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
import { ApiErrorNotice, EmptyState, ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
import { Pagination } from "../components/Pagination";
import { useLocaleFormat } from "../i18n/format";
import { brusselsLocalToIso } from "../i18n/brusselsDateTime";
import { tryBrusselsLocalToIso } from "../i18n/brusselsDateTime";
const RULE_TYPES = [
"possible_duplicate_customer",
@@ -36,7 +36,7 @@ export function DataQuality() {
const [scanError, setScanError] = useState<ApiErrorInfo | null>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [confirmingScan, setConfirmingScan] = useState(false);
const [demoScenariosOnly, setDemoScenariosOnly] = useState(searchParams.get("demo") === "true");
const demoScenariosOnly = searchParams.get("demo") === "true";
const [users, setUsers] = useState<UserRecord[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkAssignee, setBulkAssignee] = useState("");
@@ -44,6 +44,12 @@ export function DataQuality() {
const [bulkSaving, setBulkSaving] = useState(false);
const [bulkError, setBulkError] = useState<ApiErrorInfo | null>(null);
const [filtersOpen, setFiltersOpen] = useState(false);
const [refreshVersion, setRefreshVersion] = useState(0);
const bulkDueAtIso = useMemo(
() => bulkDueAt ? tryBrusselsLocalToIso(bulkDueAt) : null,
[bulkDueAt],
);
const bulkDueAtInvalid = Boolean(bulkDueAt && !bulkDueAtIso);
function updateFilters(updates: Record<string, string | boolean | number | null>) {
const next = new URLSearchParams(searchParams);
@@ -54,7 +60,7 @@ export function DataQuality() {
setSearchParams(next, { replace: true });
}
const load = useCallback(() => {
useEffect(() => {
if (user?.role !== "operations_manager") return;
// Keep the current rows visible while a filter change refetches (stale-while-revalidate);
// the initial load still shows the loading state because `issues` starts as null.
@@ -68,35 +74,46 @@ export function DataQuality() {
if (demoScenariosOnly) params.set("demo_only", "true");
params.set("page", String(page));
params.set("page_size", "25");
const controller = new AbortController();
api
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`)
.get<Page<DataQualityIssue>>(`/api/v1/data-quality/issues?${params.toString()}`, { signal: controller.signal })
.then(setIssues)
.catch(() => setError(t("list.unavailable")));
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user, t]);
useEffect(() => {
load();
}, [load]);
.catch(() => {
if (!controller.signal.aborted) setError(t("list.unavailable"));
});
return () => controller.abort();
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page, user?.role, t, refreshVersion]);
useEffect(() => {
if (user?.role !== "operations_manager") return;
api.get<UserRecord[]>("/api/v1/users").then((records) => setUsers(records.filter((record) => record.active))).catch(() => setUsers([]));
}, [user]);
const controller = new AbortController();
api.get<UserRecord[]>("/api/v1/users", { signal: controller.signal })
.then((records) => setUsers(records.filter((record) => record.active)))
.catch(() => {
if (!controller.signal.aborted) setUsers([]);
});
return () => controller.abort();
}, [user?.role]);
useEffect(() => {
// Bulk actions must never retain invisible rows after URL-driven filtering/history.
setSelected(new Set());
}, [status, ruleType, severity, assignee, overdueOnly, demoScenariosOnly, page]);
async function applyBulkWork() {
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt)) return;
if (selected.size === 0 || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid) return;
setBulkSaving(true);
setBulkError(null);
try {
await api.post("/api/v1/data-quality/issues/bulk-work", {
issue_refs: [...selected],
assigned_to_ref: bulkAssignee || undefined,
due_at: bulkDueAt ? brusselsLocalToIso(bulkDueAt) : undefined,
due_at: bulkDueAtIso ?? undefined,
});
setSelected(new Set());
setBulkAssignee("");
setBulkDueAt("");
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setBulkError(describeApiError(t, err, "list.bulkFailed"));
} finally {
@@ -129,7 +146,7 @@ export function DataQuality() {
const result = await api.post<ScanResult>("/api/v1/data-quality/scan");
setScanResult(result);
setConfirmingScan(false);
load();
setRefreshVersion((version) => version + 1);
} catch (err) {
setScanError(describeApiError(t, err, "list.scanFailed"));
} finally {
@@ -246,7 +263,7 @@ export function DataQuality() {
<input
type="checkbox"
checked={demoScenariosOnly}
onChange={(e) => { setDemoScenariosOnly(e.target.checked); updateFilters({ demo: e.target.checked, page: 1 }); }}
onChange={(e) => updateFilters({ demo: e.target.checked, page: 1 })}
/>
{t("list.demoScenariosOnly")}
</label>
@@ -258,7 +275,7 @@ export function DataQuality() {
{severity && <button type="button" onClick={() => updateFilters({ severity: null, page: 1 })}>{t(`severities.${severity}`)} ×</button>}
{assignee && <button type="button" onClick={() => updateFilters({ assignee: null, page: 1 })}>{assignee === "unassigned" ? t("list.unassigned") : users.find((record) => record.public_ref === assignee)?.display_name ?? assignee} ×</button>}
{overdueOnly && <button type="button" onClick={() => updateFilters({ overdue: null, page: 1 })}>{t("list.overdueOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => { setDemoScenariosOnly(false); updateFilters({ demo: null, page: 1 }); }}>{t("list.demoScenariosOnly")} ×</button>}
{demoScenariosOnly && <button type="button" onClick={() => updateFilters({ demo: null, page: 1 })}>{t("list.demoScenariosOnly")} ×</button>}
</div>
)}
@@ -275,8 +292,9 @@ export function DataQuality() {
<div className="bulk-toolbar" role="region" aria-label={t("list.bulkTitle")}>
<strong>{t("list.selected", { count: selected.size })}</strong>
<label>{t("list.assignTo")}<select value={bulkAssignee} onChange={(event) => setBulkAssignee(event.target.value)}><option value=""></option>{users.map((record) => <option key={record.public_ref} value={record.public_ref}>{record.display_name}</option>)}</select></label>
<label>{t("list.dueAt")}<input type="datetime-local" value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt)} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<label>{t("list.dueAt")}<input type="datetime-local" aria-invalid={bulkDueAtInvalid} value={bulkDueAt} onChange={(event) => setBulkDueAt(event.target.value)} /></label>
{bulkDueAtInvalid && <span className="error" role="alert">{t("list.invalidDueAt")}</span>}
<button type="button" className="button button-primary" disabled={bulkSaving || (!bulkAssignee && !bulkDueAt) || bulkDueAtInvalid} onClick={applyBulkWork}>{bulkSaving ? t("list.bulkSaving") : t("list.bulkApply")}</button>
<button type="button" className="button button-secondary" onClick={() => setSelected(new Set())}>{t("list.clearSelection")}</button>
</div>
)}
@@ -136,7 +136,14 @@ function MissingFieldPanel({ issue, onResolved }: { issue: IssueDetail; onResolv
function OdometerRegressionPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
const { t } = useTranslation("quality");
const { formatNumber } = useLocaleFormat();
const bookingSnapshots = issue.related_snapshots.filter((s) => s.entity_type === "booking");
const configuredCorrectableRefs = Array.isArray(issue.evidence.correctable_booking_refs)
? issue.evidence.correctable_booking_refs.filter((ref): ref is string => typeof ref === "string")
: null;
const bookingSnapshots = issue.related_snapshots.filter(
(snapshot) =>
snapshot.entity_type === "booking" &&
(configuredCorrectableRefs === null || configuredCorrectableRefs.includes(snapshot.public_ref)),
);
const [decision, setDecision] = useState<"retain_canonical" | "correct_reading">("retain_canonical");
const [bookingRef, setBookingRef] = useState(bookingSnapshots[0]?.public_ref ?? "");
const [correctedValue, setCorrectedValue] = useState("");
+9 -3
View File
@@ -41,11 +41,17 @@ export function Knowledge() {
useEffect(() => {
setStatusSettled(false);
const controller = new AbortController();
api
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`, { signal: controller.signal })
.then(setStatus)
.catch(() => setStatus(null))
.finally(() => setStatusSettled(true));
.catch(() => {
if (!controller.signal.aborted) setStatus(null);
})
.finally(() => {
if (!controller.signal.aborted) setStatusSettled(true);
});
return () => controller.abort();
}, [language]);
async function ask(questionText: string) {
+30 -2
View File
@@ -11,7 +11,12 @@ import { PRODUCT_NAME } from "../product";
export function Login() {
const { t } = useTranslation(["auth", "common"]);
const { loginAs, loginWithPassword, loading, demoMode, oidcEnabled, oidcProviderName } = useAuth();
const { manifest } = useDemoManifest();
const {
manifest,
loading: manifestLoading,
error: manifestError,
refresh: refreshManifest,
} = useDemoManifest();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
@@ -38,8 +43,14 @@ export function Login() {
}
}
async function handleGuidedDemo() {
if (manifestLoading || manifestError || !manifest?.guide_available) return;
await handleLogin("operations_manager", "/dashboard?guide=start");
}
const orgName = manifest?.organization_name ?? t("common:orgName");
const description = t("defaultDescription", { productName: PRODUCT_NAME });
const guidedDemoReady = manifest?.guide_available === true;
return (
<main className="login-shell">
@@ -84,10 +95,27 @@ export function Login() {
{t("startRecruiterTour")}
</button>
<button type="button" className="button button-secondary login-full-demo" disabled={loading} onClick={() => handleLogin("operations_manager", "/dashboard?guide=start")}>
<button
type="button"
className="button button-secondary login-full-demo"
disabled={loading || manifestLoading || manifestError || !guidedDemoReady}
onClick={handleGuidedDemo}
>
{t("startGuidedDemo")}
</button>
{manifestLoading && (
<p className="login-guide-status" role="status">{t("guidedDemoChecking")}</p>
)}
{!manifestLoading && (manifestError || !guidedDemoReady) && (
<div className="login-guide-status is-unavailable" role={manifestError ? "alert" : "status"}>
<span>{t(manifestError ? "guidedDemoManifestUnavailable" : "guidedDemoNotReady")}</span>
<button type="button" className="link-button" onClick={refreshManifest}>
{t("guidedDemoRetry")}
</button>
</div>
)}
<div className="login-options">
<button type="button" aria-label={t("exploreAsOperationsManager")} disabled={loading} onClick={() => handleLogin("operations_manager")}>
<span className="role-icon"><Icon name="activity" /></span>
+3 -2
View File
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useAuth } from "../context/AuthContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { Icon } from "../components/Icons";
import { LoadingState, PageHeader } from "../components/PageChrome";
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
export function Scenarios() {
const { t } = useTranslation("demo");
const { manifest, loading } = useDemoManifest();
const { manifest, loading, error, refresh } = useDemoManifest();
const { user } = useAuth();
function roleLabel(roles: string[]): string {
@@ -24,6 +24,7 @@ export function Scenarios() {
/>
{loading && <LoadingState label={t("scenarios.loading")} />}
{!loading && error && <ErrorState message={t("scenarios.unavailable")} onRetry={refresh} />}
{manifest && (
<div className="scenario-grid">
+11 -1
View File
@@ -22,11 +22,12 @@ export function Users() {
const [newPassword, setNewPassword] = useState("");
const load = useCallback(() => {
if (currentUser?.role !== "operations_manager") return;
api
.get<UserRecord[]>("/api/v1/users")
.then(setUsers)
.catch((err) => setError(describeApiError(t, err)));
}, [t]);
}, [currentUser?.role, t]);
useEffect(load, [load]);
@@ -91,6 +92,15 @@ export function Users() {
}
}
if (currentUser?.role !== "operations_manager") {
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.managerOnly")} />
<p>{t("users.managerOnly")}</p>
</div>
);
}
return (
<div className="page">
<PageHeader eyebrow={t("users.eyebrow")} title={t("users.title")} description={t("users.description")} />
+5 -2
View File
@@ -21,6 +21,9 @@ export function VehicleDetail() {
const [error, setError] = useState<string | null>(null);
const [tab, setTab] = useState<Tab>("overview");
const { user } = useAuth();
const visibleTabs = user?.role === "operations_manager"
? TABS
: TABS.filter((candidate) => candidate !== "quality");
const load = useCallback(() => {
if (!publicRef) return;
@@ -42,7 +45,7 @@ export function VehicleDetail() {
<PageHeader eyebrow={t("detail.eyebrow")} title={`${vehicle.public_ref} · ${vehicle.make} ${vehicle.model}`} description={`${vehicle.registration_number} · ${vehicle.location}`} actions={<div className="status-stack"><StatusBadge status={vehicle.operational_status} label={t(`statuses.${vehicle.operational_status}`, { defaultValue: vehicle.operational_status })} />{vehicle.attention && <span className="badge severity-high">{t("detail.needsAttention")}</span>}</div>} />
<div role="tablist" aria-label={t("detail.tabsAriaLabel")} className="tabs" aria-orientation="horizontal">
{TABS.map((tb) => (
{visibleTabs.map((tb) => (
<button
key={tb}
role="tab"
@@ -111,7 +114,7 @@ export function VehicleDetail() {
</ul>{user?.role === "operations_manager" && <VehicleMaintenanceActions vehicle={vehicle} onSaved={load} />}</>
)}
{tab === "quality" && (
{tab === "quality" && user?.role === "operations_manager" && (
<ul className="record-list">
{vehicle.quality_issues.length === 0 && <li>{t("detail.noQualityIssues")}</li>}
{vehicle.quality_issues.map((q) => (
+77
View File
@@ -0,0 +1,77 @@
.architecture-story { margin-bottom: 26px; padding: 0; overflow: hidden; }
.architecture-explorer { padding: 18px 22px 22px; background: linear-gradient(180deg, #fff 0%, #fbfcfd 100%); }
.architecture-zones { display: grid; grid-template-columns: 1fr 3fr 1fr; gap: 12px; margin-bottom: 9px; }
.architecture-zones > span { min-height: 31px; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 6px 10px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; font-size: .75rem; font-weight: 800; letter-spacing: .055em; text-transform: uppercase; }
.architecture-zones svg { width: 13px; height: 13px; }
.architecture-zones .zone-intent { background: var(--surface-subtle); }
.architecture-zones .zone-transaction { color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.architecture-zones .zone-edge { color: var(--info); background: var(--info-pale); border-color: #cfe3ee; }
.architecture-flow { list-style: none; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; margin: 0; padding: 0; }
.architecture-step { position: relative; min-width: 0; }
.architecture-step button { position: relative; width: 100%; min-height: 164px; height: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; padding: 14px; overflow: hidden; color: var(--ink); text-align: left; background: white; border: 1px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: border-color .18s ease, box-shadow .18s ease, transform .18s ease, background .18s ease; }
.architecture-step button::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line-strong); transition: width .18s ease, background .18s ease; }
.architecture-step button:hover { border-color: #9fd5ce; box-shadow: 0 10px 24px rgba(15, 23, 42, .07); transform: translateY(-2px); }
.architecture-step button:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.architecture-step.is-active button { background: #fbfffe; border-color: var(--teal); box-shadow: 0 12px 28px rgba(11, 111, 103, .11); transform: translateY(-2px); }
.architecture-step.is-active button::before { width: 4px; background: var(--teal); }
.architecture-step-edge.is-active button { background: #fbfdff; border-color: var(--info); box-shadow: 0 12px 28px rgba(53, 100, 128, .11); }
.architecture-step-edge.is-active button::before { background: var(--info); }
.architecture-step-top { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.architecture-step-number { color: var(--teal-dark); font-size: .75rem; font-weight: 850; letter-spacing: .1em; }
.architecture-step-icon { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }
.architecture-step-icon svg { width: 15px; height: 15px; }
.architecture-step button > strong { font-size: .875rem; line-height: 1.35; }
.architecture-step button > small { color: var(--muted); font-size: .8125rem; line-height: 1.45; }
.architecture-step-meta { width: 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 5px; margin-top: auto; }
.architecture-zone-tag { color: var(--muted); font-size: .75rem; font-weight: 800; letter-spacing: .045em; text-transform: uppercase; }
.architecture-commit-note { padding: 3px 6px; color: var(--info); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: 999px; font-size: .75rem; font-weight: 800; white-space: nowrap; }
.architecture-connector { position: absolute; z-index: 4; top: calc(50% - 1px); left: 100%; width: 12px; height: 2px; background: var(--line-strong); pointer-events: none; }
.architecture-connector::after { content: ""; position: absolute; top: -3px; right: -1px; width: 7px; height: 7px; border-top: 2px solid var(--line-strong); border-right: 2px solid var(--line-strong); transform: rotate(45deg); }
.architecture-signal { position: absolute; z-index: 2; top: -2px; left: -2px; width: 6px; height: 6px; background: var(--teal); border-radius: 50%; box-shadow: 0 0 0 3px rgba(15, 143, 131, .12); animation: architecture-signal-x 2.4s ease-in-out infinite; }
.architecture-step:nth-child(2) .architecture-signal { animation-delay: .35s; }
.architecture-step:nth-child(3) .architecture-signal { animation-delay: .7s; }
.architecture-step:nth-child(4) .architecture-signal { animation-delay: 1.05s; }
.architecture-connector.is-commit-boundary { background: var(--info); }
.architecture-connector.is-commit-boundary::after { border-color: var(--info); }
.architecture-interaction-hint { margin: 12px 0 8px; color: var(--muted); font-size: .8125rem; text-align: center; }
.architecture-active-detail { position: relative; display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: 22px; align-items: center; min-height: 142px; padding: 20px 22px; overflow: hidden; background: var(--petrol); border-radius: var(--radius); }
.architecture-active-detail::after { content: ""; position: absolute; right: -48px; bottom: -78px; width: 190px; height: 190px; border: 1px solid #2d4157; border-radius: 50%; box-shadow: 0 0 0 24px rgba(45, 65, 87, .22), 0 0 0 48px rgba(45, 65, 87, .12); pointer-events: none; }
.architecture-active-copy, .architecture-active-evidence { position: relative; z-index: 1; }
.architecture-active-copy > span { color: #5eead4; font-size: .75rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
.architecture-active-copy h3 { margin: 7px 0 6px; color: white; font-size: 1.06rem; letter-spacing: -.015em; }
.architecture-active-copy p { max-width: 72ch; margin: 0; color: #bcc9d6; font-size: .8125rem; line-height: 1.6; }
.architecture-active-evidence { display: grid; gap: 10px; margin: 0; }
.architecture-active-evidence > div { padding: 10px 12px; background: rgba(255, 255, 255, .055); border: 1px solid #2d4157; border-radius: var(--radius); }
.architecture-active-evidence dt { color: #8fa2b6; font-size: .75rem; font-weight: 800; letter-spacing: .075em; text-transform: uppercase; }
.architecture-active-evidence dd { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 0; color: white; font-size: .8125rem; font-weight: 700; }
.architecture-active-evidence dd span { padding: 4px 7px; color: #d8e5ee; background: rgba(94, 234, 212, .08); border: 1px solid rgba(94, 234, 212, .18); border-radius: 999px; }
@keyframes architecture-signal-x { 0%, 20% { opacity: 0; transform: translateX(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateX(10px); } 100% { opacity: 0; transform: translateX(10px); } }
@keyframes architecture-signal-y { 0%, 20% { opacity: 0; transform: translateY(0); } 35% { opacity: 1; } 80% { opacity: 1; transform: translateY(10px); } 100% { opacity: 0; transform: translateY(10px); } }
@media (prefers-reduced-motion: reduce) {
.architecture-signal { animation: none; }
}
@media (max-width: 1180px) {
.architecture-explorer { padding: 14px; }
.architecture-zones { display: none; }
.architecture-flow { grid-template-columns: 1fr; gap: 12px; padding: 0; }
.architecture-step button { min-height: 112px; padding: 14px 16px; }
.architecture-step button > small { max-width: 64ch; }
.architecture-connector { top: 100%; left: 27px; width: 2px; height: 12px; }
.architecture-connector::after { top: auto; right: -3px; bottom: -1px; transform: rotate(135deg); }
.architecture-signal { top: -2px; left: -2px; animation-name: architecture-signal-y; }
.architecture-active-detail { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 700px) {
.architecture-active-detail { min-height: 0; padding: 18px; }
.architecture-active-detail::after { opacity: .6; }
.architecture-active-evidence dd { display: grid; }
}
@media (forced-colors: active) {
.architecture-step.is-active button { outline: 2px solid Highlight; outline-offset: 2px; }
.architecture-signal, .architecture-active-detail::after { display: none; }
}
+8 -5
View File
@@ -398,7 +398,7 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.filter-presets { display: flex; flex-wrap: wrap; gap: 7px; margin: -6px 0 18px; }
.filter-presets button { min-height: 34px; padding: 5px 11px; color: var(--teal-dark); background: var(--teal-pale); border-color: #bfe6df; }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.state-retry { margin-top: 12px; padding-block: 6px; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); }
.route-loading { min-height: 40vh; display: grid; place-items: center; }
.route-skeleton { width: min(760px, calc(100% - 40px)); display: grid; gap: 14px; }
.route-skeleton span { display: block; height: 18px; background: linear-gradient(90deg, #edf1f5 25%, #f8fafc 50%, #edf1f5 75%); background-size: 220% 100%; border-radius: var(--radius); animation: skeleton-shift 1.25s ease-in-out infinite; }
@@ -410,9 +410,11 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.engineering-hero h2 { max-width: 720px; margin: 0; font-size: clamp(1.6rem, 3vw, 2.35rem); line-height: 1.1; letter-spacing: -.035em; }.engineering-hero p:not(.page-eyebrow) { max-width: 680px; margin: 10px 0 0; color: #c4d0dc; line-height: 1.6; }.engineering-hero .page-eyebrow { color: #5eead4; }
.engineering-hero-actions { min-width: 220px; display: grid; gap: 9px; }.engineering-hero-actions .button { width: 100%; }
.standalone-heading { padding-inline: 0; border: 0; }.proof-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 26px; }.proof-card { padding: 22px; }.proof-card > span, .highlight-icon { width: 42px; height: 42px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.proof-card svg, .highlight-icon svg { width: 21px; }.proof-card h3 { margin: 18px 0 8px; font-size: 1rem; }.proof-card p { margin: 0; color: var(--muted); font-size: var(--type-body); line-height: 1.6; }
.architecture-story { margin-bottom: 26px; overflow: hidden; }.architecture-flow { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 22px; }.architecture-step { position: relative; min-height: 104px; display: flex; flex-direction: column; justify-content: center; gap: 9px; padding: 16px; background: var(--surface-subtle); border: 1px solid var(--line); }.architecture-step > span { color: var(--teal-dark); font-size: var(--type-meta); font-weight: 800; }.architecture-step > strong { font-size: .82rem; line-height: 1.35; }.architecture-step > svg { position: absolute; z-index: 2; right: -12px; top: calc(50% - 12px); width: 24px; height: 24px; padding: 5px; color: white; background: var(--teal-dark); border-radius: 50%; }
.highlight-grid { list-style: none; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin: 0 0 18px; padding: 0; counter-reset: highlight; }.highlight-card { min-height: 440px; display: flex; flex-direction: column; padding: 24px; }.highlight-card-top { display: flex; justify-content: space-between; align-items: center; }.highlight-number { color: var(--teal-dark); font-size: .72rem; font-weight: 800; letter-spacing: .1em; }.highlight-duration { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: var(--type-meta); }.highlight-duration svg { width: 14px; }.highlight-icon { margin-top: 32px; }.highlight-card h2 { margin: 18px 0 10px; font-size: 1.18rem; letter-spacing: -.02em; }.highlight-card > p { margin: 0; color: var(--muted); line-height: 1.65; }.highlight-proof { display: grid; gap: 4px; margin: auto 0 18px; padding: 13px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }.highlight-proof strong { color: var(--muted); font-size: var(--type-label); text-transform: uppercase; letter-spacing: .06em; }.highlight-proof span { font-size: .77rem; font-weight: 700; }.highlights-next { display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 24px; }.highlights-next h2 { margin: 0; font-size: 1.15rem; }.highlights-next p:not(.page-eyebrow) { margin: 7px 0 0; color: var(--muted); }.highlights-next-actions { display: flex; flex-wrap: wrap; gap: 9px; }
.login-full-demo { width: 100%; min-height: 44px; margin: -8px 0 18px; }
.login-guide-status { display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 28px; margin: -10px 0 16px; color: var(--muted); font-size: .75rem; text-align: center; }
.login-guide-status.is-unavailable { flex-wrap: wrap; color: var(--danger); }
.login-guide-status .link-button { min-height: 32px; padding: 3px 6px; color: inherit; font-size: inherit; }
/* Correlated operation trace */
.operation-trace { margin-bottom: 14px; padding: 22px; }.operation-trace h2 { margin: 0; font-size: 1.15rem; }.operation-trace > div > p:last-child { margin: 7px 0 0; color: var(--muted); }.operation-trace ol { list-style: none; display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 20px 0 0; padding: 0; }.operation-trace li { position: relative; display: grid; grid-template-columns: 34px 1fr; gap: 10px; align-items: start; padding-right: 15px; }.operation-trace li::after { content: ""; position: absolute; top: 16px; left: 34px; right: 0; border-top: 1px solid var(--line-strong); }.operation-trace li:last-child::after { display: none; }.operation-trace li > span { position: relative; z-index: 1; width: 34px; height: 34px; display: grid; place-items: center; color: var(--muted); background: white; border: 1px solid var(--line-strong); border-radius: 50%; font-size: .72rem; font-weight: 800; }.operation-trace li.is-complete > span { color: white; background: var(--success); border-color: var(--success); }.operation-trace li svg { width: 17px; }.operation-trace li div { position: relative; z-index: 1; padding-right: 6px; background: white; }.operation-trace li strong { display: block; font-size: .78rem; }.operation-trace li small { display: block; margin-top: 4px; color: var(--muted); font-size: var(--type-meta); line-height: 1.4; }
@@ -527,10 +529,10 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
@media (max-width: 700px) {
:root { --type-body: .875rem; --type-meta: .75rem; --type-label: .75rem; --type-badge: .75rem; }
.icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input { min-width: 44px; min-height: 44px; }
.icon-button, .language-switcher select, .demo-badge-trigger, .demo-guide-trigger, .back-link, .section-heading > a, .data-table td button, .duplicate-compare > button, .resolution-actions button, .confirm-bar button, .pagination button, .toggle-matching-fields, .button-tertiary, .bulk-toolbar select, .bulk-toolbar input, .state-retry { min-width: 44px; min-height: 44px; }
.demo-guide-trigger { width: 44px; justify-content: center; }.demo-badge-trigger { width: 44px; height: 44px; padding: 0; justify-content: center; font-size: 0; }.demo-badge-trigger svg { width: 16px; height: 16px; color: #48566a; }.language-switcher select { height: 44px; }
.mobile-nav span { max-width: 54px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.architecture-flow { grid-template-columns: 1fr; gap: 8px; padding: 14px; }.architecture-step { min-height: 72px; }.architecture-step > svg { right: calc(50% - 12px); top: auto; bottom: -16px; transform: rotate(90deg); }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.engineering-hero, .highlights-next { align-items: stretch; flex-direction: column; padding: 20px; }.engineering-hero-actions { min-width: 0; }.proof-grid, .highlight-grid { grid-template-columns: 1fr; }.highlight-card { min-height: 0; }.highlight-proof { margin-top: 22px; }.highlights-next-actions { display: grid; }.operation-trace ol { grid-template-columns: 1fr; gap: 13px; }.operation-trace li { min-height: 54px; }.operation-trace li::after { top: 34px; bottom: -13px; left: 16px; right: auto; border-top: 0; border-left: 1px solid var(--line-strong); }.operation-trace li div { padding-bottom: 5px; }.knowledge-status { align-items: flex-start; }.knowledge-diagnostics { max-width: none; text-align: left; }.knowledge-progress { grid-template-columns: 1fr; }.retrieval-flow span { font-size: var(--type-meta); }.return-progress span { font-size: var(--type-meta); }.duplicate-compare > button { position: sticky; bottom: 78px; z-index: 6; width: 100%; box-shadow: var(--shadow-float); }
.resolution-actions { position: sticky; bottom: 72px; z-index: 6; padding: 9px; background: rgba(255,255,255,.97); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow-float); }.resolution-actions button { flex: 1; }
}
@@ -540,7 +542,8 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
}
@media (max-width: 700px) {
.page-actions { display: flex; min-height: 24px; }
.page-header { flex-direction: column; }
.page-actions > .button:only-child { width: 100%; min-height: 44px; }
}
@media (max-width: 440px) {
+2 -2
View File
@@ -17,9 +17,9 @@ credential values are never embedded; nodes reference named n8n credentials inst
| Live workflow ID | `mobilityops-return-processing` |
| Active status (as of 2026-08-04) | Active / Published |
| Error Workflow | `Fleet Ops — Workflow Error Handler` (wired) |
| Timeouts / bounded retries | `Record follow-up` and heartbeat HTTP nodes: 15s timeout, retry on fail (3 tries, 1000ms wait) |
| Timeouts / bounded retries | `Record follow-up` and heartbeat HTTP nodes: 2s timeout, retry on fail (2 tries, 1000ms wait); their combined 10s worst-case retry budget remains below Fleet Ops's 15s dispatcher timeout and 120s delivery lease. |
| Execution telemetry | Successful runs POST execution ID and status to `/api/v1/integrations/n8n/heartbeat`; failed runs are registered by the Error Workflow. |
| Checksum (sha256) | `19f233b6de90a3474010d5495a53c75e969fb5de591e84dd3abcf9635304c1f1` (HTTPS callback definition, 2026-08-21) |
| Checksum (sha256) | `a282d92dc33cb838609ab32ca2d9502d141d0c82d83668bdfb341a24e19e3bdc` (bounded retries and strict event/correlation forwarding, 2026-08-23) |
## 2. Fleet Ops — Scheduled Data Quality Scan
+5 -5
View File
@@ -24,7 +24,7 @@
},
{
"parameters": {
"jsCode": "const e = $json.body ?? $json;\nif (e.event_type !== 'vehicle.returned.v1') throw new Error('Unsupported event type');\nconst reasons = e.data?.attention_reasons ?? [];\nreturn [{json: {event_id: e.event_id, event_type: e.event_type, aggregate_ref: e.aggregate.public_ref, follow_up: reasons.length ? 'attention_required' : 'cleaning', summary: reasons.join('; ') || 'Create cleaning follow-up'}}];"
"jsCode": "const e = $json.body ?? $json;\nif (e.event_type !== 'vehicle.returned.v1') throw new Error('Unsupported event type');\nconst reasons = e.data?.attention_reasons ?? [];\nreturn [{json: {event_id: e.event_id, correlation_id: e.correlation_id, event_type: e.event_type, aggregate_ref: e.aggregate.public_ref, follow_up: reasons.length ? 'attention_required' : 'cleaning', summary: reasons.join('; ') || 'Create cleaning follow-up'}}];"
},
"id": "validate-node",
"name": "Validate and derive follow-up",
@@ -52,7 +52,7 @@
"rawContentType": "application/json",
"body": "={{JSON.stringify($json)}}",
"options": {
"timeout": 15000
"timeout": 2000
}
},
"id": "callback-node",
@@ -66,7 +66,7 @@
}
},
"retryOnFail": true,
"maxTries": 3,
"maxTries": 2,
"waitBetweenTries": 1000
},
{
@@ -84,7 +84,7 @@
{ "name": "execution_id", "value": "={{ $execution.id }}" },
{ "name": "status", "value": "succeeded" }
]},
"options": { "timeout": 15000 }
"options": { "timeout": 2000 }
},
"id": "heartbeat-node",
"name": "Report workflow heartbeat",
@@ -93,7 +93,7 @@
"position": [980, 300],
"credentials": { "httpHeaderAuth": { "name": "Fleet Ops Service Token" } },
"retryOnFail": true,
"maxTries": 3,
"maxTries": 2,
"waitBetweenTries": 1000
},
{
+53
View File
@@ -11,6 +11,7 @@ from pathlib import Path
import yaml
from app.core.config import get_settings
from app.main import app
ROOT = Path(__file__).resolve().parents[1]
@@ -106,6 +107,58 @@ def check_workflows(failures: list[str]) -> None:
serialized = raw.decode("utf-8")
if "http://fleetops.itworx.tech" in serialized:
fail(f"{filename} contains a cleartext Fleet Ops callback", failures)
if filename == "fleet-ops-vehicle-return.json":
check_return_workflow_timing(definition, failures)
def check_return_workflow_timing(definition: dict, failures: list[str]) -> None:
"""Keep nested retry budgets inside the dispatcher's recoverable delivery lease."""
nodes_by_name = {node.get("name"): node for node in definition.get("nodes", [])}
validation_code = (
nodes_by_name.get("Validate and derive follow-up", {}).get("parameters", {}).get("jsCode", "")
)
if not all(field in validation_code for field in ("event_id", "correlation_id")):
fail(
"Vehicle-return workflow must preserve event_id and correlation_id for its callback",
failures,
)
required_nodes = ("Record follow-up", "Report workflow heartbeat")
workflow_budget_ms = 0
for name in required_nodes:
node = nodes_by_name.get(name)
if not isinstance(node, dict):
fail(f"Vehicle-return workflow is missing timing-critical node {name!r}", failures)
return
options = node.get("parameters", {}).get("options", {})
timeout_ms = options.get("timeout")
if node.get("retryOnFail") is not True:
fail(f"{name!r} must retain bounded retries", failures)
return
max_tries = node.get("maxTries")
wait_ms = node.get("waitBetweenTries", 0)
if not all(
isinstance(value, int) and not isinstance(value, bool) and value > 0
for value in (timeout_ms, max_tries)
) or (
max_tries < 2
or not isinstance(wait_ms, int)
or isinstance(wait_ms, bool)
or wait_ms < 0
):
fail(f"{name!r} has an invalid timeout/retry budget", failures)
return
workflow_budget_ms += timeout_ms * max_tries + wait_ms * (max_tries - 1)
settings = get_settings()
dispatcher_timeout_ms = settings.n8n_http_timeout_seconds * 1000
lease_ms = settings.n8n_delivery_lease_seconds * 1000
if not workflow_budget_ms < dispatcher_timeout_ms < lease_ms:
fail(
"Vehicle-return timing contract violated: complete callback/heartbeat retry "
f"budget {workflow_budget_ms}ms must be below dispatcher timeout "
f"{dispatcher_timeout_ms:g}ms, which must be below delivery lease {lease_ms:g}ms",
failures,
)
def main() -> int:
+1
View File
@@ -12,6 +12,7 @@ LINE_LIMITS = {
}
BYTE_LIMITS = {
"frontend/src/styles.css": 78_000,
"frontend/src/styles-architecture-flow.css": 9_000,
"frontend/src/styles-data-quality.css": 8_000,
}
+2 -2
View File
@@ -5,10 +5,10 @@ DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked availabl
DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing
DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold)
DQ-0006,missing_required_field,MO-043,,low,open,Missing: location
DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057.
DQ-0007,odometer_regression,MO-050,,medium,open,"Return inspection INSP-0007 recorded 51700 km, below the 51892 km recorded by earlier inspection INSP-0057."
DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking)
DQ-0009,missing_required_field,MO-014,,low,open,Missing: location
DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060.
DQ-0010,odometer_regression,MO-021,,medium,open,"Return inspection INSP-0010 recorded 31150 km, below the 31298 km recorded by earlier inspection INSP-0060."
DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold)
DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number
DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location
1 public_ref,rule_type,entity_ref,related_ref,severity,status,evidence public_ref rule_type entity_ref related_ref severity status evidence
5 DQ-DEMO-ATTENTION,missing_required_field,MO-031,BK-DEMO-NEXT,high,open,near-future booking; required operational inspection missing DQ-DEMO-ATTENTION missing_required_field MO-031 BK-DEMO-NEXT high open near-future booking; required operational inspection missing
6 DQ-0005,vehicle_status_conflict,MO-036,,high,open,Recommended status: maintenance (44660 km reported against a 44000 km service threshold) DQ-0005 vehicle_status_conflict MO-036 high open Recommended status: maintenance (44660 km reported against a 44000 km service threshold)
7 DQ-0006,missing_required_field,MO-043,,low,open,Missing: location DQ-0006 missing_required_field MO-043 low open Missing: location
8 DQ-0007,odometer_regression,MO-050,,medium,open,Booking BK-H-0007 recorded 51700 km, below the 51892 km recorded by earlier booking BK-H-0057. DQ-0007 odometer_regression MO-050 medium open Return inspection INSP-0007 recorded 51700 km, below the 51892 km recorded by earlier inspection INSP-0057.
9 DQ-0008,vehicle_status_conflict,MO-007,,high,open,Recommended status: available (marked rented with no active booking) DQ-0008 vehicle_status_conflict MO-007 high open Recommended status: available (marked rented with no active booking)
10 DQ-0009,missing_required_field,MO-014,,low,open,Missing: location DQ-0009 missing_required_field MO-014 low open Missing: location
11 DQ-0010,odometer_regression,MO-021,,medium,open,Booking BK-H-0010 recorded 31150 km, below the 31298 km recorded by earlier booking BK-H-0060. DQ-0010 odometer_regression MO-021 medium open Return inspection INSP-0010 recorded 31150 km, below the 31298 km recorded by earlier inspection INSP-0060.
12 DQ-0011,vehicle_status_conflict,MO-028,,high,open,Recommended status: maintenance (38959 km reported against a 38000 km service threshold) DQ-0011 vehicle_status_conflict MO-028 high open Recommended status: maintenance (38959 km reported against a 38000 km service threshold)
13 DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number DQ-0012 missing_required_field MO-035 low resolved Missing: registration_number
14 DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location DQ-0013 missing_required_field MO-042 low resolved Missing: location
+460 -51
View File
@@ -3,25 +3,70 @@ from __future__ import annotations
import argparse
import csv
import random
from datetime import date, datetime, time, timedelta, timezone
from datetime import UTC, date, datetime, time, timedelta
from pathlib import Path
FIRST_NAMES = ["Sofie", "Lotte", "Emma", "Noor", "Julie", "Thomas", "Bram", "Wout", "Niels", "Pieter", "Anke", "Sara", "Eva", "Tom", "Jeroen"]
LAST_NAMES = ["Peeters", "Janssens", "Maes", "Willems", "Claes", "Vermeulen", "Jacobs", "Mertens", "Goossens", "Wouters", "De Smet", "Vandamme"]
CITIES = [("2440", "Geel"), ("2300", "Turnhout"), ("2200", "Herentals"), ("2400", "Mol"), ("2260", "Westerlo"), ("3980", "Tessenderlo-Ham")]
MAKES_MODELS = [("Adria", "Matrix"), ("Dethleffs", "Trend"), ("Carado", "T-Series"), ("Hymer", "Exsis"), ("Bürstner", "Lyseo"), ("Sunlight", "Cliff")]
FIRST_NAMES = [
"Sofie",
"Lotte",
"Emma",
"Noor",
"Julie",
"Thomas",
"Bram",
"Wout",
"Niels",
"Pieter",
"Anke",
"Sara",
"Eva",
"Tom",
"Jeroen",
]
LAST_NAMES = [
"Peeters",
"Janssens",
"Maes",
"Willems",
"Claes",
"Vermeulen",
"Jacobs",
"Mertens",
"Goossens",
"Wouters",
"De Smet",
"Vandamme",
]
CITIES = [
("2440", "Geel"),
("2300", "Turnhout"),
("2200", "Herentals"),
("2400", "Mol"),
("2260", "Westerlo"),
("3980", "Tessenderlo-Ham"),
]
MAKES_MODELS = [
("Adria", "Matrix"),
("Dethleffs", "Trend"),
("Carado", "T-Series"),
("Hymer", "Exsis"),
("Bürstner", "Lyseo"),
("Sunlight", "Cliff"),
]
def write_csv(path: Path, rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
# csv.excel defaults to CRLF even on Linux. The repository canonicalises text
# files to LF, so make that byte contract explicit and platform-independent.
writer = csv.DictWriter(handle, fieldnames=list(rows[0]), lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
def iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
def build(anchor: date, seed: int, out: Path) -> None:
@@ -34,7 +79,8 @@ def build(anchor: date, seed: int, out: Path) -> None:
postal, city = CITIES[i % len(CITIES)]
email = f"{first}.{last}.{i}@example.test".lower().replace(" ", "")
used_emails.add(email)
customers.append({
customers.append(
{
"public_ref": f"CUS-{i:04d}",
"first_name": first,
"last_name": last,
@@ -43,36 +89,48 @@ def build(anchor: date, seed: int, out: Path) -> None:
"postal_code": postal,
"city": city,
"merged_into": "",
})
}
)
# Fixed duplicate: CUS-0178 mirrors CUS-0012 with a shortened first name.
src = customers[11]
dup = customers[177]
dup.update({
dup.update(
{
"first_name": src["first_name"][0] + ".",
"last_name": src["last_name"],
"email": src["email"],
"phone": src["phone"],
"postal_code": src["postal_code"],
"city": src["city"],
})
}
)
# Two additional duplicate pairs.
for a, b in [(31, 164), (68, 149)]:
customers[b].update({
customers[b].update(
{
"first_name": customers[a]["first_name"],
"last_name": customers[a]["last_name"],
"email": customers[a]["email"],
"postal_code": customers[a]["postal_code"],
"city": customers[a]["city"],
})
}
)
vehicles = []
statuses = ["available"] * 22 + ["rented"] * 10 + ["cleaning"] * 6 + ["maintenance"] * 6 + ["blocked"] * 6
statuses = (
["available"] * 22
+ ["rented"] * 10
+ ["cleaning"] * 6
+ ["maintenance"] * 6
+ ["blocked"] * 6
)
rnd.shuffle(statuses)
for i in range(1, 51):
make, model = MAKES_MODELS[(i - 1) % len(MAKES_MODELS)]
km = 18000 + i * 730 + rnd.randint(0, 900)
vehicles.append({
vehicles.append(
{
"public_ref": f"MO-{i:03d}",
"make": make,
"model": model,
@@ -83,7 +141,8 @@ def build(anchor: date, seed: int, out: Path) -> None:
"odometer_km": km,
"next_service_km": ((km // 10000) + 1) * 10000,
"active": "true",
})
}
)
# Fixed return scenario.
vehicles[23]["operational_status"] = "rented" # MO-024
@@ -91,9 +150,21 @@ def build(anchor: date, seed: int, out: Path) -> None:
# Fixed attention and conflict scenarios.
vehicles[30]["operational_status"] = "blocked" # MO-031
vehicles[15]["operational_status"] = "available" # MO-016 legacy conflict
vehicles[6]["operational_status"] = "rented" # MO-007 has no active booking
vehicles_by_ref = {vehicle["public_ref"]: vehicle for vehicle in vehicles}
# Real missing-field evidence for every seeded open missing-field issue. Resolved
# rows deliberately keep their field populated: the seed represents the post-fix
# state while preserving the historical issue.
for vehicle_ref in ("MO-009", "MO-014", "MO-025", "MO-041", "MO-043", "MO-045", "MO-049"):
vehicles_by_ref[vehicle_ref]["location"] = ""
vehicles_by_ref["MO-026"]["registration_number"] = ""
# Deliberate service-threshold conflicts with evidence that is true in the CSV.
vehicles_by_ref["MO-028"]["next_service_km"] = 38000
vehicles_by_ref["MO-036"]["next_service_km"] = 44000
bookings = []
base_dt = datetime.combine(anchor, time(9, 0), tzinfo=timezone.utc)
base_dt = datetime.combine(anchor, time(9, 0), tzinfo=UTC)
# 220 historical bookings.
for i in range(1, 221):
end = base_dt - timedelta(days=2 + (i % 320), hours=i % 8)
@@ -102,7 +173,8 @@ def build(anchor: date, seed: int, out: Path) -> None:
vehicle_idx = (i * 7) % 50
start_km = int(vehicles[vehicle_idx]["odometer_km"]) - 3000 - (i % 700)
end_km = start_km + 200 + (i % 900)
bookings.append({
bookings.append(
{
"public_ref": f"BK-H-{i:04d}",
"customer_ref": f"CUS-{((i * 11) % 180) + 1:04d}",
"vehicle_ref": f"MO-{vehicle_idx + 1:03d}",
@@ -112,10 +184,27 @@ def build(anchor: date, seed: int, out: Path) -> None:
"start_odometer_km": start_km,
"end_odometer_km": end_km,
"requirements_complete": "true",
})
}
)
bookings_by_ref = {booking["public_ref"]: booking for booking in bookings}
# Two intentional imported regressions. The later return and its inspection retain
# the submitted reading as evidence while an earlier return remains the canonical
# higher observation used by DQ-0007 and DQ-0010.
bookings_by_ref["BK-H-0007"]["end_odometer_km"] = (
int(bookings_by_ref["BK-H-0057"]["end_odometer_km"]) - 192
)
bookings_by_ref["BK-H-0010"]["end_odometer_km"] = (
int(bookings_by_ref["BK-H-0060"]["end_odometer_km"]) - 148
)
# One historical return is curated onto the anchor day for the dashboard; its
# generated inspection inherits the same completion timestamp.
bookings_by_ref["BK-H-0001"]["starts_at"] = iso(base_dt - timedelta(days=3, hours=1))
bookings_by_ref["BK-H-0001"]["ends_at"] = iso(base_dt - timedelta(hours=1))
# Active demo return booking.
bookings.append({
bookings.append(
{
"public_ref": "BK-DEMO-RETURN",
"customer_ref": "CUS-0042",
"vehicle_ref": "MO-024",
@@ -125,14 +214,20 @@ def build(anchor: date, seed: int, out: Path) -> None:
"start_odometer_km": 53610,
"end_odometer_km": "",
"requirements_complete": "true",
})
}
)
# 22 additional future bookings.
for i in range(1, 23):
start = base_dt + timedelta(days=1 + i, hours=(i % 5))
start = (
base_dt + timedelta(hours=i)
if i in (1, 2)
else base_dt + timedelta(days=1 + i, hours=(i % 5))
)
end = start + timedelta(days=3 + (i % 8))
vehicle = 1 + ((i * 9) % 50)
bookings.append({
bookings.append(
{
"public_ref": f"BK-F-{i:03d}",
"customer_ref": f"CUS-{((i * 13) % 180) + 1:04d}",
"vehicle_ref": f"MO-{vehicle:03d}",
@@ -142,10 +237,12 @@ def build(anchor: date, seed: int, out: Path) -> None:
"start_odometer_km": "",
"end_odometer_km": "",
"requirements_complete": "false" if i in (3, 11) else "true",
})
}
)
# MO-031 near-future booking, missing return inspection attention.
bookings.append({
bookings.append(
{
"public_ref": "BK-DEMO-NEXT",
"customer_ref": "CUS-0088",
"vehicle_ref": "MO-031",
@@ -155,11 +252,13 @@ def build(anchor: date, seed: int, out: Path) -> None:
"start_odometer_km": "",
"end_odometer_km": "",
"requirements_complete": "true",
})
}
)
# Controlled legacy overlap on MO-016.
for suffix, offset in [("A", 2), ("B", 4)]:
bookings.append({
bookings.append(
{
"public_ref": f"BK-DEMO-OVERLAP-{suffix}",
"customer_ref": "CUS-0101" if suffix == "A" else "CUS-0102",
"vehicle_ref": "MO-016",
@@ -169,11 +268,99 @@ def build(anchor: date, seed: int, out: Path) -> None:
"start_odometer_km": "",
"end_odometer_km": "",
"requirements_complete": "true",
})
}
)
# Eight additional anchor-day movements make the operational overview feel like a
# working fleet while staying deterministic for any requested anchor date.
traffic_rows = [
(
"BK-T-001",
"CUS-0033",
"MO-003",
-8,
time(5, 30),
0,
time(5, 30),
"returned",
20200,
20850,
),
(
"BK-T-002",
"CUS-0055",
"MO-011",
-5,
time(6, 30),
0,
time(6, 30),
"returned",
25400,
25980,
),
(
"BK-T-003",
"CUS-0077",
"MO-020",
-10,
time(8, 45),
0,
time(8, 45),
"returned",
31800,
32350,
),
(
"BK-T-004",
"CUS-0099",
"MO-033",
-2,
time(12, 0),
0,
time(12, 0),
"returned",
41700,
42200,
),
("BK-T-005", "CUS-0111", "MO-006", 0, time(7, 15), 3, time(7, 15), "reserved", "", ""),
("BK-T-006", "CUS-0123", "MO-017", 0, time(10, 30), 5, time(10, 30), "reserved", "", ""),
("BK-T-007", "CUS-0141", "MO-029", 0, time(13, 15), 7, time(13, 15), "reserved", "", ""),
("BK-T-008", "CUS-0155", "MO-038", 0, time(14, 30), 8, time(14, 30), "reserved", "", ""),
]
for (
public_ref,
customer_ref,
vehicle_ref,
start_day,
start_time,
end_day,
end_time,
status,
start_odometer,
end_odometer,
) in traffic_rows:
bookings.append(
{
"public_ref": public_ref,
"customer_ref": customer_ref,
"vehicle_ref": vehicle_ref,
"starts_at": iso(
datetime.combine(anchor + timedelta(days=start_day), start_time, tzinfo=UTC)
),
"ends_at": iso(
datetime.combine(anchor + timedelta(days=end_day), end_time, tzinfo=UTC)
),
"status": status,
"start_odometer_km": start_odometer,
"end_odometer_km": end_odometer,
"requirements_complete": "true",
}
)
inspections = []
for i, booking in enumerate(bookings[:75], 1):
inspections.append({
inspections.append(
{
"public_ref": f"INSP-{i:04d}",
"booking_ref": booking["public_ref"],
"vehicle_ref": booking["vehicle_ref"],
@@ -184,36 +371,256 @@ def build(anchor: date, seed: int, out: Path) -> None:
"technical_warning": "false",
"odometer_km": booking["end_odometer_km"] or "",
"completed_at": booking["ends_at"],
})
}
)
inspections_by_ref = {inspection["public_ref"]: inspection for inspection in inspections}
returned_readings_by_vehicle: dict[str, list[tuple[datetime, int]]] = {}
for booking in bookings:
if booking["status"] != "returned" or not booking["end_odometer_km"]:
continue
returned_readings_by_vehicle.setdefault(booking["vehicle_ref"], []).append(
(
datetime.fromisoformat(str(booking["ends_at"]).replace("Z", "+00:00")),
int(booking["end_odometer_km"]),
)
)
for readings in returned_readings_by_vehicle.values():
readings.sort()
maintenance = []
for i in range(1, 41):
v = vehicles[(i * 3) % 50]
maintenance.append({
occurred_at = base_dt - timedelta(days=20 + i * 5)
readings = returned_readings_by_vehicle[v["public_ref"]]
prior_readings = [reading for reading in readings if reading[0] <= occurred_at]
# Keep the synthetic cross-source history internally consistent. Historical
# bookings for a vehicle can share a reading in this compact PoC dataset, so the
# nearest known returned reading is a safer seed baseline than deriving an
# unrelated value from today's canonical odometer.
maintenance_odometer = (prior_readings[-1] if prior_readings else readings[0])[1]
maintenance.append(
{
"public_ref": f"MNT-{i:04d}",
"vehicle_ref": v["public_ref"],
"occurred_at": iso(base_dt - timedelta(days=20 + i * 5)),
"odometer_km": int(v["odometer_km"]) - 1000 - i * 20,
"occurred_at": iso(occurred_at),
"odometer_km": maintenance_odometer,
"category": "periodic_service" if i % 3 else "repair",
"summary": "Synthetic scheduled service record" if i % 3 else "Synthetic minor repair record",
})
"summary": "Synthetic scheduled service record"
if i % 3
else "Synthetic minor repair record",
}
)
quality = [
{"public_ref":"DQ-DEMO-DUPLICATE","rule_type":"possible_duplicate_customer","entity_ref":"CUS-0012","related_ref":"CUS-0178","severity":"high","status":"open","evidence":"exact email; exact phone; exact postal code; similar name"},
{"public_ref":"DQ-DEMO-OVERLAP","rule_type":"booking_overlap","entity_ref":"MO-016","related_ref":"BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B","severity":"high","status":"open","evidence":"controlled legacy import overlap"},
{"public_ref":"DQ-DEMO-STATUS","rule_type":"vehicle_status_conflict","entity_ref":"MO-016","related_ref":"","severity":"high","status":"open","evidence":"vehicle marked available while reserved bookings conflict"},
{"public_ref":"DQ-DEMO-ATTENTION","rule_type":"missing_required_field","entity_ref":"MO-031","related_ref":"BK-DEMO-NEXT","severity":"high","status":"open","evidence":"near-future booking; required operational inspection missing"},
]
for i in range(5, 16):
quality.append({
"public_ref": f"DQ-{i:04d}",
"rule_type": ["missing_required_field","odometer_regression","vehicle_status_conflict"][i % 3],
"entity_ref": f"MO-{((i * 7) % 50) + 1:03d}",
{
"public_ref": "DQ-DEMO-DUPLICATE",
"rule_type": "possible_duplicate_customer",
"entity_ref": "CUS-0012",
"related_ref": "CUS-0178",
"severity": "high",
"status": "open",
"evidence": "exact email; exact phone; exact postal code; similar name",
},
{
"public_ref": "DQ-DEMO-OVERLAP",
"rule_type": "booking_overlap",
"entity_ref": "MO-016",
"related_ref": "BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B",
"severity": "high",
"status": "open",
"evidence": "controlled legacy import overlap",
},
{
"public_ref": "DQ-DEMO-STATUS",
"rule_type": "vehicle_status_conflict",
"entity_ref": "MO-016",
"related_ref": "",
"severity": ["low","medium","high"][i % 3],
"status": "open" if i < 12 else "resolved",
"evidence": "Synthetic deterministic seed issue",
})
"severity": "high",
"status": "open",
"evidence": "vehicle marked available while reserved bookings conflict",
},
{
"public_ref": "DQ-DEMO-ATTENTION",
"rule_type": "missing_required_field",
"entity_ref": "MO-031",
"related_ref": "BK-DEMO-NEXT",
"severity": "high",
"status": "open",
"evidence": "near-future booking; required operational inspection missing",
},
{
"public_ref": "DQ-0005",
"rule_type": "vehicle_status_conflict",
"entity_ref": "MO-036",
"related_ref": "",
"severity": "high",
"status": "open",
"evidence": (
"Recommended status: maintenance "
f"({vehicles_by_ref['MO-036']['odometer_km']} km reported against a "
f"{vehicles_by_ref['MO-036']['next_service_km']} km service threshold)"
),
},
{
"public_ref": "DQ-0006",
"rule_type": "missing_required_field",
"entity_ref": "MO-043",
"related_ref": "",
"severity": "low",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0007",
"rule_type": "odometer_regression",
"entity_ref": "MO-050",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": (
"Return inspection INSP-0007 recorded "
f"{inspections_by_ref['INSP-0007']['odometer_km']} km, below the "
f"{inspections_by_ref['INSP-0057']['odometer_km']} km recorded by "
"earlier inspection INSP-0057."
),
},
{
"public_ref": "DQ-0008",
"rule_type": "vehicle_status_conflict",
"entity_ref": "MO-007",
"related_ref": "",
"severity": "high",
"status": "open",
"evidence": "Recommended status: available (marked rented with no active booking)",
},
{
"public_ref": "DQ-0009",
"rule_type": "missing_required_field",
"entity_ref": "MO-014",
"related_ref": "",
"severity": "low",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0010",
"rule_type": "odometer_regression",
"entity_ref": "MO-021",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": (
"Return inspection INSP-0010 recorded "
f"{inspections_by_ref['INSP-0010']['odometer_km']} km, below the "
f"{inspections_by_ref['INSP-0060']['odometer_km']} km recorded by "
"earlier inspection INSP-0060."
),
},
{
"public_ref": "DQ-0011",
"rule_type": "vehicle_status_conflict",
"entity_ref": "MO-028",
"related_ref": "",
"severity": "high",
"status": "open",
"evidence": (
"Recommended status: maintenance "
f"({vehicles_by_ref['MO-028']['odometer_km']} km reported against a "
f"{vehicles_by_ref['MO-028']['next_service_km']} km service threshold)"
),
},
{
"public_ref": "DQ-0012",
"rule_type": "missing_required_field",
"entity_ref": "MO-035",
"related_ref": "",
"severity": "low",
"status": "resolved",
"evidence": "Missing: registration_number",
},
{
"public_ref": "DQ-0013",
"rule_type": "missing_required_field",
"entity_ref": "MO-042",
"related_ref": "",
"severity": "low",
"status": "resolved",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0014",
"rule_type": "missing_required_field",
"entity_ref": "MO-049",
"related_ref": "",
"severity": "low",
"status": "resolved",
"evidence": "Missing: registration_number",
},
{
"public_ref": "DQ-0015",
"rule_type": "missing_required_field",
"entity_ref": "MO-006",
"related_ref": "",
"severity": "low",
"status": "resolved",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0016",
"rule_type": "missing_required_field",
"entity_ref": "MO-009",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0017",
"rule_type": "missing_required_field",
"entity_ref": "MO-025",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0018",
"rule_type": "missing_required_field",
"entity_ref": "MO-026",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: registration_number",
},
{
"public_ref": "DQ-0019",
"rule_type": "missing_required_field",
"entity_ref": "MO-041",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0020",
"rule_type": "missing_required_field",
"entity_ref": "MO-045",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: location",
},
{
"public_ref": "DQ-0021",
"rule_type": "missing_required_field",
"entity_ref": "MO-049",
"related_ref": "",
"severity": "medium",
"status": "open",
"evidence": "Missing: location",
},
]
workflow_runs = []
for i in range(1, 21):
@@ -222,7 +629,8 @@ def build(anchor: date, seed: int, out: Path) -> None:
if i == 20:
status = "failed"
error = "Synthetic connection timeout to n8n"
workflow_runs.append({
workflow_runs.append(
{
"event_id": f"00000000-0000-4000-8000-{i:012d}",
"event_type": "vehicle.returned.v1",
"aggregate_ref": f"BK-H-{i:04d}",
@@ -230,7 +638,8 @@ def build(anchor: date, seed: int, out: Path) -> None:
"attempts": 3 if status == "failed" else 1,
"last_error": error,
"occurred_at": iso(base_dt - timedelta(hours=i)),
})
}
)
write_csv(out / "customers.csv", customers)
write_csv(out / "vehicles.csv", vehicles)
+3 -3
View File
@@ -1,14 +1,14 @@
public_ref,booking_ref,vehicle_ref,type,fuel_level_percent,cleanliness_ok,damage_reported,technical_warning,odometer_km,completed_at
INSP-0001,BK-H-0001,MO-008,return,60,true,false,false,21133,2026-07-29T08:00:00Z
INSP-0001,BK-H-0001,MO-008,return,60,true,false,false,21133,2026-08-01T08:00:00Z
INSP-0002,BK-H-0002,MO-015,return,70,true,false,false,26861,2026-07-28T07:00:00Z
INSP-0003,BK-H-0003,MO-022,return,80,true,false,false,31610,2026-07-27T06:00:00Z
INSP-0004,BK-H-0004,MO-029,return,90,true,false,false,36912,2026-07-26T05:00:00Z
INSP-0005,BK-H-0005,MO-036,return,50,true,false,false,41860,2026-07-25T04:00:00Z
INSP-0006,BK-H-0006,MO-043,return,60,true,false,false,46719,2026-07-24T03:00:00Z
INSP-0007,BK-H-0007,MO-050,return,70,true,false,false,51892,2026-07-23T02:00:00Z
INSP-0007,BK-H-0007,MO-050,return,70,true,false,false,51700,2026-07-23T02:00:00Z
INSP-0008,BK-H-0008,MO-007,return,80,true,false,false,20889,2026-07-22T09:00:00Z
INSP-0009,BK-H-0009,MO-014,return,90,true,false,false,25628,2026-07-21T08:00:00Z
INSP-0010,BK-H-0010,MO-021,return,50,true,false,false,31298,2026-07-20T07:00:00Z
INSP-0010,BK-H-0010,MO-021,return,50,true,false,false,31150,2026-07-20T07:00:00Z
INSP-0011,BK-H-0011,MO-028,return,60,true,false,false,36159,2026-07-19T06:00:00Z
INSP-0012,BK-H-0012,MO-035,return,70,true,false,false,41234,2026-07-18T05:00:00Z
INSP-0013,BK-H-0013,MO-042,return,80,true,false,false,46748,2026-07-17T04:00:00Z
1 public_ref booking_ref vehicle_ref type fuel_level_percent cleanliness_ok damage_reported technical_warning odometer_km completed_at
2 INSP-0001 BK-H-0001 MO-008 return 60 true false false 21133 2026-07-29T08:00:00Z 2026-08-01T08:00:00Z
3 INSP-0002 BK-H-0002 MO-015 return 70 true false false 26861 2026-07-28T07:00:00Z
4 INSP-0003 BK-H-0003 MO-022 return 80 true false false 31610 2026-07-27T06:00:00Z
5 INSP-0004 BK-H-0004 MO-029 return 90 true false false 36912 2026-07-26T05:00:00Z
6 INSP-0005 BK-H-0005 MO-036 return 50 true false false 41860 2026-07-25T04:00:00Z
7 INSP-0006 BK-H-0006 MO-043 return 60 true false false 46719 2026-07-24T03:00:00Z
8 INSP-0007 BK-H-0007 MO-050 return 70 true false false 51892 51700 2026-07-23T02:00:00Z
9 INSP-0008 BK-H-0008 MO-007 return 80 true false false 20889 2026-07-22T09:00:00Z
10 INSP-0009 BK-H-0009 MO-014 return 90 true false false 25628 2026-07-21T08:00:00Z
11 INSP-0010 BK-H-0010 MO-021 return 50 true false false 31298 31150 2026-07-20T07:00:00Z
12 INSP-0011 BK-H-0011 MO-028 return 60 true false false 36159 2026-07-19T06:00:00Z
13 INSP-0012 BK-H-0012 MO-035 return 70 true false false 41234 2026-07-18T05:00:00Z
14 INSP-0013 BK-H-0013 MO-042 return 80 true false false 46748 2026-07-17T04:00:00Z

Some files were not shown because too many files have changed in this diff Show More