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

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

View File

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