dev #44
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user