/** * PartsLink24 Service * * Main service for VIN decoding and parts catalog access via partslink24.com. * P5 Modern architecture only (JSON API). Legacy scraping is deferred. */ import { createHash } from "node:crypto"; import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { extractModelYear } from "@sase/shared"; import { isBackfillContext } from "../../jobs/prefetch-context"; import { PostHogService } from "../../posthog/posthog.service"; import { RedisService } from "../../redis/redis.service"; import { StorageService } from "../../storage/storage.service"; import { PL24AuthService } from "./pl24-auth.service"; import { PL24BudgetService } from "./pl24-budget.service"; import { PL24FordLegacyService } from "./pl24-ford-legacy.service"; import { PL24FordService } from "./pl24-ford.service"; import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service"; import { PL24OpelService } from "./pl24-opel.service"; import { PL24PsaService } from "./pl24-psa.service"; import { PL24VolvoService } from "./pl24-volvo.service"; import { PL24_DEFAULTS } from "./pl24.constants"; import { type PL24DecodedCategory, type PL24DecodedVehicle, type PL24Hotspot, type PL24MainGroup, type PL24Part, type PL24PartsResponse, PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND, getServiceApiPath, getServiceConfig, isLegacyArchitecture, isP5Modern, } from "./pl24.types"; /** * Legacy (P4) architecture → kill-switch source tag. Lets one fragile brand be * disabled (`kill-source-psa`, `kill-source-volvo`, …) without taking all of PL24 * down. Unmapped legacy brands fall back to the shared `pl24-legacy` switch. */ const LEGACY_ARCH_SOURCE_TAG: Record = { LEGACY_PSA: "psa", LEGACY_VOLVO: "volvo", LEGACY_FORD: "ford", LEGACY_OPEL: "opel", LEGACY_HYUNDAI_KIA: "hyundai-kia", }; @Injectable() export class PL24Service { private readonly logger = new Logger(PL24Service.name); private readonly baseUrl: string; private readonly timeout: number; private readonly language: string; constructor( private readonly authService: PL24AuthService, private readonly fordLegacyService: PL24FordLegacyService, private readonly psaService: PL24PsaService, private readonly volvoService: PL24VolvoService, private readonly fordService: PL24FordService, private readonly opelService: PL24OpelService, private readonly hyundaiKiaService: PL24HyundaiKiaService, private configService: ConfigService, private redis: RedisService, private storage: StorageService, private posthog: PostHogService, private readonly budget: PL24BudgetService, ) { this.baseUrl = this.configService.get("pl24.baseUrl", "https://www.partslink24.com"); this.timeout = 30000; this.language = "tr"; } /** * Decode VIN and get vehicle info with categories. * Only P5 Modern architecture is supported here; legacy is dispatched to fordLegacyService. */ async decodeVin(vin: string, userId?: string): Promise { if (!(await this.posthog.isSourceLive("pl24"))) { this.logger.warn("PL24 disabled by kill switch (kill-source-pl24)"); return null; } const cleanVin = vin.toUpperCase().replace(/[^A-HJ-NPR-Z0-9]/g, ""); this.validateVin(cleanVin); // Check Redis cache const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${cleanVin}`; const cached = await this.redis.getJson(cacheKey); if (cached) return cached; // Get service name from WMI const serviceName = this.getServiceName(cleanVin); if (!serviceName) { throw new BadRequestException( `Bu marka PL24'te desteklenmiyor. VIN: ${cleanVin.substring(0, 3)}`, ); } // Dispatch P4 legacy architectures to the generic legacy service if (!isP5Modern(serviceName)) { // Per-brand kill switch: disable one fragile legacy brand without taking all // of PL24 down. PSA/Volvo/Ford/etc. break independently upstream (cf. the // PSA illustration-dispatch outage that broke 41/43 vehicles for months). const legacyArch = getServiceConfig(serviceName)?.architecture ?? ""; const brandTag = LEGACY_ARCH_SOURCE_TAG[legacyArch] ?? "pl24-legacy"; if (!(await this.posthog.isSourceLive(brandTag))) { this.logger.warn(`PL24 ${brandTag} disabled by kill switch (kill-source-${brandTag})`); return null; } // PSA (Peugeot/Citroën/DS) uses a dedicated FI/VIN-indexed decode service. if (getServiceConfig(serviceName)?.architecture === "LEGACY_PSA") { return this.psaService.decodeVinForService(cleanVin, serviceName, userId); } // Volvo/Polestar: thin brand service supplies the vinInfoTable parser over the shared engine. if (getServiceConfig(serviceName)?.architecture === "LEGACY_VOLVO") { return this.volvoService.decodeVinForService(cleanVin, serviceName, userId); } // Ford (fordp/fordt): thin brand service reads the Ford info grid (model/year/transmission). if (getServiceConfig(serviceName)?.architecture === "LEGACY_FORD") { return this.fordService.decodeVinForService(cleanVin, serviceName, userId); } // Opel/Vauxhall: model from , year/transmission/engine from the info grid. if (getServiceConfig(serviceName)?.architecture === "LEGACY_OPEL") { return this.opelService.decodeVinForService(cleanVin, serviceName, userId); } // Hyundai/Kia: model from <title>, build-year/transmission from the English-labelled grid. if (getServiceConfig(serviceName)?.architecture === "LEGACY_HYUNDAI_KIA") { return this.hyundaiKiaService.decodeVinForService(cleanVin, serviceName, userId); } if (isLegacyArchitecture(serviceName)) { return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId); } this.logger.warn(`Unknown architecture for service: ${serviceName}`); return null; } this.logger.log(`Decoding VIN: ${cleanVin} with service: ${serviceName}`); const catalogConfig = getServiceConfig(serviceName); if (!catalogConfig) { throw new BadRequestException(`Bu marka PL24'te desteklenmiyor: ${serviceName}`); } try { // 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; // Call directAccess API const directAccessUrl = `${this.baseUrl}${catalogBase}/extern/directAccess`; const params = new URLSearchParams({ lang: this.language, serviceName, q: cleanVin, }); const response = await this.fetchWithRetry( `${directAccessUrl}?${params}`, headers, serviceName, account, ); const responseData = (await response.json()) as Record<string, any>; const vinData = responseData.data || responseData; if (responseData.error || responseData.errorCode) { this.logger.warn(`VIN decode error: ${responseData.error || responseData.errorCode}`); throw new BadRequestException(responseData.error || "VIN sorgulanamadi"); } if (vinData.resultStatus !== "VEHICLE_IDENTIFIED") { const message = (responseData.messages as string[])?.[0] || "VIN bulunamadi"; throw new NotFoundException(message); } // Parse vehicle info const vehicle = this.parseVehicleResponse(cleanVin, vinData, serviceName); // Fetch main groups (categories) const mainGroupsPath = vehicle.catalogInfo?.mainGroupsPath || ""; const categories = await this.fetchMainGroupsByPath(mainGroupsPath, headers); const result: PL24DecodedVehicle = { ...vehicle, categories, }; // Cache for 24h await this.redis.setJson(cacheKey, result, 86400); return result; } catch (error) { const err = error as Error; if ( err instanceof BadRequestException || err instanceof NotFoundException || err instanceof ServiceUnavailableException ) { throw err; } if (err.name === "TimeoutError") { throw new ServiceUnavailableException("PL24 zaman asimina ugradi. Lutfen tekrar deneyin."); } this.logger.error(`VIN decode error: ${err.message}`, err.stack); throw new ServiceUnavailableException("VIN sorgulama sirasinda bir hata olustu"); } } /** * Fetch parts for a specific category/group (on-demand). */ async fetchParts( serviceName: string, 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); if (cached) return cached; this.logger.log( `Fetching parts for service=${serviceName}, vin=${vin}, illustration=${illustrationId}`, ); try { 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({ lang: this.language, serviceName, vin, illustrationId, maingroup: mainGroup, }); const response = await this.fetchWithRetry( `${this.baseUrl}${catalogBase}/extern/bom/vin?${params}`, headers, serviceName, account, ); const data = (await response.json()) as Record<string, any>; const crumbs = (data.crumbs as Array<{ name: string }>) || []; const groupName = crumbs[crumbs.length - 1]?.name || ""; const imageData = this.extractImageData(data); const result: PL24PartsResponse = { success: true, groupId: illustrationId, groupName, schemaImageUrl: this.extractIllustrationUrl(data) || undefined, schemaWidth: imageData.schemaWidth, schemaHeight: imageData.schemaHeight, parts: this.parsePartsResponse(data), hotspots: imageData.hotspots, }; await this.redis.setJson(cacheKey, result, 3600); return result; } catch (error) { const err = error as Error; this.logger.error(`Fetch parts error: ${err.message}`); throw new ServiceUnavailableException("Parca listesi alinamadi"); } } /** * Fetch parts using a direct link path (from subcategory). * Handles JLR and Mercedes special flows. */ async fetchPartsByPath( linkPath: string, serviceName: string, body?: string, engine?: string, gearbox?: string, userId?: string, ): Promise<PL24PartsResponse> { // Ford/Fiat legacy dispatch if (this.isP4LegacyPath(linkPath)) { return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName, userId); } // PSA VIN-indexed parts (vin-image-board.action) → dedicated FI service. // Must come BEFORE isPsaBoardPath (vin-image-board.action also contains image-board.action). if (this.isPsaVinBoardPath(linkPath)) { return this.psaService.fetchVinParts(linkPath, serviceName); } // PSA catalog-browse image-board dispatch if (this.isPsaBoardPath(linkPath)) { return this.fordLegacyService.fetchPsaParts(linkPath, serviceName, body, engine, gearbox); } await this.touchActivity(); const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16); const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}parts:path:${pathHash}`; const cached = await this.redis.getJson<PL24PartsResponse>(cacheKey); if (cached) return cached; // JLR special handling if (this.isJlrIllusPath(linkPath)) { return this.fetchJlrPartsViaIllustrations(linkPath, serviceName); } // Mercedes special handling if (this.isDaimlerSubPath(linkPath)) { return this.fetchDaimlerPartsViaSubGroups(linkPath, serviceName); } // Extract position from original partinfo link for filtering const positionMatch = linkPath.match(/[?&]position=(\d+)/); const positionFilter = positionMatch ? positionMatch[1] : null; // Convert partinfo links to bom links for full illustration + parts const fetchPath = this.convertPartInfoToBom(linkPath); try { 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>; const crumbs = (data.crumbs as Array<{ name: string }>) || []; const groupName = crumbs[crumbs.length - 1]?.name || ""; const illustrationId = linkPath.match(/illustrationId=(\d+)/)?.[1] || ""; const imageData = this.extractImageData(data); let parts = this.parsePartsResponse(data); // Filter parts by position if this is a position-level request if (positionFilter && parts.length > 0) { const filtered = parts.filter( (p) => p.positionCode === positionFilter || p.positionCode === `(${positionFilter})`, ); if (filtered.length > 0) parts = filtered; } const result: PL24PartsResponse = { success: true, groupId: illustrationId, groupName, schemaImageUrl: this.extractIllustrationUrl(data) || undefined, schemaWidth: imageData.schemaWidth, schemaHeight: imageData.schemaHeight, parts, hotspots: imageData.hotspots, }; // Pre-download image buffer while auth headers are fresh (avoids token expiry on cached URL) if (result.schemaImageUrl) { const downloaded = await this.tryDownloadImageBuffer(result.schemaImageUrl, headers); if (downloaded) { result.schemaImageBuffer = downloaded.buffer; result.schemaImageContentType = downloaded.contentType; result.schemaImageUrl = undefined; // buffer takes precedence } } // Cache without buffer (binary data not suited for Redis) const { schemaImageBuffer: _buf, ...toCache } = result as any; await this.redis.setJson(cacheKey, toCache, 3600); return result; } catch (error) { const err = error as Error; this.logger.error(`Fetch parts by path error: ${err.message}`); throw new ServiceUnavailableException("Parca listesi alinamadi"); } } /** * Fetch sub-groups for a main group. */ async fetchSubGroups( serviceName: string, vehicleId: string, mainGroupId: string, userId?: string, ): Promise<PL24MainGroup[]> { this.logger.log(`Fetching sub-groups for mainGroup=${mainGroupId}`); try { 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({ lang: this.language, serviceName, vehicleId, mainGroupId, }); const response = await this.fetchWithRetry( `${this.baseUrl}${catalogBase}/extern/subgroups?${params}`, headers, serviceName, account, ); const data = (await response.json()) as Record<string, any>; return this.parseSubGroupsResponse(data); } catch (error) { const err = error as Error; this.logger.error(`Fetch sub-groups error: ${err.message}`); throw new ServiceUnavailableException("Alt gruplar alinamadi"); } } /** * Fetch sub-groups using a direct link path from main group. */ async fetchSubGroupsByPath( linkPath: string, serviceName: string, body?: string, engine?: string, gearbox?: string, userId?: string, ): Promise<PL24MainGroup[]> { // Ford/Fiat legacy dispatch if (this.isP4LegacyPath(linkPath)) { return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName, userId); } // PSA VIN-indexed drill → dedicated FI service. // scope → main groups (json-vin-main-groups.action) if (this.isPsaVinMainGroupsPath(linkPath)) { return this.psaService.fetchVinMainGroups(linkPath); } // main group → illustrations (json-vin-illustrations.action) if (this.isPsaVinIllusPath(linkPath)) { return this.psaService.fetchVinIllustrations(linkPath); } // PSA scope dispatch ("psa::{svc}::scope=..." → main groups) if (this.isPsaPath(linkPath)) { return this.fordLegacyService.fetchPsaSubGroups(linkPath, body, engine, gearbox); } // PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations) if (this.isPsaIllusPath(linkPath)) { return this.fordLegacyService.fetchPsaIllustrations( linkPath, serviceName, body, engine, gearbox, ); } this.logger.log(`Fetching sub-groups by path: ${linkPath}`); await this.touchActivity(); try { 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>; return this.parseSubGroupsResponse(data); } catch (error) { const err = error as Error; this.logger.error(`Fetch sub-groups by path error: ${err.message}`); return []; } } /** * Fetch category tree scopes for a PSA legacy catalog vehicle. * Delegates to fordLegacyService which handles PSA HTML scraping flow. */ async fetchMainGroupsForPsa( serviceName: string, familyId: string, salesTypeId: string, mode: string, upds: string, body?: string, engine?: string, gearbox?: string, ): Promise<PL24DecodedCategory[]> { return this.fordLegacyService.fetchMainGroupsForPsa( serviceName, familyId, salesTypeId, mode, upds, body, engine, gearbox, ); } /** * Fetch body types for a PSA catalog vehicle variant selector. */ async fetchPsaBodies( svc: string, familyId: string, salesTypeId: string, mode: string, upds: string, ): Promise<{ code: string; name: string }[]> { return this.fordLegacyService.fetchPsaBodies(svc, familyId, salesTypeId, mode, upds); } /** * Fetch engines for a PSA catalog vehicle given a selected body code. */ async fetchPsaEnginesForBody( svc: string, familyId: string, salesTypeId: string, bodyCode: string, mode: string, upds: string, ): Promise<{ code: string; name: string }[]> { return this.fordLegacyService.fetchPsaEnginesForBody( svc, familyId, salesTypeId, bodyCode, mode, upds, ); } /** * Fetch gearboxes for a PSA catalog vehicle given selected body + engine codes. */ async fetchPsaGearboxes( svc: string, familyId: string, salesTypeId: string, bodyCode: string, engineCode: string, mode: string, upds: string, ): Promise<{ code: string; name: string }[]> { return this.fordLegacyService.fetchPsaGearboxes( svc, familyId, salesTypeId, bodyCode, engineCode, mode, upds, ); } /** * Fetch model config (variant options) for a Ford catalog vehicle. */ async fetchFordModelConfig( svc: string, familyId: string, mode: string, upds: string, ): Promise<{ modelYears: { code: string; name: string }[]; engines: { code: string; name: string }[]; gearboxes: { code: string; name: string }[]; }> { return this.fordLegacyService.fetchFordModelConfig(svc, familyId, mode, upds); } /** * Fetch model config (year variant options) for a Volvo catalog vehicle. */ async fetchVolvoModelConfig( svc: string, mdlId: string, mode: string, upds: string, ): Promise<{ modelYears: { code: string; name: string }[]; engines: { code: string; name: string }[]; gearboxes: { code: string; name: string }[]; }> { return this.fordLegacyService.fetchVolvoModelConfig(svc, mdlId, mode, upds); } /** * Fetch main category groups for a Ford catalog vehicle variant. */ async fetchFordMainGroups( svc: string, familyId: string, modelYear: string, engine: string, gearbox: string, mode: string, upds: string, catCode?: string, ): Promise<PL24DecodedCategory[]> { return this.fordLegacyService.fetchFordMainGroups( svc, familyId, modelYear, engine, gearbox, mode, upds, catCode, ); } /** * Re-fetch main groups using a stored mainGroupsPath. */ /** * Fetch P5 restriction options from a given path (restrictions1/2/3 endpoint). * Returns an array of selectable options, each with a path to the next level. */ async fetchP5Restrictions( serviceName: string, restrictionPath: string, ): Promise<{ options: Array<{ code: string; name: string; path: string }>; isFinal: boolean; }> { await this.touchActivity(); try { // Account-aware (de + DE proxy for Fiat). resolveAccount(undefined, …) → "de" // only for fiatp_parts/fiatt_parts; all other P5 brands stay on tr (no proxy). const account = await this.resolveAccount(undefined, serviceName); await this.authService.authorizeServiceForAccount(serviceName, account); const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName); const localizedPath = restrictionPath.replace(/lang=\w+/, `lang=${this.language}`); const url = `${this.baseUrl}${localizedPath}`; const response = await this.fetchWithRetry(url, headers, serviceName, account); if (!response.ok) { this.logger.warn(`P5 restrictions fetch failed: HTTP ${response.status}`); return { options: [], isFinal: false }; } const data = (await response.json()) as Record<string, any>; let records: any[] = []; if (Array.isArray(data?.data?.records)) { records = data.data.records; } else if (Array.isArray(data)) { records = data; } const options = records .filter((r) => r.id || r.values?.caption) .map((r) => ({ code: String(r.id ?? ""), name: String(r.values?.caption ?? r.id ?? ""), path: String(r.link?.path ?? ""), })); // Detect if we've reached the mainGroups / parts level: // - The fetched URL itself contains "/mainGroup" (we ARE at the mainGroups endpoint) // - OR the records' link.wid indicates parts navigation (subGroup, partsList, etc.) const fetchedMainGroups = restrictionPath.includes("/mainGroup"); const firstWid = String(records[0]?.link?.wid ?? ""); const partsWids = [ "subGroupTable", "subGroupNodeTable", "partsListTable", "mainGroupNodeTable", ]; const widsIndicateParts = partsWids.some( (w) => firstWid.includes(w) || firstWid.includes("Group"), ); const isFinal = fetchedMainGroups || widsIndicateParts; if (isFinal) { this.logger.log( `P5 restrictions: reached final level (path=${restrictionPath.substring(0, 80)}, wid=${firstWid})`, ); } return { options, isFinal }; } catch (error) { this.logger.warn(`fetchP5Restrictions failed: ${(error as Error).message}`); return { options: [], isFinal: false }; } } async fetchMainGroups( serviceName: string, mainGroupsPath: string, ): Promise<PL24DecodedCategory[]> { await this.touchActivity(); // Ford legacy catalog vehicles store the vehicle.action URL as catalogPath. // Dispatch to fordLegacyService which fetches and extracts group links from the HTML. if (this.isP4LegacyPath(mainGroupsPath)) { const groups = await this.fordLegacyService.fetchSubGroupsByPath(mainGroupsPath, serviceName); return groups.map((g) => ({ code: g.code, nameEn: g.name, nameTr: g.name, description: g.description || null, iconUrl: g.iconUrl || null, subGroups: [], linkPath: g.linkPath, linkWid: g.linkWid, })); } try { // Account-aware: tr for most P5 brands (no proxy), de + DE proxy for Fiat. // resolveAccount(undefined, …) → "de" only for fiatp_parts/fiatt_parts. const account = await this.resolveAccount(undefined, serviceName); await this.authService.authorizeServiceForAccount(serviceName, account); const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName); const localizedPath = mainGroupsPath.replace(/lang=\w+/, `lang=${this.language}`); const response = await this.fetchWithRetry( `${this.baseUrl}${localizedPath}`, headers, serviceName, account, ); const data = (await response.json()) as Record<string, any>; return this.parseMainGroupsResponse(data); } catch (error) { const err = error as Error; this.logger.error(`Fetch main groups error: ${err.message}`); return []; } } /** * Download schema image from PL24 and upload to MinIO. */ async getSchemaImage( imageUrl: string, serviceName: string, ): Promise<{ imageUrl: string; width: number | null; height: number | null; hotspots: PL24Hotspot[]; } | null> { if (!imageUrl) return null; await this.touchActivity(); // Extract image ID for dedup const imageId = this.extractImageIdFromUrl(imageUrl); if (!imageId) return null; // Check cache const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}schema:${imageId}`; const cached = await this.redis.getJson<{ imageUrl: string; width: number | null; height: number | null; hotspots: PL24Hotspot[]; }>(cacheKey); if (cached) return cached; try { await this.authService.authorizeService(serviceName); const headers = await this.authService.buildAuthHeaders(serviceName); const response = await fetch(imageUrl, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!response.ok) { this.logger.warn(`Failed to download image: HTTP ${response.status}`); return null; } const contentType = response.headers.get("content-type") || ""; let buffer: Buffer; let width: number | null = null; let height: number | null = null; let hotspots: PL24Hotspot[] = []; if (contentType.includes("application/json")) { const jsonData = (await response.json()) as Record<string, any>; if (jsonData.image && typeof jsonData.image === "string") { buffer = Buffer.from(jsonData.image, "base64"); width = jsonData.originalWidth || jsonData.scaledWidth || null; height = jsonData.originalHeight || jsonData.scaledHeight || null; if (Array.isArray(jsonData.hotspots)) { hotspots = jsonData.hotspots.map( (hs: { key: string; areas?: Array<{ left: number; top: number; width: number; height: number; }>; }) => ({ key: hs.key, areas: Array.isArray(hs.areas) ? hs.areas.map((area) => ({ left: area.left, top: area.top, width: area.width, height: area.height, })) : [], }), ); } } else { return null; } } else { const arrayBuffer = await response.arrayBuffer(); buffer = Buffer.from(arrayBuffer); } // Upload to MinIO const minioKey = `schemas/${imageId}.png`; const uploadedUrl = await this.storage.upload(minioKey, buffer, "image/png"); const result = { imageUrl: uploadedUrl, width, height, hotspots, }; await this.redis.setJson(cacheKey, result, 86400); return result; } catch (error) { const err = error as Error; this.logger.error(`Failed to download schema image: ${err.message}`); return null; } } /** * Check if VIN manufacturer is supported. */ isSupported(vin: string): boolean { if (!vin || vin.length < 3) return false; const serviceName = this.getServiceName(vin); if (!serviceName) return false; return isP5Modern(serviceName) || serviceName === "fordt_parts"; } /** * Returns true if this VIN's WMI maps to any PL24 service (P5 Modern or P4 Legacy). * Use this to gate VIN decode attempts; use isSupported() for catalog browser eligibility. */ isDecodeable(vin: string): boolean { if (!vin || vin.length < 3) return false; return !!this.getServiceName(vin); } /** * Get supported brands list. */ getSupportedBrands(): string[] { const brands = new Set<string>(); for (const service of Object.values(PL24_WMI_SERVICE_MAP)) { const brand = SERVICE_TO_BRAND[service]; if (brand && (isP5Modern(service) || service === "fordt_parts")) { brands.add(brand); } } return Array.from(brands).sort(); } /** * Get brand display name from VIN's WMI. */ getBrandName(vin: string): string | null { const serviceName = this.getServiceName(vin); if (!serviceName) return null; return SERVICE_TO_BRAND[serviceName] || null; } /** Mark PL24 as actively used (5min TTL) to defer prefetch worker */ private async touchActivity(): Promise<void> { // Worker-originated fetches must NOT register as user activity, or the // backfill worker throttles itself via checkCooldown. if (isBackfillContext()) return; try { await this.redis.set("prefetch:activity:pl24", String(Date.now()), 90); } catch { // Non-critical — don't break the request } } // ==================== PRIVATE: Request helpers ==================== /** * 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 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; }; // Every PL24 upstream call is counted and logged here (the one choke point). const response = await this.budgetedFetch( url, buildOpts(headers), account, Boolean(dispatcher), ); if (response.status === 401) { this.logger.warn(`Got 401 (account=${account}), refreshing service token...`); // A stale *service* token is the cheap explanation; the session itself is // only dropped if the re-authorize also fails (handled in the auth service). this.authService.clearTokensForAccount(account); await this.authService.authorizeServiceForAccount(serviceName, account); const newHeaders = await this.authService.buildAuthHeadersForAccount(account, serviceName); const retry = await this.budgetedFetch( url, buildOpts(newHeaders), account, Boolean(dispatcher), ); if (!retry.ok) { throw new Error(`HTTP ${retry.status}: ${retry.statusText}`); } return retry; } if (response.status === 404) { throw new NotFoundException("VIN bulunamadi"); } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response; } /** * Single gate for PL24 upstream traffic: daily budget first (a refusal costs * no request at all), then the call, then telemetry into `proxy_logs`. */ private async budgetedFetch( url: string, opts: RequestInit & { dispatcher?: any }, account: "tr" | "de", proxied: boolean, ): Promise<Response> { // The kill switch used to gate decodeVin only, so drills, backfill and // catalog browse kept hammering PL24 after the source was "killed". if (!(await this.posthog.isSourceLive("pl24"))) { throw new ServiceUnavailableException("PL24 kapali (kill-source-pl24)"); } await this.budget.consume("catalog"); const startedAt = Date.now(); try { const response = await fetch(url, opts); this.budget.record({ kind: "catalog", url, proxied, account, statusCode: response.status, success: response.ok, startedAt, }); return response; } catch (error) { this.budget.record({ kind: "catalog", url, proxied, account, success: false, startedAt, error, }); throw error; } } // ==================== 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 { if (!vin) { throw new BadRequestException("VIN numarasi gereklidir"); } if (vin.length !== 17) { throw new BadRequestException("VIN numarasi 17 karakter olmalidir"); } if (/[IOQ]/i.test(vin)) { throw new BadRequestException( "VIN numarasi gecersiz karakterler iceriyor (I, O, Q kullanilamaz)", ); } } private getServiceName(vin: string): string | null { const wmi = vin.substring(0, 3).toUpperCase(); return PL24_WMI_SERVICE_MAP[wmi] || null; } // ==================== PRIVATE: Response parsers ==================== /** * Parse vehicle data from directAccess response. */ private parseVehicleResponse( vin: string, data: Record<string, unknown>, serviceName: string, ): Omit<PL24DecodedVehicle, "categories"> { const segments = (data.segments as Record< string, { records?: Array<{ values?: Record<string, string | undefined> }> } >) || {}; const vinfoRecords = segments.vinfoBasic?.records || []; const vehicleData: Record<string, string> = {}; for (const record of vinfoRecords) { const v = record.values; if (!v) continue; // Both shapes carry the row under `values`, but the inner field names differ: // p5vwag etc.: { description: <label>, value: <value> } // p5fiat: { key: <label>, description: <value>, code?: <code> } const label = (v.key !== undefined ? v.key : v.description) || ""; const value = (v.key !== undefined ? v.description : v.value) || ""; if (!label) continue; const key = label.toLowerCase().replace(/[\s\/]+/g, "_"); if (!(key in vehicleData)) { // Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5 // backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"), // and collapse runs of whitespace (Toyota "COROLLA (TUP)"). Otherwise the raw // escape/padding leaks straight to the UI. vehicleData[key] = value .replace(/\r?\n/g, " ") .replace(/\\-/g, "-") .replace(/\s+/g, " ") .trim(); } } // Helper: look up by multiple possible keys (EN + TR) const lookup = (...keys: string[]): string | null => { for (const k of keys) { if (vehicleData[k]) return vehicleData[k]; } return null; }; // Extract prNr records for richer vehicle attributes const prNrRecords = segments.prNr?.records || []; const prNrByCode: Record<string, string> = {}; for (const record of prNrRecords) { if (record.values) { const code = record.values.col2 || ""; const desc = record.values.col3?.replace(/\r?\n/g, " ").replace(/\\-/g, "-").trim() || ""; if (code) prNrByCode[code] = desc; } } // Extract main groups link path let mainGroupsPath = (data.link as Record<string, string>)?.path || ""; // Mercedes: convert vin_scope to vin_main if (this.isDaimlerService(serviceName) && mainGroupsPath.includes("vin_scope")) { mainGroupsPath = mainGroupsPath .replace("/groups/vin_scope", "/groups/vin_main") .replace("?", "?scope=F&subAggregate=n-r&"); } // Engine code: VAG/BMW "Motor kodu" / "Engine code". const engineCode = lookup("motor_kodu", "engine_code"); // Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a // code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.". const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no."); // Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu", // Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman", // Suzuki "Şanzıman numarası". const transmissionCode = lookup( "şanzıman_kodu", "sanzıman_kodu", "transmission_code", "vites_kutusu", "atm,mtm", "şanzıman", "şanzıman_numarası", ); // Build body type from prNr K8* (Kaporta formları); brands without a prNr segment // (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine"). const bodyType = Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || lookup("karoseri", "body", "body_type") || null; // Engine description from prNr D3* (Motor nitelikleri) const engineDesc = Object.entries(prNrByCode).find(([code]) => code.startsWith("D3"))?.[1] || null; // Transmission type from prNr G0* (Şanzıman nitelikleri) const transmissionDesc = Object.entries(prNrByCode).find(([code]) => code.startsWith("G0"))?.[1] || null; // Drive type from prNr 1X* (Tahrik türü); BMW carries it in vinfoBasic "Tahrik" (RWD/xDrive). const driveType = Object.entries(prNrByCode).find(([code]) => code.startsWith("1X"))?.[1] || lookup("tahrik", "aks_tahrigi_tanimi", "axle_drive"); // Friendly model. p5fiat uses "Model bilgisi" ("Panda POP 1.2 …"); most P5 backends use // "Model". BMW's "Model" is only the trim ("520i") with the chassis/generation in "Seri" // ("5 G30") — fold the generation in so parts aren't ambiguous across chassis (E60/F10/G30). // Mitsubishi's "Model" is an internal chassis/engine code ("KB4T 2500DIESEL/4WD(TRUCK)"); // the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix. const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi"; const baseModel = isMitsubishi ? (lookup("araç") || (data.description as string) || lookup("model") || "") .replace(/\s*\([^)]*\)\s*$/, "") .trim() : lookup("model_bilgisi", "model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || ""; // "Seri" is "{line} {chassis} [{variant}]" ("5 G30", "X3 G01", "5 E60 MUE"). The trim already // implies the line ("520i"→5, "X3 sDrive20i"→X3), so drop the leading line token and append // only the chassis(+variant) → "520i G30", "X3 sDrive20i G01" (avoids a redundant "X3 X3"). const seri = lookup("seri", "model_tanimi"); const chassis = seri ? seri.replace(/^\S+\s+/, "").trim() : null; const model = chassis && baseModel && !baseModel.toLowerCase().includes(chassis.toLowerCase()) ? `${baseModel} ${chassis}` : baseModel; return { brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""), model, // Some P5 backends have no plain model-year field: p5fiat → "MY" ("… = 2014 YILI") or // production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009"). // Falling through to the VIN year-char (extractModelYear) is the last resort and is often // wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first. year: Number.parseInt(lookup("model_yili", "year") || "", 10) || Number.parseInt( (lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match( /(19|20)\d{2}/, )?.[0] || "", 10, ) || extractModelYear(vin) || 0, series: lookup("seri", "satis_tipi", "sales_type"), bodyType, engineCode: engineCode || engineLabel?.match(/\(([A-Za-z0-9]{3,})\)/)?.[1] || // Toyota "1800CC … (2ZRFXE)" (engineDesc ? engineDesc.split("/")[0]?.trim() : null) || null, engineType: engineDesc || engineLabel, engineVolume: null, transmission: transmissionCode || transmissionDesc, driveType, colorCode: lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") || lookup("tavan_rengi", "roof_color"), productionDate: lookup("üretim_tarihi", "date_of_production"), raw: data, catalogInfo: { serviceName, vehicleId: vin, catalogPath: getServiceApiPath(serviceName), baseUrl: this.baseUrl, mainGroupsPath, }, }; } /** * Fetch main groups using the path from directAccess response. */ private async fetchMainGroupsByPath( path: string, headers: Record<string, string>, ): Promise<PL24DecodedCategory[]> { if (!path) { this.logger.warn("No main groups path available"); return []; } const localizedPath = path.replace(/lang=\w+/, `lang=${this.language}`); try { const response = await fetch(`${this.baseUrl}${localizedPath}`, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!response.ok) { this.logger.warn(`Failed to fetch main groups: HTTP ${response.status}`); return []; } const data = (await response.json()) as Record<string, any>; return this.parseMainGroupsResponse(data); } catch (error) { const err = error as Error; this.logger.warn(`Failed to fetch main groups: ${err.message}`); return []; } } private parseMainGroupsResponse(response: unknown): PL24DecodedCategory[] { const responseData = response as Record<string, unknown>; let records: Array<Record<string, unknown>> = []; if (Array.isArray(response)) { records = response; } else if (responseData.data) { const innerData = responseData.data as Record<string, unknown>; if (Array.isArray(innerData.records)) { records = innerData.records; } else if (Array.isArray(innerData)) { records = innerData as unknown as Array<Record<string, unknown>>; } } else if (responseData.mainGroups) { records = responseData.mainGroups as Array<Record<string, unknown>>; } else if (responseData.groups) { records = responseData.groups as Array<Record<string, unknown>>; } // Filter out section headers const categoryRecords = records.filter((record) => record.characteristic !== "sectionrow"); return categoryRecords.map((record) => { const values = (record.values as Record<string, string>) || {}; const link = (record.link as Record<string, unknown>) || {}; const caption = values.caption || values.captions || values.mainGroup || values.groupName || values.title || values.name || ""; const captionMatch = caption.match(/^(\d+|_\w+_)\s+(.+)$/); const code = captionMatch?.[1] || values.hg || values.groupCode || values.code || String(record.id || record.code || record.hg || ""); // MAN truck main-vin uses values.mainGroupDescription for human name // (caption above only carries the numeric code "0".."9"). const manDescription = values.mainGroupDescription || values.groupDescription; let name = captionMatch?.[2] || manDescription || values.description || caption || (record.description as string) || (record.name as string) || ""; if (!name && caption) { name = caption.replace(/^(\d+|_\w+_)\s*/, "").trim(); } // If name is just the numeric code (no description found), keep code-only as fallback. if (name && /^\d+$/.test(name) && manDescription) { name = manDescription; } return { code, nameEn: name || code, nameTr: name || code, description: (record.description as string) || null, iconUrl: null, subGroups: [], linkPath: (link.path as string) || undefined, linkWid: (link.wid as string) || undefined, }; }); } private parseSubGroupsResponse(response: unknown): PL24MainGroup[] { const responseData = response as Record<string, unknown>; let records: Array<Record<string, unknown>> = []; let bomBasePath: string | undefined; if (Array.isArray(response)) { records = response; } else if (responseData.data) { const innerData = responseData.data as Record<string, unknown>; if (Array.isArray(innerData.records)) { records = innerData.records; } else if (Array.isArray(innerData)) { records = innerData as unknown as Array<Record<string, unknown>>; } // Servicepart: extract bomBaseLink for constructing child paths const bomBaseLink = innerData.bomBaseLink as Record<string, unknown> | undefined; if (bomBaseLink?.path) { bomBasePath = bomBaseLink.path as string; } } else if (responseData.subGroups) { records = responseData.subGroups as Array<Record<string, unknown>>; } else if (responseData.groups) { records = responseData.groups as Array<Record<string, unknown>>; } const validRecords = records.filter((record) => { const link = (record.link as Record<string, unknown>) || {}; // Servicepart records have no link.path but can use bomBaseLink if (!link.path && !bomBasePath) return false; // Skip the "all" pseudo-record in servicepart responses if (record.id === "all") return false; return true; }); return validRecords.map((record) => { const values = (record.values as Record<string, string>) || {}; const link = (record.link as Record<string, unknown>) || {}; const code = values.subgroup || values.illustrationNumber || values.id || values.code || String(record.id || ""); let rawCaption = values.descr || values.captions || values.caption || values.description || values.groupDescription || values.mainGroupDescription || values.name || values.title || (record.description as string) || (record.name as string) || ""; rawCaption = rawCaption .replace(/\\-/g, "-") .replace(/\\r?\\n/g, " ") .trim(); const separatorMatch = rawCaption.match(/^[\w_]+\s*[–—-]\s*(.+)$/); let name = separatorMatch ? separatorMatch[1].trim() : rawCaption; const remarks = (values.remarks || "").replace(/\r?\n/g, " ").trim(); if (remarks) { name = `${name} (${remarks})`; } const modelDesc = (values.modelDescriptions || "").replace(/\r?\n/g, " ").trim(); if (modelDesc) { name = `${name} [${modelDesc}]`; } const illusNum = (values.illustrationNumber || "").replace(/\\-/g, "-").trim(); if (illusNum) { name = `${name} {${illusNum}}`; } if (!name) name = code; // Construct linkPath: use record's own link.path, or bomBaseLink + record id const recordLinkPath = (link.path as string) || undefined; const constructedPath = !recordLinkPath && bomBasePath ? `${bomBasePath}${record.id}` : recordLinkPath; return { id: String(record.id || ""), code, name, description: values.modelDescriptions || undefined, imageUrl: undefined, partCount: undefined, linkPath: constructedPath, linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined), unavailable: !!record.unavailable, }; }); } /** * Parse parts response (BOM). */ private parsePartsResponse(response: unknown): PL24Part[] { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || responseData; let records: Array<Record<string, unknown>> = []; if (Array.isArray(data.records)) { records = data.records; } else if (Array.isArray(data)) { records = data as unknown as Array<Record<string, unknown>>; } else if (Array.isArray(responseData.parts)) { records = responseData.parts as Array<Record<string, unknown>>; } const partRecords = records.filter( (record) => record.characteristic !== "sectionrow" && (record.partno || (record.values as Record<string, unknown>)?.partno), ); return partRecords.map((part) => { const values = (part.values as Record<string, string>) || {}; const formattedPartNo = ((part.partno as string) || values.partno || "").trim(); const cleanPartNo = formattedPartNo.replace(/\s+/g, ""); const qtyStr = values.qty || ""; const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined; const remark = values.remark?.trim() || undefined; const modelCodes = values.modelDescription?.trim() || undefined; let superseded: { oldCode: string; newCode: string } | undefined; const supersededByValue = (part.supersededBy as string) || values.supersededBy || ""; const supersedesValue = (part.supersedes as string) || values.supersedes || ""; if (supersededByValue || supersedesValue) { superseded = { oldCode: supersedesValue ? cleanPartNo : "", newCode: supersededByValue || cleanPartNo, }; } // 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 ? Number.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, formattedPartNo: formattedPartNo || undefined, name: String(part.description || values.description || ""), description: String(part.description || values.description || ""), remark, quantity, positionCode: String(part.pos || values.pos || ""), modelCodes, notes: modelCodes, unavailable: !!part.unavailable, presel: !!part.presel, superseded, hotspotId: (part.hotspotId as string) || undefined, linkPath: (part.link as Record<string, string>)?.path, price, currency, }; }); } // ==================== PRIVATE: Image extraction ==================== private async tryDownloadImageBuffer( imageUrl: string, headers: Record<string, string>, ): Promise<{ buffer: Buffer; contentType: string } | null> { this.logger.log(`[imgdl] Downloading image: ${imageUrl}`); try { const response = await fetch(imageUrl, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!response.ok) { this.logger.warn(`Image pre-download failed: HTTP ${response.status}`); return null; } const contentType = response.headers.get("content-type") || "image/png"; if (contentType.includes("application/json")) { const json = (await response.json()) as Record<string, any>; if (json.image && typeof json.image === "string") { return { buffer: Buffer.from(json.image, "base64"), contentType: "image/png" }; } return null; } return { buffer: Buffer.from(await response.arrayBuffer()), contentType }; } catch (err) { this.logger.warn(`Image pre-download error: ${(err as Error).message}`); return null; } } private extractIllustrationUrl(response: unknown): string | null { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || responseData; const images = (data.images as Array<{ id: string; uri: string; name: string; }>) || []; const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0]; if (defaultImage?.uri) { return `${this.baseUrl}${defaultImage.uri}`; } return null; } private extractImageData(response: unknown): { hotspots: PL24Hotspot[]; schemaWidth?: number; schemaHeight?: number; } { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || responseData; const images = (data.images as Array<{ id?: string; uri?: string; width?: number; height?: number; hotspots?: PL24Hotspot[]; }>) || []; const defaultImage = images.find((img) => img.id === "_DFLT_") || images[0]; if (!defaultImage) { return { hotspots: [] }; } const hotspots: PL24Hotspot[] = []; if (defaultImage.hotspots && Array.isArray(defaultImage.hotspots)) { for (const hs of defaultImage.hotspots) { if (hs.key && hs.areas) { hotspots.push({ key: hs.key, areas: hs.areas }); } } } return { hotspots, schemaWidth: defaultImage.width || undefined, schemaHeight: defaultImage.height || undefined, }; } private extractImageIdFromUrl(imageUrl: string): string | null { if (!imageUrl) return null; const standardMatch = imageUrl.match(/\/images\/(\d+)\?/); if (standardMatch) return standardMatch[1]; const tiffMatch = imageUrl.match(/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/); if (tiffMatch) return tiffMatch[1]; const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/); if (mercedesMatch) return mercedesMatch[1]; // PSA and other legacy brands use ticket-based URLs that don't match above patterns. // Fall back to a hash of the URL so caching still works. return createHash("sha256").update(imageUrl).digest("hex").substring(0, 24); } // ==================== PRIVATE: Brand-specific flows ==================== /** * Convert partinfo links to bom/bomdetails links. * partinfo returns single part detail; bom returns full illustration + all parts. * * Standard brands: /extern/partinfo/vin → /extern/bom/vin * Suzuki-style: /extern/partinfo/vin → /extern/details/vin/bomdetails */ private convertPartInfoToBom(linkPath: string): string { if (!linkPath.includes("/partinfo/")) return linkPath; // Suzuki (and similar) uses /details/vin/bomdetails instead of /bom/ const isSuzukiStyle = linkPath.includes("/p5suzuki/"); const bomPath = isSuzukiStyle ? linkPath.replace("/partinfo/vin", "/details/vin/bomdetails") : linkPath.replace("/partinfo/", "/bom/"); const url = new URL(bomPath, "http://placeholder"); // Remove partinfo-specific params url.searchParams.delete("fiValidity"); url.searchParams.delete("position"); url.searchParams.delete("positionId"); url.searchParams.delete("partno"); url.searchParams.delete("pos"); return `${url.pathname}?${url.searchParams.toString()}`; } private isP4LegacyPath(linkPath: string): boolean { // PSA (Citroen/Peugeot/DS) leaf paths — json-illustrations.action and // image-board.action — also contain ".action" but MUST route to the // dedicated PSA handlers (fetchPsaIllustrations / fetchPsaParts), not the // Ford/Fiat legacy parser. Without this /psa/ guard, the broad ".action" // match here shadows the PSA dispatch in fetchSubGroupsByPath / // fetchPartsByPath, so every PSA drill below main-group level returns empty. return linkPath.includes(".action") && !linkPath.includes("/psa/"); } private isPsaPath(linkPath: string): boolean { return linkPath.startsWith("psa::"); } private isPsaIllusPath(linkPath: string): boolean { return linkPath.includes("/psa/") && linkPath.includes("json-illustrations.action"); } private isPsaBoardPath(linkPath: string): boolean { return linkPath.includes("/psa/") && linkPath.includes("image-board.action"); } // PSA VIN-indexed (FI) drill paths — handled by the dedicated PL24PsaService. private isPsaVinMainGroupsPath(linkPath: string): boolean { return linkPath.includes("/psa/") && linkPath.includes("json-vin-main-groups.action"); } private isPsaVinIllusPath(linkPath: string): boolean { return linkPath.includes("/psa/") && linkPath.includes("json-vin-illustrations.action"); } private isPsaVinBoardPath(linkPath: string): boolean { return linkPath.includes("/psa/") && linkPath.includes("vin-image-board.action"); } private isDaimlerService(serviceName: string): boolean { return serviceName.startsWith("mercedes") || serviceName === "smart_parts"; } private isDaimlerSubPath(linkPath: string): boolean { return linkPath.includes("/p5daimler/") && linkPath.includes("vin_sub"); } private isJlrIllusPath(linkPath: string): boolean { return linkPath.includes("/p5jlr/") && linkPath.includes("vin_illus"); } /** * Mercedes/Daimler: fetch parts via subGroups. */ private async fetchDaimlerPartsViaSubGroups( subPath: string, serviceName: string, ): Promise<PL24PartsResponse> { this.logger.log(`Mercedes: Fetching subGroups from: ${subPath}`); try { await this.authService.authorizeService(serviceName); const headers = await this.authService.buildAuthHeaders(serviceName); const subResponse = await fetch(`${this.baseUrl}${subPath}`, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!subResponse.ok) { throw new Error(`HTTP ${subResponse.status}: ${subResponse.statusText}`); } const subData = (await subResponse.json()) as Record<string, any>; const records = (subData.data?.records || []) as Array<{ id?: string; link?: { path?: string }; values?: Record<string, string>; }>; if (records.length === 0) { return { success: true, groupId: "", groupName: "", parts: [], }; } const firstSubGroup = records[0]; const partsPath = firstSubGroup.link?.path; if (!partsPath) { return { success: true, groupId: "", groupName: firstSubGroup.values?.name || "", parts: [], }; } const partsResponse = await fetch(`${this.baseUrl}${partsPath}`, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!partsResponse.ok) { throw new Error(`Parts HTTP ${partsResponse.status}: ${partsResponse.statusText}`); } const partsData = (await partsResponse.json()) as Record<string, any>; const parts = this.parseDaimlerPartsResponse(partsData); const { schemaImageUrl } = this.extractDaimlerImageData(partsData); let hotspots: PL24Hotspot[] = []; let schemaWidth: number | undefined; let schemaHeight: number | undefined; if (schemaImageUrl) { const imageData = await this.fetchDaimlerImageWithHotspots(schemaImageUrl, serviceName); hotspots = imageData.hotspots; schemaWidth = imageData.width; schemaHeight = imageData.height; } const crumbs = (partsData.crumbs as Array<{ name: string }>) || []; const groupName = crumbs[crumbs.length - 1]?.name || ""; return { success: true, groupId: firstSubGroup.id || "", groupName, schemaImageUrl, schemaWidth, schemaHeight, parts, hotspots, }; } catch (error) { const err = error as Error; this.logger.error(`Mercedes parts fetch error: ${err.message}`); throw new ServiceUnavailableException("Mercedes parca listesi alinamadi"); } } private parseDaimlerPartsResponse(response: unknown): PL24Part[] { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || {}; const records = (data.records || []) as Array<{ id?: string; partno?: string; description?: string; pos?: string; hotspotId?: string; presel?: boolean; values?: { partno?: string; description?: string; remark?: string; restrictions?: string; qty?: string; pos?: string; }; }>; return records.map((record) => { const values = record.values || {}; const oemCode = (record.partno || values.partno || "").replace(/\s+/g, ""); const formattedPartNo = record.partno || values.partno || ""; return { id: record.id || oemCode, oemCode, formattedPartNo, name: record.description || values.description || "", description: values.remark || undefined, positionCode: record.pos || values.pos || undefined, hotspotId: record.hotspotId || record.pos || undefined, quantity: Number.parseInt(values.qty || "1", 10) || 1, modelCodes: values.restrictions || undefined, presel: !!record.presel, }; }); } private extractDaimlerImageData(response: unknown): { schemaImageUrl?: string; } { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || {}; const images = (data.images || []) as Array<{ id?: string; uri?: string; }>; if (images.length === 0) return {}; const imageUri = images[0].uri; if (!imageUri) return {}; const schemaImageUrl = imageUri.startsWith("http") ? imageUri : `${this.baseUrl}${imageUri}`; return { schemaImageUrl }; } private async fetchDaimlerImageWithHotspots( imageUrl: string, serviceName: string, ): Promise<{ hotspots: PL24Hotspot[]; width?: number; height?: number; }> { if (!imageUrl) return { hotspots: [] }; try { const headers = await this.authService.buildAuthHeaders(serviceName); const response = await fetch(imageUrl, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!response.ok) return { hotspots: [] }; const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { const jsonData = (await response.json()) as Record<string, any>; const hotspots: PL24Hotspot[] = []; if (Array.isArray(jsonData.hotspots)) { for (const hs of jsonData.hotspots) { if (hs.key && Array.isArray(hs.areas)) { hotspots.push({ key: hs.key, areas: hs.areas.map( (area: { left: number; top: number; width: number; height: number; }) => ({ left: area.left, top: area.top, width: area.width, height: area.height, }), ), }); } } } return { hotspots, width: jsonData.originalWidth || jsonData.scaledWidth || undefined, height: jsonData.originalHeight || jsonData.scaledHeight || undefined, }; } return { hotspots: [] }; } catch (error) { const err = error as Error; this.logger.error(`Mercedes: Failed to fetch image hotspots: ${err.message}`); return { hotspots: [] }; } } /** * JLR: fetch parts via illustrations (3-step flow). */ private async fetchJlrPartsViaIllustrations( illusPath: string, serviceName: string, ): Promise<PL24PartsResponse> { this.logger.log(`JLR: Fetching illustrations from: ${illusPath}`); try { await this.authService.authorizeService(serviceName); const headers = await this.authService.buildAuthHeaders(serviceName); const illusResponse = await fetch(`${this.baseUrl}${illusPath}`, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!illusResponse.ok) { throw new Error(`HTTP ${illusResponse.status}: ${illusResponse.statusText}`); } const illusData = (await illusResponse.json()) as Record<string, any>; const illustrations = this.parseJlrIllustrations(illusData); if (illustrations.length === 0) { return { success: true, groupId: "", groupName: "", parts: [], hotspots: [], }; } const urlParams = new URLSearchParams(illusPath.split("?")[1] || ""); const mg = urlParams.get("mg") || ""; const sg = urlParams.get("sg") || ""; const vin = urlParams.get("vin") || ""; const upds = urlParams.get("upds") || ""; const lang = urlParams.get("lang") || this.language; const firstIllus = illustrations[0]; const bomPath = `/p5jlr/extern/bom/vin_bomdetails?btnr=${firstIllus.btnr}&lang=${lang}&mg=${mg}&serviceName=${serviceName}&sg=${sg}&upds=${upds}&vin=${vin}`; const bomResponse = await fetch(`${this.baseUrl}${bomPath}`, { method: "GET", headers, signal: AbortSignal.timeout(this.timeout), }); if (!bomResponse.ok) { throw new Error(`HTTP ${bomResponse.status}: ${bomResponse.statusText}`); } const bomData = (await bomResponse.json()) as Record<string, any>; const crumbs = (bomData.crumbs as Array<{ name: string }>) || []; const groupName = crumbs[crumbs.length - 1]?.name || firstIllus.name || ""; const parts = this.parsePartsResponse(bomData); const schemaImageUrl = this.extractIllustrationUrl(bomData); const { hotspots, schemaWidth, schemaHeight } = this.extractImageData(bomData); return { success: true, groupId: String(firstIllus.btnr), groupName, schemaImageUrl: schemaImageUrl || undefined, schemaWidth, schemaHeight, parts, hotspots, }; } catch (error) { const err = error as Error; this.logger.error(`JLR fetch parts error: ${err.message}`); throw new ServiceUnavailableException("Parca listesi alinamadi (JLR)"); } } // ==================== PUBLIC: Catalog browse (VIN-less) ==================== /** * Fetch a list of vehicles/models for a service (VIN-less catalog browse). * Tries P5 Modern selection wizard endpoints. * Returns empty array if unavailable (requires discovery to find correct endpoint). */ async fetchVehicleList(serviceName: string): Promise< Array<{ vehicleId: string; model: string; year?: string; engine?: string; bodyType?: string; transmission?: string; market?: string; catalogPath?: string; metadata?: Record<string, unknown>; }> > { await this.touchActivity(); // Legacy architecture dispatch const serviceConfig = getServiceConfig(serviceName); if (serviceConfig?.architecture === "LEGACY_PSA") { return this.fordLegacyService.fetchVehicleListForPsa(serviceName); } if (serviceConfig?.architecture === "LEGACY_FORD") { return this.fordLegacyService.fetchVehicleListForFord(serviceName); } if (serviceConfig?.architecture === "LEGACY_HYUNDAI_KIA") { return this.fordLegacyService.fetchVehicleListForHyundaiKia(serviceName); } if (serviceConfig?.architecture === "LEGACY_NISSAN") { return this.fordLegacyService.fetchVehicleListForNissan(serviceName); } if (serviceConfig?.architecture === "LEGACY_OPEL") { return this.fordLegacyService.fetchVehicleListForOpel(serviceName); } if (serviceConfig?.architecture === "LEGACY_VOLVO") { return this.fordLegacyService.fetchVehicleListForVolvo(serviceName); } // Fiat (p5fiat) has a two-level model hierarchy (families → model codes) that the // generic single-endpoint P5 flow below cannot express, and is licensed only on the // de-708171 account. Handle it via a dedicated expander. if (serviceName === "fiatp_parts" || serviceName === "fiatt_parts") { return this.fetchFiatVehicleList(serviceName); } try { // Try de account; fall back to main token (demo mode) if that also fails let headers: Record<string, string>; try { await this.authService.authorizeServiceForAccount(serviceName, "de"); headers = await this.authService.buildAuthHeadersForAccount("de", serviceName); } catch { this.logger.warn(`fetchVehicleList: de auth failed for ${serviceName}, using main token`); headers = await this.authService.buildAuthHeaders(); } const catalogBase = getServiceApiPath(serviceName); // Discovered via Playwright explorer (scripts/pl24-catalog-explorer.js → docs/pl24-catalog/*.md) // Each P5 backend uses a different initial model listing endpoint const BACKEND_MODEL_PATH: Record<string, string> = { p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F) p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki p5man: "/extern/model/categories", // MAN trucks }; // catalogBase is like "/p5vwag" — strip leading slash for map lookup const backendKey = catalogBase.replace(/^\//, ""); const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies"; const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`; const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(15000), }); if (response.ok) { const data = (await response.json()) as Record<string, any>; let vehicles = this.parseVehicleListResponse(data, serviceName); // p5daimler "scope" endpoint returns 1 record whose link.path points to "modeltype" (Smart). // Follow that link to retrieve the actual model type list (C450–C454). if (vehicles.length === 1 && vehicles[0].catalogPath?.includes("modeltype")) { const modeltypePath = vehicles[0].catalogPath; const modeltypeUrl = modeltypePath.startsWith("http") ? modeltypePath : `${this.baseUrl}${modeltypePath}`; try { const modeltypeResp = await fetch(modeltypeUrl, { method: "GET", headers, signal: AbortSignal.timeout(15000), }); if (modeltypeResp.ok) { const modeltypeData = (await modeltypeResp.json()) as Record<string, any>; const modeltypeVehicles = this.parseVehicleListResponse(modeltypeData, serviceName); if (modeltypeVehicles.length > 0) { this.logger.log(`Smart: modeltype returned ${modeltypeVehicles.length} models`); vehicles = modeltypeVehicles; } } else { this.logger.warn(`Smart: modeltype HTTP ${modeltypeResp.status}`); } } catch (err) { this.logger.warn(`Smart: modeltype fetch failed: ${(err as Error).message}`); } } if (vehicles.length > 0) { return vehicles; } } // Fallback: the primary modelPath returned nothing (e.g. p5fiat is not mapped // in BACKEND_MODEL_PATH yet). Try the other known P5 listing endpoints so an // unmapped backend self-discovers its model list instead of silently seeding 0. // Only runs when the primary yields nothing → no impact on mapped backends. const candidatePaths = [ "/extern/vehicle/modelfamilies", "/extern/vehicle/models", "/extern/vehicle/modelFamilies", "/extern/vehicle/catalogs", "/extern/vehicle/scope", "/extern/vehicles/vehiclesOverview", "/extern/model/categories", ].filter((p) => p !== modelPath); for (const candidate of candidatePaths) { try { const cu = `${this.baseUrl}${catalogBase}${candidate}?lang=${this.language}&serviceName=${serviceName}`; const cr = await fetch(cu, { method: "GET", headers, signal: AbortSignal.timeout(15000), }); if (!cr.ok) continue; const cd = (await cr.json()) as Record<string, any>; const cv = this.parseVehicleListResponse(cd, serviceName); if (cv.length > 0) { this.logger.log( `fetchVehicleList: ${serviceName} discovered via ${candidate} (${cv.length} models) — pin BACKEND_MODEL_PATH["${backendKey}"]="${candidate}"`, ); return cv; } } catch { // try next candidate } } this.logger.log(`No vehicle list available for ${serviceName} (HTTP ${response.status})`); return []; } catch (err) { this.logger.warn(`fetchVehicleList failed for ${serviceName}: ${(err as Error).message}`); return []; } } /** * Fiat (p5fiat) browse model list. * * Unlike other P5 backends, Fiat exposes a two-level hierarchy: * modelOverview → model FAMILIES (124 SPIDER, 500L, LINEA, TIPO …) * models?modelFamily=N → the actual model codes (+ year range) * Each model's link.path is the maingroups endpoint consumed by the generic P5 * drill (getCategoryTree → fetchMainGroups → subgroups → bomDetails parts). * * Requires the de-708171 account (Fiat parts are licensed only there) and the * DataImpulse DE proxy — both applied via fetchWithRetry(account="de"). */ private async fetchFiatVehicleList(serviceName: string): Promise< Array<{ vehicleId: string; model: string; year?: string; engine?: string; bodyType?: string; transmission?: string; market?: string; catalogPath?: string; metadata?: Record<string, unknown>; }> > { const account = "de" as const; try { await this.authService.authorizeServiceForAccount(serviceName, account); const headers = await this.authService.buildAuthHeadersForAccount(account, serviceName); const base = getServiceApiPath(serviceName); // "/p5fiat" // 1) Model families const ovUrl = `${this.baseUrl}${base}/extern/vehicle/modelOverview?lang=${this.language}&serviceName=${serviceName}`; const ovResp = await this.fetchWithRetry(ovUrl, headers, serviceName, account); const ovData = (await ovResp.json()) as Record<string, any>; const families: any[] = (ovData.data?.records || ovData.records || []).filter( (f: any) => !f.unavailable, ); if (families.length === 0) { this.logger.warn(`Fiat ${serviceName}: modelOverview returned 0 families`); return []; } // 2) Expand each family → model codes (bounded concurrency to keep the one-time seed fast) type FiatVehicle = { vehicleId: string; model: string; year?: string; catalogPath?: string; metadata?: Record<string, unknown>; }; const out: FiatVehicle[] = []; const CONCURRENCY = 6; for (let i = 0; i < families.length; i += CONCURRENCY) { const batch = families.slice(i, i + CONCURRENCY); const batchResults = await Promise.all( batch.map(async (fam: any): Promise<FiatVehicle[]> => { const famName = String(fam.values?.description || fam.description || fam.id).trim(); const modelsPath = fam.link?.path as string | undefined; const modelsUrl = modelsPath ? modelsPath.startsWith("http") ? modelsPath : `${this.baseUrl}${modelsPath}` : `${this.baseUrl}${base}/extern/vehicle/models?lang=${this.language}&serviceName=${serviceName}&modelFamily=${fam.id}`; try { const mResp = await this.fetchWithRetry(modelsUrl, headers, serviceName, account); const mData = (await mResp.json()) as Record<string, any>; const models: any[] = (mData.data?.records || mData.records || []).filter( (m: any) => !m.unavailable && m.link?.path, ); return models.map((m: any): FiatVehicle => { const modelCode = String(m.values?.modelCode || m.id || ""); const desc = String(m.values?.description || m.description || famName) .replace(/\\-/g, "-") // PL24 escapes hyphens as "\-" .replace(/\s+/g, " ") .trim(); const year = this.formatFiatYear(m.values?.year); return { vehicleId: `${fam.id}:${modelCode}`, model: year ? `${desc} (${year})` : desc, year: year || undefined, catalogPath: m.link.path as string, metadata: { modelFamily: String(fam.id), modelCode, family: famName }, }; }); } catch (err) { this.logger.warn( `Fiat ${serviceName}: models fetch failed for family ${fam.id}: ${(err as Error).message}`, ); return []; } }), ); for (const r of batchResults) out.push(...r); } this.logger.log(`Fiat ${serviceName}: ${families.length} families → ${out.length} models`); return out; } catch (err) { this.logger.warn(`fetchFiatVehicleList failed for ${serviceName}: ${(err as Error).message}`); return []; } } /** Fiat year values arrive as "(2016,2020)" → "2016-2020"; single/open ranges handled. */ private formatFiatYear(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; const trimmed = raw.replace(/[()]/g, "").trim(); if (!trimmed) return undefined; const parts = trimmed .split(",") .map((s) => s.trim()) .filter(Boolean); return parts.length >= 2 ? `${parts[0]}-${parts[parts.length - 1]}` : parts[0] || undefined; } private parseVehicleListResponse( data: Record<string, any>, serviceName: string, ): Array<{ vehicleId: string; model: string; year?: string; engine?: string; bodyType?: string; transmission?: string; market?: string; catalogPath?: string; metadata?: Record<string, unknown>; }> { let records: any[] = []; if (Array.isArray(data)) { records = data; } else if (Array.isArray(data.data?.records)) { records = data.data.records; } else if (Array.isArray(data.vehicles)) { records = data.vehicles; } else if (Array.isArray(data.models)) { records = data.models; } else if (Array.isArray(data.data)) { records = data.data; } return records .filter((r) => r.id || r.vehicleId || r.vid) .map((r) => { const values = r.values || {}; const vehicleId = String(r.id || r.vehicleId || r.vid || ""); // modelfamilies uses values.caption; older formats use values.model / r.description const model = values.caption || values.model || values.description || r.description || r.name || vehicleId; const link = r.link || {}; return { vehicleId, model, year: values.year || values.modelYear || r.year || undefined, engine: values.engine || values.engineCode || undefined, bodyType: values.bodyType || values.body || undefined, transmission: values.transmission || undefined, market: values.market || undefined, catalogPath: link.path || undefined, metadata: r, }; }); } /** * Explore a P5 Modern service catalog — tries various endpoints and returns raw results. * Used by admin endpoint for discovery. */ async exploreP5Service(serviceName: string): Promise<Record<string, any>> { await this.touchActivity(); const results: Record<string, any> = {}; try { await this.authService.authorizeService(serviceName); const headers = await this.authService.buildAuthHeaders(serviceName); const catalogBase = getServiceApiPath(serviceName); const probeEndpoints = [ "extern/vehicles", "extern/vehicleList", "extern/models", "extern/selection/vehicles", "extern/selection/rootNode", "extern/catalogs", "extern/modelSeries", ]; await Promise.all( probeEndpoints.map(async (endpoint) => { const url = `${this.baseUrl}${catalogBase}/${endpoint}?lang=${this.language}&serviceName=${serviceName}`; try { const response = await fetch(url, { method: "GET", headers, signal: AbortSignal.timeout(8000), }); const status = response.status; let body: any = null; if (response.ok) { try { body = await response.json(); } catch { body = await response.text(); } } results[endpoint] = { status, body: body ? JSON.stringify(body).substring(0, 2000) : null, }; } catch (err) { results[endpoint] = { error: (err as Error).message }; } }), ); } catch (err) { results._authError = (err as Error).message; } return results; } private parseJlrIllustrations( response: unknown, ): Array<{ btnr: number; name: string; code: string }> { const responseData = response as Record<string, unknown>; const data = (responseData.data as Record<string, unknown>) || responseData; let records: Array<Record<string, unknown>> = []; if (Array.isArray(data.records)) { records = data.records; } else if (Array.isArray(data)) { records = data as unknown as Array<Record<string, unknown>>; } const availableRecords = records.filter( (record) => !record.unavailable && record.characteristic !== "sectionrow", ); return availableRecords .map((record) => { const values = (record.values as Record<string, string>) || {}; const link = (record.link as Record<string, unknown>) || {}; const linkPath = (link.path as string) || ""; const btnrMatch = linkPath.match(/btnr=(\d+)/); const btnr = btnrMatch ? Number.parseInt(btnrMatch[1], 10) : 0; const name = values.captions || values.caption || values.description || values.name || (record.description as string) || ""; const code = values.illustrationNumber || values.subgroup || values.code || String(record.id || ""); return { btnr, name, code }; }) .filter((illus) => illus.btnr > 0); } }