dev #44

Merged
root merged 7 commits from dev into main 2026-05-25 01:31:28 +03:00
8 changed files with 368 additions and 148 deletions

View File

@@ -42,8 +42,11 @@ const WMI_DATABASE: Record<string, string> = {
VF2: "Renault",
// Peugeot
VF3: "Peugeot",
VR3: "Peugeot", // newer Stellantis-era WMI
VR7: "Peugeot", // newer Stellantis-era WMI
// Citroen
VF7: "Citroen",
VR1: "Citroen", // newer Citroën/DS WMI
// Honda
JHM: "Honda",
SHH: "Honda",
@@ -61,6 +64,7 @@ const WMI_DATABASE: Record<string, string> = {
"3FA": "Ford",
// Opel
W0L: "Opel",
W0V: "Opel", // newer Stellantis-era WMI
// Skoda
TMB: "Skoda",
// Seat

View File

@@ -256,6 +256,10 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
VF3: { code: "PEUGEOT00", brand: "Peugeot" },
// Citroen/Peugeot (VF7 shared — Peugeot more common)
VF7: { code: "PEUGEOT00", brand: "Peugeot" },
// Newer PSA/Stellantis WMIs (Peugeot/Citroën/DS) — same EMEX catalog as VF3/VF7
VR1: { code: "PEUGEOT00", brand: "Peugeot" },
VR3: { code: "PEUGEOT00", brand: "Peugeot" },
VR7: { code: "PEUGEOT00", brand: "Peugeot" },
// Fiat
ZFA: { code: "FFIAT84", brand: "Fiat" },
// Alfa Romeo
@@ -320,6 +324,7 @@ export const CATALOG_MAP: Record<string, CatalogEntry> = {
JAA: { code: "ISUZU201702", brand: "Isuzu" },
// Opel
W0L: { code: "GM_OP201809", brand: "Opel" },
W0V: { code: "GM_OP201809", brand: "Opel" }, // newer Stellantis-era WMI
// Chevrolet
KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong

View File

@@ -18,7 +18,12 @@ import {
} from "./parts-catalogs.types";
const API_BASE = "https://gui.parts-catalogs.com/v3/api/proxy";
const REQUEST_TIMEOUT = 30_000;
const REQUEST_TIMEOUT = Number(process.env.PCAT_REQUEST_TIMEOUT_MS) || 30_000;
const MAX_RETRIES = Number(process.env.PCAT_MAX_RETRIES) || 2;
// A dead DataImpulse proxy port otherwise stalls for undici's 10s default connect
// timeout before the retry rotates to a fresh port — three of those blow the
// caller's 25s decode budget. Fail fast so retries reach a live port in time.
const PROXY_CONNECT_TIMEOUT = Number(process.env.PCAT_PROXY_CONNECT_TIMEOUT_MS) || 6_000;
@Injectable()
export class PartsCatalogsService {
@@ -41,7 +46,11 @@ export class PartsCatalogsService {
/**
* VIN decode — returns one or more car matches.
*/
async decodeVin(vin: string, signal?: AbortSignal): Promise<PcatVinResult | null> {
async decodeVin(
vin: string,
signal?: AbortSignal,
outcome?: { transient: boolean },
): Promise<PcatVinResult | null> {
try {
const data = await this.fetchWithAuth("/car/info", { q: vin }, signal);
@@ -75,7 +84,21 @@ export class PartsCatalogsService {
return { cars };
} catch (err) {
this.logger.warn(`VIN decode failed for ${vin}: ${(err as Error).message}`);
const e = err as Error & { cause?: unknown };
// Distinguish a transient transport blip (proxy timeout / dropped DataImpulse
// connection) from a genuine "not found". The caller uses `outcome.transient`
// to decide whether to poison the negative cache — a blip must not.
if (
outcome &&
(e.name === "TimeoutError" ||
e.name === "TypeError" ||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|socket hang up|other side closed|terminated|UND_ERR|ConnectTimeout|aborted/i.test(
`${e.message} ${String(e.cause ?? "")}`,
))
) {
outcome.transient = true;
}
this.logger.warn(`VIN decode failed for ${vin}: ${e.message}`);
return null;
}
}
@@ -170,7 +193,7 @@ export class PartsCatalogsService {
params?: Record<string, string>,
externalSignal?: AbortSignal,
): Promise<any> {
const maxRetries = 2;
const maxRetries = MAX_RETRIES;
let session: PcatSession | null = null;
@@ -209,7 +232,10 @@ export class PartsCatalogsService {
// Use undici ProxyAgent if proxy is configured
if (session.proxyUrl) {
const { ProxyAgent } = await import("undici");
fetchOptions.dispatcher = new ProxyAgent(session.proxyUrl);
fetchOptions.dispatcher = new ProxyAgent({
uri: session.proxyUrl,
connect: { timeout: PROXY_CONNECT_TIMEOUT },
});
}
const response = await fetch(url.toString(), fetchOptions);

View File

@@ -7,7 +7,7 @@
* - 'de' (de-708171): DataImpulse Germany proxy, Fiat + EUR prices
*/
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
import { Injectable, Logger, type OnModuleInit, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PL24_ENDPOINTS } from "./pl24.constants";
import {
@@ -20,7 +20,7 @@ import {
} from "./pl24.types";
@Injectable()
export class PL24AuthService {
export class PL24AuthService implements OnModuleInit {
private readonly logger = new Logger(PL24AuthService.name);
// ── Account 1 (tr-903645) ──────────────────────────────────────────────────
@@ -64,6 +64,61 @@ export class PL24AuthService {
}
}
onModuleInit(): void {
// In-memory auth tokens are cleared on every (re)deploy, so the first VIN
// decode afterwards otherwise pays the ~10s PL24 login + service-authorize
// handshake on the request path. Warm it in the background so users never do.
// Fire-and-forget — never block or fail boot; set PL24_PREWARM=false to disable.
if (process.env.PL24_PREWARM === "false") return;
if (!this.companyCode || !this.username || !this.password) return;
void this.prewarm().catch((err) =>
this.logger.warn(`PL24 auth pre-warm error: ${(err as Error).message}`),
);
}
/**
* Pre-fill the in-memory auth caches: the base account logins (the shared,
* dominant cost) plus the common legacy + top Turkish-market service tokens.
* Idempotent — the underlying methods cache, so this just populates the cache.
* Uses allSettled throughout: a slow/down PL24 degrades gracefully (logged).
*/
async prewarm(): Promise<void> {
const t0 = Date.now();
// 1) Base account JWT + session cookie. login() for "tr" (tokenData) and "de"
// (tokenData2) are independent; the session cookie is warmed as a side effect.
const baseResults = await Promise.allSettled([
this.getAccessTokenForAccount("tr"),
this.companyCode2 ? this.getAccessTokenForAccount("de") : Promise.resolve(""),
]);
const baseOk = baseResults.filter((r) => r.status === "fulfilled").length;
// 2) Common legacy (Ford-legacy JWT flow) + top Turkish P5 service tokens.
// Only if the tr login succeeded, so we don't stampede concurrent logins.
const services = [
"opel_parts",
"fordt_parts",
"hyundai_parts",
"nissan_parts",
"volvo_parts",
"vw_parts",
"renault_parts",
"toyota_parts",
];
let svcOk = 0;
if (baseResults[0].status === "fulfilled") {
const svcResults = await Promise.allSettled(
services.map((svc) => this.authorizeServiceForAccount(svc, "tr")),
);
svcOk = svcResults.filter((r) => r.status === "fulfilled").length;
}
this.logger.log(
`PL24 auth pre-warm done in ${Date.now() - t0}ms (logins ${baseOk}/2, services ${svcOk}/${services.length}${
baseResults[0].status === "fulfilled" ? "" : " — services skipped: tr login failed"
})`,
);
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Public: per-account API ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -552,12 +552,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
// Opel / Vauxhall
W0L: "opel_parts", // Opel AG (Germany)
W0V: "opel_parts", // Opel (newer Stellantis-era WMI)
// Citroën (PSA)
VF7: "citroen_parts", // Citroën SA (France)
VR1: "citroen_parts", // Citroën/DS (newer WMI)
// Peugeot (PSA)
VF3: "peugeot_parts", // Peugeot SA (France)
VR3: "peugeot_parts", // Peugeot (newer WMI)
VR7: "peugeot_parts", // Peugeot (newer WMI)
// Volvo
YV1: "volvo_parts", // Volvo Cars (Sweden)

View File

@@ -438,6 +438,95 @@ describe("VehiclesService", () => {
expect(partsCatalogsService.decodeVin).toHaveBeenCalled();
expect(emexService.decodeVinOrCandidates).toHaveBeenCalledWith("WBAPH5C55BA123456");
});
// ── Source race: PCAT + EMEX in parallel, PL24 joins the moment PCAT misses ──
function raceDb(savedVehicle: Record<string, unknown>) {
const chain: Record<string, any> = {};
chain.from = vi.fn().mockReturnValue(chain);
chain.innerJoin = vi.fn().mockReturnValue(chain);
chain.leftJoin = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
chain.limit = vi.fn().mockReturnValue([]);
return {
select: vi.fn().mockReturnValue(chain),
insert: vi.fn().mockImplementation(() => {
const c: Record<string, any> = {};
c.values = vi.fn().mockReturnValue(c);
c.onConflictDoUpdate = vi.fn().mockReturnValue(c);
c.onConflictDoNothing = vi.fn().mockReturnValue(c);
c.returning = vi.fn().mockReturnValue([savedVehicle]);
return c;
}),
};
}
it("PCAT single car wins and PL24 is never raced", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-pcat", vin: "WVWZZZ1JZ3W597935", source: "parts-catalogs" };
const { service, partsCatalogsService, emexService, pl24Service } = createService(
raceDb(savedVehicle),
);
partsCatalogsService.decodeVin.mockResolvedValue({
cars: [{ id: "c1", name: "Golf", catalogId: "vw", parameters: [] }],
});
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
const result = await service.decodeVin("WVWZZZ1JZ3W597935", "u1");
expect(result).toEqual(savedVehicle);
expect(pl24Service.decodeVin).not.toHaveBeenCalled();
});
it("PCAT miss + EMEX vehicle → decode succeeds via EMEX", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-emex", vin: "WBAPH5C55BA123456", source: "emex" };
const { service, partsCatalogsService, emexService } = createService(raceDb(savedVehicle));
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({
type: "vehicle",
vehicle: { brand: "BMW", model: "320i", year: 2020, engineCode: "N20", raw: {} },
});
const result = await service.decodeVin("WBAPH5C55BA123456", "u1");
expect(result).toEqual(savedVehicle);
expect(emexService.decodeVinOrCandidates).toHaveBeenCalled();
});
it("PCAT miss + EMEX miss → PL24 is raced and its vehicle wins", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const savedVehicle = { id: "v-pl24", vin: "VR3EFYHZ3PJ674071", source: "pl24" };
const { service, partsCatalogsService, emexService, pl24Service } = createService(
raceDb(savedVehicle),
);
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
pl24Service.isDecodeable.mockReturnValue(true);
pl24Service.getBrandName.mockReturnValue("Peugeot");
pl24Service.decodeVin.mockResolvedValue({
model: "208",
year: 2023,
engineType: "Petrol",
transmission: "Auto",
bodyType: "Hatchback",
});
const result = await service.decodeVin("VR3EFYHZ3PJ674071", "u1");
expect(result).toEqual(savedVehicle);
expect(pl24Service.decodeVin).toHaveBeenCalledWith("VR3EFYHZ3PJ674071", "u1");
});
it("all sources miss → throws and PL24 was attempted", async () => {
vi.mocked(isValidVin).mockReturnValue(true);
const { service, partsCatalogsService, emexService, pl24Service } = createService();
partsCatalogsService.decodeVin.mockResolvedValue(null);
emexService.decodeVinOrCandidates.mockResolvedValue({ type: "notFound" });
pl24Service.isDecodeable.mockReturnValue(true);
pl24Service.decodeVin.mockResolvedValue(null);
await expect(service.decodeVin("VR3EFYHZ3PJ674071", "u1")).rejects.toThrow(
"Şase numarası tanınamadı",
);
expect(pl24Service.decodeVin).toHaveBeenCalled();
});
});
describe("getHistory", () => {

View File

@@ -414,8 +414,12 @@ export class VehiclesService {
const budgetTimer = setTimeout(() => ac.abort(), VehiclesService.RESOLVE_BUDGET_MS);
if (ctx && !ctx.timings.cache_source) ctx.timings.cache_source = "miss";
// Tracks whether any source failed transiently (proxy/network/timeout) during
// this resolve. If so, a null result is NOT a definitive "unsupported" answer
// and must not be written to the negative cache (which would persist the blip).
const outcome = { transient: false };
try {
const result = await this.doResolveVin(vin, userId, ctx, ac.signal);
const result = await this.doResolveVin(vin, userId, ctx, ac.signal, outcome);
if (ac.signal.aborted) {
this.logger.warn(
`VIN ${vin} decode aborted by budget (${VehiclesService.RESOLVE_BUDGET_MS}ms)`,
@@ -438,6 +442,11 @@ export class VehiclesService {
}
if (result) {
await this.redis.setJson(cacheKey, result, VehiclesService.RESOLVE_TTL_POSITIVE_S);
} else if (outcome.transient) {
// A source failed transiently (proxy/network/timeout); the VIN may well be
// supported. Don't poison the negative cache — let the next attempt retry.
this.logger.warn(`VIN ${vin}: skipping negative cache — transient source failure`);
if (ctx) ctx.timings.neg_cache_skipped = 1;
} else {
await this.redis.set(negKey, "1", VehiclesService.RESOLVE_TTL_NEGATIVE_S);
}
@@ -497,17 +506,17 @@ export class VehiclesService {
userId?: string,
ctx?: ResolveContext,
signal?: AbortSignal,
outcome?: { transient: boolean },
): Promise<VinResolveResult | null> {
const corgiKnown = false;
let brandName: string | null = null;
// ── Parallel: PartsCatalogs + EMEX (EMEX capped at 3s) ──────────────
const EMEX_RACE_MS = 3000;
// ── PartsCatalogs + EMEX fire in parallel; PL24 joins the race on PCAT miss ──
const pcatStart = Date.now();
let pcatResolved = false;
const pcatPromise = this.partsCatalogsService
.decodeVin(vin, signal)
.decodeVin(vin, signal, outcome)
.catch((err: Error) => {
this.logger.warn(`PartsCatalogs decode failed for ${vin}: ${err.message}`);
return null;
@@ -534,153 +543,150 @@ export class VehiclesService {
}
});
// Emex result capped at 3s — if it doesn't arrive in time, falls back to PL24
const emexTimedPromise = Promise.race([
emexBasePromise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), EMEX_RACE_MS)),
]);
// ── Real race: whichever returns a definitive single result first wins ───
// "Definitive" = PCAT has exactly 1 car, OR EMEX returns a non-UNKNOWN single
// vehicle. If the first completer is definitive we skip the rest entirely;
// otherwise we fall back to the previous decision-tree (await both, decide).
const earlyWinner = await Promise.race([
pcatPromise.then((r) => ({ kind: "pcat" as const, r })),
emexTimedPromise.then((r) => ({ kind: "emex" as const, r })),
]);
if (signal?.aborted) return null;
// ── Fastest-source-wins race ─────────────────────────────────────────────
// PCAT + EMEX are already in flight. The instant PCAT settles WITHOUT a single
// car, PL24 joins the race (a definitive EMEX would already have returned). The
// first source to yield a definitive result wins — no artificial wait, so the
// dealer at the counter gets parts as fast as any one source can answer. EMEX
// takes no AbortSignal, so it's raced against the budget signal.
type Pl24R = Awaited<ReturnType<PL24Service["decodeVin"]>>;
type Tagged =
| { kind: "pcat"; r: Awaited<typeof pcatPromise> }
| { kind: "emex"; r: Awaited<typeof emexBasePromise> }
| { kind: "pl24"; r: Pl24R };
let pcatResult: Awaited<typeof pcatPromise> = null;
let emexResult: Awaited<typeof emexTimedPromise> = null;
if (earlyWinner.kind === "pcat") pcatResult = earlyWinner.r;
else emexResult = earlyWinner.r;
let emexResult: Awaited<typeof emexBasePromise> = null;
let pl24Launched = false;
const earlyPcatDefinitive = earlyWinner.kind === "pcat" && earlyWinner.r?.cars?.length === 1;
const earlyEmexDefinitive =
earlyWinner.kind === "emex" &&
earlyWinner.r?.type === "vehicle" &&
earlyWinner.r.vehicle.brand !== "UNKNOWN";
if (!earlyPcatDefinitive && !earlyEmexDefinitive) {
// First completer wasn't definitive — wait for the other one.
// Always await the other promise (if already resolved, returns immediately).
if (earlyWinner.kind === "pcat") {
emexResult = await emexTimedPromise;
} else if (earlyWinner.kind === "emex") {
pcatResult = await pcatPromise;
}
if (signal?.aborted) return null;
}
if (ctx) {
ctx.timings.pcat_car_count = pcatResult?.cars?.length ?? 0;
ctx.timings.emex_candidate_count =
emexResult?.type === "candidates" ? emexResult.candidates.length : 0;
}
// Decision 1: pcat returned exactly 1 car → use it, ignore EMEX
if (pcatResult?.cars?.length === 1) {
const car = pcatResult.cars[0];
if (!brandName) brandName = this.extractBrandFromPcatCar(car) || null;
const result: VinResolveResult = {
brandName,
model: car.name || null,
year: this.extractYearFromPcatCar(car) || null,
engine: translateEngineType(this.extractParamFromPcatCar(car, "engine") || null),
transmission: translateTransmission(
this.extractParamFromPcatCar(car, "transmission") || null,
),
bodyType: translateBodyType(this.extractParamFromPcatCar(car, "body") || null),
rawData: {
source: "parts-catalogs",
catalogId: car.catalogId,
carId: car.id,
parameters: car.parameters || [],
pcatCar: car,
},
source: "parts-catalogs",
corgiKnown,
corgiResult: null,
};
return result;
}
// Decision 2: pcat ≠ 1 → check EMEX result (already set by the race above)
const emexSingleVehicle =
emexResult?.type === "vehicle" && emexResult.vehicle.brand !== "UNKNOWN"
? emexResult.vehicle
: null;
if (emexSingleVehicle) {
// pcat >1 or 0 AND emex returned exactly 1 vehicle → use EMEX
if (!brandName) brandName = emexSingleVehicle.brand || null;
const result: VinResolveResult = {
brandName,
model: emexSingleVehicle.model || null,
year: emexSingleVehicle.year || null,
// engineCode is alphanumeric (e.g. "N20B20") — leave raw; only the
// engineType fallback ("Petrol"/"Diesel") goes through the dict.
engine:
emexSingleVehicle.engineCode ||
translateEngineType(emexSingleVehicle.engineType || null),
transmission: translateTransmission(emexSingleVehicle.transmission || null),
bodyType: translateBodyType(emexSingleVehicle.bodyType || null),
rawData: emexSingleVehicle.raw || null,
source: "emex",
corgiKnown,
corgiResult: null,
};
return result;
}
// Decision 3: EMEX timed out / returned multiple / returned nothing → PL24
this.logger.log(`PL24 fallback triggered for ${vin} (EMEX: ${emexResult?.type ?? "timeout"})`);
const pl24CircuitOpen = await this.isPl24CircuitOpen();
if (ctx) ctx.timings.pl24_circuit_open = pl24CircuitOpen;
if (pl24CircuitOpen) {
this.logger.warn(`PL24 skipped for ${vin}: circuit breaker is open`);
if (ctx) ctx.timings.pl24_skipped = 1;
}
if (this.pl24Service.isDecodeable(vin) && !pl24CircuitOpen) {
const launchPl24 = (): Promise<Tagged> | null => {
if (pl24Launched || !this.pl24Service.isDecodeable(vin)) return null;
pl24Launched = true;
const pl24Start = Date.now();
try {
// PL24 internal fetch chain doesn't accept AbortSignal yet; race it at the
// boundary so the budget timer returns control to the caller. Background
// work may continue (PL24 has its own per-fetch 30s cap), but the user
// and HTTP response are unblocked.
const pl24Vehicle = await this.raceWithSignal(
this.pl24Service.decodeVin(vin, userId),
signal,
);
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
if (signal?.aborted) {
// Budget aborted while waiting on PL24 — counts as a failure for CB.
await this.recordPl24Failure();
return null;
return (async (): Promise<Tagged> => {
if (await this.isPl24CircuitOpen()) {
if (ctx) {
ctx.timings.pl24_circuit_open = true;
ctx.timings.pl24_skipped = 1;
}
this.logger.warn(`PL24 skipped for ${vin}: circuit breaker is open`);
return { kind: "pl24", r: null };
}
if (pl24Vehicle) {
await this.recordPl24Success();
if (!brandName) brandName = this.pl24Service.getBrandName(vin) || null;
const result: VinResolveResult = {
this.logger.log(`PL24 race entered for ${vin}`);
try {
// PL24's fetch chain takes no AbortSignal; race it at the boundary so the
// budget timer can unblock the response (background work may still finish).
const v = await this.raceWithSignal(this.pl24Service.decodeVin(vin, userId), signal);
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
if (v) await this.recordPl24Success();
else if (signal?.aborted) await this.recordPl24Failure();
return { kind: "pl24", r: v };
} catch (err) {
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
await this.recordPl24Failure();
const e = err as Error & { cause?: unknown };
if (
outcome &&
(e.name === "TimeoutError" ||
/fetch failed|ECONNRESET|ECONNREFUSED|EAI_AGAIN|terminated|UND_ERR|ConnectTimeout|timeout|aborted/i.test(
`${e.message} ${String(e.cause ?? "")}`,
))
) {
outcome.transient = true;
}
this.logger.warn(`PL24 decode failed for ${vin}: ${e.message}`);
return { kind: "pl24", r: null };
}
})();
};
const pending = new Map<string, Promise<Tagged>>();
pending.set(
"pcat",
pcatPromise.then((r): Tagged => ({ kind: "pcat", r })),
);
pending.set(
"emex",
this.raceWithSignal(emexBasePromise, signal).then((r): Tagged => ({ kind: "emex", r })),
);
while (pending.size > 0) {
const settled = await Promise.race(pending.values());
if (signal?.aborted) return null;
pending.delete(settled.kind);
if (settled.kind === "pcat") {
pcatResult = settled.r;
if (ctx) ctx.timings.pcat_car_count = pcatResult?.cars?.length ?? 0;
if (pcatResult?.cars?.length === 1) {
const car = pcatResult.cars[0];
if (!brandName) brandName = this.extractBrandFromPcatCar(car) || null;
return {
brandName,
model: pl24Vehicle.model || null,
year: pl24Vehicle.year || null,
engine: pl24Vehicle.engineType || pl24Vehicle.engineCode || null,
transmission: pl24Vehicle.transmission || null,
bodyType: pl24Vehicle.bodyType || null,
rawData: pl24Vehicle,
source: "pl24",
model: car.name || null,
year: this.extractYearFromPcatCar(car) || null,
engine: translateEngineType(this.extractParamFromPcatCar(car, "engine") || null),
transmission: translateTransmission(
this.extractParamFromPcatCar(car, "transmission") || null,
),
bodyType: translateBodyType(this.extractParamFromPcatCar(car, "body") || null),
rawData: {
source: "parts-catalogs",
catalogId: car.catalogId,
carId: car.id,
parameters: car.parameters || [],
pcatCar: car,
},
source: "parts-catalogs",
corgiKnown,
corgiResult: null,
};
return result;
}
} catch (err) {
if (ctx) ctx.timings.pl24 = Date.now() - pl24Start;
await this.recordPl24Failure();
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
// PCAT gave 0/multiple → bring PL24 into the race (a definitive EMEX would
// already have returned above).
const p = launchPl24();
if (p) pending.set("pl24", p);
} else if (settled.kind === "emex") {
emexResult = settled.r;
if (ctx) {
ctx.timings.emex_candidate_count =
emexResult?.type === "candidates" ? emexResult.candidates.length : 0;
}
if (outcome && emexResult?.type === "error") outcome.transient = true;
if (emexResult?.type === "vehicle" && emexResult.vehicle.brand !== "UNKNOWN") {
const v = emexResult.vehicle;
if (!brandName) brandName = v.brand || null;
return {
brandName,
model: v.model || null,
year: v.year || null,
// engineCode is alphanumeric (e.g. "N20B20") — leave raw; only the
// engineType fallback ("Petrol"/"Diesel") goes through the dict.
engine: v.engineCode || translateEngineType(v.engineType || null),
transmission: translateTransmission(v.transmission || null),
bodyType: translateBodyType(v.bodyType || null),
rawData: v.raw || null,
source: "emex",
corgiKnown,
corgiResult: null,
};
}
} else if (settled.r) {
// PL24 returned a vehicle → fastest definitive wins.
if (!brandName) brandName = this.pl24Service.getBrandName(vin) || null;
const v = settled.r;
return {
brandName,
model: v.model || null,
year: v.year || null,
engine: v.engineType || v.engineCode || null,
transmission: v.transmission || null,
bodyType: v.bodyType || null,
rawData: v,
source: "pl24",
corgiKnown,
corgiResult: null,
};
}
}

View File

@@ -61,6 +61,7 @@ function SearchPage() {
const candidatesShownAtRef = useRef<number | null>(null);
const lastAttemptedVinRef = useRef<string | null>(null);
const attemptCountRef = useRef<number>(0);
const preDecodedVinRef = useRef<string | null>(null);
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -147,6 +148,36 @@ function SearchPage() {
return () => controller.abort();
}, [vin]);
// ─── Speculative pre-decode (the moment the VIN is valid) ──────────────────
// Warm the full decode in the background before the user clicks "Şase Çöz", so
// the button-click decode hits the backend lock / 24h positive cache and returns
// near-instantly — the dealer at the counter loses no time. Fire-and-forget: no
// state, no analytics, no navigation (the button path owns all of that). Deduped
// per VIN, debounced + aborted so typing/paste-then-edit doesn't spam decodes.
useEffect(() => {
const cleanVin = vin.toUpperCase().trim();
if (!isValidVin(cleanVin) || preDecodedVinRef.current === cleanVin) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
preDecodedVinRef.current = cleanVin;
// Warm the backend decode chain; the result is intentionally discarded.
fetch("/api/vehicles/decode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ vin: cleanVin }),
signal: controller.signal,
}).catch(() => {
/* speculative warm — ignore result/errors; the button-click decode owns the outcome */
});
}, 350);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [vin]);
// ─── Auto-focus ────────────────────────────────────────────────────────────
useEffect(() => {
inputRef.current?.focus();