/** * One source of truth for "is this PL24 node a parts leaf or a group to drill?". * * WHY THIS FILE EXISTS (plv2.md, findings p5core-02 / psa-05 / consumers_jobs-01): * the same question was answered by four independent heuristics — * `categories.service` (twice), `catalog.service` and the prefetch worker — and * they disagreed. Two failure modes shipped repeatedly: * * 1. `linkWid.includes("group")` as the "is a group" test. Live P5 PSA's second * level has wid `illusTable`, Volvo's and Subaru's `illustrationsTable` — * none contain "group", so those nodes fell through to the parts fetcher, * which asked an *illustration list* for parts, got records with no partno, * and rendered a silent empty panel (`parts: []`, no error). This is the same * class of bug as the 2026-06 `isPsaParent` incident. * 2. Case-sensitive `includes("/bomdetails")` while p5psa and p5volvo spell the * endpoint `/details/vin/bomDetails` — so those leaves were queued as groups, * `getChildren` returned [] and their parts were never prefetched (live today * for Mitsubishi 99.9% / Fiat 80% / Renault 70% of bomDetails leaves). * * The reliable cross-brand marker is the response's own `link.wid`: `bomlist` * (and the service-parts/partinfo variants) means parts, anything else means * drill. Path matching stays as a fallback for stored rows without a wid. */ /** `link.wid` values that identify a parts (BOM) node across every P5 backend. */ const LEAF_WIDS = new Set(["bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo"]); /** `link.wid` values that identify a drillable group node. */ const GROUP_WID_PATTERN = /(group|scope|illus|illustration|catalog|model|vpages|msppages|chemicals|category|categories)/i; /** * Path fragments that only ever appear on a parts endpoint. `image-board` has no * leading slash on purpose: the legacy PSA leaf is `vin-image-board.action`. */ const LEAF_PATH_PATTERN = /\/(bom|bomdetails|partinfo|vin_items|mdl_items|vin_bomdetails)\b|\/bom\/|\/details\/vin\/bomdetails|\/servicepart\/vin_items|image-board/i; /** True when this node yields parts (never children). */ export function isPl24LeafNode(opts: { linkPath?: string | null; linkWid?: string | null; hasSubgroups?: boolean | null; }): boolean { const wid = opts.linkWid?.toLowerCase().trim(); if (wid) { if (LEAF_WIDS.has(wid)) return true; if (GROUP_WID_PATTERN.test(wid)) return false; } // Explicit DB hint wins over path guessing when there is no usable wid. if (opts.hasSubgroups === true) return false; const lp = opts.linkPath?.toLowerCase() ?? ""; if (!lp) return false; return LEAF_PATH_PATTERN.test(lp); } /** True when this node should be drilled for children. */ export function isPl24GroupNode(opts: { linkPath?: string | null; linkWid?: string | null; hasSubgroups?: boolean | null; }): boolean { if (!opts.linkPath && !opts.linkWid) return false; return !isPl24LeafNode(opts); }