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,
|
"when": 1779086009525,
|
||||||
"tag": "0006_chilly_dark_phoenix",
|
"tag": "0006_chilly_dark_phoenix",
|
||||||
"breakpoints": true
|
"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", () => {
|
describe("getById", () => {
|
||||||
it("should return category with schema pics", async () => {
|
it("should return category with schema pics", async () => {
|
||||||
const category = { id: "c1", name: "Engine" };
|
const category = { id: "c1", name: "Engine" };
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ export class CategoriesService {
|
|||||||
externalId: g.id,
|
externalId: g.id,
|
||||||
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
|
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
|
||||||
linkWid: null as string | null,
|
linkWid: null as string | null,
|
||||||
|
hasSubgroups: g.hasSubgroups,
|
||||||
|
hasParts: g.hasParts,
|
||||||
source: "parts-catalogs" as const,
|
source: "parts-catalogs" as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -475,6 +477,8 @@ export class CategoriesService {
|
|||||||
externalId: g.id,
|
externalId: g.id,
|
||||||
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
|
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
|
||||||
linkWid: null as string | null,
|
linkWid: null as string | null,
|
||||||
|
hasSubgroups: g.hasSubgroups,
|
||||||
|
hasParts: g.hasParts,
|
||||||
source: "parts-catalogs" as const,
|
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
|
// Leaf category — get or fetch parts
|
||||||
let discoveredChildren: any[] = [];
|
let discoveredChildren: any[] = [];
|
||||||
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
|
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
|
||||||
@@ -666,6 +698,7 @@ export class CategoriesService {
|
|||||||
category.linkPath?.startsWith("pcat:")
|
category.linkPath?.startsWith("pcat:")
|
||||||
) {
|
) {
|
||||||
// PartsCatalogs: fetch parts + schema image via API
|
// PartsCatalogs: fetch parts + schema image via API
|
||||||
|
let definitive400 = false;
|
||||||
try {
|
try {
|
||||||
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
|
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
|
||||||
const withoutPrefix = category.linkPath.slice("pcat:".length);
|
const withoutPrefix = category.linkPath.slice("pcat:".length);
|
||||||
@@ -799,18 +832,32 @@ export class CategoriesService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`,
|
`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
|
// A definitive HTTP 400 means the upstream has no direct parts for this
|
||||||
if (msg.includes("HTTP 400")) {
|
// group (it is a parent group, or genuinely empty). Other errors
|
||||||
discoveredChildren = await this.getChildren(categoryId);
|
// (timeout, JWT capture miss, 5xx) are transient and must NOT mark the
|
||||||
if (discoveredChildren.length === 0) {
|
// node unavailable — the discovery step below decides what to show.
|
||||||
await this.db
|
definitive400 = msg.includes("HTTP 400");
|
||||||
.update(categories)
|
}
|
||||||
.set({ unavailable: true })
|
|
||||||
.where(eq(categories.id, categoryId));
|
// Discover sub-groups when the parts fetch produced nothing — covers
|
||||||
this.logger.warn(
|
// both an empty 200 (a parent group whose parts2 returns no rows, which
|
||||||
`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`,
|
// 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") {
|
} else if (vehicle && category.source === "emex") {
|
||||||
@@ -1173,12 +1220,20 @@ export class CategoriesService {
|
|||||||
const dbChildCount = childCountMap.get(c.id) || 0;
|
const dbChildCount = childCountMap.get(c.id) || 0;
|
||||||
// EMEX: leaf only if linkPath exists and no DB children
|
// EMEX: leaf only if linkPath exists and no DB children
|
||||||
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath 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 =
|
const isLeaf =
|
||||||
c.source === "emex"
|
c.source === "emex"
|
||||||
? !!c.linkPath && dbChildCount === 0
|
? !!c.linkPath && dbChildCount === 0
|
||||||
: c.source === "parts-catalogs"
|
: 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() ?? "";
|
const lp = c.linkPath?.toLowerCase() ?? "";
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -371,6 +371,15 @@ export const categories = pgTable(
|
|||||||
externalId: text("external_id"),
|
externalId: text("external_id"),
|
||||||
linkPath: text("link_path"),
|
linkPath: text("link_path"),
|
||||||
linkWid: varchar("link_wid", { length: 100 }),
|
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(),
|
unavailable: boolean("unavailable").default(false).notNull(),
|
||||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
Reference in New Issue
Block a user