feat(vehicles): model-browse fallback for no-catalog VINs
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user