fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
used identically by the data-quality scanner, a new non-mutating status-recommendation
preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
"maintenance + active booking -> auto rented" shortcut. Frontend
DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
<status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
after the status-conflict issue now converges on the same final vehicle status,
proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
appeared in locale prose; add a permanent test guarding against a translation file
ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
reasons, audit field/actor-type labels, automation last_error, and search
section/vehicle/booking/issue results all now carry codes the frontend localizes,
with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
decision table documenting the evaluator's rules and safe-status principles.
148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
18344bc8b7
commit
6deb95524d
@@ -5,6 +5,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { useLocaleFormat } from "../i18n/format";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
||||
n8n: "n8n",
|
||||
@@ -36,7 +37,7 @@ export function AboutDemo() {
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
eyebrow={t("about.eyebrow")}
|
||||
title={t("about.title")}
|
||||
title={t("about.title", { productName: PRODUCT_NAME })}
|
||||
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
|
||||
/>
|
||||
|
||||
@@ -58,12 +59,12 @@ export function AboutDemo() {
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>{t("about.problemTitle")}</h2>
|
||||
<p>{t("about.problemBody", { orgName: manifest.organization_name })}</p>
|
||||
<p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
<h2>{t("about.scopeTitle")}</h2>
|
||||
<p>{t("about.scopeBody")}</p>
|
||||
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
|
||||
</section>
|
||||
|
||||
<section className="record-surface about-card">
|
||||
|
||||
@@ -36,7 +36,7 @@ function ChangeDiff({
|
||||
const b = before?.[key];
|
||||
const a = after?.[key];
|
||||
if (JSON.stringify(b) === JSON.stringify(a)) continue;
|
||||
const field = humanizeField(key);
|
||||
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
|
||||
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
|
||||
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
|
||||
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
|
||||
@@ -163,7 +163,7 @@ export function Audit() {
|
||||
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
|
||||
</div>
|
||||
<div className="audit-group-meta">
|
||||
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{e.actor_type}</small></span>
|
||||
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
|
||||
{e.entity_link ? (
|
||||
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
|
||||
) : (
|
||||
|
||||
@@ -118,7 +118,21 @@ export function Automation() {
|
||||
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
|
||||
</td>
|
||||
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
|
||||
<td data-label={t("ledger.columns.lastError")}>{r.last_error ?? "—"}</td>
|
||||
<td data-label={t("ledger.columns.lastError")}>
|
||||
{r.last_error_code ? (
|
||||
<>
|
||||
<span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span>
|
||||
{r.last_error && (
|
||||
<details className="evidence-disclosure">
|
||||
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||
<pre className="evidence-block">{r.last_error}</pre>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td data-label={t("ledger.columns.when")}>
|
||||
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
|
||||
</td>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { StatusBadge } from "../components/Badge";
|
||||
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
export function BookingDetail() {
|
||||
const { t } = useTranslation(["bookings", "returns"]);
|
||||
@@ -73,11 +74,11 @@ export function BookingDetail() {
|
||||
</dl></section>
|
||||
|
||||
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
||||
<section className="record-surface scenario-callout" aria-label="Demo scenario">
|
||||
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
|
||||
<Icon name="spark" />
|
||||
<div>
|
||||
<strong>{t("returns:scenario.title")}</strong>
|
||||
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm) })}</p>
|
||||
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm), productName: PRODUCT_NAME })}</p>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ApplyRecommendedStatusResult,
|
||||
DataQualityIssueDetail as IssueDetail,
|
||||
EntitySnapshot,
|
||||
StatusRecommendation,
|
||||
} from "../api/types";
|
||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -443,15 +444,62 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
||||
);
|
||||
}
|
||||
|
||||
function statusLabel(t: (key: string, opts?: Record<string, unknown>) => string, status: string): string {
|
||||
const raw = t(`fleet:statuses.${status}`, { defaultValue: status });
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||
}
|
||||
|
||||
function StatusRecommendationEvidence({ recommendation }: { recommendation: StatusRecommendation }) {
|
||||
const { t } = useTranslation("quality");
|
||||
const { formatNumber } = useLocaleFormat();
|
||||
const facts = recommendation.facts;
|
||||
const lines: string[] = [];
|
||||
if (facts.active_booking_refs.length > 0) {
|
||||
lines.push(
|
||||
t("detail.vehicleStatusConflict.evidence.activeBookings", { refs: facts.active_booking_refs.join(", ") }),
|
||||
);
|
||||
}
|
||||
if (facts.overlapping_booking_pairs.length > 0) {
|
||||
lines.push(
|
||||
t("detail.vehicleStatusConflict.evidence.overlappingBookings", {
|
||||
pairs: facts.overlapping_booking_pairs.map((pair) => pair.join(" ↔ ")).join(", "),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (facts.service_threshold_reached) {
|
||||
lines.push(
|
||||
t("detail.vehicleStatusConflict.evidence.serviceThresholdReached", {
|
||||
odometer: formatNumber(facts.odometer_km),
|
||||
threshold: formatNumber(facts.next_service_km),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (facts.open_booking_overlap_issue_ref) {
|
||||
lines.push(
|
||||
t("detail.vehicleStatusConflict.evidence.openOverlapIssue", { ref: facts.open_booking_overlap_issue_ref }),
|
||||
);
|
||||
}
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<ul className="evidence-list">
|
||||
{lines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
|
||||
const { t } = useTranslation("quality");
|
||||
const navigate = useNavigate();
|
||||
const { manifest } = useDemoManifest();
|
||||
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
||||
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
|
||||
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
function continueDemo() {
|
||||
completeAndAdvance();
|
||||
@@ -465,23 +513,52 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
// visibility via `result` rather than disappearing the instant the reload lands.
|
||||
if (issue.status !== "open" && !result) return null;
|
||||
|
||||
async function loadRecommendation() {
|
||||
setError(null);
|
||||
setStale(false);
|
||||
setLoadingRecommendation(true);
|
||||
try {
|
||||
const preview = await api.post<StatusRecommendation>(
|
||||
`/api/v1/data-quality/issues/${issue.public_ref}/status-recommendation`,
|
||||
);
|
||||
setRecommendation(preview);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
|
||||
} finally {
|
||||
setLoadingRecommendation(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
if (!recommendation) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const applied = await api.post<ApplyRecommendedStatusResult>(
|
||||
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
|
||||
{ recommendation_token: recommendation.recommendation_token },
|
||||
);
|
||||
setResult(applied);
|
||||
onResolved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
||||
setConfirming(false);
|
||||
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
|
||||
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
|
||||
setRecommendation(null);
|
||||
setStale(true);
|
||||
} else {
|
||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const needsManualReview =
|
||||
recommendation !== null && (recommendation.manual_review_required || !recommendation.safe_to_apply);
|
||||
const noChangeNeeded = recommendation !== null && !needsManualReview && recommendation.recommended_status === null;
|
||||
const hasSafeRecommendation =
|
||||
recommendation !== null && !needsManualReview && recommendation.recommended_status !== null;
|
||||
|
||||
return (
|
||||
<section className="panel" aria-labelledby="status-conflict-heading">
|
||||
<SectionHeading
|
||||
@@ -496,7 +573,12 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
{result ? (
|
||||
<>
|
||||
<p className="quiet-empty">
|
||||
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", { status: result.applied_status, reason: result.reason })}
|
||||
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", {
|
||||
status: statusLabel(t, result.applied_status),
|
||||
reason: t(`detail.vehicleStatusConflict.reasonCodes.${result.reason_code}`, {
|
||||
defaultValue: result.reason_code,
|
||||
}),
|
||||
})}
|
||||
</p>
|
||||
<div className="result-links">
|
||||
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
|
||||
@@ -508,21 +590,63 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : !confirming ? (
|
||||
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
|
||||
{t("detail.vehicleStatusConflict.calculateAndApply")}
|
||||
) : !recommendation ? (
|
||||
<button type="button" className="button button-primary" onClick={loadRecommendation} disabled={loadingRecommendation}>
|
||||
{loadingRecommendation
|
||||
? t("detail.vehicleStatusConflict.loadingRecommendation")
|
||||
: t(stale ? "detail.vehicleStatusConflict.reviewAgain" : "detail.vehicleStatusConflict.reviewRecommendation")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.vehicleStatusConflict.confirmTitle")}>
|
||||
<p>{t("detail.vehicleStatusConflict.confirmBody")}</p>
|
||||
) : needsManualReview ? (
|
||||
<div className="status-decision" role="status">
|
||||
<h3>{t("detail.vehicleStatusConflict.manualReview.heading")}</h3>
|
||||
<p>{t("detail.vehicleStatusConflict.manualReview.body")}</p>
|
||||
<StatusRecommendationEvidence recommendation={recommendation} />
|
||||
<p className="table-subtext">{t("detail.vehicleStatusConflict.manualReview.hint")}</p>
|
||||
</div>
|
||||
) : noChangeNeeded ? (
|
||||
<div className="status-decision" role="status">
|
||||
<h3>{t("detail.vehicleStatusConflict.noConflict.heading")}</h3>
|
||||
<p>{t("detail.vehicleStatusConflict.noConflict.body")}</p>
|
||||
</div>
|
||||
) : hasSafeRecommendation && recommendation.recommended_status ? (
|
||||
<div className="status-decision" role="group" aria-label={t("detail.vehicleStatusConflict.recommendedStatus")}>
|
||||
<dl className="detail-grid">
|
||||
<div>
|
||||
<dt>{t("detail.vehicleStatusConflict.recommendedStatus")}</dt>
|
||||
<dd>
|
||||
<StatusBadge
|
||||
status={recommendation.recommended_status}
|
||||
label={t(`fleet:statuses.${recommendation.recommended_status}`, {
|
||||
defaultValue: recommendation.recommended_status,
|
||||
})}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<h3>{t("detail.vehicleStatusConflict.whyHeading")}</h3>
|
||||
<p>{t(`detail.vehicleStatusConflict.reasonCodes.${recommendation.recommendation_code}`, {
|
||||
defaultValue: recommendation.recommendation_code,
|
||||
})}</p>
|
||||
<h3>{t("detail.vehicleStatusConflict.evidenceHeading")}</h3>
|
||||
<StatusRecommendationEvidence recommendation={recommendation} />
|
||||
<h3>{t("detail.vehicleStatusConflict.consequenceHeading")}</h3>
|
||||
<ul className="evidence-list">
|
||||
<li>{t("detail.vehicleStatusConflict.consequence.statusWillChange", {
|
||||
status: statusLabel(t, recommendation.recommended_status),
|
||||
})}</li>
|
||||
<li>{t("detail.vehicleStatusConflict.consequence.issueWillBeRechecked")}</li>
|
||||
<li>{t("detail.vehicleStatusConflict.consequence.changeWillBeAudited")}</li>
|
||||
<li>{t("detail.vehicleStatusConflict.consequence.bookingsNotDeleted")}</li>
|
||||
</ul>
|
||||
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
|
||||
{submitting ? t("detail.vehicleStatusConflict.applying") : t("detail.vehicleStatusConflict.confirmYes")}
|
||||
</button>
|
||||
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
|
||||
{t("list.cancel")}
|
||||
{submitting
|
||||
? t("detail.vehicleStatusConflict.applying")
|
||||
: t("detail.vehicleStatusConflict.changeStatusTo", {
|
||||
status: statusLabel(t, recommendation.recommended_status),
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api, ApiError } from "../api/client";
|
||||
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
||||
import { Icon } from "../components/Icons";
|
||||
import { PageHeader } from "../components/PageChrome";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
interface Exchange {
|
||||
question: string;
|
||||
@@ -121,7 +122,7 @@ export function Knowledge() {
|
||||
<div className="knowledge-empty">
|
||||
<span><Icon name="knowledge" /></span>
|
||||
<h2>{t("emptyTitle")}</h2>
|
||||
<p>{t("emptyDescription")}</p>
|
||||
<p>{t("emptyDescription", { productName: PRODUCT_NAME })}</p>
|
||||
<div className="retrieval-flow" aria-hidden="true">
|
||||
<span>{t("retrievalFlow.question")}</span><i />
|
||||
<span>{providerLabel}</span><i />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
||||
import { LanguageSwitcher } from "../components/LanguageSwitcher";
|
||||
import type { Role } from "../api/types";
|
||||
import { BrandMark, Icon } from "../components/Icons";
|
||||
import { PRODUCT_NAME } from "../product";
|
||||
|
||||
export function Login() {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
@@ -25,14 +26,14 @@ export function Login() {
|
||||
}
|
||||
|
||||
const orgName = manifest?.organization_name ?? t("common:orgName");
|
||||
const description = t("defaultDescription");
|
||||
const description = t("defaultDescription", { productName: PRODUCT_NAME });
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-story" aria-labelledby="product-name">
|
||||
<div className="brand-lockup login-brand">
|
||||
<BrandMark className="brand-mark" />
|
||||
<div><strong>{t("common:appName")}</strong><span>{t("brandTagline")}</span></div>
|
||||
<div><strong>{PRODUCT_NAME}</strong><span>{t("brandTagline")}</span></div>
|
||||
</div>
|
||||
<div className="login-message">
|
||||
<p className="eyebrow">{t("orgLine", { orgName })}</p>
|
||||
|
||||
@@ -83,7 +83,7 @@ export function VehicleDetail() {
|
||||
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
|
||||
{vehicle.inspections.map((i) => (
|
||||
<li key={i.public_ref}>
|
||||
<span>{i.type}</span>
|
||||
<span>{t(`fleet:detail.inspectionTypes.${i.type}`, { defaultValue: i.type })}</span>
|
||||
<span>{formatNumber(i.odometer_km)} km</span>
|
||||
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
|
||||
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
|
||||
@@ -99,7 +99,7 @@ export function VehicleDetail() {
|
||||
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
|
||||
{vehicle.maintenance.map((m) => (
|
||||
<li key={m.public_ref}>
|
||||
<span>{m.category}</span>
|
||||
<span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span>
|
||||
<span>{m.summary}</span>
|
||||
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user