fix(catalog): route PSA VIN scope/main-group categories to drill, not empty leaf

VIN-decoded Peugeot/Citroën scope categories ("mekanik", "kaporta", …)
carry json-vin-main-groups.action / json-vin-illustrations.action link
paths, but getCategoryWithPartsInner's isPsaParent gate only recognised
the catalog-browse paths (psa:: / json-illustrations.action). So every
VIN PSA parent fell through to the leaf path, fetched no parts, and
rendered an empty panel — the largest current "0 parça" cluster
(~20-30 empty parts_panel_viewed/day across all Peugeot/Citroën models;
serkan filazi's complaint among them).

The drill already works end-to-end (getChildren → fetchSubGroupsByPath →
fetchVinMainGroups/fetchVinIllustrations → vin-image-board parts); only
the gate was out of sync with PL24Service.isPsaVin*Path. Verified live on
prod: scope "mekanik" → 6 main-groups → "motor" → 10 illustrations →
12 OEM parts + schema + 15 hotspots.

Also surface a retryable loadError (matching the pl24/emex group-node
branches) when a PSA parent drill comes back empty, instead of a
misleading empty grid. +2 regression tests pin the dispatch to the
PL24Service.isPsaVin* helpers so it can't silently drift again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 16:04:03 +03:00
parent 145689a391
commit 9cf36dc9a9
2 changed files with 110 additions and 8 deletions

View File

@@ -207,10 +207,92 @@ describe("CategoriesService", () => {
expect(service.getChildren).toHaveBeenCalledWith("c1");
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
expect(result.parts).toEqual([]);
expect(result.children).toEqual(discovered);
expect((result as { children?: unknown }).children).toEqual(discovered);
// The wrapper attaches the server-resolved ancestor trail (id+name only).
expect(result.ancestors).toEqual([{ id: "root", name: "Kök" }]);
});
// Regression guard for the PSA "0 parça" bug: VIN-decoded Peugeot/Citroën
// scope categories carry a json-vin-main-groups.action linkPath (and the
// next level json-vin-illustrations.action). These are PARENTS — they must
// route to getChildren, not fall through to the leaf path and render empty.
// The dispatch must stay in sync with PL24Service.isPsaVin*Path; this test
// fails loudly if it drifts again.
it("routes a PSA VIN scope (json-vin-main-groups.action) to children, not an empty leaf", async () => {
const category = {
id: "psa1",
name: "mekanik",
nameOriginal: "mekanik",
parentId: null,
vehicleId: "v1",
source: "pl24",
linkPath: "/psa/peugeot_parts/json-vin-main-groups.action?scope=_FCT0001&vin=VF3ABCDE",
linkWid: null,
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
const discovered = [{ id: "mg1", name: "motor", children: undefined }];
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
const result = await service.getCategoryWithParts("psa1");
expect(service.getChildren).toHaveBeenCalledWith("psa1");
expect(result.parts).toEqual([]);
expect((result as { children?: unknown }).children).toEqual(discovered);
});
it("surfaces loadError (not an empty grid) when a PSA VIN main-group drill comes back empty", async () => {
const category = {
id: "psa2",
name: "motor",
nameOriginal: "motor",
parentId: "psa1",
vehicleId: "v1",
source: "pl24",
linkPath: "/psa/peugeot_parts/json-vin-illustrations.action?mainGroup=P8&vin=VF3ABCDE",
linkWid: null,
hasSubgroups: null,
hasParts: null,
};
let selectCall = 0;
const db = {
select: vi.fn().mockImplementation(() => {
selectCall++;
const captured = selectCall;
const c: Record<string, any> = {};
c.from = vi.fn().mockReturnValue(c);
c.where = vi.fn().mockImplementation(() => (captured === 2 ? [] : c));
c.limit = vi.fn().mockReturnValue([category]);
return c;
}),
execute: vi.fn().mockResolvedValue([]),
};
const { service } = createService(db);
// Drill comes back empty (transient upstream failure).
vi.spyOn(service, "getChildren").mockResolvedValue([] as any);
const result = await service.getCategoryWithParts("psa2");
expect(service.getChildren).toHaveBeenCalledWith("psa2");
expect((result as { loadError?: boolean }).loadError).toBe(true);
expect((result as { children?: unknown }).children).toBeUndefined();
});
});
describe("getAncestors", () => {

View File

@@ -918,15 +918,29 @@ export class CategoriesService {
}
// PSA parent nodes — always fetch children on-demand:
// • psa:: scope paths (top-level scopes)
// • json-illustrations.action paths (mid-level main groups → illustration lists)
// • psa:: scope paths (catalog-browse top-level scopes)
// • json-illustrations.action (catalog-browse main group → illustrations)
// • json-vin-main-groups.action (VIN-indexed scope → main groups)
// • json-vin-illustrations.action (VIN-indexed main group → illustrations)
// The two VIN-indexed levels were MISSING here, so VIN-decoded Peugeot/Citroën
// scope categories ("mekanik", "kaporta", "ekipmanlar"…) fell through every
// parent branch to the leaf path, fetched no parts (the main-groups endpoint
// returns groups, not parts) and rendered an empty panel — the single biggest
// "0 parça" complaint. The drill itself works: getChildren →
// fetchSubGroupsByPath → fetchVinMainGroups / fetchVinIllustrations. These four
// predicates must stay in sync with PL24Service.isPsaPath / isPsaIllusPath /
// isPsaVinMainGroupsPath / isPsaVinIllusPath; the leaf level
// (vin-image-board.action / image-board.action) correctly stays a leaf below.
const psaLink = category.linkPath ?? "";
const isPsaParent =
category.linkPath?.startsWith("psa::") ||
(category.linkPath?.includes("/psa/") &&
category.linkPath?.includes("json-illustrations.action"));
psaLink.startsWith("psa::") ||
(psaLink.includes("/psa/") &&
(psaLink.includes("json-illustrations.action") ||
psaLink.includes("json-vin-main-groups.action") ||
psaLink.includes("json-vin-illustrations.action")));
if (isPsaParent && category.vehicleId) {
const psaChildren = await this.getChildren(categoryId);
return {
const base = {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
@@ -934,8 +948,14 @@ export class CategoriesService {
parts: [],
schemaPics: [],
hotspots: [],
children: psaChildren,
};
// A PSA scope / main group is never a parts leaf. If the on-demand drill
// comes back empty the upstream snapshot failed transiently — surface a
// retryable load error (matching the pl24/emex group-node branches below)
// instead of a misleading empty subcategory grid.
return psaChildren.length > 0
? { ...base, children: psaChildren }
: { ...base, loadError: true };
}
// parts-catalogs parent group — the groups2 API marked this node as having