Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
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>
2479 lines
85 KiB
TypeScript
2479 lines
85 KiB
TypeScript
/**
|
||
* 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 (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);
|
||
}
|
||
}
|