fix(catalog): pcat parent groups no longer show empty "0 parça"

parts-catalogs (pcat) catalogs are deep trees, but every pcat node was
stored as an apparent leaf (pcat: linkPath, no children) until drilled.
Opening an intermediate group called parts2, which returns HTTP 400
("The list of parts is empty"); the drill-to-children fallback only fired
on that exact error string, so any other failure (cold JWT capture,
timeout, 5xx) left both parts and children empty → the UI rendered
"0 parça / bulunamadı" on a node whose parts live 1-3 levels deeper.

#1 Persist the groups2 hasSubgroups/hasParts flags on categories
   (new nullable columns, migration 0007) and use them to classify:
   - getCategoryWithParts routes a known parent (hasSubgroups, !hasParts)
     straight to getChildren, never calling parts2.
   - enrichWithSchemaImages treats flagged parents as expandable, flagged
     parts-leaves as leaves, and only falls back to the "any pcat: is a
     leaf" heuristic for pre-migration rows (flags null).

#2 Make discovery resilient: drill to sub-groups whenever the parts fetch
   produces nothing (empty 200 OR a thrown error), not only on HTTP 400.
   Mark a node unavailable only on a definitive HTTP 400 with no
   sub-groups; transient failures are left for a later retry.

Verified live on prod (VW Passat, "Süspansiyon Çarpanlar"): full path is
Süspansiyon Çarpanlar → Süspansiyon → Süspansiyon 1…5 → 74 parts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 01:02:00 +03:00
parent 1b2cfd4a0f
commit aa4d055c77
6 changed files with 5538 additions and 14 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE "categories" ADD COLUMN "has_subgroups" boolean;--> statement-breakpoint
ALTER TABLE "categories" ADD COLUMN "has_parts" boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1779086009525,
"tag": "0006_chilly_dark_phoenix",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1779573406942,
"tag": "0007_natural_red_wolf",
"breakpoints": true
}
]
}

View File

@@ -154,6 +154,51 @@ describe("CategoriesService", () => {
});
});
describe("getCategoryWithParts", () => {
it("routes a pcat parent (hasSubgroups) to children instead of calling fetchParts", async () => {
const category = {
id: "c1",
name: "Süspansiyon Çarpanlar",
nameOriginal: "Suspension bump stops",
parentId: "root",
vehicleId: "v1",
source: "parts-catalogs",
linkPath: "pcat:vw:car1:grp1",
hasSubgroups: true,
hasParts: false,
};
// getCategoryWithParts:
// 1: select().from(categories).where().limit(1) — category lookup (limit terminal)
// 2: select().from(categories).where(parentId=c1) — DB children (where terminal) → none
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;
}),
};
const { service } = createService(db);
const partsCatalogsService = (service as any).partsCatalogsService;
// Stub the child-discovery so we only assert the routing decision.
const discovered = [{ id: "sg1", name: "Süspansiyon", children: undefined }];
vi.spyOn(service, "getChildren").mockResolvedValue(discovered as any);
const result = await service.getCategoryWithParts("c1");
expect(service.getChildren).toHaveBeenCalledWith("c1");
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
expect(result.parts).toEqual([]);
expect(result.children).toEqual(discovered);
});
});
describe("getById", () => {
it("should return category with schema pics", async () => {
const category = { id: "c1", name: "Engine" };

View File

@@ -216,6 +216,8 @@ export class CategoriesService {
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
hasSubgroups: g.hasSubgroups,
hasParts: g.hasParts,
source: "parts-catalogs" as const,
}));
@@ -475,6 +477,8 @@ export class CategoriesService {
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
hasSubgroups: g.hasSubgroups,
hasParts: g.hasParts,
source: "parts-catalogs" as const,
}));
@@ -638,6 +642,34 @@ export class CategoriesService {
};
}
// parts-catalogs parent group — the groups2 API marked this node as having
// sub-groups and no direct parts. Calling parts2 on it returns HTTP 400
// ("The list of parts is empty"), so skip straight to children discovery.
// Guard with hasParts !== true: if a node carries both flags, fall through
// and let the parts path fetch its direct parts.
if (
category.source === "parts-catalogs" &&
category.hasSubgroups === true &&
category.hasParts !== true &&
category.vehicleId
) {
const pcatChildren = await this.getChildren(categoryId);
if (pcatChildren.length > 0) {
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
children: pcatChildren,
};
}
// If discovery turned up nothing (transient fetch failure), fall through
// to the parts path rather than returning a misleading empty leaf.
}
// Leaf category — get or fetch parts
let discoveredChildren: any[] = [];
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
@@ -666,6 +698,7 @@ export class CategoriesService {
category.linkPath?.startsWith("pcat:")
) {
// PartsCatalogs: fetch parts + schema image via API
let definitive400 = false;
try {
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
const withoutPrefix = category.linkPath.slice("pcat:".length);
@@ -799,18 +832,32 @@ export class CategoriesService {
this.logger.error(
`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`,
);
// HTTP 400 = upstream API has no direct parts for this group — it may be a parent group
if (msg.includes("HTTP 400")) {
discoveredChildren = await this.getChildren(categoryId);
if (discoveredChildren.length === 0) {
await this.db
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(
`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`,
);
}
// A definitive HTTP 400 means the upstream has no direct parts for this
// group (it is a parent group, or genuinely empty). Other errors
// (timeout, JWT capture miss, 5xx) are transient and must NOT mark the
// node unavailable — the discovery step below decides what to show.
definitive400 = msg.includes("HTTP 400");
}
// Discover sub-groups when the parts fetch produced nothing — covers
// both an empty 200 (a parent group whose parts2 returns no rows, which
// does not throw) and a failed fetch. Skip only for nodes the catalog
// flags as a pure parts leaf, where an empty result is genuine/transient.
const producedNothing = dbParts.length === 0 && pics.length === 0;
const knownPureLeaf = category.hasParts === true && category.hasSubgroups !== true;
if (discoveredChildren.length === 0 && producedNothing && !knownPureLeaf) {
discoveredChildren = await this.getChildren(categoryId);
// Only flag unavailable when the upstream definitively reported an empty
// group (HTTP 400) AND there are no sub-groups to drill into. Transient
// failures are left untouched so a later visit can retry.
if (discoveredChildren.length === 0 && definitive400) {
await this.db
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(
`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`,
);
}
}
} else if (vehicle && category.source === "emex") {
@@ -1173,12 +1220,20 @@ export class CategoriesService {
const dbChildCount = childCountMap.get(c.id) || 0;
// EMEX: leaf only if linkPath exists and no DB children
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
// PartsCatalogs: leaf if linkPath starts with "pcat:" and no DB children
// PartsCatalogs: prefer the captured hasSubgroups/hasParts flags; only fall
// back to the "any pcat: linkPath is a leaf" heuristic when the flags are
// unknown (null — pre-migration rows). This stops parent groups from being
// rendered as empty leaves.
const isLeaf =
c.source === "emex"
? !!c.linkPath && dbChildCount === 0
: c.source === "parts-catalogs"
? !!c.linkPath?.startsWith("pcat:") && dbChildCount === 0
? (() => {
if (dbChildCount > 0) return false; // already has children → parent
if (c.hasSubgroups === true) return false; // known parent group
if (c.hasParts === true) return true; // known parts leaf
return !!c.linkPath?.startsWith("pcat:"); // unknown → lazy-leaf heuristic
})()
: (() => {
const lp = c.linkPath?.toLowerCase() ?? "";
return (

View File

@@ -371,6 +371,15 @@ export const categories = pgTable(
externalId: text("external_id"),
linkPath: text("link_path"),
linkWid: varchar("link_wid", { length: 100 }),
// parts-catalogs (pcat) node classification, captured from the groups2 API.
// Nullable on purpose: null = unknown / not-applicable (non-pcat sources,
// or rows created before this column existed) → callers fall back to the
// linkPath heuristic. When set, they authoritatively mark a node as a
// parent (hasSubgroups) vs a real leaf (hasParts), so we never call the
// parts2 endpoint on a parent group (which 400s) nor treat a parent as an
// empty leaf.
hasSubgroups: boolean("has_subgroups"),
hasParts: boolean("has_parts"),
unavailable: boolean("unavailable").default(false).notNull(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),