diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py index be1bef3..1036519 100644 --- a/backend/app/seed_loader.py +++ b/backend/app/seed_loader.py @@ -4,6 +4,7 @@ import csv import uuid from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta +from difflib import SequenceMatcher from pathlib import Path from sqlalchemy import delete, insert, update @@ -108,21 +109,22 @@ def load_seed(db: Session) -> SeedResult: customer_id_by_ref: dict[str, uuid.UUID] = {} customer_rows = [] + customer_row_by_ref: dict[str, dict] = {} for row in _read_csv("customers.csv"): cid = uuid.uuid4() customer_id_by_ref[row["public_ref"]] = cid - customer_rows.append( - { - "id": cid, - "public_ref": row["public_ref"], - "first_name": row["first_name"], - "last_name": row["last_name"], - "email": row["email"] or None, - "phone": row["phone"] or None, - "postal_code": row["postal_code"] or None, - "city": row["city"] or None, - } - ) + customer_row = { + "id": cid, + "public_ref": row["public_ref"], + "first_name": row["first_name"], + "last_name": row["last_name"], + "email": row["email"] or None, + "phone": row["phone"] or None, + "postal_code": row["postal_code"] or None, + "city": row["city"] or None, + } + customer_rows.append(customer_row) + customer_row_by_ref[row["public_ref"]] = customer_row db.execute(insert(Customer), customer_rows) counts["customers"] = len(customer_rows) # Second pass for merged_into (self-referencing FK) since target must exist first. @@ -223,11 +225,42 @@ def load_seed(db: Session) -> SeedResult: return "customer", customer_id_by_ref[entity_ref] return "vehicle", vehicle_id_by_ref[entity_ref] + 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 + # sentence) -- the frontend renders these as the primary, localized evidence; + # see docs/fleet-ops-correction/current-gap-audit.md §6. + if public_ref == "DQ-DEMO-DUPLICATE": + a = customer_row_by_ref[entity_ref] + b = customer_row_by_ref[related_refs[0]] + name_a = f"{a['first_name']} {a['last_name']}".strip().lower() + name_b = f"{b['first_name']} {b['last_name']}".strip().lower() + ratio = SequenceMatcher(None, name_a, name_b).ratio() + return [ + {"code": "duplicate.exact_email"}, + {"code": "duplicate.exact_phone"}, + {"code": "duplicate.same_postal_code"}, + {"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}, + ] + if public_ref == "DQ-DEMO-OVERLAP": + return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}] + if public_ref == "DQ-DEMO-STATUS": + return [{"code": "vehicle.booking_conflict"}] + if public_ref == "DQ-DEMO-ATTENTION": + return [ + { + "code": "attention.upcoming_booking_missing_inspection", + "params": {"booking_ref": related_refs[0] if related_refs else ""}, + } + ] + return [] + dq_rows = [] now = datetime.now(UTC) for row in _read_csv("data_quality_issues.csv"): entity_type, entity_id = resolve_entity(row["entity_ref"]) related_ref = row.get("related_ref") or "" + related_refs = related_ref.split("|") if related_ref else [] dq_rows.append( { "id": uuid.uuid4(), @@ -240,7 +273,8 @@ def load_seed(db: Session) -> SeedResult: "evidence_json": { "summary": row["evidence"], "entity_ref": row["entity_ref"], - "related_refs": related_ref.split("|") if related_ref else [], + "related_refs": related_refs, + "signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs), }, "proposed_action_json": {}, "detected_at": now, diff --git a/frontend/e2e/fleet-ops-correction.spec.ts b/frontend/e2e/fleet-ops-correction.spec.ts index 2325394..1bd0ba8 100644 --- a/frontend/e2e/fleet-ops-correction.spec.ts +++ b/frontend/e2e/fleet-ops-correction.spec.ts @@ -350,3 +350,30 @@ test.describe("route matrix (section 11F)", () => { }); } }); + +test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => { + // The primary evidence line at the top of every issue's detail page must render the + // structured `evidence.signals` in the operator's language; the legacy English + // `evidence.summary` string is a technical fallback only, visible solely inside + // "Technical details". Live-caught: this line was unconditionally showing raw + // English ("vehicle marked available while reserved bookings conflict") in every + // language until fixed. + const cases: { lang: string; expectedText: RegExp }[] = [ + { lang: "nl-BE", expectedText: /overlappende reserveringen/i }, + { lang: "en-GB", expectedText: /overlapping bookings/i }, + { lang: "fr-BE", expectedText: /chevauchent|chevauchement/i }, + ]; + + for (const { lang, expectedText } of cases) { + test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => { + await resetDemoData(request); + await loginAsOpsManager(page, lang); + await page.goto("/data-quality/DQ-DEMO-STATUS"); + + const summarySection = page.locator(".record-surface-evidence"); + await expect(summarySection).toBeVisible(); + await expect(summarySection).toContainText(expectedText); + await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict"); + }); + } +}); diff --git a/frontend/src/i18n/locales/en-GB/quality.json b/frontend/src/i18n/locales/en-GB/quality.json index 33278e3..6a6531b 100644 --- a/frontend/src/i18n/locales/en-GB/quality.json +++ b/frontend/src/i18n/locales/en-GB/quality.json @@ -101,6 +101,17 @@ "whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet." } }, + "evidence": { + "duplicate.exact_email": "Identical email address", + "duplicate.exact_phone": "Identical phone number", + "duplicate.same_postal_code": "Same postal code", + "duplicateSimilarName": "Strongly similar name (score {{score}})", + "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." + }, "duplicateCustomer": { "heading": "Compare and merge", "description": "Choose the canonical customer and review each conflicting field.", diff --git a/frontend/src/i18n/locales/fr-BE/quality.json b/frontend/src/i18n/locales/fr-BE/quality.json index 1057266..f0571d6 100644 --- a/frontend/src/i18n/locales/fr-BE/quality.json +++ b/frontend/src/i18n/locales/fr-BE/quality.json @@ -101,6 +101,17 @@ "whyItMatters": "Un statut incorrect peut faire apparaître un véhicule indisponible comme réservable, ou masquer un véhicule disponible de la flotte." } }, + "evidence": { + "duplicate.exact_email": "Adresse e-mail identique", + "duplicate.exact_phone": "Numéro de téléphone identique", + "duplicate.same_postal_code": "Même code postal", + "duplicateSimilarName": "Nom fortement similaire (score {{score}})", + "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." + }, "duplicateCustomer": { "heading": "Comparer et fusionner", "description": "Choisissez le client de référence et examinez chaque champ divergent.", diff --git a/frontend/src/i18n/locales/nl-BE/quality.json b/frontend/src/i18n/locales/nl-BE/quality.json index a6d84d2..07c9a2c 100644 --- a/frontend/src/i18n/locales/nl-BE/quality.json +++ b/frontend/src/i18n/locales/nl-BE/quality.json @@ -101,6 +101,17 @@ "whyItMatters": "Een foutieve status kan een niet-beschikbaar voertuig boekbaar laten lijken, of een beschikbaar voertuig verbergen voor het wagenpark." } }, + "evidence": { + "duplicate.exact_email": "Identiek e-mailadres", + "duplicate.exact_phone": "Identiek telefoonnummer", + "duplicate.same_postal_code": "Dezelfde postcode", + "duplicateSimilarName": "Sterk gelijkende naam (score {{score}})", + "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." + }, "duplicateCustomer": { "heading": "Vergelijken en samenvoegen", "description": "Kies de klant die behouden blijft en bekijk elk afwijkend veld.", diff --git a/frontend/src/pages/DataQualityIssueDetail.tsx b/frontend/src/pages/DataQualityIssueDetail.tsx index 4fb0a18..157455f 100644 --- a/frontend/src/pages/DataQualityIssueDetail.tsx +++ b/frontend/src/pages/DataQualityIssueDetail.tsx @@ -46,6 +46,79 @@ function EvidenceDisclosure({ issue }: { issue: IssueDetail }) { ); } +interface EvidenceSignal { + code: string; + params?: Record; +} + +// 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"; + +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 ( + + ); + } + + 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 }); + } + } + + return ( + + ); +} + function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) { const { t } = useTranslation("quality"); const { user } = useAuth(); @@ -726,8 +799,11 @@ export function DataQualityIssueDetail() {
{t("detail.summary.rule")}
{t(`ruleTypes.${issue.rule_type}`, { defaultValue: issue.rule_type.replace(/_/g, " ") })}
{t("detail.summary.entity")}
{issue.entity_type === "vehicle" ? {issue.entity_ref} : issue.entity_ref}
-
{t("detail.summary.evidenceSummary")}
{String(issue.evidence.summary ?? "")}
+
+
{t("detail.summary.evidenceSummary")}
+ +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 552cff3..966c7cd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -239,6 +239,10 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .tabs button { position: relative; min-height: 44px; padding: 8px 13px; border: 0; background: transparent; color: var(--muted); font-size: .72rem; font-weight: 700; text-transform: capitalize; cursor: pointer; } .tabs button.active { color: var(--teal-dark); }.tabs button.active::after { content: ""; position: absolute; inset: auto 7px -1px; height: 2px; background: var(--teal); } .record-surface { padding: 18px; margin-bottom: 18px; } +.record-surface-evidence { padding: 0 18px 16px; } +.record-surface-evidence dt { margin: 0 0 6px; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; } +.record-surface-evidence .evidence-signal-list { margin: 0; padding-left: 18px; color: var(--ink); font-size: .82rem; font-weight: 700; line-height: 1.6; } +.record-surface-evidence .evidence-signal-list li { margin-bottom: 3px; } .about-card h2 { margin: 0 0 8px; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; } .about-card p { margin: 0; color: var(--muted); font-size: .82rem; line-height: 1.62; } .about-card p code { padding: 1px 5px; background: var(--surface-subtle); border-radius: 4px; font-size: .78rem; }