fix(pl24): correct PSA VIN decode via FI/VIN-indexed flow + cycle-correct model year
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
PSA (Peugeot/Citroën/DS) VIN decode was systemically broken: the catalog
vin-group page returns all families unfiltered, so decode fell back to the first
family/salesType (a manual base variant) — yielding "{Brand} {VIN}" model names,
empty transmission, wrong model year, and manual-only parts trees (automatic
gearbox parts missing). Reported for a 1999 Peugeot 106 automatic shown as a 2029
manual with no automatic parts.
- New self-contained PL24PsaService: consumes PL24's FI flow (vin.action →
hintstoken → FI page → json-vin-main-groups → json-vin-illustrations →
vin-image-board). Reads model/year/transmission from the FI identification
table; builds the VIN-indexed parts tree (correct per actual VIN). Does not
touch Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
- Orchestrator + categories.service route PSA VIN decode/drill to the new service.
- Cycle-correct extractModelYear in @sase/shared (X→1999, not 2029): resolve the
30-yr VIN year code to the most-recent plausible year (≤ now+1); dedupe 6 copies.
Validated live against 13 already-decoded PSA VINs: 12/13 full trees with real
model/year/transmission; automatics correctly detected (106 BVA, 206 AL4, 3008 BVA8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,11 @@ function createService(db: any) {
|
||||
};
|
||||
const pl24FordLegacyService = {
|
||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||
fetchPsaParts: vi.fn().mockResolvedValue({ success: true, parts: [] }),
|
||||
};
|
||||
const pl24PsaService = {
|
||||
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
|
||||
fetchVinParts: vi.fn().mockResolvedValue({ success: true, parts: [] }),
|
||||
};
|
||||
const translationsService = {
|
||||
translate: vi
|
||||
@@ -47,6 +52,7 @@ function createService(db: any) {
|
||||
partsCatalogsService as any,
|
||||
storage as any,
|
||||
pl24FordLegacyService as any,
|
||||
pl24PsaService as any,
|
||||
translationsService as any,
|
||||
pcatSourceDb as any,
|
||||
emexSourceDb as any,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "../integrations/pl24/pl24-psa.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
@@ -25,6 +26,7 @@ export class CategoriesService {
|
||||
private partsCatalogsService: PartsCatalogsService,
|
||||
private storage: StorageService,
|
||||
private pl24FordLegacyService: PL24FordLegacyService,
|
||||
private pl24PsaService: PL24PsaService,
|
||||
private translationsService: TranslationsService,
|
||||
private pcatSourceDb: PcatSourceDbService,
|
||||
private emexSourceDb: EmexSourceDbService,
|
||||
@@ -103,10 +105,7 @@ export class CategoriesService {
|
||||
const svcName: string = rawData?.catalogInfo?.serviceName ?? "";
|
||||
if (catPath.startsWith("/psa/") && svcName && vehicle.vin) {
|
||||
try {
|
||||
const scopes = await this.pl24FordLegacyService.fetchCategoriesForPsaVin(
|
||||
svcName,
|
||||
vehicle.vin,
|
||||
);
|
||||
const scopes = await this.pl24PsaService.fetchCategoriesForPsaVin(svcName, vehicle.vin);
|
||||
if (scopes.length > 0) {
|
||||
const insertData = scopes.map((s) => ({
|
||||
vehicleId,
|
||||
@@ -1298,16 +1297,26 @@ export class CategoriesService {
|
||||
const isPsaBoard =
|
||||
category.linkPath.includes("/psa/") &&
|
||||
category.linkPath.includes("image-board.action");
|
||||
// VIN-indexed leaves (vin-image-board.action) → dedicated PSA service.
|
||||
const isPsaVinBoard =
|
||||
category.linkPath.includes("/psa/") &&
|
||||
category.linkPath.includes("vin-image-board.action");
|
||||
if (needImage && isPsaBoard && !pl24Result.schemaImageBuffer) {
|
||||
try {
|
||||
const freshResult = await this.pl24FordLegacyService.fetchPsaParts(
|
||||
category.linkPath,
|
||||
catalogInfo.serviceName,
|
||||
"_all_",
|
||||
"_all_",
|
||||
"_all_",
|
||||
true,
|
||||
);
|
||||
const freshResult = isPsaVinBoard
|
||||
? await this.pl24PsaService.fetchVinParts(
|
||||
category.linkPath,
|
||||
catalogInfo.serviceName,
|
||||
true,
|
||||
)
|
||||
: await this.pl24FordLegacyService.fetchPsaParts(
|
||||
category.linkPath,
|
||||
catalogInfo.serviceName,
|
||||
"_all_",
|
||||
"_all_",
|
||||
"_all_",
|
||||
true,
|
||||
);
|
||||
if (freshResult.schemaImageBuffer) {
|
||||
(pl24Result as any).schemaImageBuffer = freshResult.schemaImageBuffer;
|
||||
(pl24Result as any).schemaImageContentType = freshResult.schemaImageContentType;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { WMI_BRAND_MAP } from "@sase/shared";
|
||||
import { WMI_BRAND_MAP, extractModelYear } from "@sase/shared";
|
||||
|
||||
interface CorgiDecodeResult {
|
||||
brandName: string;
|
||||
@@ -8,39 +8,6 @@ interface CorgiDecodeResult {
|
||||
isKnown: boolean;
|
||||
}
|
||||
|
||||
const YEAR_MAP: Record<string, number> = {
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
D: 2013,
|
||||
E: 2014,
|
||||
F: 2015,
|
||||
G: 2016,
|
||||
H: 2017,
|
||||
J: 2018,
|
||||
K: 2019,
|
||||
L: 2020,
|
||||
M: 2021,
|
||||
N: 2022,
|
||||
P: 2023,
|
||||
R: 2024,
|
||||
S: 2025,
|
||||
T: 2026,
|
||||
V: 2027,
|
||||
W: 2028,
|
||||
X: 2029,
|
||||
Y: 2030,
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CorgiService {
|
||||
private readonly logger = new Logger(CorgiService.name);
|
||||
@@ -66,8 +33,7 @@ export class CorgiService {
|
||||
}
|
||||
|
||||
private extractYear(vin: string): number | null {
|
||||
const yearChar = vin[9];
|
||||
return YEAR_MAP[yearChar] ?? null;
|
||||
return extractModelYear(vin);
|
||||
}
|
||||
|
||||
getBrandFromWmi(wmi: string): string | null {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* persisting.
|
||||
*/
|
||||
|
||||
import { extractModelYear } from "@sase/shared";
|
||||
import {
|
||||
CATALOG_MAP,
|
||||
type DecodedCategory,
|
||||
@@ -148,40 +149,8 @@ export function mapEmexResponse(response: EmexScraperResponse): DecodedVehicle {
|
||||
* Extracts year from VIN (10th character)
|
||||
*/
|
||||
function extractYearFromVin(vin: string): number {
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
D: 2013,
|
||||
E: 2014,
|
||||
F: 2015,
|
||||
G: 2016,
|
||||
H: 2017,
|
||||
J: 2018,
|
||||
K: 2019,
|
||||
L: 2020,
|
||||
M: 2021,
|
||||
N: 2022,
|
||||
P: 2023,
|
||||
R: 2024,
|
||||
S: 2025,
|
||||
T: 2026,
|
||||
V: 2027,
|
||||
W: 2028,
|
||||
X: 2029,
|
||||
Y: 2030,
|
||||
};
|
||||
return yearMap[yearChar] || new Date().getFullYear();
|
||||
// Servis yıl vermediğinde son çare; bilinmeyen kod için içinde bulunulan yıl.
|
||||
return extractModelYear(vin) ?? new Date().getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1012,49 +1012,4 @@ export class EmexService {
|
||||
}
|
||||
return { width: 0, height: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts year from VIN (10th character)
|
||||
*/
|
||||
getYearFromVin(vin: string): number | null {
|
||||
if (!vin || vin.length < 10) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
D: 2013,
|
||||
E: 2014,
|
||||
F: 2015,
|
||||
G: 2016,
|
||||
H: 2017,
|
||||
J: 2018,
|
||||
K: 2019,
|
||||
L: 2020,
|
||||
M: 2021,
|
||||
N: 2022,
|
||||
P: 2023,
|
||||
R: 2024,
|
||||
S: 2025,
|
||||
T: 2026,
|
||||
V: 2027,
|
||||
W: 2028,
|
||||
X: 2029,
|
||||
Y: 2030,
|
||||
};
|
||||
|
||||
return yearMap[yearChar] || null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { extractModelYear } from "@sase/shared";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
@@ -4038,9 +4039,9 @@ export class PL24FordLegacyService {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: year from VIN position 10
|
||||
// Fallback: year from VIN position 10 (servis yıl vermediğinde son çare)
|
||||
if (!year) {
|
||||
year = this.getYearFromVin(vin);
|
||||
year = extractModelYear(vin) ?? 0;
|
||||
}
|
||||
|
||||
// If we still have nothing, at least return basic info
|
||||
@@ -4288,45 +4289,4 @@ export class PL24FordLegacyService {
|
||||
const match = text.match(/^(\d+)\s/);
|
||||
return match?.[1] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get year from VIN position 10.
|
||||
*/
|
||||
private getYearFromVin(vin: string): number {
|
||||
if (!vin || vin.length < 10) return 0;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
D: 2013,
|
||||
E: 2014,
|
||||
F: 2015,
|
||||
G: 2016,
|
||||
H: 2017,
|
||||
J: 2018,
|
||||
K: 2019,
|
||||
L: 2020,
|
||||
M: 2021,
|
||||
N: 2022,
|
||||
P: 2023,
|
||||
R: 2024,
|
||||
S: 2025,
|
||||
T: 2026,
|
||||
V: 2027,
|
||||
W: 2028,
|
||||
X: 2029,
|
||||
Y: 2030,
|
||||
};
|
||||
return yearMap[yearChar] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
128
apps/api/src/integrations/pl24/pl24-psa.service.spec.ts
Normal file
128
apps/api/src/integrations/pl24/pl24-psa.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
|
||||
// Pure-parser unit tests. The HTTP/FI flow is validated separately against live PL24;
|
||||
// these lock the parsing of real PL24 response shapes (captured during discovery).
|
||||
const config = { get: (_k: string, d?: unknown) => d } as never;
|
||||
const stub = {} as never;
|
||||
const svc = new PL24PsaService(config, config, stub);
|
||||
|
||||
// Typed view onto the private parsers we exercise directly (no `any`).
|
||||
type Parsers = {
|
||||
parseFiYear(raw: string | null): number | null;
|
||||
parseFiIdentification(
|
||||
html: string,
|
||||
vin: string,
|
||||
): {
|
||||
model: string | null;
|
||||
yearRaw: string | null;
|
||||
year: number | null;
|
||||
transmission: string | null;
|
||||
engine: string | null;
|
||||
bodyType: string | null;
|
||||
dam: string | null;
|
||||
};
|
||||
parseVinScopes(
|
||||
html: string,
|
||||
svc: string,
|
||||
): Array<{ code: string; nameEn: string; linkPath: string }>;
|
||||
parsePsaBomParts(html: string): Array<{
|
||||
oemCode: string;
|
||||
name: string;
|
||||
positionCode?: string;
|
||||
hotspotId?: string;
|
||||
remark?: string;
|
||||
}>;
|
||||
absPath(svc: string, url: string): string;
|
||||
};
|
||||
const p = svc as unknown as Parsers;
|
||||
|
||||
describe("PL24PsaService.parseFiYear", () => {
|
||||
it("reads a 4-digit year", () => expect(p.parseFiYear("AM 2005")).toBe(2005));
|
||||
it("expands 2-digit past year", () => expect(p.parseFiYear("AM 99")).toBe(1999));
|
||||
it("expands 2-digit recent year", () => expect(p.parseFiYear("AM 96")).toBe(1996));
|
||||
it("ignores PL24 index labels (MAJÖR ENDEKS)", () =>
|
||||
expect(p.parseFiYear('""01"" MAJÖR ENDEKS')).toBeNull());
|
||||
it("ignores BÜYÜK … ENDEKSİ index labels", () =>
|
||||
expect(p.parseFiYear("BÜYÜK 07 ENDEKSİ")).toBeNull());
|
||||
it("returns null for empty/missing", () => expect(p.parseFiYear(null)).toBeNull());
|
||||
});
|
||||
|
||||
describe("PL24PsaService.parseFiIdentification", () => {
|
||||
// Real FI identification-table shape from vin-group.action?…&openVinDialog=true (Peugeot 106).
|
||||
const fiHtml = `
|
||||
<title>Peugeot VF31AKFXLXM000752 - 106 RESTYL - partslink24</title>
|
||||
<table>
|
||||
<tr><td class="caption">Sasi numarasi</td><td>VF31AKFXLXM000752</td></tr>
|
||||
<tr><td class="caption">Model</td><td>106 RESTYL</td></tr>
|
||||
<tr><td class="caption">DAM</td><td>08074 CA</td></tr>
|
||||
<tr><td class="caption">MOTOR</td><td>TU3JP ENJEKSİYONU</td></tr>
|
||||
<tr><td class="caption">AKTARMA SİSTEMLERİ</td><td>3 OTOMATİK VİTES KUTUSU</td></tr>
|
||||
<tr><td class="caption">MODEL YILI</td><td>AM 99</td></tr>
|
||||
<tr><td class="caption">GÖVDE TİPİ</td><td>5 KAPILI HATCHBACK</td></tr>
|
||||
</table>`;
|
||||
|
||||
it("extracts model, transmission, engine, body, dam from the caption table", () => {
|
||||
const id = p.parseFiIdentification(fiHtml, "VF31AKFXLXM000752");
|
||||
expect(id.model).toBe("106 RESTYL");
|
||||
expect(id.transmission).toBe("3 OTOMATİK VİTES KUTUSU");
|
||||
expect(id.engine).toBe("TU3JP ENJEKSİYONU");
|
||||
expect(id.bodyType).toBe("5 KAPILI HATCHBACK");
|
||||
expect(id.dam).toBe("08074 CA");
|
||||
expect(id.year).toBe(1999);
|
||||
});
|
||||
|
||||
it("falls back to extractModelYear when MODEL YILI is an index label", () => {
|
||||
const html = fiHtml.replace("AM 99", '""01"" MAJÖR ENDEKS');
|
||||
// VF31AKFXLXM000752 position-10 'X' → 1999 (cycle-aware shared helper)
|
||||
expect(p.parseFiIdentification(html, "VF31AKFXLXM000752").year).toBe(1999);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24PsaService.parseVinScopes", () => {
|
||||
// Real scope-row shape: jsonUrl → json-vin-main-groups.action?scope=_FCTxxxx&vin=…
|
||||
const html = `
|
||||
<tr id="r0" jsonUrl="json-vin-main-groups.action?lang=tr&scope=_FCT0001&vin=VF31AKFXLXM000752&mode=A0LW0TRTR&upds=2024.02.13+09%3A27%3A21+CET"><td>mekanik</td></tr>
|
||||
<tr id="r1" jsonUrl="json-vin-main-groups.action?lang=tr&scope=_FCT0100&vin=VF31AKFXLXM000752&mode=A0LW0TRTR&upds=2024.02.13+09%3A27%3A21+CET"><td>kaporta</td></tr>`;
|
||||
|
||||
it("parses scopes with absolute /psa/{svc}/ linkPaths", () => {
|
||||
const scopes = p.parseVinScopes(html, "peugeot_parts");
|
||||
expect(scopes).toHaveLength(2);
|
||||
expect(scopes[0].code).toBe("_FCT0001");
|
||||
expect(scopes[0].nameEn).toBe("mekanik");
|
||||
expect(scopes[0].linkPath).toBe(
|
||||
"/psa/peugeot_parts/json-vin-main-groups.action?lang=tr&scope=_FCT0001&vin=VF31AKFXLXM000752&mode=A0LW0TRTR&upds=2024.02.13+09%3A27%3A21+CET",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24PsaService.parsePsaBomParts", () => {
|
||||
// Real image-board BOM row shape (partno + posno + formatted/name/comment cells).
|
||||
const html = `<table>
|
||||
<tr class="tc-data-row" hotspot="1" partno="00000135JA" posno="01">
|
||||
<td class="portnoFormatted">135 JA</td>
|
||||
<td class="partName">SU POMPASI</td>
|
||||
<td class="commentHtml"> </td>
|
||||
</tr></table>`;
|
||||
|
||||
it("parses a part row (oem, name, position, hotspot)", () => {
|
||||
const parts = p.parsePsaBomParts(html);
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].oemCode).toBe("135 JA");
|
||||
expect(parts[0].name).toBe("SU POMPASI");
|
||||
expect(parts[0].positionCode).toBe("01");
|
||||
expect(parts[0].hotspotId).toBe("1");
|
||||
expect(parts[0].remark).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PL24PsaService.absPath", () => {
|
||||
it("prepends /psa/{svc}/ and decodes &", () => {
|
||||
expect(p.absPath("peugeot_parts", "json-vin-illustrations.action?a=1&b=2")).toBe(
|
||||
"/psa/peugeot_parts/json-vin-illustrations.action?a=1&b=2",
|
||||
);
|
||||
});
|
||||
it("leaves absolute paths untouched", () => {
|
||||
expect(p.absPath("peugeot_parts", "/psa/x/y.action")).toBe("/psa/x/y.action");
|
||||
});
|
||||
});
|
||||
701
apps/api/src/integrations/pl24/pl24-psa.service.ts
Normal file
701
apps/api/src/integrations/pl24/pl24-psa.service.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* PL24 PSA (Peugeot / Citroën / DS) VIN-indexed decode service.
|
||||
*
|
||||
* Why this is separate from PL24FordLegacyService:
|
||||
* PSA catalogs identify a VIN through PL24's "FI" (Fahrzeug-Identifikation) flow,
|
||||
* which is fundamentally different from the family/salesType catalog browsing the
|
||||
* other P4-legacy brands use. The correct flow (reverse-engineered from live PL24
|
||||
* responses) is:
|
||||
*
|
||||
* 1. pl24-entry.action → JSESSIONID + mode + upds (initPsaSession)
|
||||
* 2. vin.action?vin=… → 302 with hintstoken + redirect to
|
||||
* vin-group.action?…&vin=…&hintstoken=…&openVinDialog=true (the FI page)
|
||||
* 3. FI page contains:
|
||||
* - the identification table (Model / MODEL YILI / AKTARMA SİSTEMLERİ /
|
||||
* MOTOR / GÖVDE TİPİ / DAM …) → real model, year, transmission
|
||||
* - scope rows → json-vin-main-groups.action?scope=_FCT0001…_FCT0500&vin=…
|
||||
* 4. drill: json-vin-main-groups → json-vin-illustrations → vin-image-board
|
||||
* (each carries vin+scope+mainGroup+mode+upds; works in any fresh session).
|
||||
*
|
||||
* The previous implementation (in pl24-ford-legacy.service.ts) called
|
||||
* vin-group.action?vin=… directly — which returns the FULL unfiltered family list,
|
||||
* so it fell back to families[0]/salesType[0] (a MANUAL base variant). That is why
|
||||
* every PSA vehicle decoded as "Peugeot {VIN}", empty transmission, wrong year, and
|
||||
* a manual-only parts tree (automatic gearbox parts missing). This service consumes
|
||||
* the FI result instead, so the parts tree matches the actual VIN.
|
||||
*
|
||||
* Self-contained on purpose: it does not import PL24FordLegacyService, so changes
|
||||
* here cannot affect Ford/Volvo/Nissan/Opel/Hyundai-Kia/Fiat.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { extractModelYear } from "@sase/shared";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
type PL24DecodedVehicle,
|
||||
type PL24MainGroup,
|
||||
type PL24Part,
|
||||
type PL24PartsResponse,
|
||||
SERVICE_TO_BRAND,
|
||||
} from "./pl24.types";
|
||||
|
||||
interface PsaSession {
|
||||
jsessionId: string;
|
||||
mode: string;
|
||||
upds: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PL24PsaService {
|
||||
private readonly logger = new Logger(PL24PsaService.name);
|
||||
private readonly baseUrl: string;
|
||||
private readonly timeout = 30000;
|
||||
private readonly language = "tr";
|
||||
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly redis: RedisService,
|
||||
) {
|
||||
this.baseUrl = this.configService.get<string>("pl24.baseUrl", "https://www.partslink24.com");
|
||||
}
|
||||
|
||||
// ==================== PUBLIC: VIN decode ====================
|
||||
|
||||
/**
|
||||
* Decode a PSA VIN via the FI flow and return the vehicle with its scope categories.
|
||||
* Signature mirrors PL24FordLegacyService.decodeVinForService so the orchestrator
|
||||
* can route LEGACY_PSA services here with a one-line change.
|
||||
*/
|
||||
async decodeVinForService(
|
||||
vin: string,
|
||||
serviceName: string,
|
||||
_userId?: string,
|
||||
): Promise<PL24DecodedVehicle | null> {
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle:${vin}`;
|
||||
const cached = await this.redis.getJson<PL24DecodedVehicle>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const fi = await this.runFi(serviceName, vin);
|
||||
if (!fi) return null;
|
||||
|
||||
const vehicle: PL24DecodedVehicle = {
|
||||
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace(/_parts$/, ""),
|
||||
model: fi.ident.model || SERVICE_TO_BRAND[serviceName] || "",
|
||||
year: fi.ident.year ?? extractModelYear(vin) ?? 0,
|
||||
series: null,
|
||||
bodyType: fi.ident.bodyType,
|
||||
engineCode: fi.ident.engineCode,
|
||||
engineType: fi.ident.engine,
|
||||
engineVolume: null,
|
||||
transmission: fi.ident.transmission,
|
||||
driveType: null,
|
||||
colorCode: null,
|
||||
productionDate: null,
|
||||
raw: { fiResolved: true, dam: fi.ident.dam, modelYearRaw: fi.ident.yearRaw },
|
||||
catalogInfo: {
|
||||
serviceName,
|
||||
vehicleId: vin,
|
||||
catalogPath: `/psa/${serviceName}`,
|
||||
baseUrl: this.baseUrl,
|
||||
psaMode: fi.session.mode,
|
||||
psaUpds: fi.session.upds,
|
||||
},
|
||||
categories: fi.scopes,
|
||||
};
|
||||
|
||||
await this.redis.setJson(cacheKey, vehicle, 86400);
|
||||
this.logger.log(
|
||||
`PSA FI: decoded VIN ${vin} (${serviceName}) — ${vehicle.brand} ${vehicle.model} ` +
|
||||
`${vehicle.year} [${vehicle.transmission ?? "?"}], ${fi.scopes.length} scopes`,
|
||||
);
|
||||
return vehicle;
|
||||
} catch (error) {
|
||||
this.logger.error(`PSA FI decode error (${serviceName}): ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetch only the scope categories for an already-decoded PSA VIN.
|
||||
* Called by CategoriesService when a PSA VIN-decoded vehicle has no stored categories.
|
||||
*/
|
||||
async fetchCategoriesForPsaVin(serviceName: string, vin: string): Promise<PL24DecodedCategory[]> {
|
||||
const fi = await this.runFi(serviceName, vin);
|
||||
if (!fi) return [];
|
||||
this.logger.log(`PSA FI: ${fi.scopes.length} scopes for ${serviceName} VIN ${vin}`);
|
||||
return fi.scopes;
|
||||
}
|
||||
|
||||
// ==================== PUBLIC: lazy drill (orchestrator dispatch targets) ====================
|
||||
|
||||
/**
|
||||
* Scope → main groups. linkPath: /psa/{svc}/json-vin-main-groups.action?scope=…&vin=…
|
||||
*/
|
||||
async fetchVinMainGroups(linkPath: string): Promise<PL24MainGroup[]> {
|
||||
return this.drillJsonLevel(linkPath, "maingroups", (item, svc) => {
|
||||
if (item.subheader || !item.jsonUrl || !item.path) return null;
|
||||
return {
|
||||
id: item.path,
|
||||
code: item.path,
|
||||
name: item.name,
|
||||
linkPath: this.absPath(svc, item.jsonUrl),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main group → illustrations. linkPath: /psa/{svc}/json-vin-illustrations.action?mainGroup=…&vin=…
|
||||
*/
|
||||
async fetchVinIllustrations(linkPath: string): Promise<PL24MainGroup[]> {
|
||||
const seen = new Set<string>();
|
||||
return this.drillJsonLevel(linkPath, "illus", (item, svc) => {
|
||||
if (item.subheader || !item.url) return null;
|
||||
const key = item.path || item.illCodeTec || item.name;
|
||||
if (seen.has(key)) return null;
|
||||
seen.add(key);
|
||||
return {
|
||||
id: item.illCodeTec || item.path || item.name,
|
||||
code: item.illCodeTec || item.path || item.name,
|
||||
name: item.name,
|
||||
linkPath: this.absPath(svc, item.url),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Illustration → parts + schema image. linkPath: /psa/{svc}/vin-image-board.action?illCode=…&vin=…
|
||||
* Same BOM/image pipeline as the family/salesType PSA flow (PL24 reuses image-board.action).
|
||||
*/
|
||||
async fetchVinParts(
|
||||
linkPath: string,
|
||||
serviceName: string,
|
||||
bypassCache = false,
|
||||
): Promise<PL24PartsResponse> {
|
||||
const svc = this.svcFromPath(linkPath) || serviceName;
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:vinparts:${pathHash}`;
|
||||
if (!bypassCache) {
|
||||
const hit = await this.redis.getJson<PL24PartsResponse>(cacheKey);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
const session = await this.initPsaSession(svc);
|
||||
if (!session) return { success: false, groupId: pathHash, groupName: "", parts: [] };
|
||||
|
||||
const url = `${this.baseUrl}${this.refreshSessionParams(linkPath, session)}`;
|
||||
const html = await this.fetchPsaPage(url, svc, session.jsessionId);
|
||||
if (!html) return { success: false, groupId: pathHash, groupName: "", parts: [] };
|
||||
|
||||
const groupName = this.extractPageTitle(html);
|
||||
const parts = this.parsePsaBomParts(html);
|
||||
|
||||
let schemaImageUrl: string | undefined;
|
||||
let schemaImageBuffer: Buffer | undefined;
|
||||
let schemaImageContentType: string | undefined;
|
||||
let schemaWidth: number | undefined;
|
||||
let schemaHeight: number | undefined;
|
||||
let schemaHotspots: PL24PartsResponse["hotspots"];
|
||||
|
||||
const ticketUrl = this.extractPsaImageTicketUrl(html);
|
||||
if (ticketUrl) {
|
||||
try {
|
||||
const ticketRaw = await this.fetchPsaPage(
|
||||
`${this.baseUrl}${ticketUrl}`,
|
||||
svc,
|
||||
session.jsessionId,
|
||||
true,
|
||||
);
|
||||
if (ticketRaw) {
|
||||
const ticketData = JSON.parse(ticketRaw) as {
|
||||
pathparams?: { ticket?: string; url?: string };
|
||||
};
|
||||
if (ticketData.pathparams?.url) {
|
||||
const ticket = ticketData.pathparams.ticket || "";
|
||||
const imgPath = ticketData.pathparams.url;
|
||||
const fullImgUrl = ticket
|
||||
? `${this.baseUrl}${imgPath}${imgPath.includes("?") ? "&" : "?"}ticket=${ticket}`
|
||||
: `${this.baseUrl}${imgPath}`;
|
||||
schemaImageUrl = fullImgUrl;
|
||||
const img = await this.fetchPsaImageData(fullImgUrl, svc, session.jsessionId);
|
||||
if (img) {
|
||||
schemaImageBuffer = img.buffer;
|
||||
schemaImageContentType = img.contentType;
|
||||
schemaWidth = img.width;
|
||||
schemaHeight = img.height;
|
||||
schemaHotspots = img.hotspots;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`PSA VIN image pipeline error: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result: PL24PartsResponse = {
|
||||
success: true,
|
||||
groupId: pathHash,
|
||||
groupName,
|
||||
schemaImageUrl,
|
||||
schemaImageBuffer,
|
||||
schemaImageContentType,
|
||||
schemaWidth,
|
||||
schemaHeight,
|
||||
hotspots: schemaHotspots,
|
||||
parts,
|
||||
};
|
||||
if (parts.length > 0) {
|
||||
const { schemaImageBuffer: _buf, ...cacheable } = result;
|
||||
await this.redis.setJson(cacheKey, cacheable, 3600);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: FI flow ====================
|
||||
|
||||
/**
|
||||
* Run the FI flow: session → vin.action (hintstoken) → FI page → parse identification + scopes.
|
||||
*/
|
||||
private async runFi(
|
||||
serviceName: string,
|
||||
vin: string,
|
||||
): Promise<{
|
||||
session: PsaSession;
|
||||
ident: ReturnType<PL24PsaService["parseFiIdentification"]>;
|
||||
scopes: PL24DecodedCategory[];
|
||||
} | null> {
|
||||
const session = await this.initPsaSession(serviceName);
|
||||
if (!session) return null;
|
||||
const { jsessionId, mode, upds } = session;
|
||||
|
||||
// Step 1: vin.action → 302 with hintstoken in the redirect target (the FI page URL).
|
||||
const vinUrl =
|
||||
`${this.baseUrl}/psa/${serviceName}/vin.action` +
|
||||
`?lang=${this.language}&mode=${mode}&upds=${upds}&vin=${encodeURIComponent(vin)}`;
|
||||
const redirect = await this.psaRequest(vinUrl, serviceName, jsessionId, { manual: true });
|
||||
if (!redirect.location) {
|
||||
this.logger.warn(`PSA FI: vin.action returned no redirect for ${vin} (${serviceName})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: follow to the FI page (vin-group.action?…&hintstoken=…&openVinDialog=true).
|
||||
const fiUrl = redirect.location.startsWith("http")
|
||||
? redirect.location
|
||||
: `${this.baseUrl}${redirect.location}`;
|
||||
const html = await this.fetchPsaPage(fiUrl, serviceName, jsessionId);
|
||||
if (!html) return null;
|
||||
|
||||
const support = this.extractScriptVariable<{ demo?: boolean; role?: string }>(
|
||||
html,
|
||||
"PL24_SUPPORT",
|
||||
);
|
||||
if (support?.demo || support?.role === "NOT_LOGGED_IN_DEMO") {
|
||||
this.logger.warn(`PSA FI: demo mode for ${serviceName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const ident = this.parseFiIdentification(html, vin);
|
||||
const scopes = this.parseVinScopes(html, serviceName);
|
||||
if (scopes.length === 0) {
|
||||
this.logger.warn(`PSA FI: no scopes parsed for ${vin} (${serviceName})`);
|
||||
}
|
||||
return { session, ident, scopes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the FI identification table (<td class="caption">label</td><td>value</td> rows).
|
||||
* Captions are Turkish (lang=tr); English fallbacks kept for robustness.
|
||||
*/
|
||||
private parseFiIdentification(html: string, vin: string) {
|
||||
const rows: Record<string, string> = {};
|
||||
for (const m of html.matchAll(
|
||||
/<td class="caption">([^<]*)<\/td>\s*<td[^>]*>([\s\S]*?)<\/td>/g,
|
||||
)) {
|
||||
const key = this.decodeEntities(m[1]).trim().toLocaleUpperCase("tr");
|
||||
const val = this.decodeEntities(m[2].replace(/<[^>]+>/g, "")).trim();
|
||||
if (key && val && !(key in rows)) rows[key] = val;
|
||||
}
|
||||
const get = (...labels: string[]): string | null => {
|
||||
for (const l of labels) {
|
||||
const v = rows[l.toLocaleUpperCase("tr")];
|
||||
if (v) return v;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const model =
|
||||
get("MODEL") ||
|
||||
(html.match(/<title>[^<]*?-\s*([^<]+?)\s*-\s*partslink24/i)?.[1]?.trim() ?? null);
|
||||
const yearRaw = get("MODEL YILI", "MODEL YEAR");
|
||||
return {
|
||||
model,
|
||||
yearRaw,
|
||||
year: this.parseFiYear(yearRaw) ?? extractModelYear(vin),
|
||||
transmission: get("AKTARMA SİSTEMLERİ", "TRANSMISSION", "AKTARMA SISTEMLERI"),
|
||||
engine: get("MOTOR", "ENGINE"),
|
||||
engineCode: null as string | null,
|
||||
bodyType: get("GÖVDE TİPİ", "SILHOUETTE", "BODY", "GOVDE TIPI"),
|
||||
dam: get("DAM"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "AM 99" → 1999, "AM 2005" → 2005, "AM 96" → 1996, null/no digits → null.
|
||||
* "01 MAJÖR ENDEKS" / "BÜYÜK 07 ENDEKSİ" are PL24 *index* labels, not calendar years —
|
||||
* a bare 2-digit there must NOT be read as a year; we return null so the caller falls
|
||||
* back to extractModelYear(vin). A real 4-digit year is always trusted.
|
||||
*/
|
||||
private parseFiYear(raw: string | null): number | null {
|
||||
if (!raw) return null;
|
||||
const four = raw.match(/\b(19|20)\d{2}\b/);
|
||||
if (four) return Number.parseInt(four[0], 10);
|
||||
if (/endeks|index/i.test(raw)) return null;
|
||||
const two = raw.match(/\b(\d{2})\b/);
|
||||
if (two) {
|
||||
const yy = Number.parseInt(two[1], 10);
|
||||
const cutoff = (new Date().getFullYear() % 100) + 1;
|
||||
return yy <= cutoff ? 2000 + yy : 1900 + yy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse scope rows from the FI page. Each row's jsonUrl points at
|
||||
* json-vin-main-groups.action?scope=_FCTxxxx&vin=… — the VIN-indexed category tree.
|
||||
*/
|
||||
private parseVinScopes(html: string, serviceName: string): PL24DecodedCategory[] {
|
||||
const scopes: PL24DecodedCategory[] = [];
|
||||
const re =
|
||||
/<tr[^>]*jsonUrl="(json-vin-main-groups\.action[^"]*scope=([^&"]+)[^"]*)"[^>]*>([\s\S]*?)<\/tr>/g;
|
||||
for (const m of html.matchAll(re)) {
|
||||
const jsonUrl = this.decodeEntities(m[1]);
|
||||
const scopeId = decodeURIComponent(m[2]);
|
||||
const name =
|
||||
m[3]
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim() || scopeId;
|
||||
scopes.push({
|
||||
code: scopeId,
|
||||
nameEn: name,
|
||||
nameTr: name,
|
||||
description: null,
|
||||
iconUrl: null,
|
||||
subGroups: [],
|
||||
linkPath: this.absPath(serviceName, jsonUrl),
|
||||
});
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: generic JSON drill ====================
|
||||
|
||||
/**
|
||||
* Fetch a json-vin-* level, refresh stale mode/upds against a fresh session, map items.
|
||||
*/
|
||||
private async drillJsonLevel(
|
||||
linkPath: string,
|
||||
cacheTag: string,
|
||||
map: (
|
||||
item: {
|
||||
name: string;
|
||||
path?: string;
|
||||
jsonUrl?: string;
|
||||
url?: string | null;
|
||||
illCodeTec?: string;
|
||||
subheader?: boolean;
|
||||
},
|
||||
svc: string,
|
||||
) => PL24MainGroup | null,
|
||||
): Promise<PL24MainGroup[]> {
|
||||
const svc = this.svcFromPath(linkPath);
|
||||
if (!svc) {
|
||||
this.logger.warn(`PSA VIN drill: cannot resolve service from ${linkPath}`);
|
||||
return [];
|
||||
}
|
||||
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
|
||||
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}psa:vin${cacheTag}:${pathHash}`;
|
||||
const cached = await this.redis.getJson<PL24MainGroup[]>(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const session = await this.initPsaSession(svc);
|
||||
if (!session) return [];
|
||||
const url = `${this.baseUrl}${this.refreshSessionParams(linkPath, session)}`;
|
||||
const raw = await this.fetchPsaPage(url, svc, session.jsessionId, true);
|
||||
if (!raw) return [];
|
||||
|
||||
let items: Parameters<typeof map>[0][] = [];
|
||||
try {
|
||||
items = (JSON.parse(raw) as { items?: typeof items }).items || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const groups = items.map((it) => map(it, svc)).filter((g): g is PL24MainGroup => g !== null);
|
||||
if (groups.length > 0) await this.redis.setJson(cacheKey, groups, 7200);
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: session + HTTP ====================
|
||||
|
||||
/**
|
||||
* entry.action → startup=true (302) → 302 with mode+upds. Returns JSESSIONID/mode/upds.
|
||||
*/
|
||||
private async initPsaSession(serviceName: string): Promise<PsaSession | null> {
|
||||
await this.authService.authorizeService(serviceName);
|
||||
const headers = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
try {
|
||||
const entryRes = await fetch(`${this.baseUrl}/psa/pl24-entry.action?service=${serviceName}`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const jsessionId = entryRes.headers.get("set-cookie")?.match(/JSESSIONID=([^;]+)/)?.[1] || "";
|
||||
const loc1 = entryRes.headers.get("location");
|
||||
if (!loc1) return null;
|
||||
const hdrs2 = jsessionId
|
||||
? { ...headers, Cookie: `${headers.Cookie}; JSESSIONID=${jsessionId}` }
|
||||
: headers;
|
||||
const startupRes = await fetch(loc1.startsWith("http") ? loc1 : `${this.baseUrl}${loc1}`, {
|
||||
method: "GET",
|
||||
headers: hdrs2,
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const loc2 = startupRes.headers.get("location") || loc1;
|
||||
const mode = loc2.match(/[?&]mode=([^&]+)/)?.[1] || "";
|
||||
// Keep upds URL-encoded exactly as returned (e.g. "2024.02.13+09%3A27%3A21+CET");
|
||||
// re-encoding the '+' / '%3A' breaks PL24's server-side session lookup.
|
||||
const upds = loc2.match(/[?&]upds=([^&]+)/)?.[1] ?? "";
|
||||
if (!mode) return null;
|
||||
return { jsessionId, mode, upds };
|
||||
} catch (error) {
|
||||
this.logger.error(`PSA session init error: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Low-level PSA fetch with JSESSIONID; optionally manual-redirect to read Location. */
|
||||
private async psaRequest(
|
||||
url: string,
|
||||
serviceName: string,
|
||||
jsessionId: string,
|
||||
opts: { json?: boolean; manual?: boolean } = {},
|
||||
): Promise<{ status: number; text: string | null; location: string | null }> {
|
||||
const base = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const headers = {
|
||||
...base,
|
||||
Cookie: jsessionId ? `${base.Cookie}; JSESSIONID=${jsessionId}` : base.Cookie,
|
||||
Accept: opts.json ? "application/json,*/*" : "text/html,application/xhtml+xml,*/*;q=0.9",
|
||||
};
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: opts.manual ? "manual" : "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
const location = res.headers.get("location");
|
||||
if (opts.manual && res.status >= 300 && res.status < 400) {
|
||||
return { status: res.status, text: null, location };
|
||||
}
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`PSA: HTTP ${res.status} for ${url.substring(0, 120)}`);
|
||||
return { status: res.status, text: null, location };
|
||||
}
|
||||
return { status: res.status, text: await res.text(), location };
|
||||
} catch (error) {
|
||||
this.logger.error(`PSA fetch error: ${(error as Error).message}`);
|
||||
return { status: 0, text: null, location: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPsaPage(
|
||||
url: string,
|
||||
serviceName: string,
|
||||
jsessionId: string,
|
||||
json = false,
|
||||
): Promise<string | null> {
|
||||
return (await this.psaRequest(url, serviceName, jsessionId, { json })).text;
|
||||
}
|
||||
|
||||
/** Two-step PL24 ImageViewer download (GetImageInfo → GetImage) + hotspot conversion. */
|
||||
private async fetchPsaImageData(
|
||||
imageUrl: string,
|
||||
serviceName: string,
|
||||
jsessionId: string,
|
||||
): Promise<{
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
hotspots?: PL24PartsResponse["hotspots"];
|
||||
} | null> {
|
||||
const base = await this.authService.buildFordLegacyHeaders(serviceName);
|
||||
const headers: Record<string, string> = {
|
||||
...base,
|
||||
Cookie: jsessionId ? `${base.Cookie}; JSESSIONID=${jsessionId}` : base.Cookie,
|
||||
Accept: "application/json,image/*,*/*;q=0.9",
|
||||
Referer: `${this.baseUrl}/psa/${serviceName}/vin-image-board.action`,
|
||||
};
|
||||
try {
|
||||
const infoRes = await fetch(`${imageUrl}&request=GetImageInfo&cv=1`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!infoRes.ok) return null;
|
||||
const info = (await infoRes.json()) as {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
Error?: string;
|
||||
hotspots?: Array<{
|
||||
hsKey: string;
|
||||
hsPartNo: string;
|
||||
hsX: number;
|
||||
hsY: number;
|
||||
hsWidth: number;
|
||||
hsHeight: number;
|
||||
}>;
|
||||
};
|
||||
if (info.Error) return null;
|
||||
const { imageWidth: w, imageHeight: h } = info;
|
||||
const rnd = Math.floor(Math.random() * 100000);
|
||||
const getImgUrl =
|
||||
`${imageUrl}&request=GetImage&format=image%2Fpng` +
|
||||
`&bbox=${encodeURIComponent(`0,0,${w},${h}`)}&width=${w}&height=${h}&scalefac=1.0&cv=1&rnd=${rnd}`;
|
||||
const imgRes = await fetch(getImgUrl, {
|
||||
method: "GET",
|
||||
headers: { ...headers, Accept: "image/png,image/*,*/*;q=0.9" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!imgRes.ok) return null;
|
||||
const contentType = imgRes.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||
const hotspots = info.hotspots?.map((hs) => ({
|
||||
key: hs.hsPartNo,
|
||||
areas: [
|
||||
{ left: hs.hsX / w, top: hs.hsY / h, width: hs.hsWidth / w, height: hs.hsHeight / h },
|
||||
],
|
||||
}));
|
||||
return { buffer, contentType, width: w, height: h, hotspots };
|
||||
} catch (error) {
|
||||
this.logger.warn(`PSA image download error: ${(error as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: pure parsers (self-contained) ====================
|
||||
|
||||
/** Parse the BOM table in *image-board.action HTML (rows carry partno="…"). */
|
||||
private parsePsaBomParts(html: string): PL24Part[] {
|
||||
const parts: PL24Part[] = [];
|
||||
for (const segment of html.split(/<tr\s/)) {
|
||||
if (!segment.includes("tc-data-row") || !segment.includes('partno="')) continue;
|
||||
const rawPartno = segment.match(/\bpartno="([^"]+)"/)?.[1];
|
||||
if (!rawPartno) continue;
|
||||
const posno = segment.match(/\bposno="([^"]*)"/)?.[1] || "";
|
||||
const hotspot = segment.match(/\bhotspot="([^"]*)"/)?.[1] || "";
|
||||
const formatted = segment
|
||||
.match(/class="portnoFormatted[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
|
||||
?.replace(/<[^>]+>/g, "")
|
||||
.trim();
|
||||
const oemCode = formatted || rawPartno.replace(/^0+/, "");
|
||||
const name =
|
||||
segment
|
||||
.match(/class="partName[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
|
||||
?.replace(/<[^>]+>/g, "")
|
||||
.trim() || "";
|
||||
const remarkRaw =
|
||||
segment
|
||||
.match(/class="commentHtml[^"]*"[^>]*>([\s\S]*?)<\/td>/)?.[1]
|
||||
?.replace(/<[^>]+>/g, "")
|
||||
.trim() || "";
|
||||
if (!oemCode) continue;
|
||||
parts.push({
|
||||
id: rawPartno,
|
||||
oemCode,
|
||||
name,
|
||||
remark: remarkRaw && remarkRaw !== " " ? remarkRaw : undefined,
|
||||
positionCode: posno || undefined,
|
||||
hotspotId: hotspot || undefined,
|
||||
unavailable: false,
|
||||
});
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Extract the json-image-ticket.action URL from image-board jsinitparams. */
|
||||
private extractPsaImageTicketUrl(html: string): string | null {
|
||||
const m = html.match(/id="jsinitparams"[^>]+data-params="([^"]+)"/);
|
||||
if (!m) return null;
|
||||
try {
|
||||
const dataParams = JSON.parse(m[1].replace(/"/g, '"').replace(/&/g, "&"));
|
||||
return (
|
||||
(dataParams.imageViewerParamsUrl as string) ||
|
||||
(dataParams.jsIlluData?.[0]?.imageViewerParamsUrl as string) ||
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractPageTitle(html: string): string {
|
||||
return (
|
||||
html
|
||||
.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]
|
||||
?.replace(/<[^>]+>/g, "")
|
||||
.trim() || ""
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal JS-variable extractor (used only for the PL24_SUPPORT demo check). */
|
||||
private extractScriptVariable<T = unknown>(html: string, varName: string): T | null {
|
||||
const m = html.match(
|
||||
new RegExp(`(?:window\\.|var\\s+)${varName}\\s*=\\s*({[\\s\\S]*?});`, "m"),
|
||||
);
|
||||
if (!m?.[1]) return null;
|
||||
try {
|
||||
return JSON.parse(m[1]) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: helpers ====================
|
||||
|
||||
private svcFromPath(linkPath: string): string | null {
|
||||
return linkPath.match(/\/psa\/([^/]+)\//)?.[1] || null;
|
||||
}
|
||||
|
||||
/** Prepend /psa/{svc}/ to a relative PL24 action URL, decoding & entities. */
|
||||
private absPath(svc: string, url: string): string {
|
||||
const clean = this.decodeEntities(url);
|
||||
return clean.startsWith("/") ? clean : `/psa/${svc}/${clean}`;
|
||||
}
|
||||
|
||||
/** Replace stale mode/upds in a stored linkPath with the active session's values. */
|
||||
private refreshSessionParams(linkPath: string, session: PsaSession): string {
|
||||
return linkPath
|
||||
.replace(/([?&]mode=)[^&]+/, `$1${session.mode}`)
|
||||
.replace(/([?&]upds=)[^&]+/, `$1${session.upds}`);
|
||||
}
|
||||
|
||||
private decodeEntities(s: string): string {
|
||||
return s
|
||||
.replace(/�?34;/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24Service } from "./pl24.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService],
|
||||
providers: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
exports: [PL24Service, PL24AuthService, PL24FordLegacyService, PL24PsaService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { extractModelYear } from "@sase/shared";
|
||||
import { isBackfillContext } from "../../jobs/prefetch-context";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { StorageService } from "../../storage/storage.service";
|
||||
import { PL24AuthService } from "./pl24-auth.service";
|
||||
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
|
||||
import { PL24PsaService } from "./pl24-psa.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import {
|
||||
type PL24DecodedCategory,
|
||||
@@ -45,6 +47,7 @@ export class PL24Service {
|
||||
constructor(
|
||||
private readonly authService: PL24AuthService,
|
||||
private readonly fordLegacyService: PL24FordLegacyService,
|
||||
private readonly psaService: PL24PsaService,
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
private storage: StorageService,
|
||||
@@ -77,6 +80,10 @@ export class PL24Service {
|
||||
|
||||
// Dispatch P4 legacy architectures to the generic legacy service
|
||||
if (!isP5Modern(serviceName)) {
|
||||
// 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);
|
||||
}
|
||||
if (isLegacyArchitecture(serviceName)) {
|
||||
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
|
||||
}
|
||||
@@ -245,7 +252,12 @@ export class PL24Service {
|
||||
if (this.isP4LegacyPath(linkPath)) {
|
||||
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName, userId);
|
||||
}
|
||||
// PSA image-board dispatch
|
||||
// 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);
|
||||
}
|
||||
@@ -389,6 +401,15 @@ export class PL24Service {
|
||||
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);
|
||||
@@ -941,44 +962,6 @@ export class PL24Service {
|
||||
return PL24_WMI_SERVICE_MAP[wmi] || null;
|
||||
}
|
||||
|
||||
private getYearFromVin(vin: string): number | null {
|
||||
if (!vin || vin.length < 10) return null;
|
||||
const yearChar = vin.charAt(9).toUpperCase();
|
||||
const yearMap: Record<string, number> = {
|
||||
"1": 2001,
|
||||
"2": 2002,
|
||||
"3": 2003,
|
||||
"4": 2004,
|
||||
"5": 2005,
|
||||
"6": 2006,
|
||||
"7": 2007,
|
||||
"8": 2008,
|
||||
"9": 2009,
|
||||
A: 2010,
|
||||
B: 2011,
|
||||
C: 2012,
|
||||
D: 2013,
|
||||
E: 2014,
|
||||
F: 2015,
|
||||
G: 2016,
|
||||
H: 2017,
|
||||
J: 2018,
|
||||
K: 2019,
|
||||
L: 2020,
|
||||
M: 2021,
|
||||
N: 2022,
|
||||
P: 2023,
|
||||
R: 2024,
|
||||
S: 2025,
|
||||
T: 2026,
|
||||
V: 2027,
|
||||
W: 2028,
|
||||
X: 2029,
|
||||
Y: 2030,
|
||||
};
|
||||
return yearMap[yearChar] || null;
|
||||
}
|
||||
|
||||
// ==================== PRIVATE: Response parsers ====================
|
||||
|
||||
/**
|
||||
@@ -1057,8 +1040,7 @@ export class PL24Service {
|
||||
return {
|
||||
brand: SERVICE_TO_BRAND[serviceName] || serviceName.replace("_parts", ""),
|
||||
model: lookup("model")?.trim() || (data.description as string)?.split(" - ")[0]?.trim() || "",
|
||||
year:
|
||||
Number.parseInt(lookup("model_yili", "year") || "", 10) || this.getYearFromVin(vin) || 0,
|
||||
year: Number.parseInt(lookup("model_yili", "year") || "", 10) || extractModelYear(vin) || 0,
|
||||
series: lookup("satis_tipi", "sales_type"),
|
||||
bodyType,
|
||||
engineCode: engineCode || (engineDesc ? engineDesc.split("/")[0]?.trim() : null),
|
||||
@@ -1531,6 +1513,19 @@ export class PL24Service {
|
||||
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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user