fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:
- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
11 of 15 seeded issues that text was literally "Synthetic deterministic seed
issue". AttentionItem now exposes evidence_signals (stable code + params, same
shape as the issue detail page) instead of a detail string; the frontend renders
them through a shared describeEvidenceSignal() used by both the dashboard and the
issue detail page. Every previously-placeholder seed row now cites a real,
per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
field, or a real pair of booking odometer readings) instead of invented prose.
- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
one, so "needs attention" led nowhere. Each now has a real open
missing_required_field issue backed by a genuinely blank field (no schema change,
no migration -- reuses the existing data-quality pipeline).
- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
show a localized explanation ("trip hasn't started yet" / "not yet closed").
MO-024's rented-but-service-overdue contradiction was already caught by the
vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
regression test rather than new logic.
Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3808bbe132
commit
4faac24b5a
@@ -19,6 +19,7 @@ from app.schemas import (
|
||||
AutomationRunOut,
|
||||
CurrentUser,
|
||||
DashboardOut,
|
||||
EvidenceSignalOut,
|
||||
TodayItem,
|
||||
)
|
||||
from app.services.operations import compute_metrics
|
||||
@@ -61,12 +62,21 @@ def get_dashboard(
|
||||
entity = customers_by_id.get(issue.entity_id)
|
||||
link_type = "customer"
|
||||
link_ref = entity.public_ref if entity else ""
|
||||
# The backend never emits prose for the attention queue -- only stable signal
|
||||
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||
# is a technical fallback only, never rendered here.
|
||||
signals = [
|
||||
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||
for s in issue.evidence_json.get("signals", [])
|
||||
]
|
||||
attention_items.append(
|
||||
AttentionItem(
|
||||
kind="quality_issue",
|
||||
severity=issue.severity,
|
||||
rule_type=issue.rule_type,
|
||||
detail=issue.evidence_json.get("summary", ""),
|
||||
evidence_signals=signals,
|
||||
link_type=link_type,
|
||||
link_ref=link_ref,
|
||||
issue_ref=issue.public_ref,
|
||||
|
||||
@@ -115,6 +115,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
return {
|
||||
"entity_type": "vehicle",
|
||||
"public_ref": vehicle.public_ref,
|
||||
"registration_number": vehicle.registration_number,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"location": vehicle.location,
|
||||
|
||||
@@ -350,11 +350,16 @@ class DashboardMetrics(BaseModel):
|
||||
pending_or_failed_workflows: int
|
||||
|
||||
|
||||
class EvidenceSignalOut(BaseModel):
|
||||
code: str
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AttentionItem(BaseModel):
|
||||
kind: Literal["quality_issue", "vehicle"]
|
||||
severity: str
|
||||
rule_type: str
|
||||
detail: str
|
||||
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
|
||||
link_type: Literal["vehicle", "booking", "customer"]
|
||||
link_ref: str
|
||||
issue_ref: str | None = None
|
||||
|
||||
@@ -139,11 +139,11 @@ def load_seed(db: Session) -> SeedResult:
|
||||
|
||||
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||
vehicle_rows = []
|
||||
vehicle_row_by_ref: dict[str, dict] = {}
|
||||
for row in _read_csv("vehicles.csv"):
|
||||
vid = uuid.uuid4()
|
||||
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||
vehicle_rows.append(
|
||||
{
|
||||
vehicle_row = {
|
||||
"id": vid,
|
||||
"public_ref": row["public_ref"],
|
||||
"make": row["make"],
|
||||
@@ -157,17 +157,18 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"active": _parse_bool(row["active"]),
|
||||
"version": 1,
|
||||
}
|
||||
)
|
||||
vehicle_rows.append(vehicle_row)
|
||||
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||
db.execute(insert(Vehicle), vehicle_rows)
|
||||
counts["vehicles"] = len(vehicle_rows)
|
||||
|
||||
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||
booking_rows = []
|
||||
booking_row_by_ref: dict[str, dict] = {}
|
||||
for row in _read_csv("bookings.csv"):
|
||||
bid = uuid.uuid4()
|
||||
booking_id_by_ref[row["public_ref"]] = bid
|
||||
booking_rows.append(
|
||||
{
|
||||
booking_row = {
|
||||
"id": bid,
|
||||
"public_ref": row["public_ref"],
|
||||
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||
@@ -179,7 +180,8 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||
}
|
||||
)
|
||||
booking_rows.append(booking_row)
|
||||
booking_row_by_ref[row["public_ref"]] = booking_row
|
||||
db.execute(insert(Booking), booking_rows)
|
||||
counts["bookings"] = len(booking_rows)
|
||||
|
||||
@@ -225,6 +227,82 @@ def load_seed(db: Session) -> SeedResult:
|
||||
return "customer", customer_id_by_ref[entity_ref]
|
||||
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||
|
||||
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
|
||||
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
|
||||
# handful of seed-only rows below -- none of them carry an active rental or a
|
||||
# real booking conflict (verified against the fixed seed dataset), only a
|
||||
# genuinely-crossed service threshold or none at all, so those two fields are
|
||||
# the only ones that vary per vehicle.
|
||||
vehicle = vehicle_row_by_ref[vehicle_ref]
|
||||
return {
|
||||
"active_booking_refs": [],
|
||||
"overlapping_booking_pairs": [],
|
||||
"service_threshold_reached": service_threshold_reached,
|
||||
"odometer_km": vehicle["odometer_km"],
|
||||
"next_service_km": vehicle["next_service_km"],
|
||||
"open_booking_overlap_issue_ref": None,
|
||||
}
|
||||
|
||||
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]
|
||||
return [
|
||||
{
|
||||
"code": "odometer.regression",
|
||||
"params": {
|
||||
"later_ref": later_ref,
|
||||
"later_km": later["end_odometer_km"],
|
||||
"earlier_ref": earlier_ref,
|
||||
"earlier_km": earlier["end_odometer_km"],
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
def _missing_field_signal(field: str) -> list[dict]:
|
||||
return [{"code": "missing_field", "params": {"field": field}}]
|
||||
|
||||
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
|
||||
# used to carry no structured signal at all -- just the placeholder summary
|
||||
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
|
||||
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
|
||||
# pair of booking odometer readings engineered into seed/bookings.csv), using the
|
||||
# exact same signal vocabulary the live scan (app.services.data_quality) already
|
||||
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
|
||||
"DQ-0005": [
|
||||
{
|
||||
"code": "vehicle.service_threshold_reached",
|
||||
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
|
||||
}
|
||||
],
|
||||
"DQ-0006": _missing_field_signal("location"),
|
||||
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
|
||||
"DQ-0008": [
|
||||
{
|
||||
"code": "vehicle.rental_ended",
|
||||
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
|
||||
}
|
||||
],
|
||||
"DQ-0009": _missing_field_signal("location"),
|
||||
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
|
||||
"DQ-0011": [
|
||||
{
|
||||
"code": "vehicle.service_threshold_reached",
|
||||
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
|
||||
}
|
||||
],
|
||||
"DQ-0012": _missing_field_signal("registration_number"),
|
||||
"DQ-0013": _missing_field_signal("location"),
|
||||
"DQ-0014": _missing_field_signal("registration_number"),
|
||||
"DQ-0015": _missing_field_signal("location"),
|
||||
"DQ-0016": _missing_field_signal("location"),
|
||||
"DQ-0017": _missing_field_signal("location"),
|
||||
"DQ-0018": _missing_field_signal("registration_number"),
|
||||
"DQ-0019": _missing_field_signal("location"),
|
||||
"DQ-0020": _missing_field_signal("location"),
|
||||
"DQ-0021": _missing_field_signal("location"),
|
||||
}
|
||||
|
||||
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
||||
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
||||
# they carry real, accurate structured signals (not just a legacy English
|
||||
@@ -253,7 +331,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||
}
|
||||
]
|
||||
return []
|
||||
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||
|
||||
dq_rows = []
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -24,6 +24,22 @@ def test_dashboard_attention_items_link_to_records(ops_client):
|
||||
assert item["severity"] in ("low", "medium", "high")
|
||||
|
||||
|
||||
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
|
||||
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
|
||||
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
|
||||
The API must never emit prose here -- only stable signal codes + params, exactly
|
||||
like the data-quality issue detail page, for the frontend to localize."""
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
assert len(body["attention_items"]) > 0
|
||||
for item in body["attention_items"]:
|
||||
assert "detail" not in item
|
||||
assert len(item["evidence_signals"]) > 0
|
||||
for signal in item["evidence_signals"]:
|
||||
assert signal["code"]
|
||||
assert signal["code"] != "Synthetic deterministic seed issue"
|
||||
|
||||
|
||||
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||
response = ops_client.get("/api/v1/dashboard")
|
||||
body = response.json()
|
||||
|
||||
@@ -220,6 +220,21 @@ def test_provide_fields_resolves_a_vehicle_missing_field_issue(ops_client):
|
||||
assert vehicle["registration_number"] == "TST-999"
|
||||
|
||||
|
||||
def test_vehicle_entity_snapshot_includes_registration_number(ops_client):
|
||||
"""The snapshot used to omit registration_number entirely, so the 'provide missing
|
||||
fields' form always showed it blank -- even for a vehicle whose plate was actually
|
||||
on file, and even when a *different* field was the genuinely missing one."""
|
||||
issues = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "missing_required_field", "status": "open"},
|
||||
).json()
|
||||
target = next(i for i in issues if i["entity_type"] == "vehicle")
|
||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||
|
||||
detail = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||
assert detail["entity_snapshot"]["registration_number"] == vehicle["registration_number"]
|
||||
|
||||
|
||||
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
||||
|
||||
@@ -23,12 +23,13 @@ def test_seed_counts_match_deterministic_dataset():
|
||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
||||
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
||||
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
|
||||
# real open issue) plus a deterministic set discovered by the post-seed scan. The
|
||||
# shared vehicle-status evaluator (app.services.vehicle_status) also catches
|
||||
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||
# previous hand-rolled scanner never checked for.
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
|
||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 33
|
||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||
finally:
|
||||
@@ -200,3 +201,76 @@ def test_seed_today_movements_are_a_credible_mix():
|
||||
assert len(returns) >= 2
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_every_blocked_vehicle_has_a_real_open_issue():
|
||||
"""A live reviewer found blocked vehicles with no explanation anywhere in the UI --
|
||||
5 with zero quality issues at all, one (MO-049) with only a resolved one. Every
|
||||
vehicle seeded as 'blocked' must now have at least one real, currently open
|
||||
DataQualityIssue an operator can click through to."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
blocked = db.scalars(select(Vehicle).where(Vehicle.operational_status == "blocked")).all()
|
||||
assert len(blocked) > 0
|
||||
for vehicle in blocked:
|
||||
open_issue = db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
)
|
||||
assert open_issue is not None, f"{vehicle.public_ref} is blocked with no open issue"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_scenario_mo024_service_conflict_is_flagged():
|
||||
"""Regression lock-in for the live-reported MO-024 defect: 'rented' with a real
|
||||
active booking (BK-DEMO-RETURN) yet already 14,820 km past its service threshold,
|
||||
with nothing surfacing the contradiction. The shared vehicle-status evaluator
|
||||
already catches this (a real conflict, not a fabricated third status) -- this test
|
||||
exists so a future change can't silently regress it back to unexplained."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
vehicle = _by_ref(db, Vehicle, "MO-024")
|
||||
assert vehicle is not None
|
||||
assert vehicle.operational_status == "rented"
|
||||
assert vehicle.odometer_km >= vehicle.next_service_km
|
||||
|
||||
issue = db.scalar(
|
||||
select(DataQualityIssue).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.rule_type == "vehicle_status_conflict",
|
||||
)
|
||||
)
|
||||
assert issue is not None
|
||||
signals = issue.evidence_json.get("signals", [])
|
||||
assert any(s["code"] == "vehicle.manual_review_required" for s in signals)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_seed_evidence_has_no_placeholder_summary():
|
||||
"""Every seed-only data-quality issue used to carry the vacuous evidence
|
||||
'Synthetic deterministic seed issue' with no structured signal at all -- a visitor
|
||||
had no way to understand why it needed attention. Every issue must now carry a real
|
||||
summary and at least one localizable signal (code + params)."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
reset_and_seed(db)
|
||||
issues = db.scalars(select(DataQualityIssue)).all()
|
||||
assert len(issues) > 0
|
||||
for issue in issues:
|
||||
summary = issue.evidence_json.get("summary", "")
|
||||
assert summary != "Synthetic deterministic seed issue", (
|
||||
f"{issue.public_ref} still has the meaningless placeholder summary"
|
||||
)
|
||||
signals = issue.evidence_json.get("signals", [])
|
||||
assert len(signals) > 0, f"{issue.public_ref} has no structured evidence signal"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -85,11 +85,16 @@ export interface DashboardMetrics {
|
||||
pending_or_failed_workflows: number;
|
||||
}
|
||||
|
||||
export interface EvidenceSignal {
|
||||
code: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AttentionItem {
|
||||
kind: string;
|
||||
severity: "low" | "medium" | "high";
|
||||
rule_type: string;
|
||||
detail: string;
|
||||
evidence_signals: EvidenceSignal[];
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
issue_ref: string | null;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { EvidenceSignal } from "../api/types";
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// The backend never emits prose for data-quality evidence -- only stable signal codes
|
||||
// plus raw data params (see backend/app/services/data_quality.py::_open_issue). This is
|
||||
// the one place that turns them into the operator's selected language, shared by the
|
||||
// issue detail page and the dashboard attention queue so the same code always reads the
|
||||
// same way everywhere it appears.
|
||||
export function describeEvidenceSignal(
|
||||
t: TFunction,
|
||||
formatNumber: (value: number) => string,
|
||||
signal: EvidenceSignal,
|
||||
): string {
|
||||
const { code, params = {} } = signal;
|
||||
if (code.startsWith("vehicle.")) {
|
||||
return t(`quality:detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
|
||||
}
|
||||
switch (code) {
|
||||
case "missing_field":
|
||||
return t("quality:detail.evidence.missingField", {
|
||||
field: t(`quality:detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
|
||||
});
|
||||
case "overlap.reserved_bookings":
|
||||
return t("quality:detail.evidence.overlapReservedBookings", {
|
||||
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
|
||||
});
|
||||
case "odometer.regression":
|
||||
return t("quality:detail.evidence.odometerRegression", {
|
||||
laterRef: params.later_ref,
|
||||
laterKm: formatNumber(Number(params.later_km ?? 0)),
|
||||
earlierRef: params.earlier_ref,
|
||||
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
|
||||
});
|
||||
case "duplicate.similar_name":
|
||||
return t("quality:detail.evidence.duplicateSimilarName", { score: params.score });
|
||||
case "attention.upcoming_booking_missing_inspection":
|
||||
return t("quality:detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
|
||||
default:
|
||||
return t(`quality:detail.evidence.${code}`, { defaultValue: code });
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Ends",
|
||||
"startOdometer": "Start odometer",
|
||||
"endOdometer": "End odometer",
|
||||
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||
"requirementsComplete": "Requirements complete",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Missing field: {{field}}",
|
||||
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
|
||||
"seedPlaceholder": "Synthetic seed data with no further detail."
|
||||
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Compare and merge",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Fin",
|
||||
"startOdometer": "Kilométrage de départ",
|
||||
"endOdometer": "Kilométrage de retour",
|
||||
"startOdometerPending": "Pas encore enregistré — trajet pas encore commencé",
|
||||
"endOdometerPending": "Pas encore enregistré — pas encore clôturé",
|
||||
"requirementsComplete": "Exigences complètes",
|
||||
"yes": "Oui",
|
||||
"no": "Non"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Champ manquant : {{field}}",
|
||||
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
|
||||
"odometerRegression": "La réservation {{laterRef}} a enregistré {{laterKm}} km, en dessous des {{earlierKm}} km enregistrés par la réservation antérieure {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante.",
|
||||
"seedPlaceholder": "Donnée de démonstration synthétique sans autre détail."
|
||||
"upcomingBookingMissingInspection": "La réservation {{bookingRef}} commence bientôt mais l'inspection opérationnelle requise est encore manquante."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Comparer et fusionner",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"ends": "Einde",
|
||||
"startOdometer": "Startkilometerstand",
|
||||
"endOdometer": "Eindkilometerstand",
|
||||
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
|
||||
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
|
||||
"requirementsComplete": "Vereisten volledig",
|
||||
"yes": "Ja",
|
||||
"no": "Nee"
|
||||
|
||||
@@ -109,8 +109,7 @@
|
||||
"missingField": "Ontbrekend veld: {{field}}",
|
||||
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
|
||||
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
|
||||
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog.",
|
||||
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
|
||||
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
|
||||
},
|
||||
"duplicateCustomer": {
|
||||
"heading": "Vergelijken en samenvoegen",
|
||||
|
||||
@@ -68,8 +68,8 @@ export function BookingDetail() {
|
||||
<div><dt>{t("detail.vehicle")}</dt><dd><Link to={`/vehicles/${booking.vehicle_ref}`}>{booking.vehicle_ref}</Link></dd></div>
|
||||
<div><dt>{t("detail.starts")}</dt><dd>{formatDateTime(booking.starts_at)}</dd></div>
|
||||
<div><dt>{t("detail.ends")}</dt><dd>{formatDateTime(booking.ends_at)}</dd></div>
|
||||
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : "—"}</dd></div>
|
||||
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : "—"}</dd></div>
|
||||
<div><dt>{t("detail.startOdometer")}</dt><dd>{booking.start_odometer_km !== null ? `${formatNumber(booking.start_odometer_km)} km` : t("detail.startOdometerPending")}</dd></div>
|
||||
<div><dt>{t("detail.endOdometer")}</dt><dd>{booking.end_odometer_km !== null ? `${formatNumber(booking.end_odometer_km)} km` : t("detail.endOdometerPending")}</dd></div>
|
||||
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
|
||||
</dl></section>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
||||
import { describeEvidenceSignal } from "../data/evidenceSignals";
|
||||
import type { AttentionItem } from "../api/types";
|
||||
|
||||
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
|
||||
{ key: "available", labelKey: "readiness.available", tone: "ready" },
|
||||
@@ -29,9 +31,20 @@ function attentionItemTitle(
|
||||
return `${ruleLabel} — ${item.link_ref}`;
|
||||
}
|
||||
|
||||
// The backend never emits prose here either -- only stable signal codes + params, exactly
|
||||
// like the data-quality issue detail page. Multiple signals on one item (e.g. the
|
||||
// duplicate-customer match) are joined into one readable line for this compact row.
|
||||
function attentionItemDetail(
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
formatNumber: (value: number) => string,
|
||||
item: Pick<AttentionItem, "evidence_signals">,
|
||||
): string {
|
||||
return item.evidence_signals.map((signal) => describeEvidenceSignal(t, formatNumber, signal)).join(" · ");
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
||||
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
|
||||
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
|
||||
const greetingPeriod = useGreetingPeriod();
|
||||
const { user } = useAuth();
|
||||
const { manifest } = useDemoManifest();
|
||||
@@ -71,9 +84,9 @@ export function Dashboard() {
|
||||
|
||||
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
||||
const matchesSeverity = severity === "all" || item.severity === severity;
|
||||
const haystack = `${attentionItemTitle(t, item)} ${item.detail} ${item.link_ref}`.toLowerCase();
|
||||
const haystack = `${attentionItemTitle(t, item)} ${attentionItemDetail(t, formatNumber, item)} ${item.link_ref}`.toLowerCase();
|
||||
return matchesSeverity && haystack.includes(query.toLowerCase());
|
||||
}) ?? [], [data, query, severity, t]);
|
||||
}) ?? [], [data, query, severity, t, formatNumber]);
|
||||
|
||||
const isUnfiltered = severity === "all" && query === "";
|
||||
const attentionTiers = useMemo(() => {
|
||||
@@ -157,7 +170,7 @@ export function Dashboard() {
|
||||
<SeverityBadge severity={item.severity} />
|
||||
<div className="queue-copy">
|
||||
<p className="attention-title">{title}</p>
|
||||
<p className="attention-detail">{item.detail}</p>
|
||||
<p className="attention-detail">{attentionItemDetail(t, formatNumber, item)}</p>
|
||||
</div>
|
||||
<span className="queue-ref">{item.link_ref}</span>
|
||||
<Icon name="chevron" className="row-chevron" />
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useDemoGuide } from "../context/DemoGuideContext";
|
||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||
import { describeEvidenceSignal } from "../data/evidenceSignals";
|
||||
import type { EvidenceSignal } from "../api/types";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
|
||||
@@ -47,74 +49,21 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface EvidenceSignal {
|
||||
code: string;
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// The backend never emits prose for evidence -- only stable signal codes + raw data
|
||||
// params (see app/services/data_quality.py::_open_issue). This is the one place that
|
||||
// turns them into the operator's selected language; the legacy `evidence.summary`
|
||||
// string is a technical fallback only, shown solely inside EvidenceDisclosure above.
|
||||
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario)
|
||||
// carry no structured signals -- rather than show nothing, this known placeholder
|
||||
// summary gets a localized rendering. Any other un-signalled legacy text still falls
|
||||
// back to the raw string (better than a blank primary evidence area), but only
|
||||
// "Technical details" is meant to guarantee raw-text visibility.
|
||||
const SEED_PLACEHOLDER_SUMMARY = "Synthetic deterministic seed issue";
|
||||
|
||||
// params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
|
||||
// one place that turns them into the operator's selected language; the legacy
|
||||
// `evidence.summary` string is a technical fallback only, shown solely inside
|
||||
// EvidenceDisclosure above.
|
||||
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
|
||||
const { t } = useTranslation("quality");
|
||||
const { formatNumber } = useLocaleFormat();
|
||||
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
|
||||
if (signals.length === 0) {
|
||||
const rawSummary = String(issue.evidence.summary ?? "");
|
||||
if (!rawSummary) return null;
|
||||
const text =
|
||||
rawSummary === SEED_PLACEHOLDER_SUMMARY
|
||||
? t("detail.evidence.seedPlaceholder")
|
||||
: rawSummary;
|
||||
return (
|
||||
<ul className="evidence-signal-list">
|
||||
<li>{text}</li>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function describe(signal: EvidenceSignal): string {
|
||||
const { code, params = {} } = signal;
|
||||
if (code.startsWith("vehicle.")) {
|
||||
return t(`detail.vehicleStatusConflict.reasonCodes.${code}`, { defaultValue: code });
|
||||
}
|
||||
switch (code) {
|
||||
case "missing_field":
|
||||
return t("detail.evidence.missingField", {
|
||||
field: t(`detail.missingField.fields.${params.field}`, { defaultValue: String(params.field) }),
|
||||
});
|
||||
case "overlap.reserved_bookings":
|
||||
return t("detail.evidence.overlapReservedBookings", {
|
||||
refs: Array.isArray(params.refs) ? params.refs.join(" ↔ ") : "",
|
||||
});
|
||||
case "odometer.regression":
|
||||
return t("detail.evidence.odometerRegression", {
|
||||
laterRef: params.later_ref,
|
||||
laterKm: formatNumber(Number(params.later_km ?? 0)),
|
||||
earlierRef: params.earlier_ref,
|
||||
earlierKm: formatNumber(Number(params.earlier_km ?? 0)),
|
||||
});
|
||||
case "duplicate.similar_name":
|
||||
return t("detail.evidence.duplicateSimilarName", { score: params.score });
|
||||
case "attention.upcoming_booking_missing_inspection":
|
||||
return t("detail.evidence.upcomingBookingMissingInspection", { bookingRef: params.booking_ref });
|
||||
default:
|
||||
return t(`detail.evidence.${code}`, { defaultValue: code });
|
||||
}
|
||||
}
|
||||
if (signals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ul className="evidence-signal-list">
|
||||
{signals.map((signal, index) => (
|
||||
<li key={`${signal.code}-${index}`}>{describe(signal)}</li>
|
||||
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ BK-H-0003,CUS-0034,MO-022,2026-07-22T06:00:00Z,2026-07-27T06:00:00Z,returned,314
|
||||
BK-H-0004,CUS-0045,MO-029,2026-07-20T05:00:00Z,2026-07-26T05:00:00Z,returned,36708,36912,true
|
||||
BK-H-0005,CUS-0056,MO-036,2026-07-18T04:00:00Z,2026-07-25T04:00:00Z,returned,41655,41860,true
|
||||
BK-H-0006,CUS-0067,MO-043,2026-07-16T03:00:00Z,2026-07-24T03:00:00Z,returned,46513,46719,true
|
||||
BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51892,true
|
||||
BK-H-0007,CUS-0078,MO-050,2026-07-14T02:00:00Z,2026-07-23T02:00:00Z,returned,51685,51700,true
|
||||
BK-H-0008,CUS-0089,MO-007,2026-07-12T09:00:00Z,2026-07-22T09:00:00Z,returned,20681,20889,true
|
||||
BK-H-0009,CUS-0100,MO-014,2026-07-10T08:00:00Z,2026-07-21T08:00:00Z,returned,25419,25628,true
|
||||
BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31298,true
|
||||
BK-H-0010,CUS-0111,MO-021,2026-07-18T07:00:00Z,2026-07-20T07:00:00Z,returned,31088,31150,true
|
||||
BK-H-0011,CUS-0122,MO-028,2026-07-16T06:00:00Z,2026-07-19T06:00:00Z,returned,35948,36159,true
|
||||
BK-H-0012,CUS-0133,MO-035,2026-07-14T05:00:00Z,2026-07-18T05:00:00Z,returned,41022,41234,true
|
||||
BK-H-0013,CUS-0144,MO-042,2026-07-12T04:00:00Z,2026-07-17T04:00:00Z,returned,46535,46748,true
|
||||
|
||||
|
@@ -3,14 +3,20 @@ DQ-DEMO-DUPLICATE,possible_duplicate_customer,CUS-0012,CUS-0178,high,open,exact
|
||||
DQ-DEMO-OVERLAP,booking_overlap,MO-016,BK-DEMO-OVERLAP-A|BK-DEMO-OVERLAP-B,high,open,controlled legacy import overlap
|
||||
DQ-DEMO-STATUS,vehicle_status_conflict,MO-016,,high,open,vehicle marked available while reserved bookings conflict
|
||||
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,Synthetic deterministic seed issue
|
||||
DQ-0006,missing_required_field,MO-043,,low,open,Synthetic deterministic seed issue
|
||||
DQ-0007,odometer_regression,MO-050,,medium,open,Synthetic deterministic seed issue
|
||||
DQ-0008,vehicle_status_conflict,MO-007,,high,open,Synthetic deterministic seed issue
|
||||
DQ-0009,missing_required_field,MO-014,,low,open,Synthetic deterministic seed issue
|
||||
DQ-0010,odometer_regression,MO-021,,medium,open,Synthetic deterministic seed issue
|
||||
DQ-0011,vehicle_status_conflict,MO-028,,high,open,Synthetic deterministic seed issue
|
||||
DQ-0012,missing_required_field,MO-035,,low,resolved,Synthetic deterministic seed issue
|
||||
DQ-0013,odometer_regression,MO-042,,medium,resolved,Synthetic deterministic seed issue
|
||||
DQ-0014,vehicle_status_conflict,MO-049,,high,resolved,Synthetic deterministic seed issue
|
||||
DQ-0015,missing_required_field,MO-006,,low,resolved,Synthetic deterministic seed issue
|
||||
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-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-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
|
||||
DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number
|
||||
DQ-0015,missing_required_field,MO-006,,low,resolved,Missing: location
|
||||
DQ-0016,missing_required_field,MO-009,,medium,open,Missing: location
|
||||
DQ-0017,missing_required_field,MO-025,,medium,open,Missing: location
|
||||
DQ-0018,missing_required_field,MO-026,,medium,open,Missing: registration_number
|
||||
DQ-0019,missing_required_field,MO-041,,medium,open,Missing: location
|
||||
DQ-0020,missing_required_field,MO-045,,medium,open,Missing: location
|
||||
DQ-0021,missing_required_field,MO-049,,medium,open,Missing: location
|
||||
|
||||
|
+10
-10
@@ -7,12 +7,12 @@ MO-005,Bürstner,Lyseo,2024,2-MOB-005,Geel,available,22213,30000,true
|
||||
MO-006,Sunlight,Cliff,2025,2-MOB-006,Geel,cleaning,23163,30000,true
|
||||
MO-007,Adria,Matrix,2019,2-MOB-007,Geel,rented,23689,30000,true
|
||||
MO-008,Dethleffs,Trend,2020,2-MOB-008,Geel,available,23933,30000,true
|
||||
MO-009,Carado,T-Series,2021,2-MOB-009,Geel,blocked,25203,30000,true
|
||||
MO-009,Carado,T-Series,2021,2-MOB-009,,blocked,25203,30000,true
|
||||
MO-010,Hymer,Exsis,2022,2-MOB-010,Geel,available,25333,30000,true
|
||||
MO-011,Bürstner,Lyseo,2023,2-MOB-011,Geel,available,26160,30000,true
|
||||
MO-012,Sunlight,Cliff,2024,2-MOB-012,Geel,available,26859,30000,true
|
||||
MO-013,Adria,Matrix,2025,2-MOB-013,Geel,rented,27750,30000,true
|
||||
MO-014,Dethleffs,Trend,2019,2-MOB-014,Geel,rented,28428,30000,true
|
||||
MO-014,Dethleffs,Trend,2019,2-MOB-014,,rented,28428,30000,true
|
||||
MO-015,Carado,T-Series,2020,2-MOB-015,Geel,available,29661,30000,true
|
||||
MO-016,Hymer,Exsis,2021,2-MOB-016,Geel,available,30497,40000,true
|
||||
MO-017,Bürstner,Lyseo,2022,2-MOB-017,Geel,available,30596,40000,true
|
||||
@@ -23,10 +23,10 @@ MO-021,Carado,T-Series,2019,2-MOB-021,Geel,cleaning,34098,40000,true
|
||||
MO-022,Hymer,Exsis,2020,2-MOB-022,Geel,available,34410,40000,true
|
||||
MO-023,Bürstner,Lyseo,2021,2-MOB-023,Geel,rented,35624,40000,true
|
||||
MO-024,Sunlight,Cliff,2022,2-MOB-024,Geel,rented,54820,40000,true
|
||||
MO-025,Adria,Matrix,2023,2-MOB-025,Geel,blocked,36429,40000,true
|
||||
MO-026,Dethleffs,Trend,2024,2-MOB-026,Geel,blocked,37273,40000,true
|
||||
MO-025,Adria,Matrix,2023,2-MOB-025,,blocked,36429,40000,true
|
||||
MO-026,Dethleffs,Trend,2024,,Geel,blocked,37273,40000,true
|
||||
MO-027,Carado,T-Series,2025,2-MOB-027,Geel,available,38502,40000,true
|
||||
MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,40000,true
|
||||
MO-028,Hymer,Exsis,2019,2-MOB-028,Geel,cleaning,38959,38000,true
|
||||
MO-029,Bürstner,Lyseo,2020,2-MOB-029,Geel,rented,39712,40000,true
|
||||
MO-030,Sunlight,Cliff,2021,2-MOB-030,Geel,maintenance,40213,50000,true
|
||||
MO-031,Adria,Matrix,2022,2-MOB-031,Geel,blocked,41149,50000,true
|
||||
@@ -34,18 +34,18 @@ MO-032,Dethleffs,Trend,2023,2-MOB-032,Geel,rented,41791,50000,true
|
||||
MO-033,Carado,T-Series,2024,2-MOB-033,Geel,available,42561,50000,true
|
||||
MO-034,Hymer,Exsis,2025,2-MOB-034,Geel,available,43057,50000,true
|
||||
MO-035,Bürstner,Lyseo,2019,2-MOB-035,Geel,rented,44034,50000,true
|
||||
MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,50000,true
|
||||
MO-036,Sunlight,Cliff,2020,2-MOB-036,Geel,available,44660,44000,true
|
||||
MO-037,Adria,Matrix,2021,2-MOB-037,Geel,available,45654,50000,true
|
||||
MO-038,Dethleffs,Trend,2022,2-MOB-038,Geel,available,46630,50000,true
|
||||
MO-039,Carado,T-Series,2023,2-MOB-039,Geel,rented,46657,50000,true
|
||||
MO-040,Hymer,Exsis,2024,2-MOB-040,Geel,available,47909,50000,true
|
||||
MO-041,Bürstner,Lyseo,2025,2-MOB-041,Geel,blocked,48443,50000,true
|
||||
MO-041,Bürstner,Lyseo,2025,2-MOB-041,,blocked,48443,50000,true
|
||||
MO-042,Sunlight,Cliff,2019,2-MOB-042,Geel,available,49548,50000,true
|
||||
MO-043,Adria,Matrix,2020,2-MOB-043,Geel,available,49519,50000,true
|
||||
MO-043,Adria,Matrix,2020,2-MOB-043,,available,49519,50000,true
|
||||
MO-044,Dethleffs,Trend,2021,2-MOB-044,Geel,available,50611,60000,true
|
||||
MO-045,Carado,T-Series,2022,2-MOB-045,Geel,blocked,51260,60000,true
|
||||
MO-045,Carado,T-Series,2022,2-MOB-045,,blocked,51260,60000,true
|
||||
MO-046,Hymer,Exsis,2023,2-MOB-046,Geel,cleaning,51957,60000,true
|
||||
MO-047,Bürstner,Lyseo,2024,2-MOB-047,Geel,available,52452,60000,true
|
||||
MO-048,Sunlight,Cliff,2025,2-MOB-048,Geel,maintenance,53693,60000,true
|
||||
MO-049,Adria,Matrix,2019,2-MOB-049,Geel,blocked,53935,60000,true
|
||||
MO-049,Adria,Matrix,2019,2-MOB-049,,blocked,53935,60000,true
|
||||
MO-050,Dethleffs,Trend,2020,2-MOB-050,Geel,cleaning,54692,60000,true
|
||||
|
||||
|
Reference in New Issue
Block a user