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:
Sase Dev
2026-02-14 22:25:06 +00:00
parent 7b024df4d5
commit 4a06ba5fdb
53 changed files with 17053 additions and 682 deletions

View File

@@ -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"