feat(vehicles): automatic Vinpin VIN-decode fallback (flag-gated, off by default)

When PL24/pcat/emex can't decode a Fiat VIN, decode it via the Vinpin ePER
web catalog (warm-session Playwright worker, single seat, BullMQ concurrency 1),
cache the exact vehicle in vinpin_decodes, match it to PL24's existing
catalog_vehicle for that model, and serve the parts from there. Vinpin = decode
oracle only; PL24 already holds the parts (e.g. Egea/Linea/Doblo).

Strictly gated behind VINPIN_ENABLED (default false) + a Fiat-only brand
allowlist: with the flag off, decodeVin behaviour is byte-identical and the
queue is never touched (covered by tests). Coordinates/selectors in
vinpin.constants.ts are marked TUNE-AGAINST-LIVE-PAID-SEAT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:36:39 +03:00
parent 3096d35113
commit dfe90fb968
22 changed files with 1310 additions and 0 deletions

View File

@@ -20,6 +20,10 @@ const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
// Sample VW Golf used by the "try example" affordance + the activation experiment.
const EXAMPLE_VIN = "WVWZZZ1JZ3W597935";
// Vinpin ePER background-decode polling budget (the VINPIN_ENABLED fallback).
const VINPIN_MAX_POLLS = 6;
const VINPIN_POLL_INTERVAL_MS = 3000;
interface VehicleHistoryItem {
id: string;
vin: string;
@@ -162,6 +166,12 @@ function SearchPage() {
// into the existing /dashboard/catalog instead of dead-ending.
const [noCatalog, setNoCatalog] = useState<{ brandName: string; display: string } | null>(null);
// Vinpin ePER fallback: the VIN is being identified from the authorized
// catalog in the background. We poll the decode endpoint a few times until it
// resolves to a catalog vehicle (navigate) or gives up (noCatalog/error).
const [decoding, setDecoding] = useState<{ vin: string; display: string } | null>(null);
const decodePollRef = useRef<number | null>(null);
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
@@ -260,10 +270,55 @@ function SearchPage() {
inputRef.current?.focus();
}, []);
// ─── Vinpin ePER fallback helpers ──────────────────────────────────────────
function goToCatalogVehicle(cv: any) {
setDecoding(null);
navigate({
to: "/dashboard/catalog/$brandName/$modelId",
params: { brandName: cv.brandName, modelId: cv.id },
search: { body: undefined, engine: undefined, gearbox: undefined, mgp: undefined },
});
}
// Re-hit the decode endpoint until the background Vinpin decode resolves to a
// catalog vehicle, gives up (noCatalog), or we exhaust the poll budget.
function startVinpinPoll(cleanVin: string, pollCount: number) {
if (decodePollRef.current !== null) window.clearTimeout(decodePollRef.current);
if (pollCount >= VINPIN_MAX_POLLS) {
// Took too long — fall back to a friendly model-browse prompt.
setDecoding(null);
setNoCatalog({ brandName: "Fiat", display: "" });
return;
}
decodePollRef.current = window.setTimeout(async () => {
try {
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
if (data.catalogVehicle) {
goToCatalogVehicle(data.catalogVehicle);
} else if (data.id) {
setDecoding(null);
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
} else if (data.noCatalog) {
setDecoding(null);
setNoCatalog({ brandName: data.noCatalog.brandName, display: data.noCatalog.display });
} else if (data.decoding) {
startVinpinPoll(cleanVin, pollCount + 1);
} else {
setDecoding(null);
}
} catch {
// Transient — keep polling until the budget is exhausted.
startVinpinPoll(cleanVin, pollCount + 1);
}
}, VINPIN_POLL_INTERVAL_MS);
}
// ─── Decode runner (shared by submit and retry) ────────────────────────────
async function runDecode(cleanVin: string, attempt: number) {
setError(null);
setNoCatalog(null);
setDecoding(null);
if (decodePollRef.current !== null) window.clearTimeout(decodePollRef.current);
setLoading(true);
const decodeStart = performance.now();
try {
@@ -300,6 +355,33 @@ function SearchPage() {
return;
}
// Vinpin ePER fallback resolved the VIN to an existing catalog vehicle —
// land the user on that EXACT catalog (parts come from PL24).
if (data.catalogVehicle) {
capture("vin_decode_vinpin_resolved", {
vin: cleanVin,
catalog_vehicle_id: data.catalogVehicle.id,
response_time_ms: responseTimeMs,
query_source: querySourceRef.current,
});
goToCatalogVehicle(data.catalogVehicle);
return;
}
// Vinpin ePER fallback is identifying the VIN in the background → poll.
if (data.decoding) {
setPreview(null);
setPreviewError(false);
setDecoding({ vin: cleanVin, display: data.decoding.display ?? "" });
capture("vin_decode_vinpin_pending", {
vin: cleanVin,
response_time_ms: responseTimeMs,
query_source: querySourceRef.current,
});
startVinpinPoll(cleanVin, 0);
return;
}
if (data.noCatalog) {
// Brand recognized but no catalog for this VIN — clear the live preview
// and surface the model-browse fallback instead of an error.
@@ -451,6 +533,9 @@ function SearchPage() {
if (correctionTimerRef.current !== null) {
window.clearTimeout(correctionTimerRef.current);
}
if (decodePollRef.current !== null) {
window.clearTimeout(decodePollRef.current);
}
};
}, []);
@@ -460,6 +545,7 @@ function SearchPage() {
setVin(cleaned);
setError(null);
setNoCatalog(null);
setDecoding(null);
setReportSent(false);
if (corrections.length > 0) {
for (const c of corrections) correctionsRef.current.add(c);
@@ -717,6 +803,26 @@ function SearchPage() {
</div>
)}
{/* ─── Vinpin ePER background decode (VINPIN_ENABLED fallback) ─────── */}
{decoding && (
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">
<div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
<Loader2 className="size-5 animate-spin text-brand" />
</div>
<div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
{t("search.decodingTitle")}
</p>
{decoding.display && (
<p className="mt-0.5 text-sm font-medium text-foreground">{decoding.display}</p>
)}
<p className="mt-1 text-sm text-muted-foreground">{t("search.decodingHint")}</p>
</div>
</div>
</div>
)}
{/* ─── No-catalog fallback: brand known, offer model-browse ───────── */}
{noCatalog && (
<div className="rounded-2xl border border-brand/30 bg-background p-5 sm:p-6">