feat: Ford legacy support, EMEX browser pooling, collapsible sidebar
- Add PL24 Ford legacy service for fordt_parts architecture - Refactor EMEX to use persistent browser pool instead of per-call instances - Make vehicle decode resilient: fallback to PL24 when Corgi doesn't recognize VIN - Add collapsible sidebar with persistent user preference - Improve brand access guard and categories service - Add debug/test scripts for VIN e2e testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
@@ -41,6 +42,7 @@ export class PL24Service {
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -74,8 +76,11 @@ export class PL24Service {
|
||||
);
|
||||
}
|
||||
|
||||
// Only P5 Modern is supported for now
|
||||
// Dispatch legacy architectures to their dedicated services
|
||||
if (!isP5Modern(serviceName)) {
|
||||
if (serviceName === "fordt_parts") {
|
||||
return this.fordLegacyService.decodeVin(cleanVin);
|
||||
}
|
||||
this.logger.warn(
|
||||
`Legacy architecture not supported yet: ${serviceName}`,
|
||||
);
|
||||
@@ -251,6 +256,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24PartsResponse> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -368,6 +378,11 @@ export class PL24Service {
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
// Ford legacy dispatch
|
||||
if (this.isFordLegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
|
||||
}
|
||||
|
||||
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
|
||||
|
||||
try {
|
||||
@@ -530,7 +545,8 @@ export class PL24Service {
|
||||
isSupported(vin: string): boolean {
|
||||
if (!vin || vin.length < 3) return false;
|
||||
const serviceName = this.getServiceName(vin);
|
||||
return !!serviceName && isP5Modern(serviceName);
|
||||
if (!serviceName) return false;
|
||||
return isP5Modern(serviceName) || serviceName === "fordt_parts";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -540,13 +556,22 @@ export class PL24Service {
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Request helpers ====================
|
||||
|
||||
/**
|
||||
@@ -861,6 +886,7 @@ export class PL24Service {
|
||||
const responseData = response as Record<string, unknown>;
|
||||
|
||||
let records: Array<Record<string, unknown>> = [];
|
||||
let bomBasePath: string | undefined;
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
records = response;
|
||||
@@ -871,6 +897,11 @@ export class PL24Service {
|
||||
} 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) {
|
||||
@@ -879,9 +910,11 @@ export class PL24Service {
|
||||
|
||||
const availableRecords = records.filter((record) => {
|
||||
if (record.unavailable) return false;
|
||||
// Filter out illustration headers that have no navigable link
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
if (!link.path) return false;
|
||||
// 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;
|
||||
});
|
||||
|
||||
@@ -924,6 +957,12 @@ export class PL24Service {
|
||||
|
||||
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,
|
||||
@@ -931,8 +970,8 @@ export class PL24Service {
|
||||
description: values.modelDescriptions || undefined,
|
||||
imageUrl: undefined,
|
||||
partCount: undefined,
|
||||
linkPath: (link.path as string) || undefined,
|
||||
linkWid: (link.wid as string) || undefined,
|
||||
linkPath: constructedPath,
|
||||
linkWid: (link.wid as string) || (bomBasePath ? "servicePartsItemsTable" : undefined),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1081,7 +1120,7 @@ export class PL24Service {
|
||||
if (standardMatch) return standardMatch[1];
|
||||
|
||||
const tiffMatch = imageUrl.match(
|
||||
/\/tiffimages\/[^/]+\/[^/]+\/([a-zA-Z0-9]+)\.\w+/,
|
||||
/\/tiffimages\/(?:[^/]+\/)+([a-zA-Z0-9_-]+)\.\w+/,
|
||||
);
|
||||
if (tiffMatch) return tiffMatch[1];
|
||||
|
||||
@@ -1094,21 +1133,35 @@ export class PL24Service {
|
||||
// ==================== PRIVATE: Brand-specific flows ====================
|
||||
|
||||
/**
|
||||
* Convert partinfo links to bom links.
|
||||
* 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;
|
||||
|
||||
const bomPath = linkPath.replace("/partinfo/", "/bom/");
|
||||
// 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, but keep illustration info for correct BOM context
|
||||
// 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 isFordLegacyPath(linkPath: string): boolean {
|
||||
return linkPath.includes("/ford/") && linkPath.includes(".action");
|
||||
}
|
||||
|
||||
private isDaimlerService(serviceName: string): boolean {
|
||||
return (
|
||||
serviceName.startsWith("mercedes") || serviceName === "smart_parts"
|
||||
|
||||
Reference in New Issue
Block a user