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

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:
2026-06-04 12:27:04 +03:00
parent 5573c4a401
commit 223c4bc12f
12 changed files with 982 additions and 248 deletions

View File

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