diff --git a/apps/api/src/categories/categories.service.spec.ts b/apps/api/src/categories/categories.service.spec.ts index 71eebd7..3851a2d 100644 --- a/apps/api/src/categories/categories.service.spec.ts +++ b/apps/api/src/categories/categories.service.spec.ts @@ -17,6 +17,8 @@ function createService(db: any) { }; const pl24Service = { getCategories: vi.fn().mockResolvedValue([]), + // P4→P5 onarma yolu bunu çağırır (plv2 Faz 2). + decodeVin: vi.fn().mockResolvedValue(null), }; const emexService = { isSupported: vi.fn().mockReturnValue(false), @@ -462,3 +464,81 @@ describe("CategoriesService", () => { }); }); }); + +// ── P4 → P5 kendi kendini onarma (plv2 Faz 2, bulgu psa-07) ── +// PSA/Volvo upstream'de P5'e taşındı; daha önce decode edilmiş araçlar donmuş +// 2024-02-13 PSA anlık görüntüsüne / ölü P4 Volvo ucuna işaret etmeye devam +// ediyor ve decodeVin'in db_hit kısa devresi kod düzeltmesini onlara ulaştırmıyor. +describe("CategoriesService — bayat PL24 mimarisini onarma", () => { + const makeVehicle = (catalogPath: string) => ({ + id: "veh-1", + vin: "VF3MCBHZWHS150390", + rawData: { catalogInfo: { serviceName: "peugeot_parts", catalogPath } }, + }); + + const setup = (catalogPath: string, decodeResult: unknown) => { + const { service, redis, pl24Service } = createService({} as never); + const p = service as unknown as { + healStalePl24Architecture( + id: string, + v: { vin: string | null; rawData: unknown }, + n: number, + ): Promise; + db: unknown; + }; + // setNx: ilk denemeye izin ver (günlük tek deneme kilidi) + (redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(true); + (redis as unknown as { del: unknown }).del = vi.fn().mockResolvedValue(undefined); + pl24Service.decodeVin = vi.fn().mockResolvedValue(decodeResult); + return { service, p, redis, pl24Service, vehicle: makeVehicle(catalogPath) }; + }; + + it("P4 PSA yolundaki araç yeniden decode edilir ve ağaç değişir", async () => { + const fresh = { + catalogInfo: { serviceName: "peugeot_parts", catalogPath: "/p5psa" }, + categories: [ + { + code: "_FCT0001", + nameTr: "Mekanik", + nameEn: "Mechanical", + linkPath: "/p5psa/x", + linkWid: "mainGroupTable", + }, + ], + }; + const { p, pl24Service, vehicle } = setup("/psa/peugeot_parts", fresh); + const inserted: unknown[] = []; + (p as unknown as { db: unknown }).db = { + transaction: async (fn: (tx: unknown) => Promise) => { + await fn({ + delete: () => ({ where: async () => undefined }), + insert: () => ({ values: async (rows: unknown[]) => inserted.push(...rows) }), + update: () => ({ set: () => ({ where: async () => undefined }) }), + }); + }, + }; + + await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(true); + expect(pl24Service.decodeVin).toHaveBeenCalledWith("VF3MCBHZWHS150390"); + expect(inserted).toHaveLength(1); + expect((inserted[0] as { name: string }).name).toBe("Mekanik"); + }); + + it("zaten P5 olan araca dokunulmaz (decode çağrılmaz)", async () => { + const { p, pl24Service, vehicle } = setup("/p5psa", null); + await expect(p.healStalePl24Architecture("veh-1", vehicle, 6)).resolves.toBe(false); + expect(pl24Service.decodeVin).not.toHaveBeenCalled(); + }); + + it("yeniden decode boş dönerse eski ağaç korunur", async () => { + const { p, vehicle } = setup("/psa/peugeot_parts", { categories: [] }); + await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false); + }); + + it("günde bir denenir (setNx kilidi)", async () => { + const { p, redis, pl24Service, vehicle } = setup("/psa/peugeot_parts", { categories: [] }); + (redis as unknown as { setNx: unknown }).setNx = vi.fn().mockResolvedValue(false); + await expect(p.healStalePl24Architecture("veh-1", vehicle, 120)).resolves.toBe(false); + expect(pl24Service.decodeVin).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index d2c199c..be4bbe2 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -21,8 +21,13 @@ 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 { + isPl24GroupNode, + isPl24LeafNode, + isStalePl24Architecture, +} from "../integrations/pl24/pl24-tree"; import { PL24Service } from "../integrations/pl24/pl24.service"; +import { getServiceApiPath } from "../integrations/pl24/pl24.types"; import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexicon"; import { RedisService } from "../redis/redis.service"; import { StorageService } from "../storage/storage.service"; @@ -71,6 +76,20 @@ export class CategoriesService { .from(categories) .where(eq(categories.vehicleId, vehicleId)); + // ── P4 → P5 self-healing (plv2.md Faz 2, bulgu psa-07) ── + // PSA and Volvo moved to P5 upstream; rows decoded before that still point at + // the frozen 2024-02-13 PSA snapshot or the dead P4 Volvo endpoint, and + // decodeVin's db_hit short-circuit means no code fix ever reaches them. Heal + // one vehicle per view instead of a bulk re-decode storm against the single + // surviving account. + const healed = await this.healStalePl24Architecture(vehicleId, vehicle, dbCategories.length); + if (healed) { + dbCategories = await this.db + .select() + .from(categories) + .where(eq(categories.vehicleId, vehicleId)); + } + // If no categories in DB, fetch from PL24 if (dbCategories.length === 0 && vehicle.rawData) { const rawData = vehicle.rawData as any; @@ -988,6 +1007,104 @@ export class CategoriesService { * The per-node trail excludes the node itself, ordered root-first. Used by the * catalog search so each hit can show where it sits in the tree. */ + /** + * Re-decode a vehicle whose stored catalogInfo still points at a retired PL24 + * architecture (P4 PSA/Volvo) and replace its category tree with the P5 one. + * + * Returns true when the tree was rebuilt. Deliberately conservative: + * - only PSA/Volvo rows whose service is P5 today are touched; + * - a failed or empty re-decode leaves the old tree in place (stale data beats + * no data), and the attempt is remembered for a day so a permanently + * unresolvable VIN cannot re-hit PL24 on every page view; + * - the old rows are deleted only once the new tree is in hand, because the + * unique (vehicle_id, name, source) index would otherwise reject the insert. + */ + private async healStalePl24Architecture( + vehicleId: string, + vehicle: { vin: string | null; rawData: unknown }, + existingCategoryCount: number, + ): Promise { + const rawData = vehicle.rawData as { + catalogInfo?: { serviceName?: string; catalogPath?: string }; + } | null; + const catalogInfo = rawData?.catalogInfo; + const serviceName = catalogInfo?.serviceName; + if (!vehicle.vin || !serviceName) return false; + if ( + !isStalePl24Architecture({ + catalogPath: catalogInfo?.catalogPath, + currentApiPath: getServiceApiPath(serviceName), + }) + ) { + return false; + } + + const attemptKey = `pl24:heal:${vehicleId}`; + if (!(await this.redis.setNx(attemptKey, "1", 86_400))) return false; + + this.logger.log( + `[pl24-heal] ${vehicle.vin} (${serviceName}) was decoded on ${catalogInfo?.catalogPath} — re-decoding on P5`, + ); + + let decoded: Awaited> | null = null; + try { + await this.redis.del(`pl24:vehicle:${vehicle.vin}`); + decoded = await this.pl24Service.decodeVin(vehicle.vin); + } catch (err) { + this.logger.warn(`[pl24-heal] ${vehicle.vin} re-decode failed: ${(err as Error).message}`); + return false; + } + if (!decoded?.categories?.length) { + this.logger.warn( + `[pl24-heal] ${vehicle.vin} re-decode returned no categories — keeping old tree`, + ); + return false; + } + + await this.db.transaction(async (tx) => { + await tx.delete(categories).where(eq(categories.vehicleId, vehicleId)); + const seen = new Set(); + const rows = decoded.categories + .filter((c) => { + const name = c.nameTr || c.nameEn; + if (!name || seen.has(name)) return false; + seen.add(name); + return true; + }) + .map((c) => ({ + vehicleId, + catalogVehicleId: null as string | null, + name: c.nameTr || c.nameEn, + nameOriginal: c.nameEn, + parentId: null as string | null, + externalId: c.code, + linkPath: c.linkPath || null, + linkWid: c.linkWid || null, + source: "pl24" as const, + })); + if (rows.length) await tx.insert(categories).values(rows); + await tx + .update(vehicles) + .set({ + rawData: { ...(rawData ?? {}), catalogInfo: decoded.catalogInfo }, + fullyFetched: false, + fullyFetchedAt: null, + }) + .where(eq(vehicles.id, vehicleId)); + }); + + await Promise.all([ + this.redis.del(`cat:tree:${vehicleId}`), + this.redis.del(`prefetch:complete:${vehicleId}`), + this.redis.del(`prefetch:noresult:${vehicleId}`), + ]); + + this.logger.log( + `[pl24-heal] ${vehicle.vin} migrated to ${decoded.catalogInfo?.catalogPath}: ${existingCategoryCount} stale → ${decoded.categories.length} fresh categories`, + ); + return true; + } + private async buildBreadcrumbs( ids: string[], ): Promise>> { diff --git a/apps/api/src/integrations/pl24/pl24-tree.spec.ts b/apps/api/src/integrations/pl24/pl24-tree.spec.ts index 0e09a7b..00ae487 100644 --- a/apps/api/src/integrations/pl24/pl24-tree.spec.ts +++ b/apps/api/src/integrations/pl24/pl24-tree.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isPl24GroupNode, isPl24LeafNode } from "./pl24-tree"; +import { isPl24GroupNode, isPl24LeafNode, isStalePl24Architecture } 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. @@ -110,3 +110,30 @@ describe("isPl24LeafNode — canlı P5 şekilleri", () => { ).toBe(true); }); }); + +describe("isStalePl24Architecture — P4→P5 göç tespiti", () => { + it("eski PSA/Volvo yolu + artık P5 olan servis → bayat", () => { + expect( + isStalePl24Architecture({ catalogPath: "/psa/peugeot_parts", currentApiPath: "/p5psa" }), + ).toBe(true); + expect(isStalePl24Architecture({ catalogPath: "/volvo", currentApiPath: "/p5volvo" })).toBe( + true, + ); + }); + + it("zaten P5 kaydı bayat değil", () => { + expect(isStalePl24Architecture({ catalogPath: "/p5psa", currentApiPath: "/p5psa" })).toBe( + false, + ); + }); + + it("hâlâ P4 olan markalar (Ford/Opel/Hyundai) dokunulmaz", () => { + expect(isStalePl24Architecture({ catalogPath: "/ford", currentApiPath: "/ford" })).toBe(false); + expect(isStalePl24Architecture({ catalogPath: "/opel", currentApiPath: "/opel" })).toBe(false); + }); + + it("eksik bilgi → bayat sayma (güvenli taraf)", () => { + expect(isStalePl24Architecture({ catalogPath: null, currentApiPath: "/p5psa" })).toBe(false); + expect(isStalePl24Architecture({ catalogPath: "/psa/x", currentApiPath: null })).toBe(false); + }); +}); diff --git a/apps/api/src/integrations/pl24/pl24-tree.ts b/apps/api/src/integrations/pl24/pl24-tree.ts index f4e9bbe..7a5e9ac 100644 --- a/apps/api/src/integrations/pl24/pl24-tree.ts +++ b/apps/api/src/integrations/pl24/pl24-tree.ts @@ -63,3 +63,26 @@ export function isPl24GroupNode(opts: { if (!opts.linkPath && !opts.linkWid) return false; return !isPl24LeafNode(opts); } + +/** + * True when a stored vehicle's catalogInfo points at an architecture the service + * no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5. + * + * These vehicles keep serving a tree built from the frozen P4 PSA snapshot + * (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s + * db_hit short-circuit means a code fix never reaches them. Detecting the + * mismatch at read time lets each vehicle heal itself on first view instead of + * needing a bulk re-decode storm against the one surviving account. + */ +export function isStalePl24Architecture(opts: { + catalogPath?: string | null; + currentApiPath?: string | null; +}): boolean { + const stored = opts.catalogPath?.toLowerCase() ?? ""; + const current = opts.currentApiPath?.toLowerCase() ?? ""; + if (!stored || !current) return false; + // Only the two migrated legacy backends; unknown/other paths are left alone. + const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo"); + if (!storedIsLegacyPsaOrVolvo) return false; + return current.startsWith("/p5"); +}