feat(pl24): bayat P4 mimarisindeki araçları görüntülendikçe P5'e taşı #265

Open
root wants to merge 1 commits from dev into main
4 changed files with 249 additions and 2 deletions
Showing only changes of commit ddc223f745 - Show all commits

View File

@@ -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<boolean>;
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<void>) => {
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();
});
});

View File

@@ -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<boolean> {
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<ReturnType<PL24Service["decodeVin"]>> | 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<string>();
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<Map<string, Array<{ id: string; name: string }>>> {

View File

@@ -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);
});
});

View File

@@ -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");
}