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:
2
apps/api/drizzle/0007_natural_red_wolf.sql
Normal file
2
apps/api/drizzle/0007_natural_red_wolf.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "categories" ADD COLUMN "has_subgroups" boolean;--> statement-breakpoint
|
||||
ALTER TABLE "categories" ADD COLUMN "has_parts" boolean;
|
||||
5406
apps/api/drizzle/meta/0007_snapshot.json
Normal file
5406
apps/api/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" };
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user