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,
|
AutomationRunOut,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DashboardOut,
|
DashboardOut,
|
||||||
|
EvidenceSignalOut,
|
||||||
TodayItem,
|
TodayItem,
|
||||||
)
|
)
|
||||||
from app.services.operations import compute_metrics
|
from app.services.operations import compute_metrics
|
||||||
@@ -61,12 +62,21 @@ def get_dashboard(
|
|||||||
entity = customers_by_id.get(issue.entity_id)
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
link_type = "customer"
|
link_type = "customer"
|
||||||
link_ref = entity.public_ref if entity else ""
|
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(
|
attention_items.append(
|
||||||
AttentionItem(
|
AttentionItem(
|
||||||
kind="quality_issue",
|
kind="quality_issue",
|
||||||
severity=issue.severity,
|
severity=issue.severity,
|
||||||
rule_type=issue.rule_type,
|
rule_type=issue.rule_type,
|
||||||
detail=issue.evidence_json.get("summary", ""),
|
evidence_signals=signals,
|
||||||
link_type=link_type,
|
link_type=link_type,
|
||||||
link_ref=link_ref,
|
link_ref=link_ref,
|
||||||
issue_ref=issue.public_ref,
|
issue_ref=issue.public_ref,
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
|||||||
return {
|
return {
|
||||||
"entity_type": "vehicle",
|
"entity_type": "vehicle",
|
||||||
"public_ref": vehicle.public_ref,
|
"public_ref": vehicle.public_ref,
|
||||||
|
"registration_number": vehicle.registration_number,
|
||||||
"make": vehicle.make,
|
"make": vehicle.make,
|
||||||
"model": vehicle.model,
|
"model": vehicle.model,
|
||||||
"location": vehicle.location,
|
"location": vehicle.location,
|
||||||
|
|||||||
@@ -350,11 +350,16 @@ class DashboardMetrics(BaseModel):
|
|||||||
pending_or_failed_workflows: int
|
pending_or_failed_workflows: int
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceSignalOut(BaseModel):
|
||||||
|
code: str
|
||||||
|
params: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class AttentionItem(BaseModel):
|
class AttentionItem(BaseModel):
|
||||||
kind: Literal["quality_issue", "vehicle"]
|
kind: Literal["quality_issue", "vehicle"]
|
||||||
severity: str
|
severity: str
|
||||||
rule_type: str
|
rule_type: str
|
||||||
detail: str
|
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
|
||||||
link_type: Literal["vehicle", "booking", "customer"]
|
link_type: Literal["vehicle", "booking", "customer"]
|
||||||
link_ref: str
|
link_ref: str
|
||||||
issue_ref: str | None = None
|
issue_ref: str | None = None
|
||||||
|
|||||||
@@ -139,11 +139,11 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
|
|
||||||
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
vehicle_rows = []
|
vehicle_rows = []
|
||||||
|
vehicle_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("vehicles.csv"):
|
for row in _read_csv("vehicles.csv"):
|
||||||
vid = uuid.uuid4()
|
vid = uuid.uuid4()
|
||||||
vehicle_id_by_ref[row["public_ref"]] = vid
|
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||||
vehicle_rows.append(
|
vehicle_row = {
|
||||||
{
|
|
||||||
"id": vid,
|
"id": vid,
|
||||||
"public_ref": row["public_ref"],
|
"public_ref": row["public_ref"],
|
||||||
"make": row["make"],
|
"make": row["make"],
|
||||||
@@ -157,17 +157,18 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"active": _parse_bool(row["active"]),
|
"active": _parse_bool(row["active"]),
|
||||||
"version": 1,
|
"version": 1,
|
||||||
}
|
}
|
||||||
)
|
vehicle_rows.append(vehicle_row)
|
||||||
|
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||||
db.execute(insert(Vehicle), vehicle_rows)
|
db.execute(insert(Vehicle), vehicle_rows)
|
||||||
counts["vehicles"] = len(vehicle_rows)
|
counts["vehicles"] = len(vehicle_rows)
|
||||||
|
|
||||||
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
booking_rows = []
|
booking_rows = []
|
||||||
|
booking_row_by_ref: dict[str, dict] = {}
|
||||||
for row in _read_csv("bookings.csv"):
|
for row in _read_csv("bookings.csv"):
|
||||||
bid = uuid.uuid4()
|
bid = uuid.uuid4()
|
||||||
booking_id_by_ref[row["public_ref"]] = bid
|
booking_id_by_ref[row["public_ref"]] = bid
|
||||||
booking_rows.append(
|
booking_row = {
|
||||||
{
|
|
||||||
"id": bid,
|
"id": bid,
|
||||||
"public_ref": row["public_ref"],
|
"public_ref": row["public_ref"],
|
||||||
"customer_id": customer_id_by_ref[row["customer_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"]),
|
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||||
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
"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)
|
db.execute(insert(Booking), booking_rows)
|
||||||
counts["bookings"] = len(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 "customer", customer_id_by_ref[entity_ref]
|
||||||
return "vehicle", vehicle_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]:
|
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
|
# 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
|
# 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 ""},
|
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
return []
|
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||||
|
|
||||||
dq_rows = []
|
dq_rows = []
|
||||||
now = datetime.now(UTC)
|
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")
|
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):
|
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||||
response = ops_client.get("/api/v1/dashboard")
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
body = response.json()
|
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"
|
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):
|
def test_resolve_overlap_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap",
|
"/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(Vehicle)) == 50
|
||||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
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
|
# 21 from the CSV (15 original + 6 giving every unexplained blocked vehicle a
|
||||||
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
# 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
|
# 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
|
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||||
# previous hand-rolled scanner never checked for.
|
# 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(OutboxEvent)) == 20
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||||
finally:
|
finally:
|
||||||
@@ -200,3 +201,76 @@ def test_seed_today_movements_are_a_credible_mix():
|
|||||||
assert len(returns) >= 2
|
assert len(returns) >= 2
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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;
|
pending_or_failed_workflows: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EvidenceSignal {
|
||||||
|
code: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AttentionItem {
|
export interface AttentionItem {
|
||||||
kind: string;
|
kind: string;
|
||||||
severity: "low" | "medium" | "high";
|
severity: "low" | "medium" | "high";
|
||||||
rule_type: string;
|
rule_type: string;
|
||||||
detail: string;
|
evidence_signals: EvidenceSignal[];
|
||||||
link_type: "vehicle" | "booking" | "customer";
|
link_type: "vehicle" | "booking" | "customer";
|
||||||
link_ref: string;
|
link_ref: string;
|
||||||
issue_ref: string | null;
|
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",
|
"ends": "Ends",
|
||||||
"startOdometer": "Start odometer",
|
"startOdometer": "Start odometer",
|
||||||
"endOdometer": "End odometer",
|
"endOdometer": "End odometer",
|
||||||
|
"startOdometerPending": "Not yet recorded — trip hasn't started yet",
|
||||||
|
"endOdometerPending": "Not yet recorded — not yet closed",
|
||||||
"requirementsComplete": "Requirements complete",
|
"requirementsComplete": "Requirements complete",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No"
|
"no": "No"
|
||||||
|
|||||||
@@ -109,8 +109,7 @@
|
|||||||
"missingField": "Missing field: {{field}}",
|
"missingField": "Missing field: {{field}}",
|
||||||
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
|
||||||
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
|
"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.",
|
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing."
|
||||||
"seedPlaceholder": "Synthetic seed data with no further detail."
|
|
||||||
},
|
},
|
||||||
"duplicateCustomer": {
|
"duplicateCustomer": {
|
||||||
"heading": "Compare and merge",
|
"heading": "Compare and merge",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
"ends": "Fin",
|
"ends": "Fin",
|
||||||
"startOdometer": "Kilométrage de départ",
|
"startOdometer": "Kilométrage de départ",
|
||||||
"endOdometer": "Kilométrage de retour",
|
"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",
|
"requirementsComplete": "Exigences complètes",
|
||||||
"yes": "Oui",
|
"yes": "Oui",
|
||||||
"no": "Non"
|
"no": "Non"
|
||||||
|
|||||||
@@ -109,8 +109,7 @@
|
|||||||
"missingField": "Champ manquant : {{field}}",
|
"missingField": "Champ manquant : {{field}}",
|
||||||
"overlapReservedBookings": "Réservations qui se chevauchent : {{refs}}",
|
"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}}.",
|
"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.",
|
"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": {
|
"duplicateCustomer": {
|
||||||
"heading": "Comparer et fusionner",
|
"heading": "Comparer et fusionner",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
"ends": "Einde",
|
"ends": "Einde",
|
||||||
"startOdometer": "Startkilometerstand",
|
"startOdometer": "Startkilometerstand",
|
||||||
"endOdometer": "Eindkilometerstand",
|
"endOdometer": "Eindkilometerstand",
|
||||||
|
"startOdometerPending": "Nog niet vastgelegd — rit nog niet gestart",
|
||||||
|
"endOdometerPending": "Nog niet vastgelegd — nog niet afgesloten",
|
||||||
"requirementsComplete": "Vereisten volledig",
|
"requirementsComplete": "Vereisten volledig",
|
||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"no": "Nee"
|
"no": "Nee"
|
||||||
|
|||||||
@@ -109,8 +109,7 @@
|
|||||||
"missingField": "Ontbrekend veld: {{field}}",
|
"missingField": "Ontbrekend veld: {{field}}",
|
||||||
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
|
"overlapReservedBookings": "Overlappende reserveringen: {{refs}}",
|
||||||
"odometerRegression": "Boeking {{laterRef}} registreerde {{laterKm}} km, lager dan de {{earlierKm}} km van eerdere boeking {{earlierRef}}.",
|
"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.",
|
"upcomingBookingMissingInspection": "Boeking {{bookingRef}} start binnenkort maar de vereiste operationele inspectie ontbreekt nog."
|
||||||
"seedPlaceholder": "Synthetisch demogegeven zonder verder detail."
|
|
||||||
},
|
},
|
||||||
"duplicateCustomer": {
|
"duplicateCustomer": {
|
||||||
"heading": "Vergelijken en samenvoegen",
|
"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.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.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.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.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` : "—"}</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>
|
<div><dt>{t("detail.requirementsComplete")}</dt><dd>{booking.requirements_complete ? t("detail.yes") : t("detail.no")}</dd></div>
|
||||||
</dl></section>
|
</dl></section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { SeverityBadge, StatusBadge } from "../components/Badge";
|
|||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { ErrorState, IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
import { N8N_STATE_META, MCP_STATE_META } from "../data/integrationLabels";
|
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 }> = [
|
const FLEET_METRIC_KEYS: Array<{ key: keyof DashboardData["metrics"]; labelKey: string; tone: string }> = [
|
||||||
{ key: "available", labelKey: "readiness.available", tone: "ready" },
|
{ key: "available", labelKey: "readiness.available", tone: "ready" },
|
||||||
@@ -29,9 +31,20 @@ function attentionItemTitle(
|
|||||||
return `${ruleLabel} — ${item.link_ref}`;
|
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() {
|
export function Dashboard() {
|
||||||
const { t } = useTranslation(["dashboard", "common", "integrations"]);
|
const { t } = useTranslation(["dashboard", "common", "integrations", "quality"]);
|
||||||
const { formatTime, formatShortDate } = useLocaleFormat();
|
const { formatTime, formatShortDate, formatNumber } = useLocaleFormat();
|
||||||
const greetingPeriod = useGreetingPeriod();
|
const greetingPeriod = useGreetingPeriod();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { manifest } = useDemoManifest();
|
const { manifest } = useDemoManifest();
|
||||||
@@ -71,9 +84,9 @@ export function Dashboard() {
|
|||||||
|
|
||||||
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
const attention = useMemo(() => data?.attention_items.filter((item) => {
|
||||||
const matchesSeverity = severity === "all" || item.severity === severity;
|
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());
|
return matchesSeverity && haystack.includes(query.toLowerCase());
|
||||||
}) ?? [], [data, query, severity, t]);
|
}) ?? [], [data, query, severity, t, formatNumber]);
|
||||||
|
|
||||||
const isUnfiltered = severity === "all" && query === "";
|
const isUnfiltered = severity === "all" && query === "";
|
||||||
const attentionTiers = useMemo(() => {
|
const attentionTiers = useMemo(() => {
|
||||||
@@ -157,7 +170,7 @@ export function Dashboard() {
|
|||||||
<SeverityBadge severity={item.severity} />
|
<SeverityBadge severity={item.severity} />
|
||||||
<div className="queue-copy">
|
<div className="queue-copy">
|
||||||
<p className="attention-title">{title}</p>
|
<p className="attention-title">{title}</p>
|
||||||
<p className="attention-detail">{item.detail}</p>
|
<p className="attention-detail">{attentionItemDetail(t, formatNumber, item)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="queue-ref">{item.link_ref}</span>
|
<span className="queue-ref">{item.link_ref}</span>
|
||||||
<Icon name="chevron" className="row-chevron" />
|
<Icon name="chevron" className="row-chevron" />
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useDemoGuide } from "../context/DemoGuideContext";
|
|||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
|
import { describeEvidenceSignal } from "../data/evidenceSignals";
|
||||||
|
import type { EvidenceSignal } from "../api/types";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ApiErrorNotice, ErrorState, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
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
|
// 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
|
// params (see app/services/data_quality.py::_open_issue). describeEvidenceSignal is the
|
||||||
// turns them into the operator's selected language; the legacy `evidence.summary`
|
// one place that turns them into the operator's selected language; the legacy
|
||||||
// string is a technical fallback only, shown solely inside EvidenceDisclosure above.
|
// `evidence.summary` string is a technical fallback only, shown solely inside
|
||||||
// A handful of seed-only rows (generic filler, not tied to a scripted demo scenario)
|
// EvidenceDisclosure above.
|
||||||
// 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 }) {
|
function EvidenceSignalList({ issue }: { issue: IssueDetail }) {
|
||||||
const { t } = useTranslation("quality");
|
const { t } = useTranslation("quality");
|
||||||
const { formatNumber } = useLocaleFormat();
|
const { formatNumber } = useLocaleFormat();
|
||||||
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
|
const signals = (issue.evidence.signals as EvidenceSignal[] | undefined) ?? [];
|
||||||
if (signals.length === 0) {
|
if (signals.length === 0) return null;
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="evidence-signal-list">
|
<ul className="evidence-signal-list">
|
||||||
{signals.map((signal, index) => (
|
{signals.map((signal, index) => (
|
||||||
<li key={`${signal.code}-${index}`}>{describe(signal)}</li>
|
<li key={`${signal.code}-${index}`}>{describeEvidenceSignal(t, formatNumber, signal)}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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-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-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-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-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-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-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-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
|
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-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-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-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-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,Synthetic deterministic seed issue
|
DQ-0006,missing_required_field,MO-043,,low,open,Missing: location
|
||||||
DQ-0007,odometer_regression,MO-050,,medium,open,Synthetic deterministic seed issue
|
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,Synthetic deterministic seed issue
|
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,Synthetic deterministic seed issue
|
DQ-0009,missing_required_field,MO-014,,low,open,Missing: location
|
||||||
DQ-0010,odometer_regression,MO-021,,medium,open,Synthetic deterministic seed issue
|
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,Synthetic deterministic seed issue
|
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,Synthetic deterministic seed issue
|
DQ-0012,missing_required_field,MO-035,,low,resolved,Missing: registration_number
|
||||||
DQ-0013,odometer_regression,MO-042,,medium,resolved,Synthetic deterministic seed issue
|
DQ-0013,missing_required_field,MO-042,,low,resolved,Missing: location
|
||||||
DQ-0014,vehicle_status_conflict,MO-049,,high,resolved,Synthetic deterministic seed issue
|
DQ-0014,missing_required_field,MO-049,,low,resolved,Missing: registration_number
|
||||||
DQ-0015,missing_required_field,MO-006,,low,resolved,Synthetic deterministic seed issue
|
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-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-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-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-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-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-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-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-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-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
|
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-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-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-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-025,Adria,Matrix,2023,2-MOB-025,,blocked,36429,40000,true
|
||||||
MO-026,Dethleffs,Trend,2024,2-MOB-026,Geel,blocked,37273,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-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-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-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
|
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-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-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-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-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-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-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-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-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-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-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-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-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
|
MO-050,Dethleffs,Trend,2020,2-MOB-050,Geel,cleaning,54692,60000,true
|
||||||
|
|||||||
|
Reference in New Issue
Block a user