diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index a26a697..e186e48 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -1,6 +1,10 @@ import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm"; -import { groupCategoriesByCanonical, overlayTemplate } from "../categories/canonical-grouping"; +import { + buildCanonicalInput, + groupCategoriesByCanonical, + overlayTemplate, +} from "../categories/canonical-grouping"; import { DATABASE, type Database } from "../database/database.provider"; import { brands, @@ -414,59 +418,13 @@ export class CatalogService { for (const r of rows) partCounts.set(r.categoryId, r.count); } - // Part-holding leaves give the granular view; fall back to root categories - // for freshly-decoded vehicles whose parts aren't populated yet. - // HYBRID: part-holding leaves + un-drilled roots, so every bucket shows - // immediately (structure exists at decode) while parts fill via prefetch. - const hasChildren = new Set(cats.map((c) => c.parentId).filter((p): p is string => !!p)); - const parentOf = new Map(cats.map((c) => [c.id, c.parentId])); - const rootOf = (id: string): string => { - let cur = id; - let p = parentOf.get(cur) ?? null; - while (p) { - cur = p; - p = parentOf.get(cur) ?? null; - } - return cur; - }; - const partLeaves = cats.filter((c) => (partCounts.get(c.id) ?? 0) > 0); - const drilledRoots = new Set(partLeaves.map((c) => rootOf(c.id))); - const undrilledRoots = cats.filter((c) => c.parentId === null && !drilledRoots.has(c.id)); - // Un-drilled BRANCH entries mapped to a bucket (browsable, drill on-demand) — - // see CategoriesService.getCanonicalTree. - const childrenOf = new Map(); - for (const c of cats) - if (c.parentId) { - const a = childrenOf.get(c.parentId) ?? []; - a.push(c.id); - childrenOf.set(c.parentId, a); - } - const subMemo = new Map(); - const subtreeParts = (id: string): number => { - const m = subMemo.get(id); - if (m !== undefined) return m; - let s = partCounts.get(id) ?? 0; - for (const ch of childrenOf.get(id) ?? []) s += subtreeParts(ch); - subMemo.set(id, s); - return s; - }; - const catById = new Map(cats.map((c) => [c.id, c])); - // Un-drilled bucket entries; a "system container" branch is flattened to its - // children (see CategoriesService.getCanonicalTree). - const undrilledEntries: typeof cats = []; - for (const c of cats) { - if (!c.canonicalId || c.parentId === null) continue; - if (subtreeParts(c.id) > 0) continue; - const p = catById.get(c.parentId); - if (p && p.canonicalId === c.canonicalId) continue; - const kids = (childrenOf.get(c.id) ?? []) - .map((id) => catById.get(id)) - .filter((k): k is (typeof cats)[number] => !!k?.canonicalId); - if (kids.length > 0) undrilledEntries.push(...kids); - else undrilledEntries.push(c); - } - const input = [...partLeaves, ...undrilledRoots, ...undrilledEntries]; - const groups = groupCategoriesByCanonical(input, partCounts, buckets, { + // Show each bucket's natural sub-category level (see buildCanonicalInput). + const { + input: shown, + counts: shownCounts, + hasChildren, + } = buildCanonicalInput(cats, partCounts, buckets); + const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, { requireParts: false, subLeaves, hasChildren, diff --git a/apps/api/src/categories/canonical-grouping.ts b/apps/api/src/categories/canonical-grouping.ts index 8136609..23f66a8 100644 --- a/apps/api/src/categories/canonical-grouping.ts +++ b/apps/api/src/categories/canonical-grouping.ts @@ -54,6 +54,69 @@ export interface CanonicalGroup { type LeafWithSub = CanonicalLeaf & { subId: string | null }; +// Build the canonical-view input from a vehicle's raw category tree. Shows each +// bucket's NATURAL sub-category level: the children of every bucket "entry" +// container ("Fren sistemi" → Disk frenler / Fren Kaliyeri / El fren sistemi …), +// with SUBTREE part counts, drilled or not — matching the source catalog. So +// clicking a bucket lands on its sub-categories, not a redundant "Fren sistemi" +// hop, and partially-drilled vehicles still show every sub-category (0 parça +// until drilled). Cross-bucket children ("Debriyaj sistemi" under Fren) are +// handled by their own entry → nothing double-shows. Bucketing is materialized +// (canonicalId) with an on-the-fly fallback for freshly-decoded categories. +export function buildCanonicalInput< + T extends { id: string; name: string; canonicalId: string | null; parentId: string | null }, +>( + cats: T[], + partCounts: Map, + buckets: CanonicalBucket[], +): { input: T[]; counts: Map; hasChildren: Set } { + const hasChildren = new Set(cats.map((c) => c.parentId).filter((p): p is string => !!p)); + const childrenOf = new Map(); + for (const c of cats) + if (c.parentId) { + const a = childrenOf.get(c.parentId) ?? []; + a.push(c.id); + childrenOf.set(c.parentId, a); + } + const subMemo = new Map(); + const subtreeParts = (id: string): number => { + const m = subMemo.get(id); + if (m !== undefined) return m; + let s = partCounts.get(id) ?? 0; + for (const ch of childrenOf.get(id) ?? []) s += subtreeParts(ch); + subMemo.set(id, s); + return s; + }; + const catById = new Map(cats.map((c) => [c.id, c])); + const bucketSlugToId = new Map(buckets.map((b) => [b.slug, b.id])); + const bucketOf = (c: T): string | null => { + if (c.canonicalId) return c.canonicalId; + const f = foldName(c.name); + if (classifyNode(f) !== "part") return null; + const { slug } = mapToCanonical(f); + return slug ? (bucketSlugToId.get(slug) ?? null) : null; + }; + const input: T[] = []; + const counts = new Map(); + const push = (c: T) => { + if (counts.has(c.id)) return; + counts.set(c.id, subtreeParts(c.id)); + input.push(c); + }; + for (const c of cats) { + const b = bucketOf(c); + if (!b) continue; + const p = c.parentId ? catById.get(c.parentId) : null; + if (p && bucketOf(p) === b) continue; // not a bucket entry — shown via its parent + const sameKids = (childrenOf.get(c.id) ?? []) + .map((id) => catById.get(id)) + .filter((k): k is T => !!k && bucketOf(k) === b); + if (sameKids.length > 0) for (const k of sameKids) push(k); + else push(c); // leaf entry (İç Aydınlatma Sistemi) or no same-bucket child + } + return { input, counts, hasChildren }; +} + export interface TemplateRow { bucketId: string; subId: string | null; diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index 43ee701..9e3a4f0 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -25,7 +25,11 @@ import { classifyNode, foldName, mapToCanonical } from "../jobs/canonical-lexico import { RedisService } from "../redis/redis.service"; import { StorageService } from "../storage/storage.service"; import { TranslationsService } from "../translations/translations.service"; -import { groupCategoriesByCanonical, overlayTemplate } from "./canonical-grouping"; +import { + buildCanonicalInput, + groupCategoriesByCanonical, + overlayTemplate, +} from "./canonical-grouping"; @Injectable() export class CategoriesService { @@ -597,67 +601,12 @@ export class CategoriesService { .groupBy(parts.categoryId); const partCounts = new Map(partCountRows.map((r) => [r.categoryId, r.count])); - // HYBRID input so EVERY bucket shows immediately, not only the drilled one: - // • part-holding leaves — the rich, granular view where drilled - // • un-drilled roots (whose subtree has no parts yet) — browsable headings - // The category STRUCTURE is created at decode (all roots exist right away), - // so all buckets are visible instantly; parts fill in as the prefetch worker - // deep-drills in the background. Without this, a partially-drilled vehicle - // (e.g. only "Şanzıman" drilled) would hide every other bucket. - // hasChildren: branch nodes — used to dedupe duplicate roots. - const hasChildren = new Set(cats.map((c) => c.parentId).filter((p): p is string => !!p)); - const parentOf = new Map(cats.map((c) => [c.id, c.parentId])); - const rootOf = (id: string): string => { - let cur = id; - let p = parentOf.get(cur) ?? null; - while (p) { - cur = p; - p = parentOf.get(cur) ?? null; - } - return cur; - }; - const partLeaves = cats.filter((c) => (partCounts.get(c.id) ?? 0) > 0); - const drilledRoots = new Set(partLeaves.map((c) => rootOf(c.id))); - const undrilledRoots = cats.filter((c) => c.parentId === null && !drilledRoots.has(c.id)); - // Also surface un-drilled BRANCH entries that map to a bucket but whose parts - // aren't drilled yet ("Debriyaj sistemi" under "Fren sistemi"): without this a - // bucket the vehicle DOES have — just not deep-drilled — shows as "0". These - // are browsable (clicking drills them on-demand, no wait for prefetch). - const childrenOf = new Map(); - for (const c of cats) - if (c.parentId) { - const a = childrenOf.get(c.parentId) ?? []; - a.push(c.id); - childrenOf.set(c.parentId, a); - } - const subMemo = new Map(); - const subtreeParts = (id: string): number => { - const m = subMemo.get(id); - if (m !== undefined) return m; - let s = partCounts.get(id) ?? 0; - for (const ch of childrenOf.get(id) ?? []) s += subtreeParts(ch); - subMemo.set(id, s); - return s; - }; - const catById = new Map(cats.map((c) => [c.id, c])); - // Un-drilled bucket entries. A "system container" branch ("Fren sistemi" → - // Fren) is a redundant hop, so we surface its CHILDREN instead (Balata/Disk/ - // Debriyaj sistemi…) — the user drills bucket → sub-category directly. Leaf - // entries ("İç Aydınlatma Sistemi") are shown as-is (browsable, on-demand). - const undrilledEntries: typeof cats = []; - for (const c of cats) { - if (!c.canonicalId || c.parentId === null) continue; - if (subtreeParts(c.id) > 0) continue; // has parts → shown as leaves - const p = catById.get(c.parentId); - if (p && p.canonicalId === c.canonicalId) continue; // not a bucket entry point - const kids = (childrenOf.get(c.id) ?? []) - .map((id) => catById.get(id)) - .filter((k): k is (typeof cats)[number] => !!k?.canonicalId); - if (kids.length > 0) undrilledEntries.push(...kids); - else undrilledEntries.push(c); - } - const input = [...partLeaves, ...undrilledRoots, ...undrilledEntries]; - const groups = groupCategoriesByCanonical(input, partCounts, buckets, { + const { + input: shown, + counts: shownCounts, + hasChildren, + } = buildCanonicalInput(cats, partCounts, buckets); + const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, { requireParts: false, subLeaves, hasChildren,