/** * 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. * * `detailstable` is Mitsubishi's: `/p5mitsubishi/extern/details/vinDetails` * answers with 16-ish records carrying `partno`/`qty`, i.e. it IS the parts * list — but each record's own link is a `partInfoTable` per-part detail, and * neither the wid nor the path matched anything here, so the whole list was * drilled as a group. Prod on 2026-09-20: 2,193 Mitsubishi parts lists turned * into group nodes and their 19,576 individual parts ("SCREW,LOCK CYLINDER", * "BOLT,STEERING COLUMN WASHER") became categories — 19,576 fake tree nodes * with 2 parts between them, each re-fetched on every prefetch pass. */ const LEAF_WIDS = new Set([ "bomlist", "bomoverviewlist", "servicepartsitemstable", "partinfo", "detailstable", ]); /** * Nodes that describe ONE part rather than a list of them. They are neither a * group to drill nor a list to fetch: the parent's own response already carried * the part. Queueing them buys nothing and costs one upstream request each — * 19,576 of them on prod before this was recognised. */ const PART_DETAIL_WIDS = new Set(["partinfotable"]); const PART_DETAIL_PATH = /\/details\/vinpartinfo\b/i; /** True when this node is a single part's detail view, not a listing. */ export function isPl24PartDetailNode(opts: { linkPath?: string | null; linkWid?: string | null; }): boolean { const wid = opts.linkWid?.toLowerCase().trim(); if (wid && PART_DETAIL_WIDS.has(wid)) return true; return PART_DETAIL_PATH.test(opts.linkPath ?? ""); } /** `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; // A per-part detail node is not a group; drilling it returns nothing. if (isPl24PartDetailNode(opts)) return false; return !isPl24LeafNode(opts); } /** * True when a stored vehicle's catalogInfo points at an architecture the service * no longer uses — i.e. the row was decoded before PSA/Volvo moved to P5. * * These vehicles keep serving a tree built from the frozen P4 PSA snapshot * (upds 2024-02-13) or the dead P4 Volvo endpoint (HTTP 503), and `decodeVin`'s * db_hit short-circuit means a code fix never reaches them. Detecting the * mismatch at read time lets each vehicle heal itself on first view instead of * needing a bulk re-decode storm against the one surviving account. */ export function isStalePl24Architecture(opts: { catalogPath?: string | null; currentApiPath?: string | null; }): boolean { const stored = opts.catalogPath?.toLowerCase() ?? ""; const current = opts.currentApiPath?.toLowerCase() ?? ""; if (!stored || !current) return false; // Only the two migrated legacy backends; unknown/other paths are left alone. const storedIsLegacyPsaOrVolvo = stored.startsWith("/psa") || stored.startsWith("/volvo"); if (!storedIsLegacyPsaOrVolvo) return false; return current.startsWith("/p5"); } /** * True when a stored `catalog_vehicles` browse row was listed under an * architecture the service table no longer uses. * * Browse rows are a PERMANENT cache: `CatalogService.getModels` returns early * whenever the brand already has rows, so `fetchVehicleList` is never called * again for that brand. The 188 rows listed while PSA and Volvo were still P4 * (127 LEGACY_PSA + 61 LEGACY_VOLVO on prod, 2026-09-20) are therefore pinned * forever: Peugeot/Citroën browse serves the frozen 2024-02-13 snapshot and * Volvo/Polestar browse serves an endpoint that answers HTTP 503. Detecting the * mismatch at list time lets the brand re-list itself once, the same way * `isStalePl24Architecture` heals a VIN-decoded vehicle. */ export function isStaleBrowseArchitecture(opts: { storedArchitecture?: string | null; currentArchitecture?: string | null; }): boolean { const stored = opts.storedArchitecture?.trim(); const current = opts.currentArchitecture?.trim(); // An unknown service (no config) or an unlabelled row is left alone: without a // current architecture to compare against there is nothing to migrate TO. if (!stored || !current) return false; return stored !== current; }