fix(pl24): correct Opel/Vauxhall + Hyundai/Kia VIN decode via thin brand services
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Same shared-core + thin-brand pattern (P4BrandHooks). Both decoded with polluted models (Opel kept
the platform code "…: P10"; Hyundai kept the VIN breadcrumb), wrong VIN-char years, empty transmission.
Fix reads the vin-group info grid + title:
- Opel: model from <title> (segment after the platform code), year from Model yili, transmission from
  Şanzıman kodu, engine from Motor tipi. Verified 6/6 prod (ASTRA-J/2014, CORSA-D/2011, INSIGNIA-A, AGILA-A…).
- Hyundai/Kia: model from <title>, build-year from Üretim tarihi, transmission/body from the
  ENGLISH-labelled grid (plain lowercase — tr-locale would map I→ı and miss "TRANSMISSION").
  Verified (GETZ 02/2004/5 SPEED MT, RIO / STONIC 17/2019…).

Nissan deferred: both prod Nissan VINs are unresolvable upstream (PL24 returns "Model seçimi", no
model) — nothing to parse. Engine/categories/drill unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:49:34 +03:00
parent 836b7c43a6
commit 45953f6dd5
6 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
const svc = new PL24HyundaiKiaService({} as never);
const p = svc as unknown as {
parseHyundaiKiaVinInfo(
html: string,
vin: string,
): {
model?: string;
year?: number;
transmission?: string | null;
bodyType?: string | null;
} | null;
};
describe("PL24HyundaiKiaService.parseHyundaiKiaVinInfo", () => {
const html = `<title>Hyundai KMHBU51HP5U295422: GETZ 02: -OCT.2006 - partslink24</title>
<table><tbody>
<tr><td class="caption">Sasi numarasi</td><td>KMHBU51HP5U295422</td></tr>
<tr><td class="caption">Üretim tarihi</td><td>14.Eyl.2004</td></tr>
<tr><td class="caption">BODY TYPE</td><td>5 DR WAGON</td></tr>
<tr><td class="caption">TRANSMISSION</td><td>5 SPEED MT 2WD</td></tr>
<tr><td class="caption">ENGINE CAPACITY</td><td>1300 CC</td></tr>
<tr><td class="caption">FUEL TYPE</td><td>MPI-SOHC</td></tr>
</tbody></table>`;
it("reads model from title + build-year/transmission from the English grid", () => {
const r = p.parseHyundaiKiaVinInfo(html, "KMHBU51HP5U295422");
expect(r?.model).toBe("GETZ 02");
expect(r?.year).toBe(2004);
expect(r?.transmission).toBe("5 SPEED MT 2WD"); // would be missed by tr-locale lowercase
expect(r?.bodyType).toBe("5 DR WAGON");
});
it("strips ' - partslink24' when the title has no date-range colon (Kia)", () => {
const r = p.parseHyundaiKiaVinInfo(
"<title>Kia KNAD6814BL6353828: RIO / STONIC 17 - partslink24</title>",
"KNAD6814BL6353828",
);
expect(r?.model).toBe("RIO / STONIC 17");
});
});

View File

@@ -0,0 +1,82 @@
/**
* PL24 Hyundai / Kia — thin brand service over the shared P4 engine.
*
* Friendly model lives in the <title> ("Hyundai {VIN}: GETZ 02: -OCT.2006 - partslink24"); the
* generic parser kept the VIN-prefixed breadcrumb ("{VIN}: GET"). The info grid uses English
* labels: Üretim tarihi (build date) / TRANSMISSION / BODY TYPE / ENGINE CAPACITY / FUEL TYPE.
*/
import { Injectable } from "@nestjs/common";
import {
type P4BrandHooks,
type P4VehicleInfo,
PL24FordLegacyService,
} from "./pl24-ford-legacy.service";
import type { PL24DecodedVehicle } from "./pl24.types";
@Injectable()
export class PL24HyundaiKiaService {
constructor(private readonly core: PL24FordLegacyService) {}
private readonly hooks: P4BrandHooks = {
parseVehicleInfo: (html, vin) => this.parseHyundaiKiaVinInfo(html, vin),
};
decodeVinForService(
vin: string,
serviceName: string,
userId?: string,
): Promise<PL24DecodedVehicle | null> {
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
}
private parseHyundaiKiaVinInfo(html: string, vin: string): P4VehicleInfo | null {
// title: "Hyundai {VIN}: GETZ 02: -OCT.2006 - partslink24" → model = segment between the
// VIN colon and the next colon ("GETZ 02").
const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "")
.replace(/&[^;]+;/g, " ")
.replace(/\s*-\s*partslink24\s*$/i, "");
let model: string | null = null;
const afterVin = title.split(new RegExp(`${vin}\\s*:\\s*`, "i"))[1];
if (afterVin) model = afterVin.split(":")[0].replace(/\s+/g, " ").trim() || null;
if (!model) return null; // no model in title → let generic try
// Hyundai/Kia grid uses ENGLISH labels (TRANSMISSION/BODY TYPE/…); plain lowercase only —
// Turkish-locale lowercase would map "I"→dotless "ı" and break the match.
const map: Record<string, string> = {};
for (const m of html.matchAll(
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
)) {
const k = m[1]
.replace(/&[^;]+;/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
const v = m[2]
.replace(/&[^;]+;/g, " ")
.replace(/\s+/g, " ")
.trim();
if (k && v && !(k in map)) map[k] = v;
}
const get = (...keys: string[]): string | null => {
for (const k of keys) {
const v = map[k.toLowerCase()];
if (v) return v;
}
return null;
};
const prod = get("üretim tarihi", "uretim tarihi", "build date");
const year = prod
? Number.parseInt(prod.match(/(19|20)\d{2}/)?.[0] || "", 10) || undefined
: undefined;
const cap = get("engine capacity");
const fuel = get("fuel type");
return {
model,
year,
engineType: [cap, fuel].filter(Boolean).join(" ") || null,
transmission: get("transmission"),
bodyType: get("body type"),
productionDate: prod,
};
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { PL24OpelService } from "./pl24-opel.service";
const svc = new PL24OpelService({} as never);
const p = svc as unknown as {
parseOpelVinInfo(
html: string,
vin: string,
): {
model?: string;
year?: number;
transmission?: string | null;
engineType?: string | null;
} | null;
};
describe("PL24OpelService.parseOpelVinInfo", () => {
const html = `<title>Opel W0LPD5EA6EG003869: P10 - ASTRA-J [2010-2020] - partslink24</title>
<table><tbody>
<tr><td class="caption">Sasi numarasi</td><td>W0LPD5EA6EG003869</td></tr>
<tr><td class="caption">Model yili</td><td>2014 (A)</td></tr>
<tr><td class="caption">Üretim tarihi</td><td>28.Ağu.2013</td></tr>
<tr><td class="caption">Motor tipi</td><td>A13DTE</td></tr>
<tr><td class="caption">Şanzıman kodu</td><td>MEM</td></tr>
<tr><td class="caption">Karoseri</td><td>D69</td></tr>
</tbody></table>`;
it("takes the friendly model from the title and year/transmission from the grid", () => {
const r = p.parseOpelVinInfo(html, "W0LPD5EA6EG003869");
expect(r?.model).toBe("ASTRA-J [2010-2020]");
expect(r?.year).toBe(2014);
expect(r?.transmission).toBe("MEM");
expect(r?.engineType).toBe("A13DTE");
});
it("returns null when the title has only a platform code (no ' - model')", () => {
expect(p.parseOpelVinInfo("<title>Opel W0L: H00 - partslink24</title>", "x")).toBeNull();
});
});

View File

@@ -0,0 +1,77 @@
/**
* PL24 Opel / Vauxhall — thin brand service over the shared P4 engine.
*
* Opel's friendly model name lives in the <title> ("Opel {VIN}: {code} - ASTRA-J [2010-2020] -
* partslink24"); the generic parser split on bare "-" and kept the platform code ("…: P10"). The
* vin-group info grid carries Model yili / Üretim tarihi / Motor tipi / Şanzıman kodu / Karoseri.
*/
import { Injectable } from "@nestjs/common";
import {
type P4BrandHooks,
type P4VehicleInfo,
PL24FordLegacyService,
} from "./pl24-ford-legacy.service";
import type { PL24DecodedVehicle } from "./pl24.types";
@Injectable()
export class PL24OpelService {
constructor(private readonly core: PL24FordLegacyService) {}
private readonly hooks: P4BrandHooks = {
parseVehicleInfo: (html, vin) => this.parseOpelVinInfo(html, vin),
};
decodeVinForService(
vin: string,
serviceName: string,
userId?: string,
): Promise<PL24DecodedVehicle | null> {
return this.core.decodeVinForService(vin, serviceName, userId, this.hooks);
}
private parseOpelVinInfo(html: string, _vin: string): P4VehicleInfo | null {
// model = the title segment after the first " - " (the platform code precedes it)
const title = (html.match(/<title>([^<]*)<\/title>/i)?.[1] || "")
.replace(/&[^;]+;/g, " ")
.replace(/\s*-\s*partslink24\s*$/i, "")
.trim();
const segs = title.split(/\s+-\s+/);
const model = segs.length > 1 ? segs.slice(1).join(" - ").replace(/\s+/g, " ").trim() : null;
if (!model) return null; // only a platform code / no model in title → let generic try
const map: Record<string, string> = {};
for (const m of html.matchAll(
/<td[^>]*class="caption"[^>]*>([^<]*)<\/td>\s*<td[^>]*>([^<]*)<\/td>/g,
)) {
const k = m[1]
.replace(/&[^;]+;/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLocaleLowerCase("tr");
const v = m[2]
.replace(/&[^;]+;/g, " ")
.replace(/\s+/g, " ")
.trim();
if (k && v && !(k in map)) map[k] = v;
}
const get = (...keys: string[]): string | null => {
for (const k of keys) {
const v = map[k.toLocaleLowerCase("tr")];
if (v) return v;
}
return null;
};
const yearRaw = get("model yılı", "model yili", "üretim tarihi", "uretim tarihi");
const year = yearRaw
? Number.parseInt(yearRaw.match(/(19|20)\d{2}/)?.[0] || "", 10) || undefined
: undefined;
return {
model,
year,
engineType: get("motor tipi", "motor kodu"),
transmission: get("şanzıman kodu", "sanzıman kodu", "şanzıman tipi"),
bodyType: get("karoseri"),
productionDate: get("üretim tarihi", "uretim tarihi"),
};
}
}

View File

@@ -2,6 +2,8 @@ import { Module } from "@nestjs/common";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24FordService } from "./pl24-ford.service";
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
import { PL24OpelService } from "./pl24-opel.service";
import { PL24PsaService } from "./pl24-psa.service";
import { PL24VolvoService } from "./pl24-volvo.service";
import { PL24Service } from "./pl24.service";
@@ -13,6 +15,8 @@ const PL24_PROVIDERS = [
PL24PsaService,
PL24VolvoService,
PL24FordService,
PL24OpelService,
PL24HyundaiKiaService,
];
@Module({

View File

@@ -21,6 +21,8 @@ import { StorageService } from "../../storage/storage.service";
import { PL24AuthService } from "./pl24-auth.service";
import { PL24FordLegacyService } from "./pl24-ford-legacy.service";
import { PL24FordService } from "./pl24-ford.service";
import { PL24HyundaiKiaService } from "./pl24-hyundai-kia.service";
import { PL24OpelService } from "./pl24-opel.service";
import { PL24PsaService } from "./pl24-psa.service";
import { PL24VolvoService } from "./pl24-volvo.service";
import { PL24_DEFAULTS } from "./pl24.constants";
@@ -52,6 +54,8 @@ export class PL24Service {
private readonly psaService: PL24PsaService,
private readonly volvoService: PL24VolvoService,
private readonly fordService: PL24FordService,
private readonly opelService: PL24OpelService,
private readonly hyundaiKiaService: PL24HyundaiKiaService,
private configService: ConfigService,
private redis: RedisService,
private storage: StorageService,
@@ -96,6 +100,14 @@ export class PL24Service {
if (getServiceConfig(serviceName)?.architecture === "LEGACY_FORD") {
return this.fordService.decodeVinForService(cleanVin, serviceName, userId);
}
// Opel/Vauxhall: model from <title>, year/transmission/engine from the info grid.
if (getServiceConfig(serviceName)?.architecture === "LEGACY_OPEL") {
return this.opelService.decodeVinForService(cleanVin, serviceName, userId);
}
// Hyundai/Kia: model from <title>, build-year/transmission from the English-labelled grid.
if (getServiceConfig(serviceName)?.architecture === "LEGACY_HYUNDAI_KIA") {
return this.hyundaiKiaService.decodeVinForService(cleanVin, serviceName, userId);
}
if (isLegacyArchitecture(serviceName)) {
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName, userId);
}