feat(pl24): PSA + Volvo'yu P5'e taşı, eksik katalogları ekle, ağaç sınıflandırmasını birleştir
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
Faz 1 / adım 3-4 (analiz: /home/s/ss/plv2.md §3.3/§3.5, bulgular types_wmi-01/02/03/04/05,
p5core-02/03/04/05, psa-04/05/06, consumers_jobs-01/02).
CANLI GERÇEK (2026-09-16 keşfi): PL24 manufacturers API'si peugeot/citroen/
citroenDs/psa_opel/psa_vauxhall'ı `/p5psa`, volvo/polestar'ı `/p5volvo`,
subaru'yu `/p5subaru` olarak veriyor. Eski Volvo P4 ucu **503** döndürüyor
(ölü); eski PSA P4 ucu 200 döndürüyor ama verisi **2024-02-13'te donmuş**
(P5 upds 2026-09-07). Kodumuz her ikisini de P4'e yönlendiriyordu.
SERVİS TABLOSU:
- peugeot/citroen/citroenDs + YENİ psa_opel_parts, psa_vauxhall_parts → /p5psa
- volvo/polestar → /p5volvo · YENİ subaru_parts → /p5subaru
- YENİ abarth/alfa/jeep/lancia → /p5fiat
- WMI düzeltmeleri: ZAR→alfa, ZLA→lancia (fiatp'den), VXK→psa_opel (PSA-platform
Corsa F/Mokka B), YENİ JF1/JF2→subaru, 1C4/1J4→jeep. SERVICE_TO_BRAND genişledi.
- P4 kodu ve eski yollar DOKUNULMADI: mevcut 322 PSA + 36 Volvo aracı eski
ağaçlarıyla çalışmaya devam eder (veri göçü ayrı iş).
PARSER (canlı yanıt şekillerine göre):
- `normalizeLabel()`: JS toLowerCase Türkçe "İ"yi "i̇" (i + birleşen nokta)
yapıyordu → PSA'nın "AKTARMA SİSTEMLERİ"/"GÖVDE TİPİ" etiketleri HİÇBİR ZAMAN
eşleşmiyordu, şanzıman/gövde sessizce null kalıyordu. Artık tr-locale +
diakritik temizliği + ı→i.
- `parsePsaModelYear()`: "AM 2005" → 2005; "MAJÖR ENDEKS" bir yıl DEĞİL (bu
yüzden Citroën'ler 2001 görünüyordu). `damToModelYear()`: PSA DAM alanı
(1976-01-01'den gün) → üretim yılı, model yılı Temmuz'da döner.
PSA VIN'lerinde 10. karakter model yılı olmadığı için VIN fallback'i en sona.
- Alan listeleri: motor/engine (PSA "MOTOR", Volvo "Motor"), aktarma_sistemleri,
mission, govde_tipi, kaporta_stili, türü→series.
- Alt grup kodu: PSA'da `record.id` = illusPath ve birden çok illüstrasyon
paylaşıyor (canlı: 36 kayıt / 11 id) → benzersiz kod `values.illustration`.
- Parça filtresi: Volvo BOM'unun başlık satırı (id "null_null", link yok,
unavailable) hayalet parça olarak geçiyordu → elendi.
- Adet: qty (VAG/Subaru) · coef (PSA) · unit (Volvo). Uygulanabilirlik:
modelDescription · restriction.
AĞAÇ SINIFLANDIRMASI (yeni `pl24-tree.ts`, tek kaynak):
Dört ayrı sezgisel vardı ve birbiriyle çelişiyordu:
- `linkWid.includes("group")` → p5psa `illusTable`, p5volvo/p5subaru
`illustrationsTable` yakalanmıyordu → o seviye yaprak sanılıp parça olarak
çekiliyor, partno'suz kayıtlar 0 parçaya dönüşüyor, panel sessizce boş
kalıyordu (2026-06 isPsaParent olayının aynısı).
- case-sensitive `includes("/bomdetails")` → p5psa/p5volvo'nun camelCase
`/details/vin/bomDetails` yaprakları grup sanılıyor, parçaları hiç prefetch
edilmiyordu (bugün Mitsubishi %99,9 / Fiat %80 / Renault %70 bomDetails
yaprağı 0 parça — aynı kök neden).
Artık `isPl24LeafNode`/`isPl24GroupNode` tek kaynak; categories.service (2 yer),
catalog.service (2 yer) ve prefetch-worker aynı fonksiyonu kullanıyor.
Test: yeni `pl24-tree.spec.ts` (7 test, canlı VW/PSA/Volvo/Subaru wid+path
çiftleriyle regresyon kilidi). Etkilenen paketler: 208 test geçti. tsc + biome temiz.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<kind>/(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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
112
apps/api/src/integrations/pl24/pl24-tree.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
65
apps/api/src/integrations/pl24/pl24-tree.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -56,6 +56,58 @@ const LEGACY_ARCH_SOURCE_TAG: Record<string, string> = {
|
||||
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<string, string>) || {};
|
||||
const link = (record.link as Record<string, unknown>) || {};
|
||||
|
||||
// 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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const partRecords = records.filter(
|
||||
(record) =>
|
||||
record.characteristic !== "sectionrow" &&
|
||||
(record.partno || (record.values as Record<string, unknown>)?.partno),
|
||||
);
|
||||
const partRecords = records.filter((record) => {
|
||||
if (record.characteristic === "sectionrow") return false;
|
||||
if (!(record.partno || (record.values as Record<string, unknown>)?.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<string, string>) || {};
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -339,19 +339,61 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
|
||||
// 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<string, PL24CatalogConfig> = {
|
||||
|
||||
// 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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user