fix: bypass Corgi WMI in VIN decode chain + misc integrations

- vehicles.service.ts: remove Corgi from resolveVin chain; go straight
  to PartsCatalogs → PL24 → EMEX without offline WMI lookup
- End condition simplified: return null only when EMEX also fails (no
  corgiKnown fallback)
- Add scripts/test-emex-http.mjs: proxy timing test for EMEX HTTP decode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-06 19:51:40 +00:00
parent ce8784014e
commit 19ccaa7b68
16 changed files with 726 additions and 288 deletions

View File

@@ -891,6 +891,8 @@ export class CatalogService {
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
price: p.price != null ? String(p.price) : null,
currency: p.price != null ? (p.currency ?? "EUR") : null,
source: "pl24" as const,
}));

View File

@@ -794,6 +794,8 @@ export class CategoriesService {
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
price: p.price != null ? String(p.price) : null,
currency: p.price != null ? (p.currency ?? "EUR") : null,
source: "pl24" as const,
}));

View File

@@ -35,6 +35,10 @@ export default () => ({
companyCode: process.env.PL24_COMPANY_CODE,
username: process.env.PL24_USERNAME,
password: process.env.PL24_PASSWORD,
companyCode2: process.env.PL24_COMPANY_CODE_2,
username2: process.env.PL24_USERNAME_2,
password2: process.env.PL24_PASSWORD_2,
proxyDe: process.env.PL24_PROXY_DE,
},
emex: {
username: process.env.EMEX_USERNAME,

View File

@@ -5,6 +5,7 @@ import {
text,
boolean,
integer,
numeric,
timestamp,
jsonb,
index,
@@ -351,6 +352,8 @@ export const parts = pgTable(
remark: text("remark"),
modelCodes: varchar("model_codes", { length: 500 }),
presel: boolean("presel").default(false).notNull(),
price: numeric("price", { precision: 10, scale: 2 }),
currency: varchar("currency", { length: 3 }),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},

View File

@@ -77,14 +77,14 @@ export class EmexBrowserService implements OnModuleInit, OnModuleDestroy {
this.semaphore = new Semaphore(MAX_CONCURRENT_PAGES);
this.useProxy =
this.configService.get<string>('EMEX_USE_PROXY', 'false') === 'true';
this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
this.proxyHost = this.configService.get<string>(
'EMEX_PROXY_HOST',
'74.81.81.81',
);
this.proxyPortStart = this.configService.get<number>(
'EMEX_PROXY_PORT_START',
10000,
10001,
);
this.proxyPortEnd = this.configService.get<number>(
'EMEX_PROXY_PORT_END',

View File

@@ -19,6 +19,7 @@ import {
import { ConfigService } from '@nestjs/config';
import * as path from 'path';
import { ProxyAgent } from 'undici';
import {
EmexScraperResponse,
EmexCategoryTreeNode,
@@ -99,6 +100,7 @@ export class EmexService {
private readonly scraperPath: string;
private readonly timeout: number;
private readonly debug: boolean;
private readonly proxyAgent: ProxyAgent | null;
constructor(
private configService: ConfigService,
@@ -117,6 +119,24 @@ export class EmexService {
this.timeout = this.configService.get<number>('EMEX_TIMEOUT', 60000);
this.debug = this.configService.get<boolean>('EMEX_DEBUG', false);
const useProxy = this.configService.get<string>('EMEX_USE_PROXY', 'true') === 'true';
if (useProxy) {
const host = this.configService.get<string>('EMEX_PROXY_HOST', '74.81.81.81');
const portStart = this.configService.get<number>('EMEX_PROXY_PORT_START', 10001);
const portEnd = this.configService.get<number>('EMEX_PROXY_PORT_END', 10099);
const user = this.configService.get<string>('EMEX_PROXY_USER', '1726bbe361918676d44e');
const pass = this.configService.get<string>('EMEX_PROXY_PASS', 'f11c7b6128cc86c6');
const port = Math.floor(Math.random() * (portEnd - portStart + 1)) + portStart;
this.proxyAgent = new ProxyAgent({
uri: `http://${user}:${pass}@${host}:${port}`,
connect: { timeout: 30000 },
requestTls: { timeout: 30000 },
});
this.logger.log(`EMEX HTTP proxy enabled: ${host}:${port}`);
} else {
this.proxyAgent = null;
}
this.logger.log(`EMEX Service initialized with scraper path: ${this.scraperPath}`);
}
@@ -230,7 +250,8 @@ export class EmexService {
const res = await fetch(url, {
headers: { 'User-Agent': EMEX_UA, 'Accept': 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(this.timeout),
});
...(this.proxyAgent ? { dispatcher: this.proxyAgent } : {}),
} as RequestInit);
if (!res.ok) {
throw new Error(`EMEX HTTP ${res.status} for ${url}`);
}

View File

@@ -2,7 +2,9 @@
* PartsLink24 Authentication Service
*
* Handles JWT authentication, token refresh, and session management
* for the partslink24.com API. Tokens cached in-memory (short-lived).
* for the partslink24.com API. Supports two accounts:
* - 'tr' (tr-903645): direct connection, primary VAG account
* - 'de' (de-708171): DataImpulse Germany proxy, Fiat + EUR prices
*/
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@@ -20,144 +22,92 @@ import type {
@Injectable()
export class PL24AuthService {
private readonly logger = new Logger(PL24AuthService.name);
private tokenData: PL24TokenData | null = null;
private serviceTokens = new Map<
string,
{ token: string; expiresAt: Date }
>();
// ── Account 1 (tr-903645) ──────────────────────────────────────────────────
private tokenData: PL24TokenData | null = null;
private serviceTokens = new Map<string, { token: string; expiresAt: Date }>();
// ── Account 2 (de-708171) ──────────────────────────────────────────────────
private tokenData2: PL24TokenData | null = null;
private serviceTokens2 = new Map<string, { token: string; expiresAt: Date }>();
private proxyAgent: any = null; // undici.ProxyAgent, lazy-init
// ── Config ─────────────────────────────────────────────────────────────────
private readonly baseUrl: string;
private readonly companyCode: string;
private readonly username: string;
private readonly password: string;
private readonly companyCode2: string;
private readonly username2: string;
private readonly password2: string;
private readonly proxyUrl: string | null;
private readonly timeout: number;
constructor(private configService: ConfigService) {
this.baseUrl = this.configService.get<string>(
"pl24.baseUrl",
"https://www.partslink24.com",
);
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
this.username = this.configService.get<string>("pl24.username", "");
this.password = this.configService.get<string>("pl24.password", "");
this.companyCode2 = this.configService.get<string>("pl24.companyCode2", "");
this.username2 = this.configService.get<string>("pl24.username2", "");
this.password2 = this.configService.get<string>("pl24.password2", "");
this.proxyUrl = this.configService.get<string>("pl24.proxyDe", "") || null;
this.timeout = 30000;
if (!this.companyCode || !this.username || !this.password) {
this.logger.warn(
"PL24 credentials not configured. Set PL24_BASE_URL, PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
"PL24 account 1 credentials not configured. Set PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
);
}
if (this.companyCode2 && !this.proxyUrl) {
this.logger.warn("PL24 account 2 (de) configured but PL24_PROXY_DE not set");
}
}
/**
* Login to PL24 and get access token.
* Uses squeezeOut=true to force logout other sessions.
*/
async login(forceNew = false): Promise<PL24TokenData> {
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
return this.tokenData;
// ═══════════════════════════════════════════════════════════════════════════
// ── Public: per-account API ──────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/** Return access token for the given account. */
async getAccessTokenForAccount(account: "tr" | "de"): Promise<string> {
if (account === "de") {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
}
return this.tokenData2!.accessToken;
}
this.logger.log("Logging in to PL24...");
const loginRequest: PL24LoginRequest = {
authentication: {
account: this.companyCode,
user: this.username,
pwd: this.password,
},
device: {
id: "0",
os: "Windows 10",
offset: "0",
lang: "en-US",
"os-version": "0",
},
"app-version": "",
squeezeOut: true,
};
try {
const response = await fetch(
`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
body: JSON.stringify(loginRequest),
signal: AbortSignal.timeout(this.timeout),
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as PL24LoginResponse;
if (data.status === "USER_ALREADY_LOGGED_IN") {
this.logger.warn("User already logged in, session squeezed out");
}
if (!data.token?.access_token) {
this.logger.error(
`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`,
);
throw new UnauthorizedException(
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
);
}
// Extract session cookie
const setCookie = response.headers.get("set-cookie");
const sessionCookie = this.extractSessionCookie(setCookie);
// Decode JWT for expiration + services
const payload = this.decodeJWT(data.token.access_token);
this.tokenData = {
accessToken: data.token.access_token,
refreshToken: data.refreshToken || "",
sessionCookie,
expiresAt: new Date(payload.exp * 1000),
services: payload.services || [],
};
this.logger.log(
`PL24 login successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
);
this.logger.log(
`Available services: ${this.tokenData.services.length}`,
);
return this.tokenData;
} catch (error) {
const err = error as Error;
if (err.name === "TimeoutError") {
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
}
this.logger.error(`PL24 login error: ${err.message}`);
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.accessToken;
}
/**
* Get service-specific authorization token.
* Required for accessing specific catalogs.
*/
async authorizeService(serviceName: string): Promise<string> {
const cached = this.serviceTokens.get(serviceName);
/** Return session cookie for the given account. */
async getSessionCookieForAccount(account: "tr" | "de"): Promise<string> {
if (account === "de") {
if (!this.tokenData2 || !this.isTokenValid(this.tokenData2)) {
await this.login2();
}
return this.tokenData2!.sessionCookie;
}
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.sessionCookie;
}
/** Authorize a service catalog for the given account and return the service token. */
async authorizeServiceForAccount(serviceName: string, account: "tr" | "de"): Promise<string> {
const cache = account === "de" ? this.serviceTokens2 : this.serviceTokens;
const cached = cache.get(serviceName);
if (cached && cached.expiresAt > new Date()) {
return cached.token;
}
const mainToken = await this.getAccessToken();
const mainToken = await this.getAccessTokenForAccount(account);
const sessionCookie = await this.getSessionCookieForAccount(account);
this.logger.log(`Authorizing service: ${serviceName}`);
this.logger.log(`Authorizing service ${serviceName} for account ${account}`);
const authorizeRequest: PL24AuthorizeRequest = {
serviceNames: [
@@ -177,61 +127,201 @@ export class PL24AuthService {
};
try {
const response = await fetch(
`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${mainToken}`,
Cookie: this.tokenData?.sessionCookie || "",
},
body: JSON.stringify(authorizeRequest),
signal: AbortSignal.timeout(this.timeout),
const dispatcher = await this.getProxyAgent4Account(account);
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${mainToken}`,
Cookie: sessionCookie,
},
);
body: JSON.stringify(authorizeRequest),
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`, fetchOpts);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as PL24AuthorizeResponse;
const accessToken = data.access_token || data.token?.access_token;
if (!accessToken) {
throw new Error("No service token in response");
}
const payload = this.decodeJWT(accessToken);
this.serviceTokens.set(serviceName, {
cache.set(serviceName, {
token: accessToken,
expiresAt: new Date(payload.exp * 1000),
});
this.logger.log(`Service ${serviceName} authorized successfully`);
this.logger.log(`Service ${serviceName} authorized for account ${account}`);
return accessToken;
} catch (error) {
const err = error as Error;
this.logger.error(`Service authorization error: ${err.message}`);
throw new UnauthorizedException(
`Servis yetkilendirme hatasi: ${err.message}`,
);
this.logger.error(`Service authorization error (${account}): ${err.message}`);
throw new UnauthorizedException(`Servis yetkilendirme hatasi: ${err.message}`);
}
}
/** Build standard JSON API auth headers for the given account. */
async buildAuthHeadersForAccount(
account: "tr" | "de",
serviceName?: string,
includeContentType = false,
): Promise<Record<string, string>> {
const token = serviceName
? await this.authorizeServiceForAccount(serviceName, account)
: await this.getAccessTokenForAccount(account);
const sessionCookie = await this.getSessionCookieForAccount(account);
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Cookie: sessionCookie,
Accept: "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
};
if (includeContentType) {
headers["Content-Type"] = "application/json";
}
return headers;
}
/** Build Ford-legacy HTML page auth headers for the given account. */
async buildFordLegacyHeadersForAccount(
serviceName: string,
account: "tr" | "de",
): Promise<Record<string, string>> {
const token = await this.authorizeServiceForAccount(serviceName, account);
const sessionCookie = await this.getSessionCookieForAccount(account);
return {
Authorization: `Bearer ${token}`,
Cookie: sessionCookie,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
};
}
/** Return PL24TOKEN cookie value for the given account (used as Ford hintstoken param). */
getPL24TokenValueForAccount(account: "tr" | "de"): string | null {
const data = account === "de" ? this.tokenData2 : this.tokenData;
if (!data?.sessionCookie) return null;
const match = data.sessionCookie.match(/PL24TOKEN=([^;]+)/);
return match?.[1] || null;
}
/** Return ProxyAgent for account 'de', null for 'tr'. */
async getProxyAgent4Account(account: "tr" | "de"): Promise<any | null> {
if (account !== "de") return null;
return this.getProxyAgent();
}
/** Clear in-memory tokens for the given account. */
clearTokensForAccount(account: "tr" | "de"): void {
if (account === "de") {
this.tokenData2 = null;
this.serviceTokens2.clear();
this.logger.log("PL24 account 2 (de) tokens cleared");
} else {
this.tokenData = null;
this.serviceTokens.clear();
this.logger.log("PL24 account 1 (tr) tokens cleared");
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ── Public: legacy API (backwards-compatible, always 'tr') ──────────────
// ═══════════════════════════════════════════════════════════════════════════
async login(forceNew = false): Promise<PL24TokenData> {
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
return this.tokenData;
}
this.logger.log("Logging in to PL24 (account 1 tr)...");
const loginRequest: PL24LoginRequest = {
authentication: {
account: this.companyCode,
user: this.username,
pwd: this.password,
},
device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" },
"app-version": "",
squeezeOut: true,
};
try {
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
body: JSON.stringify(loginRequest),
signal: AbortSignal.timeout(this.timeout),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as PL24LoginResponse;
if (data.status === "USER_ALREADY_LOGGED_IN") {
this.logger.warn("User already logged in, session squeezed out");
}
if (!data.token?.access_token) {
this.logger.error(`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`);
throw new UnauthorizedException(
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
);
}
const setCookie = response.headers.get("set-cookie");
const sessionCookie = this.extractSessionCookie(setCookie);
const payload = this.decodeJWT(data.token.access_token);
this.tokenData = {
accessToken: data.token.access_token,
refreshToken: data.refreshToken || "",
sessionCookie,
expiresAt: new Date(payload.exp * 1000),
services: payload.services || [],
};
this.logger.log(`PL24 login (tr) successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`);
return this.tokenData;
} catch (error) {
const err = error as Error;
if (err.name === "TimeoutError") {
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
}
this.logger.error(`PL24 login error: ${err.message}`);
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
}
}
async authorizeService(serviceName: string): Promise<string> {
return this.authorizeServiceForAccount(serviceName, "tr");
}
async getAccessToken(): Promise<string> {
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.accessToken;
return this.getAccessTokenForAccount("tr");
}
async getSessionCookie(): Promise<string> {
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
await this.login();
}
return this.tokenData!.sessionCookie;
return this.getSessionCookieForAccount("tr");
}
getAvailableServices(): string[] {
@@ -243,65 +333,117 @@ export class PL24AuthService {
}
clearTokens(): void {
this.tokenData = null;
this.serviceTokens.clear();
this.logger.log("All PL24 tokens cleared");
this.clearTokensForAccount("tr");
}
/**
* Build authorization headers for API requests.
*/
async buildAuthHeaders(
serviceName?: string,
includeContentType = false,
): Promise<Record<string, string>> {
const token = serviceName
? await this.authorizeService(serviceName)
: await this.getAccessToken();
return this.buildAuthHeadersForAccount("tr", serviceName, includeContentType);
}
const sessionCookie = await this.getSessionCookie();
async buildFordLegacyHeaders(serviceName: string): Promise<Record<string, string>> {
return this.buildFordLegacyHeadersForAccount(serviceName, "tr");
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Cookie: sessionCookie,
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
};
getPL24TokenValue(): string | null {
return this.getPL24TokenValueForAccount("tr");
}
if (includeContentType) {
headers["Content-Type"] = "application/json";
// ═══════════════════════════════════════════════════════════════════════════
// ── Private helpers ──────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════════════════
/** Login with account 2 (de-708171) via DataImpulse DE proxy. */
private async login2(forceNew = false): Promise<PL24TokenData> {
if (!forceNew && this.tokenData2 && this.isTokenValid(this.tokenData2)) {
return this.tokenData2;
}
return headers;
}
if (!this.companyCode2 || !this.username2 || !this.password2) {
throw new UnauthorizedException("PL24 account 2 (de) credentials not configured");
}
/**
* Build headers for Ford legacy HTML page requests.
* Uses text/html Accept instead of application/json.
*/
async buildFordLegacyHeaders(
serviceName: string,
): Promise<Record<string, string>> {
const token = await this.authorizeService(serviceName);
const sessionCookie = await this.getSessionCookie();
this.logger.log("Logging in to PL24 (account 2 de)...");
return {
Authorization: `Bearer ${token}`,
Cookie: sessionCookie,
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
const loginRequest: PL24LoginRequest = {
authentication: {
account: this.companyCode2,
user: this.username2,
pwd: this.password2,
},
device: { id: "0", os: "Windows 10", offset: "0", lang: "en-US", "os-version": "0" },
"app-version": "",
squeezeOut: true,
};
try {
const dispatcher = await this.getProxyAgent();
const fetchOpts: RequestInit & { dispatcher?: any } = {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
},
body: JSON.stringify(loginRequest),
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) fetchOpts.dispatcher = dispatcher;
const response = await fetch(`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`, fetchOpts);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as PL24LoginResponse;
if (!data.token?.access_token) {
this.logger.error(`PL24 login (de) failed: ${data.status} - ${data.message || "No token"}`);
throw new UnauthorizedException(
`PL24 (de) giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
);
}
const setCookie = response.headers.get("set-cookie");
const sessionCookie = this.extractSessionCookie(setCookie);
const payload = this.decodeJWT(data.token.access_token);
this.tokenData2 = {
accessToken: data.token.access_token,
refreshToken: data.refreshToken || "",
sessionCookie,
expiresAt: new Date(payload.exp * 1000),
services: payload.services || [],
};
this.logger.log(`PL24 login (de) successful. Token expires at ${this.tokenData2.expiresAt.toISOString()}`);
return this.tokenData2;
} catch (error) {
const err = error as Error;
if (err.name === "TimeoutError") {
throw new UnauthorizedException("PL24 (de) giris zaman asimina ugradi");
}
this.logger.error(`PL24 login (de) error: ${err.message}`);
throw new UnauthorizedException(`PL24 (de) giris hatasi: ${err.message}`);
}
}
/**
* Get PL24TOKEN cookie value for Ford hintstoken parameter.
*/
getPL24TokenValue(): string | null {
if (!this.tokenData?.sessionCookie) return null;
const match = this.tokenData.sessionCookie.match(/PL24TOKEN=([^;]+)/);
return match?.[1] || null;
/** Lazy-init ProxyAgent for the de account. */
private async getProxyAgent(): Promise<any> {
if (this.proxyAgent) return this.proxyAgent;
if (!this.proxyUrl) return null;
try {
const { ProxyAgent } = await import("undici");
this.proxyAgent = new ProxyAgent(this.proxyUrl);
this.logger.log("PL24 DE ProxyAgent initialized");
} catch (err) {
this.logger.error(`Failed to init ProxyAgent: ${(err as Error).message}`);
return null;
}
return this.proxyAgent;
}
private isTokenValid(token: PL24TokenData): boolean {
@@ -312,9 +454,7 @@ export class PL24AuthService {
private decodeJWT(token: string): PL24JWTPayload {
try {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
if (parts.length !== 3) throw new Error("Invalid JWT format");
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
return JSON.parse(payload);
} catch {
@@ -325,12 +465,8 @@ export class PL24AuthService {
private extractSessionCookie(setCookie: string | null): string {
if (!setCookie) return "";
const match = setCookie.match(/PL24TOKEN=([^;]+)/);
if (match) {
return `PL24TOKEN=${match[1]}`;
}
if (match) return `PL24TOKEN=${match[1]}`;
return setCookie.split(";")[0];
}
}

View File

@@ -56,10 +56,14 @@ export class PL24FordLegacyService {
}
/**
* Decode a VIN for any P4 legacy service (Ford, PSA, Hyundai-Kia, Nissan, Opel, Volvo, etc.).
* Decode a VIN for any P4 legacy service (Ford, PSA, Hyundai-Kia, Nissan, Opel, Volvo, Fiat, etc.).
* All P4 catalogs share the same JSP/.action architecture.
*/
async decodeVinForService(vin: string, serviceName: string): Promise<PL24DecodedVehicle | null> {
async decodeVinForService(
vin: string,
serviceName: string,
userId?: string,
): Promise<PL24DecodedVehicle | null> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
if (cached) return cached;
@@ -67,25 +71,33 @@ export class PL24FordLegacyService {
this.logger.log(`P4 legacy: decoding VIN ${vin} with service ${serviceName}`);
try {
// PSA servisleri için JSESSIONID session gerekiyor — ayrı akış
// PSA servisleri için JSESSIONID session gerekiyor — ayrı akış (always tr account)
const config = getServiceConfig(serviceName);
if (config?.architecture === "LEGACY_PSA") {
return this.decodeVinPsa(vin, serviceName, cacheKey);
}
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
await this.authService.authorizeService(serviceName);
// Resolve account (Fiat → de, others → round-robin or tr)
const account = await this.resolveAccount(userId, serviceName);
const html = await this.fetchVinGroupPage(vin, serviceName);
// Fiat uses same P4 flow as Ford — dedicated method for clarity
if (config?.architecture === "LEGACY_FIAT") {
return this.decodeVinFiat(vin, serviceName, cacheKey, account);
}
// Ford / Hyundai-Kia / Nissan / Opel / Volvo devam eder...
await this.authService.authorizeServiceForAccount(serviceName, account);
const html = await this.fetchVinGroupPage(vin, serviceName, account);
if (!html) return null;
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`P4 legacy: demo mode for ${serviceName}, retrying with fresh auth`);
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const retryHtml = await this.fetchVinGroupPage(vin, serviceName);
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
if (!retryHtml) return null;
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
@@ -105,11 +117,12 @@ export class PL24FordLegacyService {
}
/**
* Fetch sub-groups for a Ford legacy link path.
* Fetch sub-groups for a Ford/Fiat legacy link path.
*/
async fetchSubGroupsByPath(
linkPath: string,
serviceName: string,
userId?: string,
): Promise<PL24MainGroup[]> {
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:subgroups:${pathHash}`;
@@ -119,7 +132,8 @@ export class PL24FordLegacyService {
this.logger.log(`Ford legacy: fetching sub-groups from ${linkPath}`);
try {
const html = await this.fetchP4Page(linkPath, serviceName);
const account = await this.resolveAccount(userId, serviceName);
const html = await this.fetchP4Page(linkPath, serviceName, false, account);
if (!html) return [];
// Opel/Ford: json-main-group.action → { vCfgData: [...] } (Opel) OR { maingroups: [...] } (Ford)
@@ -253,11 +267,12 @@ export class PL24FordLegacyService {
}
/**
* Fetch parts for a Ford legacy link path.
* Fetch parts for a Ford/Fiat legacy link path.
*/
async fetchPartsByPath(
linkPath: string,
serviceName: string,
userId?: string,
): Promise<PL24PartsResponse> {
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}ford:parts:${pathHash}`;
@@ -267,6 +282,7 @@ export class PL24FordLegacyService {
this.logger.log(`Ford legacy: fetching parts from ${linkPath}`);
try {
const account = await this.resolveAccount(userId, serviceName);
const isImageBoard = linkPath.includes("image-board.action");
// For image-board pages, capture the JSESSIONID so ticket + ImageViewer requests
@@ -274,11 +290,11 @@ export class PL24FordLegacyService {
let html: string | null;
let pageJsessionId = "";
if (isImageBoard) {
const result = await this.fetchP4PageWithSession(linkPath, serviceName);
const result = await this.fetchP4PageWithSession(linkPath, serviceName, false, account);
html = result.html;
pageJsessionId = result.jsessionId;
} else {
html = await this.fetchP4Page(linkPath, serviceName);
html = await this.fetchP4Page(linkPath, serviceName, false, account);
}
if (!html) {
@@ -1748,6 +1764,71 @@ export class PL24FordLegacyService {
return scopes;
}
// ==================== PRIVATE: Account routing ====================
/**
* Resolve which PL24 account to use for a P4 legacy request.
* Fiat services always use 'de'; others use round-robin via Redis.
*/
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
// Fiat always needs de account
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
return "de";
}
// No userId → tr (catalog browser / prefetch)
if (!userId) return "tr";
// Cached account for this user
const redisKey = `pl24:account:${userId}`;
const cached = await this.redis.get(redisKey);
if (cached === "tr" || cached === "de") return cached;
// Round-robin assignment
const counter = await this.redis.incr("pl24:rr");
const account: "tr" | "de" = counter % 2 !== 0 ? "tr" : "de";
await this.redis.set(redisKey, account, 86400);
return account;
}
// ==================== PRIVATE: Fiat VIN decode ====================
/**
* Decode a Fiat VIN via P4 legacy .action endpoints (same flow as Ford).
* Requires de-708171 account.
* NOTE: basePath (/fca) needs Playwright verification with de account.
*/
private async decodeVinFiat(
vin: string,
serviceName: string,
cacheKey: string,
account: "tr" | "de",
): Promise<PL24DecodedVehicle | null> {
await this.authService.authorizeServiceForAccount(serviceName, account);
const html = await this.fetchVinGroupPage(vin, serviceName, account);
if (!html) return null;
const support = this.extractScriptVariable<FordPL24Support>(html, "PL24_SUPPORT");
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`Fiat: demo mode for ${serviceName}, retrying with fresh auth`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const retryHtml = await this.fetchVinGroupPage(vin, serviceName, account);
if (!retryHtml) return null;
const retrySupport = this.extractScriptVariable<FordPL24Support>(retryHtml, "PL24_SUPPORT");
if (retrySupport?.demo || retrySupport?.role === "NOT_LOGGED_IN_DEMO") {
this.logger.warn(`Fiat: still demo after retry for ${serviceName}`);
return null;
}
return this.parseAndCacheVehicle(retryHtml, vin, serviceName, cacheKey);
}
return this.parseAndCacheVehicle(html, vin, serviceName, cacheKey);
}
// ==================== PRIVATE: PSA VIN decode ====================
/**
@@ -2786,13 +2867,17 @@ export class PL24FordLegacyService {
* Fetch the VIN group page for any P4 legacy service.
* URL pattern: {basePath}/{serviceName}/vin-group.action?vin=...&lang=...
*/
private async fetchVinGroupPage(vin: string, serviceName: string): Promise<string | null> {
private async fetchVinGroupPage(
vin: string,
serviceName: string,
account: "tr" | "de" = "tr",
): Promise<string | null> {
const config = getServiceConfig(serviceName);
const vinGroupPath = config
? `${config.basePath}/${serviceName}/vin-group.action`
: FORD_LEGACY_ENDPOINTS.VIN_GROUP;
const token = this.authService.getPL24TokenValue();
const token = this.authService.getPL24TokenValueForAccount(account);
const params = new URLSearchParams({
vin,
lang: this.language,
@@ -2800,40 +2885,43 @@ export class PL24FordLegacyService {
});
const url = `${this.baseUrl}${vinGroupPath}?${params}`;
return this.fetchP4Page(url, serviceName, true);
return this.fetchP4Page(url, serviceName, true, account);
}
/**
* Fetch a Ford legacy page (HTML). Handles 401 retry.
* Fetch a Ford/Fiat legacy page (HTML). Handles 401 retry.
* Uses DataImpulse DE proxy when account='de'.
*/
private async fetchP4Page(
url: string,
serviceName: string,
isFullUrl = false,
account: "tr" | "de" = "tr",
): Promise<string | null> {
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
const headers = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
const dispatcher = await this.authService.getProxyAgent4Account(account);
try {
let response = await fetch(fullUrl, {
const buildOpts = (hdrs: Record<string, string>): RequestInit & { dispatcher?: any } => {
const opts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers,
headers: hdrs,
signal: AbortSignal.timeout(this.timeout),
redirect: "follow",
});
};
if (dispatcher) opts.dispatcher = dispatcher;
return opts;
};
try {
let response = await fetch(fullUrl, buildOpts(headers));
if (response.status === 401) {
this.logger.warn("Ford legacy: 401, refreshing auth");
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
response = await fetch(fullUrl, {
method: "GET",
headers: newHeaders,
signal: AbortSignal.timeout(this.timeout),
redirect: "follow",
});
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
response = await fetch(fullUrl, buildOpts(newHeaders));
}
if (!response.ok) {
@@ -2865,27 +2953,31 @@ export class PL24FordLegacyService {
url: string,
serviceName: string,
isFullUrl = false,
account: "tr" | "de" = "tr",
): Promise<{ html: string | null; jsessionId: string }> {
const fullUrl = isFullUrl ? url : `${this.baseUrl}${url}`;
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
const headers = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
const dispatcher = await this.authService.getProxyAgent4Account(account);
const doFetch = async (hdrs: Record<string, string>) => {
return fetch(fullUrl, {
const opts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: hdrs,
signal: AbortSignal.timeout(this.timeout),
redirect: "follow",
});
};
if (dispatcher) opts.dispatcher = dispatcher;
return fetch(fullUrl, opts);
};
try {
let response = await doFetch(headers);
if (response.status === 401) {
this.logger.warn("Ford legacy: 401, refreshing auth");
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
const newHeaders = await this.authService.buildFordLegacyHeaders(serviceName);
this.logger.warn(`Ford legacy: 401, refreshing auth (account=${account})`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildFordLegacyHeadersForAccount(serviceName, account);
response = await doFetch(newHeaders);
}

View File

@@ -58,9 +58,9 @@ export class PL24Service {
/**
* Decode VIN and get vehicle info with categories.
* Only P5 Modern architecture is supported.
* Only P5 Modern architecture is supported here; legacy is dispatched to fordLegacyService.
*/
async decodeVin(vin: string): Promise<PL24DecodedVehicle | null> {
async decodeVin(vin: string, userId?: string): Promise<PL24DecodedVehicle | null> {
const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, "");
this.validateVin(cleanVin);
@@ -80,7 +80,7 @@ export class PL24Service {
// Dispatch P4 legacy architectures to the generic legacy service
if (!isP5Modern(serviceName)) {
if (isLegacyArchitecture(serviceName)) {
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName);
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
}
this.logger.warn(`Unknown architecture for service: ${serviceName}`);
return null;
@@ -98,9 +98,10 @@ export class PL24Service {
}
try {
// Get service-specific authorization
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
// Resolve account for this user (round-robin tr/de, or Fiat always de)
const account = await this.resolveAccount(userId, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const catalogBase = catalogConfig.apiPath;
@@ -116,6 +117,7 @@ export class PL24Service {
`${directAccessUrl}?${params}`,
headers,
serviceName,
account,
);
const responseData = (await response.json()) as Record<string, any>;
@@ -191,6 +193,7 @@ export class PL24Service {
vin: string,
illustrationId: string,
mainGroup: string,
userId?: string,
): Promise<PL24PartsResponse> {
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:${vin}:${illustrationId}`;
const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey);
@@ -201,8 +204,9 @@ export class PL24Service {
);
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const account = await this.resolveAccount(userId, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const catalogBase = getServiceApiPath(serviceName);
@@ -218,6 +222,7 @@ export class PL24Service {
`${this.baseUrl}${catalogBase}/extern/bom/vin?${params}`,
headers,
serviceName,
account,
);
const data = (await response.json()) as Record<string, any>;
@@ -257,10 +262,11 @@ export class PL24Service {
body?: string,
engine?: string,
gearbox?: string,
userId?: string,
): Promise<PL24PartsResponse> {
// Ford legacy dispatch
// Ford/Fiat legacy dispatch
if (this.isP4LegacyPath(linkPath)) {
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName, userId);
}
// PSA image-board dispatch
if (this.isPsaBoardPath(linkPath)) {
@@ -291,13 +297,15 @@ export class PL24Service {
const fetchPath = this.convertPartInfoToBom(linkPath);
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const account = await this.resolveAccount(userId, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const response = await this.fetchWithRetry(
`${this.baseUrl}${fetchPath}`,
headers,
serviceName,
account,
);
const data = (await response.json()) as Record<string, any>;
@@ -348,12 +356,14 @@ export class PL24Service {
serviceName: string,
vehicleId: string,
mainGroupId: string,
userId?: string,
): Promise<PL24MainGroup[]> {
this.logger.log(`Fetching sub-groups for mainGroup=${mainGroupId}`);
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const account = await this.resolveAccount(userId, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const catalogBase = getServiceApiPath(serviceName);
const params = new URLSearchParams({
@@ -367,6 +377,7 @@ export class PL24Service {
`${this.baseUrl}${catalogBase}/extern/subgroups?${params}`,
headers,
serviceName,
account,
);
const data = (await response.json()) as Record<string, any>;
@@ -387,10 +398,11 @@ export class PL24Service {
body?: string,
engine?: string,
gearbox?: string,
userId?: string,
): Promise<PL24MainGroup[]> {
// Ford legacy dispatch
// Ford/Fiat legacy dispatch
if (this.isP4LegacyPath(linkPath)) {
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName, userId);
}
// PSA scope dispatch ("psa::{svc}::scope=..." → main groups)
if (this.isPsaPath(linkPath)) {
@@ -405,13 +417,15 @@ export class PL24Service {
await this.touchActivity();
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const account = await this.resolveAccount(userId, serviceName);
await this.authService.authorizeServiceForAccount(serviceName, account);
const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const response = await this.fetchWithRetry(
`${this.baseUrl}${linkPath}`,
headers,
serviceName,
account,
);
const data = (await response.json()) as Record<string, any>;
@@ -746,30 +760,34 @@ export class PL24Service {
/**
* Fetch with 401 retry (re-auth on token expiry).
* Uses DataImpulse DE proxy for account 'de', direct connection for 'tr'.
*/
private async fetchWithRetry(
url: string,
headers: Record<string, string>,
serviceName: string,
account: "tr" | "de" = "tr",
): Promise<Response> {
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(this.timeout),
});
const dispatcher = await this.authService.getProxyAgent4Account(account);
const buildOpts = (hdrs: Record<string, string>): RequestInit & { dispatcher?: any } => {
const opts: RequestInit & { dispatcher?: any } = {
method: "GET",
headers: hdrs,
signal: AbortSignal.timeout(this.timeout),
};
if (dispatcher) opts.dispatcher = dispatcher;
return opts;
};
const response = await fetch(url, buildOpts(headers));
if (response.status === 401) {
this.logger.warn("Got 401, refreshing token...");
this.authService.clearTokens();
await this.authService.authorizeService(serviceName);
const newHeaders =
await this.authService.buildAuthHeaders(serviceName);
this.logger.warn(`Got 401 (account=${account}), refreshing token...`);
this.authService.clearTokensForAccount(account);
await this.authService.authorizeServiceForAccount(serviceName, account);
const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName);
const retry = await fetch(url, {
method: "GET",
headers: newHeaders,
signal: AbortSignal.timeout(this.timeout),
});
const retry = await fetch(url, buildOpts(newHeaders));
if (!retry.ok) {
throw new Error(`HTTP ${retry.status}: ${retry.statusText}`);
@@ -788,6 +806,38 @@ export class PL24Service {
return response;
}
// ==================== PRIVATE: Account routing ====================
/**
* Resolve which PL24 account to use for a request.
*
* Rules:
* 1. Fiat services (fiatp_parts, fiatt_parts) always use 'de'
* 2. No userId → 'tr' (catalog browser, prefetch jobs)
* 3. Redis key `pl24:account:{userId}` already set → return cached account
* 4. Otherwise: round-robin via INCR pl24:rr — odd='tr', even='de' — cache 24h
*/
private async resolveAccount(userId?: string, serviceName?: string): Promise<"tr" | "de"> {
// Rule 1: Fiat always needs de account
if (serviceName && ["fiatp_parts", "fiatt_parts"].includes(serviceName)) {
return "de";
}
// Rule 2: no userId → tr (catalog browser / prefetch)
if (!userId) return "tr";
// Rule 3: cached account for this user
const redisKey = `pl24:account:${userId}`;
const cached = await this.redis.get(redisKey);
if (cached === "tr" || cached === "de") return cached;
// Rule 4: round-robin assignment
const counter = await this.redis.incr("pl24:rr");
const account: "tr" | "de" = counter % 2 !== 0 ? "tr" : "de";
await this.redis.set(redisKey, account, 86400);
return account;
}
// ==================== PRIVATE: VIN helpers ====================
private validateVin(vin: string): void {
@@ -1209,6 +1259,14 @@ export class PL24Service {
};
}
// Price extraction (de account with German market returns EUR prices in BOM)
const priceRaw = String(
values.listPrice || values.netPrice || values.price || values.grossPrice || values.retailPrice || "",
).trim();
const priceNum = priceRaw ? parseFloat(priceRaw.replace(",", ".")) : Number.NaN;
const price = Number.isNaN(priceNum) ? undefined : priceNum;
const currency = price !== undefined ? (values.currency || "EUR") : undefined;
return {
id: String(part.id || ""),
oemCode: cleanPartNo,
@@ -1229,6 +1287,8 @@ export class PL24Service {
superseded,
hotspotId: (part.hotspotId as string) || undefined,
linkPath: (part.link as Record<string, string>)?.path,
price,
currency,
};
});
}

View File

@@ -96,7 +96,8 @@ export type PL24ApiArchitecture =
| "LEGACY_FORD"
| "LEGACY_NISSAN"
| "LEGACY_OPEL"
| "LEGACY_VOLVO";
| "LEGACY_VOLVO"
| "LEGACY_FIAT";
export interface PL24CatalogConfig {
basePath: string;
@@ -365,6 +366,19 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
apiPath: "/volvo",
architecture: "LEGACY_VOLVO",
},
// Fiat Group (FCA/Stellantis) — P4 Legacy, requires de-708171 account
// NOTE: basePath/apiPath require Playwright verification with de account
fiatp_parts: {
basePath: "/fca",
apiPath: "/fca",
architecture: "LEGACY_FIAT",
},
fiatt_parts: {
basePath: "/fca",
apiPath: "/fca",
architecture: "LEGACY_FIAT",
},
};
// ==================== HELPER FUNCTIONS ====================
@@ -516,6 +530,16 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
"1FA": "fordp_parts",
"3FA": "fordp_parts",
// Fiat Passenger (fiatp_parts) — requires de-708171 account
ZFA: "fiatp_parts", // Fiat SpA Italy (most common)
ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.)
ZFF: "fiatp_parts", // Abarth / Fiat Sport
ZAR: "fiatp_parts", // Alfa Romeo
ZLA: "fiatp_parts", // Lancia
// Fiat Commercial (fiatt_parts)
ZFC: "fiatt_parts", // Fiat Commercial
// Hyundai
KMH: "hyundai_parts", // Hyundai Korea Motor House
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
@@ -605,6 +629,8 @@ export interface PL24Part {
additionalInfo?: Record<string, string>;
hotspotId?: string;
linkPath?: string;
price?: number;
currency?: string;
}
export interface PL24HotspotArea {
@@ -759,6 +785,9 @@ export const SERVICE_TO_BRAND: Record<string, string> = {
// Volvo/Polestar
volvo_parts: "Volvo",
polestar_parts: "Polestar",
// Fiat Group
fiatp_parts: "Fiat",
fiatt_parts: "Fiat",
};
// Display names for services that share a brand (multi-catalog brands)
@@ -792,4 +821,7 @@ export const SERVICE_DISPLAY_NAMES: Record<string, string> = {
// Citroen
citroen_parts: "Citroen",
citroenDs_parts: "Citroen DS",
// Fiat Group
fiatp_parts: "Fiat",
fiatt_parts: "Fiat Ticari",
};

View File

@@ -75,6 +75,8 @@ export class PartsService {
remark: p.remark || null,
modelCodes: p.modelCodes || null,
presel: p.presel || false,
price: p.price != null ? String(p.price) : null,
currency: p.price != null ? (p.currency ?? "EUR") : null,
source: "pl24" as const,
}));

View File

@@ -78,16 +78,13 @@ export class VehiclesService {
.limit(1);
if (existing && !pcatCarId) {
const age = Date.now() - new Date(existing.updatedAt).getTime();
if (age < 24 * 60 * 60 * 1000) {
// Fresh cache (<24h) — check brand access, link user, return
if (existing.brandId) {
await this.checkBrandAccess(userId, existing.brandId);
}
await this.ensureUserVehicleLink(userId, existing.id);
await this.logQuery(userId, vin, existing.brandId, "cache", true, Date.now() - startTime);
return existing;
// DB'de kayıtlı araç varsa doğrudan dön — yaş kontrolü yok
if (existing.brandId) {
await this.checkBrandAccess(userId, existing.brandId);
}
await this.ensureUserVehicleLink(userId, existing.id);
await this.logQuery(userId, vin, existing.brandId, "cache", true, Date.now() - startTime);
return existing;
}
// 2. Resolve VIN via cached decode chain (Corgi → PartsCatalogs → PL24 → EMEX)
@@ -233,10 +230,10 @@ export class VehiclesService {
return cached;
}
// 1. Corgi decode (offline)
const corgiResult = this.corgiService.decodeVin(vin);
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
let brandName = corgiKnown ? corgiResult.brandName : null;
// Corgi bypassed — go straight to external sources
const corgiResult = null;
const corgiKnown = false;
let brandName: string | null = null;
// 2. PartsCatalogs (first external source)
let pcatCandidates: PcatCar[] | null = null;
@@ -361,7 +358,7 @@ export class VehiclesService {
}
// Nothing recognized this VIN
if (!emexVehicle && !corgiKnown) {
if (!emexVehicle) {
return null;
}
@@ -387,9 +384,9 @@ export class VehiclesService {
* Resolve a specific PartsCatalogs car by ID (after user selects from candidates).
*/
private async resolvePcatCarById(vin: string, pcatCarId: string): Promise<VinResolveResult | null> {
const corgiResult = this.corgiService.decodeVin(vin);
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
let brandName = corgiKnown ? corgiResult.brandName : null;
const corgiResult = null;
const corgiKnown = false;
let brandName: string | null = null;
// Re-decode VIN to get fresh car list, then find the selected car
const pcatResult = await this.partsCatalogsService.decodeVin(vin);
@@ -427,8 +424,8 @@ export class VehiclesService {
* (after user selects from the multi-candidate modal).
*/
private async resolveEmexCarByIndex(vin: string, index: number): Promise<VinResolveResult | null> {
const corgiResult = this.corgiService.decodeVin(vin);
const corgiKnown = !!(corgiResult && corgiResult.isKnown);
const corgiResult = null;
const corgiKnown = false;
const decoded = await this.emexService.decodeVinByIndex(vin, index);
if (!decoded) {

View File

@@ -18,6 +18,8 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const [copiedId, setCopiedId] = useState<string | null>(null);
const hasPrices = parts.some((p) => p.price != null);
// Map group → IDs of available (non-unavailable) parts
const availableByGroup = useMemo(() => {
const map = new Map<number, string[]>();
@@ -81,6 +83,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
</thead>
<tbody>
@@ -155,6 +158,16 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
<td className="px-3 py-2 text-muted-foreground">
{part.position}
</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">
{part.price != null
? new Intl.NumberFormat("de-DE", {
style: "currency",
currency: part.currency ?? "EUR",
}).format(part.price)
: "—"}
</td>
)}
</tr>
);
})}

View File

@@ -12,7 +12,8 @@ export interface Part {
remark?: string;
modelCodes?: string;
presel?: boolean;
price?: number;
price?: number | null;
currency?: string | null;
note?: string;
}