M3: implement Data Quality Workbench
Five rule scanners (duplicate customers, missing fields, odometer regression, booking overlap, status conflict) run automatically after seed and via an explicit scan endpoint. Issue defer/reject/merge-customers endpoints with transactional customer merge (booking rewiring, tombstone, audit). Data Quality nav + workbench UI with two-column duplicate comparison and inline (non-native) confirm. Dashboard attention items now link to issues. 35 backend tests passing, ruff clean. Fixed a real false-positive bug in odometer-regression detection found through iteration on seed data, and two TS narrowing errors. Verified end-to-end via browser: S2 merge and S4 overlap scenarios.
This commit is contained in:
@@ -8,6 +8,8 @@ import { Vehicles } from "./pages/Vehicles";
|
||||
import { VehicleDetail } from "./pages/VehicleDetail";
|
||||
import { Bookings } from "./pages/Bookings";
|
||||
import { BookingDetail } from "./pages/BookingDetail";
|
||||
import { DataQuality } from "./pages/DataQuality";
|
||||
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
|
||||
import { Audit } from "./pages/Audit";
|
||||
|
||||
export function App() {
|
||||
@@ -27,6 +29,8 @@ export function App() {
|
||||
<Route path="/vehicles/:publicRef" element={<VehicleDetail />} />
|
||||
<Route path="/bookings" element={<Bookings />} />
|
||||
<Route path="/bookings/:publicRef" element={<BookingDetail />} />
|
||||
<Route path="/data-quality" element={<DataQuality />} />
|
||||
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
|
||||
<Route path="/audit" element={<Audit />} />
|
||||
</Route>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface AttentionItem {
|
||||
detail: string;
|
||||
link_type: "vehicle" | "booking" | "customer";
|
||||
link_ref: string;
|
||||
issue_ref: string | null;
|
||||
}
|
||||
|
||||
export interface TodayItem {
|
||||
@@ -144,6 +145,28 @@ export interface RegisterReturnResult {
|
||||
next_booking_risk: NextBookingRisk | null;
|
||||
}
|
||||
|
||||
export interface EntitySnapshot {
|
||||
public_ref: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DataQualityIssueDetail extends DataQualityIssue {
|
||||
entity_snapshot: EntitySnapshot | null;
|
||||
related_snapshots: EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface MergeCustomersRequest {
|
||||
survivor_ref: string;
|
||||
field_overrides?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MergeCustomersResult {
|
||||
issue_ref: string;
|
||||
survivor_ref: string;
|
||||
loser_ref: string;
|
||||
rewired_bookings: number;
|
||||
}
|
||||
|
||||
export interface AuditEvent {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ const NAV_ITEMS = [
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
{ to: "/vehicles", label: "Vehicles" },
|
||||
{ to: "/bookings", label: "Bookings" },
|
||||
{ to: "/data-quality", label: "Data Quality" },
|
||||
{ to: "/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ export function Dashboard() {
|
||||
<SeverityBadge severity={item.severity} />
|
||||
<div>
|
||||
<p className="attention-title">
|
||||
{item.link_type === "vehicle" ? (
|
||||
{item.issue_ref ? (
|
||||
<Link to={`/data-quality/${item.issue_ref}`}>{item.title}</Link>
|
||||
) : item.link_type === "vehicle" ? (
|
||||
<Link to={`/vehicles/${item.link_ref}`}>{item.title}</Link>
|
||||
) : (
|
||||
item.title
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { DataQualityIssue } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
|
||||
const RULE_TYPES = [
|
||||
"possible_duplicate_customer",
|
||||
"missing_required_field",
|
||||
"odometer_regression",
|
||||
"booking_overlap",
|
||||
"vehicle_status_conflict",
|
||||
];
|
||||
|
||||
export function DataQuality() {
|
||||
const [issues, setIssues] = useState<DataQualityIssue[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState("open");
|
||||
const [ruleType, setRuleType] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set("status", status);
|
||||
if (ruleType) params.set("rule_type", ruleType);
|
||||
api
|
||||
.get<DataQualityIssue[]>(`/api/v1/data-quality/issues?${params.toString()}`)
|
||||
.then(setIssues)
|
||||
.catch(() => setError("Data-quality issues are unavailable right now."));
|
||||
}, [status, ruleType]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Data Quality</h1>
|
||||
|
||||
<form className="filters" aria-label="Filter data-quality issues">
|
||||
<label>
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="deferred">Deferred</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Rule type
|
||||
<select value={ruleType} onChange={(e) => setRuleType(e.target.value)}>
|
||||
<option value="">All rule types</option>
|
||||
{RULE_TYPES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r.replace(/_/g, " ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
{!error && !issues && <p>Loading issues…</p>}
|
||||
{issues && issues.length === 0 && <p>No issues match these filters.</p>}
|
||||
|
||||
{issues && issues.length > 0 && (
|
||||
<table className="data-table">
|
||||
<caption className="visually-hidden">Data-quality issues</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Reference</th>
|
||||
<th scope="col">Rule</th>
|
||||
<th scope="col">Entity</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{issues.map((i) => (
|
||||
<tr key={i.public_ref}>
|
||||
<th scope="row">
|
||||
<Link to={`/data-quality/${i.public_ref}`}>{i.public_ref}</Link>
|
||||
</th>
|
||||
<td>{i.rule_type.replace(/_/g, " ")}</td>
|
||||
<td>{i.entity_ref}</td>
|
||||
<td>
|
||||
<SeverityBadge severity={i.severity} />
|
||||
</td>
|
||||
<td>
|
||||
<StatusBadge status={i.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, ApiError } from "../api/client";
|
||||
import type { DataQualityIssueDetail as IssueDetail, EntitySnapshot } from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
const MERGE_FIELDS = ["first_name", "last_name", "email", "phone", "postal_code", "city"];
|
||||
|
||||
function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
|
||||
const { user } = useAuth();
|
||||
const [survivorRef, setSurvivorRef] = useState(issue.entity_snapshot?.public_ref ?? "");
|
||||
const [fieldChoices, setFieldChoices] = useState<Record<string, "a" | "b">>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
|
||||
return <p className="error">Both customers in this comparison could not be loaded.</p>;
|
||||
}
|
||||
const a: EntitySnapshot = issue.entity_snapshot;
|
||||
const b: EntitySnapshot = issue.related_snapshots[0];
|
||||
|
||||
const survivor = survivorRef === a.public_ref ? a : b;
|
||||
const loser = survivorRef === a.public_ref ? b : a;
|
||||
|
||||
async function handleMerge() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const field of MERGE_FIELDS) {
|
||||
const choice = fieldChoices[field];
|
||||
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
|
||||
if (chosenSide !== survivor && chosenSide[field]) {
|
||||
overrides[field] = String(chosenSide[field]);
|
||||
}
|
||||
}
|
||||
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/merge-customers`, {
|
||||
survivor_ref: survivor.public_ref,
|
||||
field_overrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
});
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Could not merge these customers.");
|
||||
setConfirming(false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (user?.role !== "operations_manager") {
|
||||
return (
|
||||
<p className="panel">
|
||||
Merging duplicate customers requires the Operations Manager role. Switch role to resolve
|
||||
this issue.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel duplicate-compare" aria-labelledby="compare-heading">
|
||||
<h2 id="compare-heading">Compare and merge</h2>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
|
||||
<fieldset>
|
||||
<legend>Keep as survivor</legend>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="survivor"
|
||||
checked={survivorRef === a.public_ref}
|
||||
onChange={() => setSurvivorRef(a.public_ref)}
|
||||
/>
|
||||
{a.public_ref}
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="survivor"
|
||||
checked={survivorRef === b.public_ref}
|
||||
onChange={() => setSurvivorRef(b.public_ref)}
|
||||
/>
|
||||
{b.public_ref}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<table className="data-table compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Field</th>
|
||||
<th scope="col">{a.public_ref}</th>
|
||||
<th scope="col">{b.public_ref}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MERGE_FIELDS.map((field) => {
|
||||
const valueA = a[field] ? String(a[field]) : "—";
|
||||
const valueB = b[field] ? String(b[field]) : "—";
|
||||
const differ = valueA !== valueB;
|
||||
return (
|
||||
<tr key={field}>
|
||||
<th scope="row">{field.replace(/_/g, " ")}</th>
|
||||
<td>
|
||||
{differ ? (
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={`field-${field}`}
|
||||
checked={(fieldChoices[field] ?? "a") === "a"}
|
||||
onChange={() => setFieldChoices((c) => ({ ...c, [field]: "a" }))}
|
||||
/>
|
||||
{valueA}
|
||||
</label>
|
||||
) : (
|
||||
valueA
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{differ ? (
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="radio"
|
||||
name={`field-${field}`}
|
||||
checked={fieldChoices[field] === "b"}
|
||||
onChange={() => setFieldChoices((c) => ({ ...c, [field]: "b" }))}
|
||||
/>
|
||||
{valueB}
|
||||
</label>
|
||||
) : (
|
||||
valueB
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<strong>{loser.public_ref}</strong> will become a tombstone linked to{" "}
|
||||
<strong>{survivor.public_ref}</strong>; its bookings will be rewired to the survivor.
|
||||
</p>
|
||||
|
||||
{!confirming && (
|
||||
<button type="button" onClick={() => setConfirming(true)}>
|
||||
Merge into {survivor.public_ref}
|
||||
</button>
|
||||
)}
|
||||
{confirming && (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label="Confirm merge">
|
||||
<p>
|
||||
Merge {loser.public_ref} into {survivor.public_ref}? This cannot be undone.
|
||||
</p>
|
||||
<button type="button" onClick={handleMerge} disabled={submitting}>
|
||||
{submitting ? "Merging…" : "Yes, merge"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirming(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataQualityIssueDetail() {
|
||||
const { publicRef } = useParams<{ publicRef: string }>();
|
||||
const [issue, setIssue] = useState<IssueDetail | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!publicRef) return;
|
||||
api
|
||||
.get<IssueDetail>(`/api/v1/data-quality/issues/${publicRef}`)
|
||||
.then(setIssue)
|
||||
.catch(() => setError("This issue could not be found."));
|
||||
}, [publicRef]);
|
||||
|
||||
useEffect(() => {
|
||||
setIssue(null);
|
||||
setError(null);
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleAction(action: "defer" | "reject") {
|
||||
if (!issue) return;
|
||||
setActionError(null);
|
||||
try {
|
||||
await api.post(`/api/v1/data-quality/issues/${issue.public_ref}/${action}`);
|
||||
load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : `Could not ${action} this issue.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="error" role="alert">{error}</p>;
|
||||
if (!issue) return <p>Loading issue…</p>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<p><Link to="/data-quality">← Back to data quality</Link></p>
|
||||
<h1>{issue.public_ref}</h1>
|
||||
<p>
|
||||
<SeverityBadge severity={issue.severity} /> <StatusBadge status={issue.status} />
|
||||
</p>
|
||||
<dl className="detail-grid">
|
||||
<div><dt>Rule</dt><dd>{issue.rule_type.replace(/_/g, " ")}</dd></div>
|
||||
<div><dt>Entity</dt><dd>{issue.entity_ref}</dd></div>
|
||||
<div><dt>Evidence</dt><dd>{String(issue.evidence.summary ?? "")}</dd></div>
|
||||
</dl>
|
||||
|
||||
{actionError && <p className="error" role="alert">{actionError}</p>}
|
||||
|
||||
{issue.status === "open" && issue.rule_type === "possible_duplicate_customer" && (
|
||||
<DuplicateCustomerPanel issue={issue} onResolved={load} />
|
||||
)}
|
||||
|
||||
{issue.status === "open" && (
|
||||
<section className="panel" aria-labelledby="resolution-heading">
|
||||
<h2 id="resolution-heading">Resolution</h2>
|
||||
{issue.rule_type !== "possible_duplicate_customer" && (
|
||||
<>
|
||||
<p>Evidence for this issue:</p>
|
||||
<pre className="evidence-block">{JSON.stringify(issue.evidence, null, 2)}</pre>
|
||||
</>
|
||||
)}
|
||||
<p>Defer to review later, or reject if this is not a real issue.</p>
|
||||
<div className="resolution-actions">
|
||||
<button type="button" onClick={() => handleAction("defer")}>
|
||||
Defer
|
||||
</button>
|
||||
<button type="button" onClick={() => handleAction("reject")}>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +112,7 @@ export function VehicleDetail() {
|
||||
{vehicle.quality_issues.length === 0 && <li>No quality issues recorded.</li>}
|
||||
{vehicle.quality_issues.map((q) => (
|
||||
<li key={q.public_ref}>
|
||||
<Link to={`/data-quality/${q.public_ref}`}>{q.public_ref}</Link>
|
||||
<SeverityBadge severity={q.severity} />
|
||||
<span>{q.rule_type.replace(/_/g, " ")}</span>
|
||||
<StatusBadge status={q.status} />
|
||||
|
||||
@@ -188,6 +188,38 @@ a { color: #1f5c8f; }
|
||||
.return-form button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.return-result .error { margin-top: 12px; }
|
||||
|
||||
.duplicate-compare fieldset {
|
||||
border: 1px solid #dce3eb; border-radius: 10px; padding: 12px 16px; margin: 12px 0;
|
||||
}
|
||||
.duplicate-compare legend { font-weight: 700; color: #375065; padding: 0 6px; }
|
||||
.duplicate-compare fieldset label { margin-right: 20px; }
|
||||
.compare-table th, .compare-table td { vertical-align: top; }
|
||||
.compare-table label { display: inline-flex; align-items: center; gap: 6px; font-weight: 400; }
|
||||
.duplicate-compare > button {
|
||||
padding: 12px 20px; border-radius: 10px; border: none;
|
||||
background: #14324f; color: white; font-weight: 700; cursor: pointer; margin-top: 12px;
|
||||
}
|
||||
.confirm-bar {
|
||||
margin-top: 12px; padding: 14px; border-radius: 10px;
|
||||
background: #fdf1de; border: 1px solid #f0d29e;
|
||||
}
|
||||
.confirm-bar p { margin: 0 0 10px; font-weight: 600; color: #8a5a10; }
|
||||
.confirm-bar button {
|
||||
padding: 10px 16px; border-radius: 8px; border: none; font-weight: 700; cursor: pointer; margin-right: 8px;
|
||||
}
|
||||
.confirm-bar button:first-of-type { background: #9a2530; color: white; }
|
||||
.confirm-bar button:last-of-type { background: white; border: 1px solid #cfd8e2; }
|
||||
|
||||
.evidence-block {
|
||||
background: #f6f8fb; border: 1px solid #dce3eb; border-radius: 10px;
|
||||
padding: 12px; overflow-x: auto; font-size: 0.85rem;
|
||||
}
|
||||
.resolution-actions { display: flex; gap: 10px; margin-top: 12px; }
|
||||
.resolution-actions button {
|
||||
padding: 10px 18px; border-radius: 8px; border: 1px solid #cfd8e2;
|
||||
background: white; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.app-header { flex-direction: column; align-items: flex-start; }
|
||||
.user-badge { margin-left: 0; }
|
||||
|
||||
Reference in New Issue
Block a user