Files
sase.tr/apps/api/src/integrations/pl24/pl24.service.ts
semih 56a8ea09be
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
feat(pl24): günlük HTTP bütçesi, kullanıcı rezervi ve proxy_logs telemetrisi
Faz 1 / adım 2 (analiz: /home/s/ss/plv2.md, bulgular consumers_jobs-03/06/07,
deploy_ops-13).

SORUN: İki PL24 hesap banı da günde 5-7 bin kategori yazan (≈10 bin+ istek)
otomatik drill dalgalarının ardından geldi; gerçek kullanıcı decode'u ≤14/gün.
Mevcut korumaların hepsi iş-seviyesinde (iş/dk, iş/gün) — HTTP isteği sayan
hiçbir tavan yoktu. Üstelik PL24 çağrıları `proxy_logs`'a hiç yazılmıyordu
(yalnız pcat/emex), bu yüzden hacim ancak ban'dan SONRA, DB satır sayılarından
geriye dönük çıkarılabildi. Kill switch de yalnız decodeVin'i kapatıyordu; drill,
backfill ve katalog gezinmesi "kapalı" kaynağa istek atmaya devam ediyordu.

DEĞİŞİKLİK:
- Yeni `PL24BudgetService`: tüm PL24 upstream trafiği için tek geçit.
  - Günlük sayaç `pl24:http:<gün>` (Redis, 8 gün TTL); tavan `PL24_HTTP_DAILY_MAX`
    (varsayılan 1200).
  - Kullanıcı rezervi `PL24_HTTP_USER_RESERVE` (varsayılan %40): backfill tavanın
    %60'ında durur, kullanıcı decode'u sonuna kadar akar — kaçak bir backfill
    müşteriyi asla aç bırakamaz. Lane ayrımı mevcut `isBackfillContext()` ile.
  - Reddedilen çağrı ağa hiç çıkmaz ve telemetriye yazılmaz.
  - Redis düşerse fail-open (auth/katalog telemetri yüzünden bloklanmaz).
- Telemetri: `ProxyServiceLeg` += `pl24_http`, `pl24_auth`. Login, authorize ve
  tüm katalog fetch'leri `proxy_logs`'a yazıyor (provider dataimpulse/none,
  sessionKey=hesap, status, süre, 403/429 → banned, taşıma hatası sınıflandırma).
  Artık "hangi gün kaç istek attık, ilk 401/403 ne zaman başladı" sorusu
  ban'dan ÖNCE yanıtlanabilir.
- Kill switch yayıldı: `kill-source-pl24` artık `budgetedFetch` içinde, yani
  decode + drill + backfill + browse + görsel indirme dahil TÜM katalog trafiğini
  kapatıyor (eskiden yalnız decodeVin).
- `fetchWithRetry` 401 çaresi netleşti: yalnız servis token'ı tazelenir; oturumun
  kendisi gerekirse auth katmanında düşürülür.

Test: yeni `pl24-budget.service.spec.ts` (9 test: tavan, kullanıcı rezervi,
reddedilen çağrının ağa çıkmaması, Redis fail-open, telemetri biçimi/leg ayrımı,
ban sinyali, taşıma hatası sınıflandırma). Etkilenen paketler: 197 test geçti.
tsc + biome temiz.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 13:13:05 +03:00

2479 lines
85 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {
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<string>("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<PL24DecodedVehicle | null> {
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<PL24DecodedVehicle>(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 <title>, 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 (C450C454).
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);
}
}