diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 3bdb642..c567c36 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -19,6 +19,7 @@ import { userBrands, userSubscriptions, } from "../database/schema/core"; +import { isPl24LeafNode } from "../integrations/pl24/pl24-tree"; import { PL24Service } from "../integrations/pl24/pl24.service"; import { type PL24DecodedCategory, @@ -1249,18 +1250,10 @@ export class CatalogService { return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds); } + /** Shared PL24 leaf classifier — see integrations/pl24/pl24-tree. */ private isLeafPath(linkPath: string): boolean { - const lower = linkPath.toLowerCase(); return ( - lower.includes("/bom/") || - lower.includes("/bomdetails") || - lower.includes("/partinfo/") || - // PL24 P5 leaf items endpoints — chemicals, servicepart, accessories, - // any /extern//(vin|mdl)_items combination. These return parts, - // not subgroups, so they must short-circuit drill-down. - /\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) || - lower.includes("image-board.action") || // PSA illustration leaf - lower.includes("json-vin-bom-detail.action") + isPl24LeafNode({ linkPath }) || linkPath.toLowerCase().includes("json-vin-bom-detail.action") ); } @@ -1367,13 +1360,9 @@ export class CatalogService { return cats.map((c) => { const dbChildCount = childCountMap.get(c.id) || 0; - const isLeaf = - c.linkPath?.includes("/bom/") || - c.linkPath?.includes("/bomdetails") || - c.linkPath?.includes("/partinfo/") || - c.linkPath?.includes("/servicepart/vin_items") || - c.linkPath?.includes("image-board.action") || // PSA illustration leaf - (!c.linkPath && dbChildCount === 0); + const isLeaf = c.linkPath + ? isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid }) + : dbChildCount === 0; return { ...c, schemaImageUrl: picMap.get(c.id) || null, diff --git a/apps/api/src/categories/categories.controller.ts b/apps/api/src/categories/categories.controller.ts index 3d02bd1..81f7ee4 100644 --- a/apps/api/src/categories/categories.controller.ts +++ b/apps/api/src/categories/categories.controller.ts @@ -13,10 +13,7 @@ export class CategoriesController { } @Get("tree/:vehicleId") - async getCategoryTree( - @Param("vehicleId") vehicleId: string, - @Query("source") source?: string, - ) { + async getCategoryTree(@Param("vehicleId") vehicleId: string, @Query("source") source?: string) { return this.categoriesService.getCategoryTree(vehicleId, source); } diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index ca3c71a..d2c199c 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -21,6 +21,7 @@ import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catal 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 { isPl24GroupNode, isPl24LeafNode } from "../integrations/pl24/pl24-tree"; import { PL24Service } from "../integrations/pl24/pl24.service"; import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon"; import { RedisService } from "../redis/redis.service"; @@ -896,17 +897,13 @@ export class CategoriesService { // subgroups. Drilling into them used to insert per-part endpoints as // ghost child categories — keep the regex wide so any /extern/{kind}/ // (vin|mdl)_items endpoint is recognised, not just /servicepart/. - const lp = linkPath.toLowerCase(); + // One shared classifier (integrations/pl24/pl24-tree) — the inline lists here, + // in catalog.service and in the prefetch worker used to disagree, which is how + // p5psa/p5volvo camelCase `bomDetails` leaves and `illusTable` group levels + // ended up on the wrong side (silent empty panels / unfetched parts). if ( - lp.includes("/bom/") || - lp.includes("/bomdetails") || - lp.includes("/partinfo/") || - /\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) || - // PSA / Hyundai / Opel / Volvo image-board pages and the Ford VIN - // vin-image-board.action equivalent. Drilling into them yields BOM rows, - // not sub-groups — let getCategoryWithParts handle those as parts. - lp.includes("image-board.action") || - lp.includes("json-vin-bom-detail.action") + isPl24LeafNode({ linkPath }) || + linkPath.toLowerCase().includes("json-vin-bom-detail.action") ) { return []; } @@ -1316,9 +1313,13 @@ export class CategoriesService { // AND lowercase (groupReferenceTable, groupTable, groupsTable). The old // case-sensitive includes("Group") missed the lowercase ones (~1157 nodes), // so they skipped this group-drill branch and fell to the parts path. + // A PL24 node is a parent when the shared classifier says it is not a parts + // leaf. The old `linkWid.includes("group")` test missed p5psa `illusTable` + // and p5volvo/p5subaru `illustrationsTable`, so those levels were fetched as + // parts, parsed to zero rows and rendered as an empty panel with no error. if ( category.source === "pl24" && - category.linkWid?.toLowerCase().includes("group") && + isPl24GroupNode({ linkPath: category.linkPath, linkWid: category.linkWid }) && category.vehicleId ) { const groupChildren = await this.getChildren(categoryId); @@ -2173,15 +2174,8 @@ export class CategoriesService { return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic })() : (() => { - const lp = c.linkPath?.toLowerCase() ?? ""; - return ( - lp.includes("/bom/") || - lp.includes("/bomdetails") || - lp.includes("/partinfo/") || - /\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lp) || - lp.includes("image-board.action") || - (!c.linkPath && dbChildCount === 0) - ); + if (!c.linkPath) return dbChildCount === 0; + return isPl24LeafNode({ linkPath: c.linkPath, linkWid: c.linkWid }); })(); return { ...c, diff --git a/apps/api/src/integrations/pl24/pl24-tree.spec.ts b/apps/api/src/integrations/pl24/pl24-tree.spec.ts new file mode 100644 index 0000000..0e09a7b --- /dev/null +++ b/apps/api/src/integrations/pl24/pl24-tree.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { isPl24GroupNode, isPl24LeafNode } from "./pl24-tree"; + +// Canlı P5 yanıtlarından (2026-09-16 keşfi, plv2-artefakt/) alınan gerçek +// wid + path çiftleri. Bu dosya "0 parça" sınıfı hatanın regresyon kilidi. + +describe("isPl24LeafNode — canlı P5 şekilleri", () => { + it("VW (p5vwag): maingroups/subgroups grup, bom/vin yaprak", () => { + expect( + isPl24GroupNode({ + linkWid: "mainGroupsTable", + linkPath: "/p5vwag/extern/groups/vin_maingroups?vin=X", + }), + ).toBe(true); + expect( + isPl24GroupNode({ + linkWid: "subGroupsIllusTable", + linkPath: "/p5vwag/extern/groups/vin_subgroups_illus?maingroup=4", + }), + ).toBe(true); + expect( + isPl24LeafNode({ + linkWid: "bomlist", + linkPath: "/p5vwag/extern/bom/vin?illustration=407-000", + }), + ).toBe(true); + }); + + it("PSA (p5psa): scope + mainGroups + illusTable grup, bomDetails yaprak", () => { + expect( + isPl24GroupNode({ + linkWid: "scopeTable", + linkPath: "/p5psa/extern/group/vin/scope?modelCode=1PD2", + }), + ).toBe(true); + expect( + isPl24GroupNode({ + linkWid: "mainGroupTable", + linkPath: "/p5psa/extern/group/vin/mainGroups?scope=_FCT0001", + }), + ).toBe(true); + // Eski `includes("group")` kuralının kaçırdığı seviye — sessiz boş panelin kaynağı. + expect( + isPl24GroupNode({ + linkWid: "illusTable", + linkPath: "/p5psa/extern/group/vin/illus?mainGroup=_FCT0001_FCT0512", + }), + ).toBe(true); + // camelCase bomDetails — eski `includes("/bomdetails")` kaçırıyordu. + expect( + isPl24LeafNode({ + linkWid: "bomlist", + linkPath: "/p5psa/extern/details/vin/bomDetails?illustration=D2F001A48A", + }), + ).toBe(true); + }); + + it("Volvo (p5volvo): illustrationsTable grup, bom yaprak", () => { + expect( + isPl24GroupNode({ + linkWid: "illustrationsTable", + linkPath: "/p5volvo/extern/groups/vin/illustration?group=0b00c8af80205942", + }), + ).toBe(true); + expect(isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5volvo/extern/bom/vin?..." })).toBe( + true, + ); + expect( + isPl24LeafNode({ + linkWid: "partinfo", + linkPath: "/p5volvo/extern/partinfo/vin?partno=36050493", + }), + ).toBe(true); + }); + + it("Subaru (p5subaru): subgroups/illustrations grup, bom/vin?figNum yaprak", () => { + expect( + isPl24GroupNode({ + linkWid: "illustrationsTable", + linkPath: "/p5subaru/extern/groups/vin/illustrations?subGroup=001", + }), + ).toBe(true); + expect( + isPl24LeafNode({ linkWid: "bomlist", linkPath: "/p5subaru/extern/bom/vin?figNum=01" }), + ).toBe(true); + }); + + it("wid yoksa yol kalıbına düşer (eski DB satırları)", () => { + expect(isPl24LeafNode({ linkPath: "/p5vwag/extern/bom/vin?illustration=1" })).toBe(true); + expect( + isPl24LeafNode({ linkPath: "/psa/peugeot_parts/vin-image-board.action?illCode=1" }), + ).toBe(true); + expect( + isPl24GroupNode({ + linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001", + }), + ).toBe(true); + }); + + it("hasSubgroups=true yol tahminini ezer", () => { + expect(isPl24LeafNode({ linkPath: "/p5x/extern/unknown", hasSubgroups: true })).toBe(false); + }); + + it("servicepart öğe listesi yapraktır", () => { + expect( + isPl24LeafNode({ + linkWid: "servicePartsItemsTable", + linkPath: "/p5vwag/extern/servicepart/vin_items?x=1", + }), + ).toBe(true); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24-tree.ts b/apps/api/src/integrations/pl24/pl24-tree.ts new file mode 100644 index 0000000..f4e9bbe --- /dev/null +++ b/apps/api/src/integrations/pl24/pl24-tree.ts @@ -0,0 +1,65 @@ +/** + * One source of truth for "is this PL24 node a parts leaf or a group to drill?". + * + * WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01): + * the same question was answered by four independent heuristics — + * `categories.service` (twice), `catalog.service` and the prefetch worker — and + * they disagreed. Two failure modes shipped repeatedly: + * + * 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second + * level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` — + * none contain "group", so those nodes fell through to the parts fetcher, + * which asked an *illustration list* for parts, got records with no partno, + * and rendered a silent empty panel (`parts: []`, no error). This is the same + * class of bug as the 2026-06 `isPsaParent` incident. + * 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the + * endpoint `/details/vin/bomDetails` — so those leaves were queued as groups, + * `getChildren` returned [] and their parts were never prefetched (live today + * for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves). + * + * The reliable cross-brand marker is the response's own `link.wid`: `bomlist` + * (and the service-parts/partinfo variants) means parts, anything else means + * drill. Path matching stays as a fallback for stored rows without a wid. + */ + +/** `link.wid` values that identify a parts (BOM) node across every P5 backend. */ +const LEAF_WIDS = new Set(["bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo"]); + +/** `link.wid` values that identify a drillable group node. */ +const GROUP_WID_PATTERN = + /(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i; + +/** + * Path fragments that only ever appear on a parts endpoint. `image-board` has no + * leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`. + */ +const LEAF_PATH_PATTERN = + /\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i; + +/** True when this node yields parts (never children). */ +export function isPl24LeafNode(opts: { + linkPath?: string | null; + linkWid?: string | null; + hasSubgroups?: boolean | null; +}): boolean { + const wid = opts.linkWid?.toLowerCase().trim(); + if (wid) { + if (LEAF_WIDS.has(wid)) return true; + if (GROUP_WID_PATTERN.test(wid)) return false; + } + // Explicit DB hint wins over path guessing when there is no usable wid. + if (opts.hasSubgroups === true) return false; + const lp = opts.linkPath?.toLowerCase() ?? ""; + if (!lp) return false; + return LEAF_PATH_PATTERN.test(lp); +} + +/** True when this node should be drilled for children. */ +export function isPl24GroupNode(opts: { + linkPath?: string | null; + linkWid?: string | null; + hasSubgroups?: boolean | null; +}): boolean { + if (!opts.linkPath && !opts.linkWid) return false; + return !isPl24LeafNode(opts); +} diff --git a/apps/api/src/integrations/pl24/pl24.service.ts b/apps/api/src/integrations/pl24/pl24.service.ts index 8914af9..24e4f06 100644 --- a/apps/api/src/integrations/pl24/pl24.service.ts +++ b/apps/api/src/integrations/pl24/pl24.service.ts @@ -56,6 +56,58 @@ const LEGACY_ARCH_SOURCE_TAG: Record = { LEGACY_HYUNDAI_KIA: "hyundai-kia", }; +/** + * Normalise a vinfoBasic label into a lookup key. + * + * JS `toLowerCase()` maps Turkish "İ" to "i" + U+0307 (combining dot), so PSA + * labels like "AKTARMA SİSTEMLERİ" / "GÖVDE TİPİ" produced keys no lookup could + * ever match and the transmission/body fields silently stayed null. Lower-case + * with the Turkish locale, then strip combining marks and fold "ı" → "i" so a + * single spelling matches both "Model yılı" and "MODEL YILI". + */ +export function normalizeLabel(label: string): string { + return label + .toLocaleLowerCase("tr") + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/ı/g, "i") + .replace(/[\s/]+/g, "_") + .trim(); +} + +/** + * PSA model year: "AM 2005" → 2005. Index labels ("01 MAJÖR ENDEKS", + * 'MAJÖR ENDEKS "0C"') are NOT years — returning one there is how a Citroën + * ended up as a 2001 model. PSA VINs also don't encode the model year in + * position 10, so the caller must fall back to DAM or leave it null. + */ +export function parsePsaModelYear(value: string | null | undefined): number | null { + if (!value) return null; + if (/endeks/i.test(value)) return null; + const m = value.match(/\b(?:AM\s*)?((?:19|20)\d{2})\b/i); + if (m) return Number(m[1]); + const short = value.match(/^\s*AM\s*(\d{2})\s*$/i); + if (short) { + const n = Number(short[1]); + return n >= 70 ? 1900 + n : 2000 + n; + } + return null; +} + +/** + * PSA "DAM" (e.g. "10479CJ") = days since 1976-01-01 + plant code → build date. + * PSA model years roll in July, so a build after June belongs to the next one. + */ +export function damToModelYear(dam: string | null | undefined): number | null { + if (!dam) return null; + const m = dam.match(/^(\d{4,5})/); + if (!m) return null; + const date = new Date(Date.UTC(1976, 0, 1) + Number(m[1]) * 86_400_000); + const year = date.getUTCFullYear(); + if (year < 1980 || year > 2100) return null; + return date.getUTCMonth() >= 6 ? year + 1 : year; +} + @Injectable() export class PL24Service { private readonly logger = new Logger(PL24Service.name); @@ -1110,7 +1162,7 @@ export class PL24Service { const label = (v.key !== undefined ? v.key : v.description) || ""; const value = (v.key !== undefined ? v.description : v.value) || ""; if (!label) continue; - const key = label.toLowerCase().replace(/[\s\/]+/g, "_"); + const key = normalizeLabel(label); if (!(key in vehicleData)) { // Normalize like prNr col3: newlines→space, unescape the literal "\-" some P5 // backends emit (JLR "XJ 2010 \- 2019", Toyota/Suzuki dates "2023\-11\-29"/"2005\-07"), @@ -1157,26 +1209,40 @@ export class PL24Service { const engineCode = lookup("motor_kodu", "engine_code"); // Non-VAG P5 OEMs label the engine differently and give a description (sometimes with a // code in parens): JLR "Motor Tipi", Toyota "ENGINE 1", MAN "Yedek motor", Suzuki "Motor No.". - const engineLabel = lookup("motor_tipi", "engine_1", "yedek_motor", "motor_no."); + // PSA labels the full designation "MOTOR" ("TÜRBO DİZEL DV6TED4…"), Volvo + // "Motor" ("D4162T"), Subaru "Engine" — none of which the VAG-shaped list had. + const engineLabel = lookup( + "motor_tipi", + "engine_1", + "yedek_motor", + "motor_no.", + "motor", + "engine", + ); // Transmission: VAG "Şanzıman kodu"; other OEMs use their own labels — JLR "Vites Kutusu", // Toyota "ATM,MTM" (key "atm,mtm" — only spaces/slashes are underscored), MAN "Şanzıman", // Suzuki "Şanzıman numarası". + // normalizeLabel folds ı→i and strips diacritics, so "Şanzıman kodu" and + // "ŞANZIMAN KODU" both arrive as "sanziman_kodu". PSA uses "AKTARMA + // SİSTEMLERİ" ("5 MEKANİK VİTES KUTUSU"), Subaru "Mission". const transmissionCode = lookup( - "şanzıman_kodu", - "sanzıman_kodu", + "sanziman_kodu", "transmission_code", "vites_kutusu", "atm,mtm", - "şanzıman", - "şanzıman_numarası", + "aktarma_sistemleri", + "sanziman", + "sanziman_numarasi", + "mission", ); // Build body type from prNr K8* (Kaporta formları); brands without a prNr segment // (e.g. BMW) carry it in vinfoBasic "Karoseri" ("Limousine"). const bodyType = Object.entries(prNrByCode).find(([code]) => code.startsWith("K8"))?.[1] || - lookup("karoseri", "body", "body_type") || + // PSA "GÖVDE TİPİ" ("4 KAPILI SEDAN"), Volvo "Kaporta Stili" ("Sedan"). + lookup("karoseri", "body", "body_type", "govde_tipi", "kaporta_stili") || null; // Engine description from prNr D3* (Motor nitelikleri) @@ -1199,7 +1265,7 @@ export class PL24Service { // the friendly name is in "Araç" / description ("L200(EUR/MMTH)") — strip the region suffix. const isMitsubishi = getServiceApiPath(serviceName) === "/p5mitsubishi"; const baseModel = isMitsubishi - ? (lookup("araç") || (data.description as string) || lookup("model") || "") + ? (lookup("arac") || (data.description as string) || lookup("model") || "") .replace(/\s*\([^)]*\)\s*$/, "") .trim() : lookup("model_bilgisi", "model")?.trim() || @@ -1222,17 +1288,20 @@ export class PL24Service { // production date ("05/09/2014"); p5daimler → only "Teslimat tarihi" (delivery, "04.05.2009"). // Falling through to the VIN year-char (extractModelYear) is the last resort and is often // wrong for Mercedes (10th-char "1" → 2001 for a 2015 car), so read the dates first. + // PSA writes "AM 2005" (or an index label that is NOT a year) and its VINs + // do not encode the model year in position 10, so parse the PSA forms + // first and fall back to the DAM build date before ever touching the VIN. year: + parsePsaModelYear(lookup("model_yili", "year")) || Number.parseInt(lookup("model_yili", "year") || "", 10) || Number.parseInt( - (lookup("my", "üretim_tarihi", "uretim_tarihi", "teslimat_tarihi") || "").match( - /(19|20)\d{2}/, - )?.[0] || "", + (lookup("my", "uretim_tarihi", "teslimat_tarihi") || "").match(/(19|20)\d{2}/)?.[0] || "", 10, ) || + damToModelYear(lookup("dam")) || extractModelYear(vin) || 0, - series: lookup("seri", "satis_tipi", "sales_type"), + series: lookup("seri", "satis_tipi", "sales_type", "turu"), bodyType, engineCode: engineCode || @@ -1246,7 +1315,7 @@ export class PL24Service { colorCode: lookup("dis_rengi_boya_numarasi", "exterior_color___paint_code") || lookup("tavan_rengi", "roof_color"), - productionDate: lookup("üretim_tarihi", "date_of_production"), + productionDate: lookup("uretim_tarihi", "date_of_production", "teslimat_tarihi"), raw: data, catalogInfo: { serviceName, @@ -1411,9 +1480,14 @@ export class PL24Service { const values = (record.values as Record) || {}; const link = (record.link as Record) || {}; + // PSA (p5psa) shares one `record.id` (the illusPath) across many + // illustrations — 36 live records had only 11 distinct ids — so the unique + // key is `values.illustration` ("D2F 0 01A 48A", spaces stripped). Without + // this the children collapse onto each other and most get dropped. const code = values.subgroup || values.illustrationNumber || + values.illustration?.replace(/\s+/g, "") || values.id || values.code || String(record.id || ""); @@ -1491,11 +1565,14 @@ export class PL24Service { records = responseData.parts as Array>; } - const partRecords = records.filter( - (record) => - record.characteristic !== "sectionrow" && - (record.partno || (record.values as Record)?.partno), - ); + const partRecords = records.filter((record) => { + if (record.characteristic === "sectionrow") return false; + if (!(record.partno || (record.values as Record)?.partno)) return false; + // Volvo (p5volvo) prefixes each BOM with a header row that carries a + // partno but no link and is flagged unavailable — a phantom part if kept. + if (record.id === "null_null" || (record.unavailable === true && !record.link)) return false; + return true; + }); return partRecords.map((part) => { const values = (part.values as Record) || {}; @@ -1503,11 +1580,14 @@ export class PL24Service { const formattedPartNo = ((part.partno as string) || values.partno || "").trim(); const cleanPartNo = formattedPartNo.replace(/\s+/g, ""); - const qtyStr = values.qty || ""; + // qty (VAG/Subaru) · coef (PSA) · unit (Volvo) — same field, three names. + const qtyStr = values.qty || values.coef || values.unit || ""; const quantity = Number.parseInt(qtyStr.trim(), 10) || undefined; const remark = values.remark?.trim() || undefined; - const modelCodes = values.modelDescription?.trim() || undefined; + // PSA/Volvo/Subaru express applicability as `restriction` + // ("+ DIESEL TURBO DV6TED4 WITHOUT FAP"); VAG uses modelDescription. + const modelCodes = (values.modelDescription || values.restriction)?.trim() || undefined; let superseded: { oldCode: string; newCode: string } | undefined; const supersededByValue = (part.supersededBy as string) || values.supersededBy || ""; diff --git a/apps/api/src/integrations/pl24/pl24.types.ts b/apps/api/src/integrations/pl24/pl24.types.ts index 427df35..a1034b7 100644 --- a/apps/api/src/integrations/pl24/pl24.types.ts +++ b/apps/api/src/integrations/pl24/pl24.types.ts @@ -339,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record = { // PSA Group (Citroën, Peugeot) citroen_parts: { - basePath: "/psa", - apiPath: "/psa", - architecture: "LEGACY_PSA", + basePath: "/pl24-app/citroen_parts", + apiPath: "/p5psa", + architecture: "P5_MODERN", }, citroenDs_parts: { - basePath: "/psa", - apiPath: "/psa", - architecture: "LEGACY_PSA", + basePath: "/pl24-app/citroenDs_parts", + apiPath: "/p5psa", + architecture: "P5_MODERN", }, peugeot_parts: { - basePath: "/psa", - apiPath: "/psa", - architecture: "LEGACY_PSA", + basePath: "/pl24-app/peugeot_parts", + apiPath: "/p5psa", + architecture: "P5_MODERN", + }, + + // Stellantis-era Opel/Vauxhall — the PSA-platform catalogue (Corsa F, Mokka B…). + // GM-era W0L/W0V cars stay in the legacy opel_parts P4 catalogue below. + psa_opel_parts: { + basePath: "/pl24-app/psa_opel_parts", + apiPath: "/p5psa", + architecture: "P5_MODERN", + }, + psa_vauxhall_parts: { + basePath: "/pl24-app/psa_vauxhall_parts", + apiPath: "/p5psa", + architecture: "P5_MODERN", + }, + + // Subaru (own P5 backend) + subaru_parts: { + basePath: "/pl24-app/subaru_parts", + apiPath: "/p5subaru", + architecture: "P5_MODERN", + }, + + // Rest of the Fiat/Stellantis family — same /p5fiat backend as fiatp/fiatt + abarth_parts: { + basePath: "/pl24-app/abarth_parts", + apiPath: "/p5fiat", + architecture: "P5_MODERN", + }, + alfa_parts: { + basePath: "/pl24-app/alfa_parts", + apiPath: "/p5fiat", + architecture: "P5_MODERN", + }, + jeep_parts: { + basePath: "/pl24-app/jeep_parts", + apiPath: "/p5fiat", + architecture: "P5_MODERN", + }, + lancia_parts: { + basePath: "/pl24-app/lancia_parts", + apiPath: "/p5fiat", + architecture: "P5_MODERN", }, // Ford Group @@ -404,14 +446,14 @@ export const PL24_SERVICE_CATALOGS: Record = { // Volvo/Polestar volvo_parts: { - basePath: "/volvo", - apiPath: "/volvo", - architecture: "LEGACY_VOLVO", + basePath: "/pl24-app/volvo_parts", + apiPath: "/p5volvo", + architecture: "P5_MODERN", }, polestar_parts: { - basePath: "/volvo", - apiPath: "/volvo", - architecture: "LEGACY_VOLVO", + basePath: "/pl24-app/polestar_parts", + apiPath: "/p5volvo", + architecture: "P5_MODERN", }, // Fiat Group (FCA) — P5 Modern catalog at /p5fiat, requires de-708171 account. @@ -588,8 +630,8 @@ export const PL24_WMI_SERVICE_MAP: Record = { ZFA: "fiatp_parts", // Fiat SpA Italy (most common) ZCF: "fiatp_parts", // Tofaş Turkey (Linea, Fiorino, etc.) ZFF: "fiatp_parts", // Abarth / Fiat Sport - ZAR: "fiatp_parts", // Alfa Romeo - ZLA: "fiatp_parts", // Lancia + ZAR: "alfa_parts", // Alfa Romeo (kendi kataloğu, /p5fiat backend) + ZLA: "lancia_parts", // Lancia (kendi kataloğu, /p5fiat backend) // Fiat Commercial (fiatt_parts) ZFC: "fiatt_parts", // Fiat Commercial @@ -615,9 +657,16 @@ export const PL24_WMI_SERVICE_MAP: Record = { JNK: "infiniti_parts", // Infiniti (Japan/Korea) // Opel / Vauxhall + // Subaru (canlı WMI decode: JF1 → subaru_parts, error:false) + JF1: "subaru_parts", + JF2: "subaru_parts", + // Jeep (Stellantis; /p5fiat backend) + "1C4": "jeep_parts", + "1J4": "jeep_parts", + W0L: "opel_parts", // Opel AG (Germany) W0V: "opel_parts", // Opel (newer Stellantis-era WMI) - VXK: "opel_parts", // PSA/Stellantis-platform Opel (Corsa F, Mokka B — France/Spain plants) + VXK: "psa_opel_parts", // PSA-platform Opel (Corsa F, Mokka B) → Stellantis kataloğu /p5psa // Citroën (PSA) VF7: "citroen_parts", // Citroën SA (France) @@ -846,6 +895,13 @@ export const SERVICE_TO_BRAND: Record = { vauxhall_parts: "Opel", // Volvo/Polestar volvo_parts: "Volvo", + subaru_parts: "Subaru", + psa_opel_parts: "Opel", + psa_vauxhall_parts: "Opel", + abarth_parts: "Abarth", + alfa_parts: "Alfa Romeo", + jeep_parts: "Jeep", + lancia_parts: "Lancia", polestar_parts: "Polestar", // Fiat Group fiatp_parts: "Fiat", diff --git a/apps/api/src/jobs/prefetch-worker.service.ts b/apps/api/src/jobs/prefetch-worker.service.ts index c93187c..4e61ca3 100644 --- a/apps/api/src/jobs/prefetch-worker.service.ts +++ b/apps/api/src/jobs/prefetch-worker.service.ts @@ -10,6 +10,7 @@ import { and, asc, eq, gt, inArray, isNull, notExists, sql } from "drizzle-orm"; import { CategoriesService } from "../categories/categories.service"; import { DATABASE, type Database } from "../database/database.provider"; import { categories, parts, vehicles } from "../database/schema/core"; +import { isPl24LeafNode } from "../integrations/pl24/pl24-tree"; import { PostHogService } from "../posthog/posthog.service"; import { RedisService } from "../redis/redis.service"; import { QUEUE_NAMES, getBullConnection } from "./bull.config"; @@ -965,13 +966,11 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy { // flag (null, rare pre-migration rows) → treated as leaf, preserving the old // 1-level behaviour for those. if (source === "parts-catalogs") return hasSubgroups !== true; - // PL24 leaf indicators - return ( - linkPath.includes("/bom/") || - linkPath.includes("/bomdetails") || - linkPath.includes("/partinfo/") || - linkPath.includes("/servicepart/vin_items") - ); + // PL24: one shared classifier (integrations/pl24/pl24-tree). The old inline + // list was case-sensitive, so p5psa/p5volvo's camelCase `/details/vin/ + // bomDetails` was never recognised as a leaf and its parts were never + // prefetched (still true today for Mitsubishi/Fiat/Renault). + return isPl24LeafNode({ linkPath, hasSubgroups }); } /**