dev #98

Merged
root merged 4 commits from dev into main 2026-06-04 12:45:44 +03:00
18 changed files with 1244 additions and 251 deletions

View File

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

View File

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

View File

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

View File

@@ -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();
}
/**

View File

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

View File

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

View 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">&nbsp;</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 &amp;", () => {
expect(p.absPath("peugeot_parts", "json-vin-illustrations.action?a=1&amp;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");
});
});

View 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 !== "&nbsp;" ? 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(/&quot;/g, '"').replace(/&amp;/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 &amp; 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(/&#0?34;/g, '"')
.replace(/&quot;/g, '"')
.replace(/&#0?39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
}

View File

@@ -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 {}

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

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { PL24_WMI_SERVICE_MAP, SERVICE_TO_BRAND } from "./pl24.types";
// Q6 routing additions (undecoded-vin-rca.md). Both target services are already
// live in prod (nissan_parts: JN1 success; mercedesvans_parts: WDF decodes today),
// so these add WMIs to working routes — no dead-route / circuit-breaker risk.
describe("PL24_WMI_SERVICE_MAP — Q6 routing additions", () => {
it("routes restored Nissan WMIs to nissan_parts (757905f regression)", () => {
expect(PL24_WMI_SERVICE_MAP.SJN).toBe("nissan_parts"); // Nissan UK (Sunderland)
expect(PL24_WMI_SERVICE_MAP.VSK).toBe("nissan_parts"); // Nissan Spain
expect(PL24_WMI_SERVICE_MAP.MNT).toBe("nissan_parts"); // Nissan Thailand
});
it("routes W1V to the (live-proven) mercedesvans_parts service", () => {
expect(PL24_WMI_SERVICE_MAP.W1V).toBe("mercedesvans_parts");
});
it("the target services resolve to the correct brand", () => {
expect(SERVICE_TO_BRAND[PL24_WMI_SERVICE_MAP.SJN]).toBe("Nissan");
expect(SERVICE_TO_BRAND[PL24_WMI_SERVICE_MAP.W1V]).toBe("Mercedes-Benz");
});
it("new routes reuse existing services (no orphan service names)", () => {
for (const wmi of ["SJN", "VSK", "MNT", "W1V"]) {
const svc = PL24_WMI_SERVICE_MAP[wmi];
expect(SERVICE_TO_BRAND[svc]).toBeDefined();
}
});
});

View File

@@ -452,6 +452,7 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
WDF: "mercedesvans_parts",
WD3: "mercedesvans_parts",
WD4: "mercedesvans_parts",
W1V: "mercedesvans_parts", // Mercedes-Benz Vans (Sprinter/Vito) — same P5 path proven live via WDF sibling
// smart
WME: "smart_parts",
@@ -551,6 +552,9 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
JN6: "nissan_parts", // Nissan Japan (pickup/van)
JN8: "nissan_parts", // Nissan Japan (SUV)
VNK: "nissan_parts", // Nissan UK/Europe
SJN: "nissan_parts", // Nissan UK (Sunderland) — re-added; dropped in 757905f refactor
VSK: "nissan_parts", // Nissan Spain (Barcelona)
MNT: "nissan_parts", // Nissan Thailand
// Infiniti
JNK: "infiniti_parts", // Infiniti (Japan/Korea)

View File

@@ -1,3 +1,4 @@
import { useFeatureFlag } from "@/hooks/use-feature-flag";
import { capture } from "@/lib/posthog";
import { Button } from "@sase/ui";
import { Link } from "@tanstack/react-router";
@@ -8,6 +9,21 @@ interface DemoFooterCtaProps {
source: string;
}
// A/B test `demo-cta-copy` (PostHog experiment). `control` = the original
// "sınırsız sorgulama" framing; `benefit` = a B2B value-led hook (find the
// right OEM part for a customer's vehicle in seconds). `undefined` (flag not
// yet loaded / user out of rollout) falls through to control.
const CTA_COPY = {
control: {
headline: "Sınırsız şase sorgulamak için ücretsiz hesap aç",
button: "Hesap Aç",
},
benefit: {
headline: "Müşteri aracının doğru OEM parçasını saniyede bul",
button: "Ücretsiz Dene",
},
} as const;
/**
* Footer conversion card on /demo pages — anti-gimmick B2B trust strip +
* single "Hesap Aç" primary button routed to /register (no VIN carried;
@@ -16,10 +32,14 @@ interface DemoFooterCtaProps {
* the headline; `mb-20` clears the Chatwoot widget in the bottom-right.
*/
export function DemoFooterCta({ source }: DemoFooterCtaProps) {
const flag = useFeatureFlag("demo-cta-copy");
const variant = flag === "benefit" ? "benefit" : "control";
const copy = CTA_COPY[variant];
return (
<div className="mb-20 mt-8 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:mb-0 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold">Sınırsız şase sorgulamak için ücretsiz hesap </p>
<p className="text-sm font-semibold">{copy.headline}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal
</p>
@@ -29,8 +49,11 @@ export function DemoFooterCta({ source }: DemoFooterCtaProps) {
className="w-full shrink-0 sm:w-auto"
data-faro-user-action-name={`demo-footer-cta-${source}`}
>
<Link to="/register" onClick={() => capture("demo_to_register_click", { source })}>
Hesap
<Link
to="/register"
onClick={() => capture("demo_to_register_click", { source, cta_variant: variant })}
>
{copy.button}
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>

View File

@@ -0,0 +1,27 @@
import { subscribeFeatureFlag } from "@/lib/posthog";
import { useEffect, useState } from "react";
/**
* Reactively read a PostHog feature flag in a component.
*
* Returns `undefined` until PostHog has loaded and evaluated flags (PostHog is
* lazy loaded), then the flag value — a `boolean` for simple flags or the
* variant key `string` for multivariant / experiment flags. The value updates
* automatically if flags reload (e.g. after login re-evaluates targeting).
*
* Always design the UI so `undefined` renders the safe/control branch — that's
* what shows during the brief load window and for users PostHog can't reach.
*
* @example
* const variant = useFeatureFlag("demo-cta-copy");
* const enabled = useFeatureFlag("new-billing-flow") === true;
*/
export function useFeatureFlag(key: string): string | boolean | undefined {
const [value, setValue] = useState<string | boolean | undefined>(undefined);
useEffect(() => {
return subscribeFeatureFlag(key, setValue);
}, [key]);
return value;
}

View File

@@ -32,7 +32,13 @@ export function initPostHog(): void {
person_profiles: "always",
capture_pageview: false,
capture_pageleave: false,
// Autocapture stays OFF: it serializes the text/attributes of clicked
// elements, which on the logged-in dashboard would ship customer VINs,
// OEM codes and PII to PostHog. Heatmaps below give us click/scroll/
// rage-click maps from coordinates only — the visual signal without the
// PII leak.
autocapture: false,
enable_heatmaps: true,
session_recording: {
maskAllInputs: false,
maskInputOptions: { password: true },
@@ -78,3 +84,39 @@ export function capturePageView(path: string): void {
export function setPeopleProperties(properties: Record<string, unknown>): void {
load().then((ph) => ph.people?.set(properties));
}
/**
* Subscribe to a feature flag's value, reactively.
*
* Fires `cb` once with the current value as soon as PostHog has loaded its
* flags, then again on every reload (e.g. after `identify()` re-evaluates
* targeting). Returns an unsubscribe function. Because PostHog itself is lazy
* loaded, the first callback is async — until then, treat the value as
* `undefined` (callers should default to the control behaviour).
*
* Works for both boolean flags (`true`/`false`) and multivariant /
* experiment flags (the variant key string, e.g. `"control"` / `"test"`).
* Reading a flag here also emits `$feature_flag_called`, which is what powers
* experiment exposure tracking — so just rendering a variant counts a user in.
*/
export function subscribeFeatureFlag(
key: string,
cb: (value: string | boolean | undefined) => void,
): () => void {
let unsub: (() => void) | undefined;
let cancelled = false;
load().then((ph) => {
if (cancelled) return;
cb(ph.getFeatureFlag(key));
unsub = ph.onFeatureFlags(() => cb(ph.getFeatureFlag(key)));
});
return () => {
cancelled = true;
unsub?.();
};
}
/** Read a feature flag's payload (the JSON attached to the matched variant). */
export function getFeatureFlagPayload(key: string): Promise<unknown> {
return load().then((ph) => ph.getFeatureFlagPayload(key));
}

View File

@@ -16,6 +16,7 @@ import {
validateVinCheckDigit,
extractWmi,
extractModelYear,
getBrandFromWmi,
formatTRY,
kurusToLira,
liraToKurus,
@@ -468,6 +469,27 @@ describe("extractModelYear", () => {
it("handles lowercase input", () => {
expect(extractModelYear("wvwzzz1jzaw597935")).toBe(2010);
});
// VIN yıl kodu 30 yılda bir tekrarlar; gelecekteki imkânsız yıla değil, makul
// en yeni (geçmiş) yıla çözülmeli. Peugeot 106 (X = 1999), 2029 DEĞİL.
it("resolves an ambiguous code to the most recent plausible (past) year, not the future", () => {
expect(extractModelYear("VF31AKFXLXM000752", 2026)).toBe(1999);
});
it("tolerates the next model year being stamped early (+1 window)", () => {
// 'V' = 2027; 2026'da satılan bir 2027 modeli geçerli, 1997'ye düşmemeli.
expect(extractModelYear("WVWZZZ1JZVW597935", 2026)).toBe(2027);
});
it("pulls a code more than one year ahead back into the previous cycle", () => {
// 'W' = 2028; 2026 referansında makul değil → 1998.
expect(extractModelYear("WVWZZZ1JZWW597935", 2026)).toBe(1998);
});
it("advances the resolved year as the reference year moves forward", () => {
// Aynı 'X' kodu, 2030 referansında artık 2029 olarak makul.
expect(extractModelYear("VF31AKFXLXM000752", 2030)).toBe(2029);
});
});
// --------------- utils/currency ---------------
@@ -782,3 +804,33 @@ describe("ERROR_CODES", () => {
expect(ERROR_CODES.CORGI_ERROR).toBe("INT_003");
});
});
describe("getBrandFromWmi — RCA Q5 additions", () => {
// Verified WMI->brand additions (undecoded-vin-rca.md Q5). UX/telemetry only.
const ADDED: Array<[string, string]> = [
["W1V", "Mercedes-Benz"],
["VXF", "Fiat"], // resolved Fiat (not Opel)
["YAR", "Toyota"], // resolved Toyota ProAce (not Opel)
["NL1", "Hyundai"],
["KPA", "SsangYong"],
["PL1", "Proton"],
["LSV", "Volkswagen"],
["LVV", "Chery"],
["ZCF", "Iveco"],
];
it.each(ADDED)("maps %s -> %s", (wmi, brand) => {
expect(getBrandFromWmi(wmi)).toBe(brand);
});
it("is case-insensitive", () => {
expect(getBrandFromWmi("w1v")).toBe("Mercedes-Benz");
});
it("does NOT map likely VIN corruptions (would mislabel typo classes)", () => {
// WF1->WF0(Ford), WAA->WAU(Audi), W0W->WVW(VW) were deliberately skipped.
expect(getBrandFromWmi("WF1")).toBeNull();
expect(getBrandFromWmi("WAA")).toBeNull();
expect(getBrandFromWmi("W0W")).toBeNull();
expect(getBrandFromWmi("VE1")).toBeNull(); // deferred — unresolved
});
});

View File

@@ -182,6 +182,21 @@ export const WMI_BRAND_MAP: Record<string, string> = {
ZAR: "Alfa Romeo", // Alfa Romeo (145/146)
ZLA: "Lancia", // Lancia (Kappa) — CarBrandLogo'da logo yok
WMA: "MAN", // MAN — ticari araç/kamyon
// ── RCA undecoded-VIN audit eklemeleri (2026-06-03) ──
// Çoğu UX/telemetri amaçlı: sert "marka desteklenmiyor" yerine "X olarak
// tanındı" mesajı (decode routing'i DEĞİŞTİRMEZ). WMI'lar authoritative
// registry + prod sibling + VDS parmak izi ile doğrulandı (undecoded-vin-rca.md Q5).
// Bilerek EKLENMEDİ (bozulma): WF1→WF0(Ford), WAA→WAU(Audi), W0W→WVW(VW) — typo sınıfı.
W1V: "Mercedes-Benz", // Mercedes-Benz Vans — Sprinter/Vito (+ PL24 mercedesvans routing)
VXF: "Fiat", // Stellantis France — Fiat LCV (Scudo/Ducato sınıfı)
YAR: "Toyota", // Toyota ProAce ailesi (Stellantis Vigo, Toyota markalı)
NL1: "Hyundai", // Hyundai Assan (HAOS) — Türkiye ticari
KPA: "SsangYong", // SsangYong / KG Mobility — Kore (katalog yok; logo fallback)
PL1: "Proton", // Proton — Malezya (katalog yok)
LSV: "Volkswagen", // SAIC Volkswagen — Çin
LVV: "Chery", // Chery — Çin (CarBrandLogo'da logo yok)
ZCF: "Iveco", // Iveco — İtalya (Daily; ticari)
};
/**
@@ -191,39 +206,62 @@ export function getBrandFromWmi(wmi: string): string | null {
return WMI_BRAND_MAP[wmi.toUpperCase()] ?? null;
}
export function extractModelYear(vin: string): number | null {
const yearChar = vin.toUpperCase()[9];
const yearMap: 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,
};
return yearMap[yearChar] ?? null;
// VIN 10. karakter → model yılı kodu. Değerler 30 yıllık döngünün son penceresi
// (2001-2030) için "çapa" yıllardır; extractModelYear() bunları referans yıla göre
// gerçek yıla çözer. ISO 3779 / SAE: I, O, Q, U, Z ve "0" yıl kodu olarak kullanılmaz.
const VIN_YEAR_CODE_ANCHOR: 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,
};
/**
* VIN'in 10. karakterinden model yılını çıkarır.
*
* VIN yıl kodu 30 yılda bir tekrarlar — örn. "X" hem 1999, hem 2029, hem 1969'a
* karşılık gelir. Bu yüzden kodu tek başına bir yıla eşlemek yanlıştır. Burada
* "makul en yeni" yıla çözeriz: referans yılından (varsayılan: içinde bulunulan
* yıl) en fazla 1 yıl ileride olan en güncel aday. Böylece 1999 model bir araç
* asla "2029" gibi imkânsız bir geleceğe düşmez. (+1 toleransı, üreticilerin bir
* sonraki model yılını takvim yılından önce damgalamasını karşılar.)
*
* NOT: Bu yalnızca decode servisi bir yıl döndürmediğinde başvurulan son-çare
* tahminidir. Servis (PL24/EMEX/NHTSA vb.) bir model yılı veriyorsa HER ZAMAN o
* değer kullanılmalıdır; bu fonksiyon onun yerine geçmez.
*/
export function extractModelYear(vin: string, referenceYear?: number): number | null {
if (!vin || vin.length < 10) return null;
const anchor = VIN_YEAR_CODE_ANCHOR[vin.toUpperCase()[9]];
if (anchor == null) return null;
const cutoff = (referenceYear ?? new Date().getFullYear()) + 1;
let year = anchor;
while (year > cutoff) year -= 30; // imkânsız geleceği bir önceki 30-yıllık döngüye çek
return year;
}

View File

@@ -0,0 +1,88 @@
# PostHog Özellik Yol Haritası — sase.tr
> İlke: **ölçemediğini yönetemezsin.** Bu doküman PostHog'un sase.tr için
> yüksek-kaldıraçlı özelliklerini uygulama fazlarına böler.
> **Kapsam dışı:** #6 Data pipelines / Destinations (CDP) — bilinçli olarak hariç.
## Bağlam (neye göre önceliklendirildi)
- **Aktivasyon ~%3.2**, **retention ~0** → birincil dert teşhis + aktivasyon/churn.
- Funnel: **demo → signup → activation → subscription** (enstrümante).
- Gelir: **Stripe + EFT/havale** karışık (1:1 user-abonelik; org/team entity yok).
- **Meta Ads** aktif → cohort/audience köprüsü değerli.
- Panel **PII-yoğun** (müşteri VIN / OEM kodları) → autocapture kapalı, replay maskeleme borcu var.
---
## Faz 0 — Tamamlandı / uçuşta
Temel ölçüm hijyeni + ilk deney altyapısı.
- ✅ Identity stitching + first-touch attribution fix (anon→signup doğru bağlanıyor).
- ✅ Server-side capture açık (posthog-node); revenue event `subscription_activated` (`$revenue`, Stripe+EFT ortak chokepoint).
- ✅ Empty-catalog spike alert + temel insight/cohort/alert seti.
-**Feature flag altyapısı**: `subscribeFeatureFlag` + `useFeatureFlag` hook (boolean kill-switch + multivariant/deney).
-**Heatmaps açık** (koordinat-bazlı; autocapture kapalı → PII sızmaz).
- 🚀 **İlk A/B** `demo-cta-copy` (deney **82775**): control = "sınırsız sorgulama", benefit = B2B OEM değer kancası. Primary metric: demo→`user_signed_up`. **Prod merge + launch bekliyor.**
---
## Faz 1 — Teşhis *(kod yok; MCP + insight, deploy beklemez)*
**Hedef:** %3.2 aktivasyon ve ~0 retention'ın **kökünü ölçmek**. Sonraki tüm fazların backlog'unu bu besler.
1. **Funnel Correlation Analysis** ⭐ — dönüşeni dönüşmeyenden ayıran davranışı otomatik bulur ("ilk gün 3+ VIN sorgulayan 6x daha çok abone"). → **Kuzey Yıldızı aktivasyon eylemini** tanımla.
2. **Retention + Lifecycle + Stickiness** — yeni/dönen/uykuda kırılımı + "haftada kaç gün" + churn'ün başladığı an.
3. **User Paths** — demo + panelde gerçek gezinme + terk noktaları (heatmap'in "nerede"sini "sonra ne oldu"yla tamamlar).
4. **Web Analytics dashboard** — hazır gelen GA-benzeri panel; sadece aç.
5. **Max AI** — founder için doğal-dil self-serve analitik; aç.
**Guardrail (bu fazda kapat):** Session Replay **PII maskeleme** — replay yoğun kullanılıyor ve müşteri VIN/PII maskesiz kaydediliyor. Daha fazla replay'e dayanmadan önce maskele.
**Çıktı:** "aktivasyon sürücüleri + churn başlangıç noktası" raporu + net Kuzey Yıldızı tanımı.
---
## Faz 2 — Operasyonelleştir *(low-code)*
**Hedef:** teşhisi sürekli, otomatik izlemeye çevir.
7. **Error Tracking**`$exception` zaten akıyor; PostHog issue + alert kur. Sentry ile konsolidasyon/çift-kontrol kararı.
8. **Dashboard Subscriptions** — kilit metrik panelini **haftalık e-posta/Slack digest** olarak otomatik gönder (~2 dk kurulum).
9. **Cohorts (davranışsal)** — "aktive-ama-abone-değil", "churn-riskli", "power user". Faz 3-4'ün hedefleme yapı taşı.
**Başarı:** kilit metrikler haftalık otomatik geliyor; cohort'lar hazır.
---
## Faz 3 — Gelir zekâsı
**Hedef:** davranış ↔ gelir join.
10. **Revenue Analytics + Data Warehouse** — Stripe'ı kaynak olarak bağla (MRR / gross-net churn / expansion hazır gelir); EFT'yi event'le besle; davranışla join'le.
- LTV by onboarding adımı / plan / kanal.
- **İhtiyaç:** kısıtlı (read-only) Stripe key.
**Başarı:** gerçek LTV/CAC görünürlüğü → Meta Ads ROAS buna bağlanır.
---
## Faz 4 — Büyüme döngüsü *(deney + hedefleme)*
**Hedef:** Faz 1 bulgularıyla beslenen sürekli deney akışı.
11. **Cohort → feature flag rollout hedefleme** (kademeli/segmentli açılış).
12. **Cohort → Meta Ads custom audience export** (retargeting; örn. "aktive-ama-abone-değil").
13. **2. & 3. A/B testleri** — pricing CTA, empty-catalog CTA (ikisi de enstrümante).
14. **Survey** — aktivasyon "neyi başaramadın?" mikro-anketi (kalitatif "neden").
**Başarı:** ayda ≥1-2 deney; aktivasyon ve retention'da ölçülü artış.
---
## Parked *(şimdilik düşük uyum — model değişirse yeniden değerlendir)*
- **Group Analytics** — 1:1 user-abonelik modeli (org/team entity yok).
- **LLM Observability** — VIN decode pipeline LLM kullanmıyor.
## Kapsam dışı
- **#6 Data pipelines / Destinations (CDP)** — bu plandan bilinçli olarak çıkarıldı.
---
### İlerleme notu
- **Faz 1 hemen başlanabilir** (MCP/insight; prod merge'inden bağımsız).
- **Faz 0** prod merge + deney launch bekliyor.
- Her faz bir sonrakinin girdisini üretir: teşhis → izleme → gelir → deney döngüsü.