feat(vehicles): model-browse fallback for no-catalog VINs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

When a VIN can't be decoded by any source but its WMI brand is known and
has a browse-able catalog (SERVICE_TO_BRAND), return a structured
{ noCatalog: { brandName, display }, vin } 200 response instead of
dead-ending. The web surfaces a "kataloğunu modelden incele" CTA that
deep-links into the existing /dashboard/catalog browse, where the parts
usually exist (Fiat Egea NM4356 -> PL24 TIPO-EGEA; old Renault VF1 -> 147k
emex parts) but aren't reachable by the specific VIN's index entry.

Brands with no browse catalog (Honda, Maserati, Alfa, ...) keep the
existing informative dead-end. New analytics: vin_decode_no_catalog +
vin_no_catalog_browse_clicked (this case no longer emits vin_decode_error).

Tests: 2 api (browseable -> fallback, non-browseable -> throws) + 1 web
(CTA renders, no error banner). RCA writeup:
/home/s/ss/katalogsiz-vin-rca-2026-06-23.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 08:58:58 +03:00
parent b3dd119c99
commit 9232e40942
6 changed files with 207 additions and 9 deletions

View File

@@ -198,6 +198,77 @@ describe("VehiclesService", () => {
);
});
it("returns a model-browse fallback (no throw) when brand is known but no catalog has the VIN", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const selectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]), // no cache, no source resolves the VIN
};
const insertChain = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
};
const db = {
select: vi.fn().mockReturnValue(selectChain),
insert: vi.fn().mockReturnValue(insertChain),
};
const { service, corgiService, vinApiService } = createService(db);
// Brand resolves via Corgi WMI (canonical "Fiat", a browse-able brand) but no
// catalog source has this exact VIN — the Fiat Egea / NM4356 case.
corgiService.decodeVin.mockReturnValue({
isKnown: true,
brandName: "Fiat",
modelYear: 2018,
});
vinApiService.decodeVin.mockResolvedValue({
make: "FIAT",
model: "Tipo",
modelYear: "2018",
});
const result: any = await service.decodeVin("NM435600006123456", "u1");
expect(result).toMatchObject({
noCatalog: { brandName: "Fiat" },
vin: "NM435600006123456",
});
expect(result.noCatalog.display).toContain("Fiat");
});
it("keeps the informative dead-end (throws) when the identified brand has no browse catalog", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const selectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([]),
};
const insertChain = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
};
const db = {
select: vi.fn().mockReturnValue(selectChain),
insert: vi.fn().mockReturnValue(insertChain),
};
const { service, corgiService, vinApiService } = createService(db);
// Honda is NOT a browse-able SERVICE_TO_BRAND value → no model-browse target,
// so we keep the existing "identified but no catalog" dead-end.
corgiService.decodeVin.mockReturnValue({
isKnown: true,
brandName: "Honda",
modelYear: 2019,
});
vinApiService.decodeVin.mockResolvedValue({ make: "HONDA", model: "Civic" });
await expect(service.decodeVin("SHHFK2750KU123456", "u1")).rejects.toThrow(
BadRequestException,
);
});
it.skip("should save with null brandId when brand not found in DB [TODO: rewrite for parallel pcat+emex flow]", async () => {
vi.mocked(isValidVin).mockReturnValue(true);

View File

@@ -31,6 +31,7 @@ import { EmexCandidate } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { PcatCar, PcatVinResult } from "../integrations/parts-catalogs/parts-catalogs.types";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { SERVICE_TO_BRAND } from "../integrations/pl24/pl24.types";
import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
@@ -167,8 +168,8 @@ export class VehiclesService {
// the dealer what car it is. The miss is logged (with wmi) for the coverage
// backlog either way. Skip on budget-abort (already a timeout, don't pile on).
if (!aborted) {
const basic = await this.identifyBasic(vin);
if (basic) {
const ident = await this.identifyBasicStructured(vin);
if (ident) {
ctx.timings.identified_no_catalog = 1;
await this.logQuery(
userId,
@@ -177,11 +178,23 @@ export class VehiclesService {
"none",
false,
Date.now() - startTime,
`No catalog — identified as ${basic}`,
`No catalog — identified as ${ident.display}`,
ctx.timings,
);
// Brand is known AND has a browse catalog → offer the existing
// /dashboard/catalog model-browse instead of dead-ending. The parts
// usually exist there (e.g. Fiat Egea NM4, old Renault VF1); they're
// just not reachable by this specific VIN's index entry.
if (ident.browseBrand) {
return {
noCatalog: { brandName: ident.browseBrand, display: ident.display },
vin,
};
}
// Identified but no browse catalog (Honda, Mazda, Alfa Romeo, …) →
// keep the informative dead-end.
throw new BadRequestException(
`Bu araç ${basic} olarak tanındı, ancak bu şase için parça kataloğu henüz mevcut değil. Talebiniz kaydedildi.`,
`Bu araç ${ident.display} olarak tanındı, ancak bu şase için parça kataloğu henüz mevcut değil. Talebiniz kaydedildi.`,
);
}
}
@@ -446,6 +459,10 @@ export class VehiclesService {
private static readonly RESOLVE_TTL_NEGATIVE_S = 1_800; // 30m
private static readonly RESOLVE_LOCK_TTL_S = 60;
private static readonly RESOLVE_WAIT_POLL_MS = 250;
// Canonical brand names that have a browse-able /dashboard/catalog (PL24-backed).
// When a VIN can't be decoded but its WMI brand is in here, we offer model-browse
// instead of dead-ending — the parts usually exist, just not reachable by this VIN.
private static readonly BROWSEABLE_BRANDS = new Set<string>(Object.values(SERVICE_TO_BRAND));
private static readonly RESOLVE_WAIT_TIMEOUT_MS = 30_000;
// Hard ceiling for the full decode chain. The previous record was a 17-min
// PL24 decode (responseTimeMs=1006123) that ran long after the request was
@@ -703,13 +720,21 @@ export class VehiclesService {
/**
* Best-effort identification for VINs no catalog could decode: offline Corgi WMI
* → brand, NHTSA → model/year. Returns a display string ("Renault Clio 2018") or
* null if even the brand is unknown. Only used to give the user a meaningful
* "identified but no catalog yet" message instead of a bare "unsupported".
* → brand, NHTSA → model/year.
* - `display` is a human string ("Renault Clio 2018") for the message/log, or null
* if even the brand is unknown.
* - `browseBrand` is the canonical catalog brand — set ONLY from the Corgi WMI map
* (and only when that brand has a browse-able catalog), never from NHTSA's make
* (uppercase/odd, e.g. "HONDA"). It's the safe deep-link target for model-browse,
* so the user isn't dead-ended when a VIN can't decode but the brand catalog exists.
*/
private async identifyBasic(vin: string): Promise<string | null> {
private async identifyBasicStructured(
vin: string,
): Promise<{ browseBrand: string | null; display: string } | null> {
const corgi = this.corgiService.decodeVin(vin);
let brand: string | null = corgi?.isKnown ? corgi.brandName : null;
const browseBrand =
corgi?.isKnown && corgi.brandName ? this.resolveBrowseBrand(corgi.brandName) : null;
let model: string | null = null;
let year: string | number | null = corgi?.modelYear ?? null;
try {
@@ -723,7 +748,23 @@ export class VehiclesService {
// NHTSA is best-effort; a brand from Corgi alone is still useful.
}
if (!brand && !model) return null;
return [brand, model, year].filter(Boolean).join(" ").trim() || null;
const display = [brand, model, year].filter(Boolean).join(" ").trim();
if (!display) return null;
return { browseBrand, display };
}
/**
* Map a Corgi WMI brand to the canonical, browse-able catalog brand name, or null
* when it has no browse catalog (then we keep the informative dead-end). Canonical
* casing matters: the web deep-links into /dashboard/catalog/:brandName whose
* getBrandIdByName lookup is a case-sensitive eq(brands.name, …). SERVICE_TO_BRAND
* values are already canonical, so we adopt that exact spelling.
*/
private resolveBrowseBrand(brand: string): string | null {
for (const canonical of VehiclesService.BROWSEABLE_BRANDS) {
if (canonical.toLowerCase() === brand.toLowerCase()) return canonical;
}
return null;
}
/** Actual decode chain — does NOT touch the cache or lock. */

View File

@@ -703,6 +703,9 @@
"previewLoading": "Fetching vehicle info...",
"vehicleIdentified": "Vehicle identified",
"previewHint": "Click Decode VIN to access the full parts catalog.",
"noCatalogTitle": "We recognized this vehicle",
"noCatalogHint": "There's no ready catalog for this VIN yet. Browse {brand} models in the catalog to find the customer vehicle's parts.",
"browseCatalogCta": "Browse the {brand} catalog by model",
"recent": "Recent searches",
"seeAll": "See all →",
"historyAria": "{brand} {model} — VIN {vin}, open from history",

View File

@@ -703,6 +703,9 @@
"previewLoading": "Araç bilgileri alınıyor...",
"vehicleIdentified": "Araç tanımlandı",
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
"noCatalogTitle": "Bu aracı tanıdık",
"noCatalogHint": "Bu şase için hazır katalog henüz yok. {brand} modellerini katalogdan seçerek müşteri aracının parçalarına ulaşabilirsiniz.",
"browseCatalogCta": "{brand} kataloğunu modelden incele",
"recent": "Son Aramalar",
"seeAll": "Tümünü Gör →",
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",

View File

@@ -169,6 +169,31 @@ test("retry suppressed for subscription block (abone olun)", async () => {
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
});
test("no-catalog fallback: identified brand shows a model-browse CTA, not an error", async () => {
(api.post as any).mockResolvedValueOnce({
noCatalog: { brandName: "Fiat", display: "Fiat Tipo 2018" },
vin: TEST_VIN,
});
renderSearch();
typeVin(TEST_VIN);
await submitForm();
// The model-browse CTA renders (deep-links into /dashboard/catalog/:brand).
await waitFor(() => {
expect(screen.getByRole("link", { name: /modelden incele/i })).toBeInTheDocument();
});
// It is a helpful state, NOT an error banner.
expect(screen.queryByRole("alert")).toBeNull();
// Analytics: the no-catalog event fires (replacing vin_decode_error for this case).
expect(capture).toHaveBeenCalledWith(
"vin_decode_no_catalog",
expect.objectContaining({ vin: TEST_VIN, brand_name: "Fiat" }),
);
});
// ─── FN-415: locator-stability + hit-target audit (AC 1, 3, 4) ────────────
test("locator stability: canonical Faro/ARIA hooks resolve and no data-testid is used", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");

View File

@@ -158,6 +158,10 @@ function SearchPage() {
const [previewLoading, setPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
// Decoded the brand but no catalog has this exact VIN → offer model-browse
// into the existing /dashboard/catalog instead of dead-ending.
const [noCatalog, setNoCatalog] = useState<{ brandName: string; display: string } | null>(null);
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
@@ -259,6 +263,7 @@ function SearchPage() {
// ─── Decode runner (shared by submit and retry) ────────────────────────────
async function runDecode(cleanVin: string, attempt: number) {
setError(null);
setNoCatalog(null);
setLoading(true);
const decodeStart = performance.now();
try {
@@ -295,6 +300,21 @@ function SearchPage() {
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.
setPreview(null);
setPreviewError(false);
setNoCatalog({ brandName: data.noCatalog.brandName, display: data.noCatalog.display });
capture("vin_decode_no_catalog", {
vin: cleanVin,
brand_name: data.noCatalog.brandName,
response_time_ms: responseTimeMs,
query_source: querySourceRef.current,
});
return;
}
capture("vin_decode_success", {
vin: cleanVin,
vehicle_id: data.id,
@@ -324,6 +344,7 @@ function SearchPage() {
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
setError(null);
setNoCatalog(null);
const cleanVin = vin.toUpperCase().trim();
const querySource = querySourceRef.current;
@@ -438,6 +459,7 @@ function SearchPage() {
const { cleaned, corrections } = sanitizeVin(upper);
setVin(cleaned);
setError(null);
setNoCatalog(null);
setReportSent(false);
if (corrections.length > 0) {
for (const c of corrections) correctionsRef.current.add(c);
@@ -695,6 +717,39 @@ function SearchPage() {
</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">
<div className="flex items-start gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-brand/10">
<Car className="size-5 text-brand" />
</div>
<div className="min-w-0 flex-1">
<p className="font-[family-name:var(--font-display)] text-lg font-bold">
{t("search.noCatalogTitle")}
</p>
<p className="mt-0.5 text-sm font-medium text-foreground">{noCatalog.display}</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("search.noCatalogHint", { brand: noCatalog.brandName })}
</p>
</div>
</div>
<Separator className="my-4 bg-border" />
<Button asChild className="h-11 w-full rounded-xl">
<Link
to="/dashboard/catalog/$brandName"
params={{ brandName: noCatalog.brandName }}
search={{ catalog: undefined }}
onClick={() =>
capture("vin_no_catalog_browse_clicked", { brand_name: noCatalog.brandName })
}
>
{t("search.browseCatalogCta", { brand: noCatalog.brandName })}
</Link>
</Button>
</div>
)}
{/* ─── SECTION 2: Live Preview Card ───────────────────────────────── */}
{previewLoading && (
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">