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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user