29 Commits

Author SHA1 Message Date
de960a0dd4 feat(catalog): eper as the default Fiat catalog source, pcat one tap away
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fiat dealers know the native ePER tree; parts-catalogs' broader-but-lazy folders
are where they hit gaps (a motor OEM missing under "Motor" — the reason we
crawled pekidi ePER). So for a multi-source decoded vehicle, default the
"Orijinal Katalog" view to eper-pekidi — fully populated with parts + diagrams —
instead of showing every source's roots mixed together.

- categories.service.getCategoryTree(vehicleId, source?): prefer eper-pekidi when
  present; ?source= overrides; cache keyed per source
- categories: getCatalogSources(vehicleId) → sources present, default (eper) first
- controller: tree/:id/sources endpoint + ?source query
- web: source switcher pill (ePER ⇄ Diğer) in source mode when >1 source;
  defaults to the first (eper). pcat never hidden, just one tap away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 06:42:04 +03:00
cee11b5769 Merge pull request 'fix(vinpin): reject stale-breadcrumb decodes + harden matcher' (#214) from fix/vinpin-false-positive into dev 2026-07-13 12:09:49 +03:00
70d901f9f5 fix(vinpin): reject stale-breadcrumb decodes (require SINCOM) + harden matcher
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
A failed ePER VIN lookup ("vehicle not found") whose Cyrillic "не найден" alert
OCRs to garbage ("He HaaeHs") slipped the not-found check; the parser then
grabbed the operator's last-browsed catalog from the breadcrumb
("FIAT » TIPO-EGEA") and false-mapped it to a catalog — NM4131 landed on the
ancient "Tipo 1100-1370-1580 (1987-1993)", plus 2× PANDA. Every genuine decode
carries a SINCOM; the breadcrumb false positives never do.

- parser: isUsableParse now requires a SINCOM (a model alone is not a decode)
- matcher: modelTokens keeps 4-digit engine displacements (1100/1370/1580) —
  only 1950-2039 count as years — so ancient catalogs no longer collapse to
  ["TIPO"] and win the fewest-extra-tokens tiebreak
- matcher: recency tiebreak on equal score — never fall back to an ancient
  generation for a bare / year-less model

15 unit tests (parser + matcher) pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:08:40 +03:00
731ac93a55 Merge pull request 'public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth' (#212) from dev into main
Reviewed-on: #212
2026-07-10 12:44:09 +03:00
oyp
0e8b2ab294 public-api: katalog gezinme (VIN'siz araç ağacı) endpoint'leri /v1/catalog/* — marka→model→motor→kategori, API-key auth
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:27:36 +03:00
814b798b3f Merge pull request 'feat(internal-admin): extend limiti 90 → 3650 gün' (#211) from fix/extend-days-cap into main
Reviewed-on: #211
2026-07-09 11:27:23 +03:00
c51b7b4e78 feat(internal-admin): extend limiti 90 → 3650 gün
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Panelin 'Bonus süre ekle (goodwill)' aksiyonu 360 gün gibi süreler
gerektiriyor; 90 gün sınırı iş ihtiyacını karşılamıyordu. Üst sınır
3650 (10 yıl) parmak-kayması koruması olarak kalıyor. Panel tarafı
(sp) aynı limite güncellendi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 10:35:28 +03:00
ff81b244b7 Merge pull request 'fix: şerit-bazlı rate bütçesi' (#209) from dev into main 2026-07-09 10:01:55 +03:00
8dd2d223e9 fix(prefetch): şerit-bazlı kaynak bütçesi — fast lane açlığını bitir
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Fast worker canlıydı ama pcat'in 90/dk penceresini ana worker yiyordu → fast
işler sürekli 18s source-rate defer (BMW init'i slot bulamadı). checkSourceRate
artık şerit-bazlı: fast = total/3 (pcat 30), main = kalan (60); toplam
değişmedi → upstream yükü aynı. Cooldown pause'u da doğru worker'ı duraklatıyor.
Spec fast-queue davranışına güncellendi (17/17).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:01:47 +03:00
36ed37d5c0 Merge pull request 'feat: prefetch fast kuyruk' (#208) from dev into main 2026-07-09 09:13:34 +03:00
c8803ada35 feat(prefetch): ayrı FAST kuyruk — taze araçlar backlog'la yarışmasın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kök neden: ana kuyruk saatlerce üretim>tüketim rejiminde (children patlaması);
lifo'da sonradan eklenen HER iş bir öncekini gömer → taze araç init'leri bile
açlığa düşer (BMW 320i 58dk 0 parça). Fix: catalog-prefetch-fast kuyruğu +
ikinci worker (conc=4, 120/dk) — decode-tetiği ve fast:true işler (children
zinciri dahil) buraya akar; per-source redis rate gate iki worker'ı da ortak
sınırlar (upstream korunur). vehicles.service fast kuyruğa bağlandı.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 09:13:26 +03:00
ccc2cd75a5 Merge pull request 'feat: birleşikte görünüm değiştiriciler' (#207) from dev into main 2026-07-09 08:11:24 +03:00
f2a37b78cb feat(web): Birleşik görünümde grid/ağaç/kolon değiştiricileri çalışsın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
CanonicalView viewMode prop'u: tree/columns için kanonik veriden sentetik
hiyerarşi (kova → alt-yaprak → gerçek kategoriler; sentetik düğümler children'lı
geldiğinden lazy-fetch tetiklenmez, gerçek kategoriler children'sız geçip normal
drill yapar; tpl-* placeholder'lar bu görünümlere alınmaz — grid'de kalır).
vehicles sayfasında CategoryViewToggle artık canonical'da da görünür; katalog
sayfası viewMode'u geçirir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:11:16 +03:00
0e2871c964 Merge pull request 'feat: katalog sayfası Birleşik toggle' (#206) from dev into main 2026-07-09 08:07:21 +03:00
116def2c79 feat(web): pl24 katalog sayfasına Birleşik/Orijinal toggle
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
/dashboard/catalog/:brand/:modelId sayfasında birleşik görünüm yoktu — backend
endpoint'i (/catalog/vehicles/:id/canonical) hazırdı ama web'e hiç bağlanmamıştı.
CanonicalView'a endpoint/catalogMode/brandName/variantSearch prop'ları eklendi
(varyant qs dahil); sayfaya vehicles/$id ile aynı toggle. Varsayılan Orijinal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 08:07:13 +03:00
bcf8bb38cd Merge pull request 'fix: kanonik sayaç + entry cap' (#205) from dev into main 2026-07-09 07:45:09 +03:00
d38bcaa36f fix(canonical): kova başlık sayacı subgroup'ları da saysın + entry cap 40/kova
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
(1) Placeholder'lar subgroup içinde olduğundan başlık "0 kategori" diyordu —
sayaç artık flat+subgroup birleşik unique. (2) Kalabalık markalar (pl24 BMW
elektrik 537 entry) görünümü boğuyordu — kova başına en-paylaşılan 40 entry
(vehicle_count desc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:45:02 +03:00
25965717c3 Merge pull request 'fix: entries ambiguity' (#204) from dev into main 2026-07-09 07:28:15 +03:00
671e9af15b fix(canonical-backfill): entries GROUP BY'da name'i c.name ile nitele (parent join ambiguity)
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:28:07 +03:00
da95b0484d Merge pull request 'canonical: şablon entry-seviyesi' (#203) from dev into main 2026-07-09 07:23:51 +03:00
5ed3959a12 feat(canonical): şablon ENTRY-seviyesi — decode anında tam gezinilebilir yapı
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Kullanıcının orijinal tasarımı: aynı (kaynak, marka)da kategori yapısı birebir
aynı — değişen yalnız parça kodları/şemalar. Önceki şablon sadece kova/alt-yaprak
BAŞLIKLARINI overlay ediyordu → taze araçta "Aydınlatma — 0 kategori". Yeni:
canonical_template_entries (migration 0031) — markanın paylaşılan kaynak-kategori
ADLARI (≥2 araç); backfill'de rebuild. overlayTemplate eksik adları disabled
placeholder olarak basar (unavailable=true, CategoryGrid zaten soluk+tıklanamaz
render ediyor) → decode anında her kova tam adlarıyla dolu; drill tamamlanınca
placeholder'lar gerçek satıra dönüşür.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:23:43 +03:00
eb92c5c5f8 Merge pull request 'fix: orijinal default' (#202) from dev into main 2026-07-09 07:12:00 +03:00
8a079d84f8 fix(web): Orijinal Katalog tekrar varsayılan — taze araçta boş kanonik kovalar çözülene dek
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 07:11:52 +03:00
1c4c896d64 Merge pull request 'feature/vin-ocr' (#201) from feature/vin-ocr into main
Reviewed-on: #201
2026-07-09 07:05:10 +03:00
cafe206001 Merge pull request 'Merge branch 'feature/widget-canonical' into dev' (#199) from dev into main 2026-07-08 06:48:39 +03:00
a803d9da10 Merge pull request 'fix(vinpin): query_logs muhasebesi — pending poll'lar failure satırı yazmasın' (#200) from fix/vinpin-query-log-accounting into main 2026-07-08 06:47:50 +03:00
Semih
8054e2faf3 fix(vinpin): query_logs muhasebesi — poll'lar failure satırı yazmasın
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Vinpin fallback'i failure logQuery'sinden ÖNCE denenir oldu:

- Pending decode'un her 3sn'lik frontend poll'u 'No catalog — identified
  as Fiat' failure satırı yazıyordu: vinpin'in 10sn'de ÇÖZDÜĞÜ tek Egea
  VIN'i 17 failure satırı üretti, ham 24h success -20.7pp okundu
  (2026-07-07, 4 kullanıcı/4 VIN, gerçek infra hatası 0).
- Resolved VIN'lerde her decode kalıcı olarak fail+success çifti
  yazıyordu (failure log vinpin lookup'tan önceydi ve negatif cache
  vinpin çözümünden haberdar değil).

Yeni muhasebe: VIN başına TEK coverage-gap failure satırı (ilk görüş,
enqueue anı — coverage backlog sinyali korunur); pending poll'lar hiç
loglanmaz; resolved decode tek success satırı loglar; not_found/failed
eskisi gibi caller'ın failure log'una düşer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 06:40:23 +03:00
006cf1fd63 Merge pull request 'feat(widget): kanonik (birleşik) kategori görünümü' (#198) from feature/widget-canonical into main
Reviewed-on: #198
2026-07-06 11:47:33 +03:00
454eab6b0e Merge pull request 'dev' (#197) from dev into main
Reviewed-on: #197
2026-07-06 11:10:03 +03:00
25 changed files with 927 additions and 72 deletions

View File

@@ -0,0 +1,17 @@
-- 0031: brand-template ENTRY names — the browsable source-category names every
-- vehicle of (source, brand) shares. Lets a freshly-decoded vehicle show the
-- FULL canonical structure (named entries under each bucket) before its own
-- deep drill; only part counts/schemas are vehicle-specific. Rebuilt by the
-- global canonical-backfill run alongside canonical_structure_template.
CREATE TABLE IF NOT EXISTS "canonical_template_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source" varchar(20) NOT NULL,
"brand" varchar(120) NOT NULL,
"canonical_category_id" uuid NOT NULL,
"canonical_subcategory_id" uuid,
"name" varchar(500) NOT NULL,
"vehicle_count" integer DEFAULT 0 NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "canonical_template_entries_sb_idx" ON "canonical_template_entries" ("source","brand");

View File

@@ -218,6 +218,13 @@
"when": 1783091194145, "when": 1783091194145,
"tag": "0030_categories_dedup_unique", "tag": "0030_categories_dedup_unique",
"breakpoints": true "breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1783092194145,
"tag": "0031_canonical_template_entries",
"breakpoints": true
} }
] ]
} }

View File

@@ -10,6 +10,7 @@ import {
brands, brands,
canonicalCategories, canonicalCategories,
canonicalStructureTemplate, canonicalStructureTemplate,
canonicalTemplateEntries,
catalogVehicles, catalogVehicles,
categories, categories,
parts, parts,
@@ -452,7 +453,32 @@ export class CatalogService {
gte(canonicalStructureTemplate.vehicleCount, 2), gte(canonicalStructureTemplate.vehicleCount, 2),
), ),
); );
return overlayTemplate(groups, template, buckets, subLeaves); const templateEntriesRaw = await this.db
.select({
bucketId: canonicalTemplateEntries.canonicalCategoryId,
subId: canonicalTemplateEntries.canonicalSubcategoryId,
name: canonicalTemplateEntries.name,
vehicleCount: canonicalTemplateEntries.vehicleCount,
})
.from(canonicalTemplateEntries)
.where(
and(
eq(canonicalTemplateEntries.source, source),
eq(canonicalTemplateEntries.brand, brand),
gte(canonicalTemplateEntries.vehicleCount, 2),
),
);
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
// drown the view; the widely-shared ones are the real "brand structure".
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
const perBucket = new Map<string, number>();
const templateEntries = templateEntriesRaw.filter((e) => {
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
perBucket.set(e.bucketId, n);
return n <= 40;
});
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
} }
async getCategoryTree( async getCategoryTree(

View File

@@ -34,6 +34,15 @@ interface CanonicalLeaf {
name: string; name: string;
source: string; source: string;
partCount: number; partCount: number;
// Template placeholder: the brand is known to have this entry but THIS
// vehicle's row isn't drilled yet — rendered disabled ("dolduruluyor").
unavailable?: boolean;
}
export interface TemplateEntry {
bucketId: string;
subId: string | null;
name: string;
} }
export interface CanonicalSubgroup { export interface CanonicalSubgroup {
@@ -132,8 +141,13 @@ export function overlayTemplate(
template: TemplateRow[], template: TemplateRow[],
buckets: CanonicalBucket[], buckets: CanonicalBucket[],
subLeaves: SubLeafRef[], subLeaves: SubLeafRef[],
// Entry-level template: the brand's shared source-category NAMES per bucket/
// sub. Names this vehicle doesn't have yet are added as disabled placeholders
// so the FULL browsable structure shows immediately after decode ("yapı aynı,
// sadece parça kodları/şemalar araca özel").
entries: TemplateEntry[] = [],
): CanonicalGroup[] { ): CanonicalGroup[] {
if (template.length === 0) return groups; if (template.length === 0 && entries.length === 0) return groups;
const bucketById = new Map(buckets.map((b) => [b.id, b])); const bucketById = new Map(buckets.map((b) => [b.id, b]));
const subById = new Map(subLeaves.map((s) => [s.id, s])); const subById = new Map(subLeaves.map((s) => [s.id, s]));
const byBucket = new Map(groups.map((g) => [g.canonical.id, g])); const byBucket = new Map(groups.map((g) => [g.canonical.id, g]));
@@ -176,6 +190,49 @@ export function overlayTemplate(
g.subgroups.sort((a, z) => z.partCount - a.partCount); g.subgroups.sort((a, z) => z.partCount - a.partCount);
} }
// Merge entry-level template names as placeholders (skip ones the vehicle
// already shows anywhere in the bucket — matched on folded name).
if (entries.length > 0) {
let tplSeq = 0;
for (const e of entries) {
let g = byBucket.get(e.bucketId);
if (!g) {
const b = bucketById.get(e.bucketId);
if (!b) continue;
g = { canonical: { id: b.id, slug: b.slug, name: b.name }, partCount: 0, categories: [] };
result.push(g);
byBucket.set(e.bucketId, g);
}
const have = new Set<string>();
for (const c of g.categories) have.add(foldName(c.name));
for (const sg of g.subgroups ?? []) for (const c of sg.categories) have.add(foldName(c.name));
if (have.has(foldName(e.name))) continue;
const leaf: CanonicalLeaf = {
id: `tpl-${++tplSeq}`,
name: e.name,
source: "template",
partCount: 0,
unavailable: true,
};
const sub = e.subId ? subById.get(e.subId) : undefined;
if (sub) {
if (!g.subgroups) g.subgroups = [];
let sg = g.subgroups.find((x) => x.canonical.id === sub.id);
if (!sg) {
sg = {
canonical: { id: sub.id, slug: sub.slug, name: sub.name },
partCount: 0,
categories: [],
};
g.subgroups.push(sg);
}
sg.categories.push(leaf);
} else {
g.categories.push(leaf);
}
}
}
const order = new Map(buckets.map((b, i) => [b.id, i])); const order = new Map(buckets.map((b, i) => [b.id, i]));
result.sort( result.sort(
(a, z) => (order.get(a.canonical.id ?? "") ?? 999) - (order.get(z.canonical.id ?? "") ?? 999), (a, z) => (order.get(a.canonical.id ?? "") ?? 999) - (order.get(z.canonical.id ?? "") ?? 999),

View File

@@ -5,9 +5,19 @@ import { CategoriesService } from "./categories.service";
export class CategoriesController { export class CategoriesController {
constructor(private categoriesService: CategoriesService) {} constructor(private categoriesService: CategoriesService) {}
// Sources this vehicle has (default/eper first) — drives the source switcher.
// Declared before "tree/:vehicleId" so the literal segment wins the match.
@Get("tree/:vehicleId/sources")
async getCatalogSources(@Param("vehicleId") vehicleId: string) {
return this.categoriesService.getCatalogSources(vehicleId);
}
@Get("tree/:vehicleId") @Get("tree/:vehicleId")
async getCategoryTree(@Param("vehicleId") vehicleId: string) { async getCategoryTree(
return this.categoriesService.getCategoryTree(vehicleId); @Param("vehicleId") vehicleId: string,
@Query("source") source?: string,
) {
return this.categoriesService.getCategoryTree(vehicleId, source);
} }
@Get("search/:vehicleId") @Get("search/:vehicleId")

View File

@@ -8,6 +8,7 @@ import { DATABASE, type Database } from "../database/database.provider";
import { import {
canonicalCategories, canonicalCategories,
canonicalStructureTemplate, canonicalStructureTemplate,
canonicalTemplateEntries,
categories, categories,
parts, parts,
schemaPics, schemaPics,
@@ -49,8 +50,8 @@ export class CategoriesService {
private emexSourceDb: EmexSourceDbService, private emexSourceDb: EmexSourceDbService,
) {} ) {}
async getCategoryTree(vehicleId: string) { async getCategoryTree(vehicleId: string, sourceFilter?: string) {
const cacheKey = `cat:tree:${vehicleId}`; const cacheKey = `cat:tree:${vehicleId}${sourceFilter ? `:${sourceFilter}` : ""}`;
const cached = await this.redis.getJson<any[]>(cacheKey); const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached; if (cached) return cached;
@@ -456,8 +457,23 @@ export class CategoriesService {
partCountRows.map((r) => [r.categoryId, r.count]), partCountRows.map((r) => [r.categoryId, r.count]),
); );
// Default catalog source for a multi-source vehicle: prefer eper-pekidi — the
// native OEM ePER tree Fiat dealers already know, fully populated with parts +
// diagrams — over parts-catalogs' broader-but-lazy (drill-on-demand) folders,
// which is where dealers hit gaps (a motor OEM missing under "Motor"). pcat is
// never dropped: the ?source override + /sources endpoint keep it one tap away.
const presentSources = [...new Set(dbCategories.map((c) => c.source))];
const activeSource =
sourceFilter && presentSources.includes(sourceFilter)
? sourceFilter
: presentSources.includes("eper-pekidi")
? "eper-pekidi"
: null;
const forTree = activeSource
? dbCategories.filter((c) => c.source === activeSource)
: dbCategories;
// Build tree // Build tree
const tree = this.buildTree(dbCategories, directPartCounts); const tree = this.buildTree(forTree, directPartCounts);
// Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy // Cache a populated tree for an hour; an EMPTY tree (transient decode/proxy
// failure) only for 60s so a blip doesn't poison the catalog for an hour — // failure) only for 60s so a blip doesn't poison the catalog for an hour —
// it self-heals on the next request after the source recovers, while still // it self-heals on the next request after the source recovers, while still
@@ -479,6 +495,25 @@ export class CategoriesService {
return tree; return tree;
} }
/**
* The catalog sources a decoded vehicle has categories from, ordered with the
* default first (eper-pekidi — the dealer-familiar native ePER catalog). Drives
* the per-vehicle source switcher (ePER ⇄ Diğer); a single source → no switcher.
*/
async getCatalogSources(vehicleId: string): Promise<string[]> {
const rows = await this.db
.selectDistinct({ source: categories.source })
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
const present = rows.map((r) => r.source);
const order = ["eper-pekidi", "parts-catalogs", "emex", "pl24"];
return present.sort((a, b) => {
const ia = order.indexOf(a);
const ib = order.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
}
/** /**
* The unified canonical taxonomy (20 top buckets), source/brand-independent. * The unified canonical taxonomy (20 top buckets), source/brand-independent.
* Used for the browse landing + as the fixed set of headings every vehicle's * Used for the browse landing + as the fixed set of headings every vehicle's
@@ -636,7 +671,32 @@ export class CategoriesService {
gte(canonicalStructureTemplate.vehicleCount, 2), gte(canonicalStructureTemplate.vehicleCount, 2),
), ),
); );
return overlayTemplate(groups, template, buckets, subLeaves); const templateEntriesRaw = await this.db
.select({
bucketId: canonicalTemplateEntries.canonicalCategoryId,
subId: canonicalTemplateEntries.canonicalSubcategoryId,
name: canonicalTemplateEntries.name,
vehicleCount: canonicalTemplateEntries.vehicleCount,
})
.from(canonicalTemplateEntries)
.where(
and(
eq(canonicalTemplateEntries.source, source),
eq(canonicalTemplateEntries.brand, brand),
gte(canonicalTemplateEntries.vehicleCount, 2),
),
);
// Cap placeholders at the MOST-SHARED 40 per bucket — some brands carry
// hundreds of model-variant names (pl24 BMW elektrik: 500+) that would
// drown the view; the widely-shared ones are the real "brand structure".
templateEntriesRaw.sort((a, z) => z.vehicleCount - a.vehicleCount);
const perBucket = new Map<string, number>();
const templateEntries = templateEntriesRaw.filter((e) => {
const n = (perBucket.get(e.bucketId) ?? 0) + 1;
perBucket.set(e.bucketId, n);
return n <= 40;
});
return overlayTemplate(groups, template, buckets, subLeaves, templateEntries);
} }
/** /**

View File

@@ -1017,6 +1017,25 @@ export const categoryCanonicalMap = pgTable(
// brand template so the user sees the FULL canonical structure instantly, before // brand template so the user sees the FULL canonical structure instantly, before
// this vehicle's own tree is deep-drilled by the prefetch backfill. Rebuilt at // this vehicle's own tree is deep-drilled by the prefetch backfill. Rebuilt at
// the end of each global canonical-backfill run (derived, so full-refresh). // the end of each global canonical-backfill run (derived, so full-refresh).
// Entry-level companion of canonicalStructureTemplate: the browsable source-
// category NAMES every vehicle of (source, brand) shares under a bucket/sub.
// A freshly-decoded vehicle renders these immediately (placeholder until its
// own row exists) — only parts/schemas are vehicle-specific.
export const canonicalTemplateEntries = pgTable(
"canonical_template_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
source: varchar("source", { length: 20 }).notNull(),
brand: varchar("brand", { length: 120 }).notNull(),
canonicalCategoryId: uuid("canonical_category_id").notNull(),
canonicalSubcategoryId: uuid("canonical_subcategory_id"),
name: varchar("name", { length: 500 }).notNull(),
vehicleCount: integer("vehicle_count").default(0).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("canonical_template_entries_sb_idx").on(table.source, table.brand)],
);
export const canonicalStructureTemplate = pgTable( export const canonicalStructureTemplate = pgTable(
"canonical_structure_template", "canonical_structure_template",
{ {

View File

@@ -12,6 +12,18 @@ describe("modelTokens", () => {
expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]); expect(modelTokens("Tipo-Egea MCA (2020-....)")).toEqual(["TIPO", "EGEA", "MCA"]);
expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]); expect(modelTokens("DOBLO REST. 2005 (2005-2016)")).toEqual(["DOBLO", "REST"]);
}); });
it("keeps engine displacements as tokens (only 1950-2039 count as years)", () => {
// The 4-digit engine sizes must survive — dropping them made the ancient
// "TIPO 1100-1370-1580" collapse to just ["TIPO"] and win the tiebreak.
expect(modelTokens("TIPO 1100-1370-1580 (1987-1993)")).toEqual([
"TIPO",
"1100",
"1370",
"1580",
]);
expect(modelTokens("TIPO 1750-2000 (1990-1993)")).toEqual(["TIPO", "1750"]); // 2000 = year-range
});
}); });
describe("parseYearRange", () => { describe("parseYearRange", () => {
@@ -52,4 +64,25 @@ describe("pickBestCatalogMatch", () => {
it("returns null for an empty model", () => { it("returns null for an empty model", () => {
expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull(); expect(pickBestCatalogMatch({ model: null, modelYear: "2018" }, candidates)).toBeNull();
}); });
it("never falls back onto an ancient catalog for a bare, year-less model", () => {
const withAncient: CatalogCandidate[] = [
...candidates,
{ id: "tipo-1987", model: "TIPO 1100-1370-1580 (1987-1993)", year: "1987-1993" },
];
// Bare "TIPO", no year (the exact shape of the NM4131 false positive that
// used to land on the 1987 Tipo). Must resolve to a modern Egea instead.
const id = pickBestCatalogMatch({ model: "TIPO", modelYear: null }, withAncient);
expect(id).not.toBe("tipo-1987");
expect(["egea", "egea-mca"]).toContain(id);
});
it("breaks a score tie toward the newer catalog", () => {
const puntos: CatalogCandidate[] = [
{ id: "punto-old", model: "PUNTO (1993-1999)", year: "1993-1999" },
{ id: "punto-new", model: "PUNTO (2012-2018)", year: "2012-2018" },
];
// Identical tokens + no year → equal score → recency decides.
expect(pickBestCatalogMatch({ model: "PUNTO", modelYear: null }, puntos)).toBe("punto-new");
});
}); });

View File

@@ -26,14 +26,18 @@ export interface DecodedForMatch {
} }
/** Tokenize a model string into upper-case model tokens, dropping parenthetical /** Tokenize a model string into upper-case model tokens, dropping parenthetical
* groups (year ranges) and bare 4-digit years. "TIPO - EGEA (2015-2021)" → * groups (year ranges) and bare 4-digit *years*. "TIPO - EGEA (2015-2021)" →
* ["TIPO","EGEA"]. Keeps "500" / "500X" (not 4-digit years). */ * ["TIPO","EGEA"]. Keeps "500"/"500X" AND engine displacements like 1100 / 1370
* / 1580 / 2400 — only 1950-2039 (plausible car years) are treated as years.
* Dropping displacements made ancient catalogs ("TIPO 1100-1370-1580") collapse
* to just ["TIPO"], looking falsely specific and winning the fewest-extra-tokens
* tiebreak over the modern Egea. */
export function modelTokens(s: string | null | undefined): string[] { export function modelTokens(s: string | null | undefined): string[] {
if (!s) return []; if (!s) return [];
return s return s
.toUpperCase() .toUpperCase()
.replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges .replace(/\([^)]*\)/g, " ") // drop parenthetical year ranges
.replace(/\b\d{4}\b/g, " ") // drop bare years (keeps 3-digit "500") .replace(/\b(19[5-9]\d|20[0-3]\d)\b/g, " ") // drop bare YEARS 1950-2039; keep displacements
.replace(/[^A-Z0-9]+/g, " ") .replace(/[^A-Z0-9]+/g, " ")
.split(" ") .split(" ")
.map((t) => t.trim()) .map((t) => t.trim())
@@ -81,7 +85,7 @@ export function pickBestCatalogMatch(
if (decTokens.length === 0) return null; if (decTokens.length === 0) return null;
const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null; const decYear = decoded.modelYear ? Number.parseInt(decoded.modelYear, 10) : null;
let best: { id: string; score: number } | null = null; let best: { id: string; score: number; startYear: number } | null = null;
for (const cand of candidates) { for (const cand of candidates) {
const candText = `${cand.model ?? ""} ${cand.year ?? ""}`; const candText = `${cand.model ?? ""} ${cand.year ?? ""}`;
@@ -100,8 +104,12 @@ export function pickBestCatalogMatch(
const extra = candTokens.filter((t) => !decTokens.includes(t)).length; const extra = candTokens.filter((t) => !decTokens.includes(t)).length;
score -= extra; score -= extra;
if (!best || score > best.score) { // Final tiebreak on equal score: prefer the newer catalog. Without a year
best = { id: cand.id, score }; // signal an ambiguous model (e.g. bare "TIPO") must never fall back onto an
// ancient generation — modern is the overwhelmingly likelier intent.
const startYear = range?.start ?? 0;
if (!best || score > best.score || (score === best.score && startYear > best.startYear)) {
best = { id: cand.id, score, startYear };
} }
} }

View File

@@ -35,4 +35,18 @@ describe("parseVinpinModal", () => {
expect(isUsableParse(p)).toBe(false); expect(isUsableParse(p)).toBe(false);
expect(isUsableParse(parseVinpinModal(null))).toBe(false); expect(isUsableParse(parseVinpinModal(null))).toBe(false);
}); });
it("rejects a stale-breadcrumb model with no SINCOM (vehicle-not-found false positive)", () => {
// Real garbled OCR of a failed NM4131 decode: the "не найден" alert is
// mangled ("He HaaeHs") so it slips the not-found check, and the screen
// still shows the operator's last catalog ("FIAT » TIPO - EGEA"). A model
// token is present but there is NO SINCOM → must be treated as unusable so
// it is not false-mapped onto the ancient "TIPO 1987-1993" catalog.
const p = parseVinpinModal(
"Fiat Dealer Spare Parts FIAT TIPO - EGBA 500 HYBRID TIPO-EGEA MCA (2020) DOBLO FREEMONT LINEA IDEA To yKasaHHeIM N3paMETpaM 3BTOMOBHMN He HaaeHs",
);
expect(p.model).not.toBeNull(); // breadcrumb model still extracted…
expect(p.sincom).toBeNull(); // …but no SINCOM
expect(isUsableParse(p)).toBe(false); // → not a real decode
});
}); });

View File

@@ -91,7 +91,18 @@ export function parseVinpinModal(rawText: string | null | undefined): VinpinPars
return { model, sincom, trim, modelYear, engine }; return { model, sincom, trim, modelYear, engine };
} }
/** A parse is usable if we got at least a model or a SINCOM code. */ /**
* A parse is usable only when we got a SINCOM code — the trim identifier that a
* genuine ePER decode always emits ("356G37001162"). A model *alone* is a stale
* breadcrumb: when the VIN search returns "vehicle not found" (a TR NM4 VIN ePER
* can't resolve), the screen still shows whatever catalog the operator was last
* browsing (e.g. "FIAT » TIPO - EGEA"), and the OCR of the Cyrillic "не найден"
* alert garbles enough to slip past the not-found check. Grabbing that
* breadcrumb model ("TIPO"/"PANDA") and matching it to a catalog is a false
* positive that lands users on the wrong (often ancient) catalog. Requiring the
* SINCOM cleanly separates the two: every genuine decode carries one; the
* breadcrumb false positives never do.
*/
export function isUsableParse(p: VinpinParsed): boolean { export function isUsableParse(p: VinpinParsed): boolean {
return Boolean(p.model || p.sincom); return Boolean(p.sincom);
} }

View File

@@ -31,8 +31,8 @@ export class BillingService {
reason: string; reason: string;
founderId: string; founderId: string;
}) { }) {
if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 90) { if (!Number.isFinite(input.days) || input.days <= 0 || input.days > 3650) {
throw new BadRequestException("days must be 1..90"); throw new BadRequestException("days must be 1..3650");
} }
const [sub] = await this.db const [sub] = await this.db
.select() .select()

View File

@@ -25,6 +25,12 @@ export const QUEUE_NAMES = {
SUBSCRIPTION_EXPIRY: "subscription-expiry", SUBSCRIPTION_EXPIRY: "subscription-expiry",
QUERY_CLEANUP: "query-cleanup", QUERY_CLEANUP: "query-cleanup",
CATALOG_PREFETCH: "catalog-prefetch", CATALOG_PREFETCH: "catalog-prefetch",
// Dedicated lane for freshly-decoded vehicles: the main queue's deep-drill
// backlog runs in a production>consumption regime for hours at a time, where
// lifo tail-insertion starves ANY job pushed a moment earlier — including new
// vehicles' init chains (BMW 320i sat 58min at 0 parts). A separate queue +
// worker gives fresh decodes their own consumption budget.
CATALOG_PREFETCH_FAST: "catalog-prefetch-fast",
TRANSLATION: "translation", TRANSLATION: "translation",
LIFECYCLE_EMAIL: "lifecycle-email", LIFECYCLE_EMAIL: "lifecycle-email",
EXPERT_REWARDS: "expert-rewards", EXPERT_REWARDS: "expert-rewards",

View File

@@ -8,7 +8,9 @@ import {
CanonicalBackfillQueueProvider, CanonicalBackfillQueueProvider,
} from "./queues/canonical-backfill.queue"; } from "./queues/canonical-backfill.queue";
import { import {
CATALOG_PREFETCH_FAST_QUEUE,
CATALOG_PREFETCH_QUEUE, CATALOG_PREFETCH_QUEUE,
CatalogPrefetchFastQueueProvider,
CatalogPrefetchQueueProvider, CatalogPrefetchQueueProvider,
} from "./queues/catalog-prefetch.queue"; } from "./queues/catalog-prefetch.queue";
import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue"; import { EMEX_SCRAPE_QUEUE, EmexScrapeQueueProvider } from "./queues/emex-scrape.queue";
@@ -32,6 +34,7 @@ import { VINPIN_DECODE_QUEUE, VinpinDecodeQueueProvider } from "./queues/vinpin-
SubscriptionExpiryQueueProvider, SubscriptionExpiryQueueProvider,
QueryCleanupQueueProvider, QueryCleanupQueueProvider,
CatalogPrefetchQueueProvider, CatalogPrefetchQueueProvider,
CatalogPrefetchFastQueueProvider,
LifecycleEmailQueueProvider, LifecycleEmailQueueProvider,
ExpertRewardsQueueProvider, ExpertRewardsQueueProvider,
PartPriceRefreshQueueProvider, PartPriceRefreshQueueProvider,
@@ -44,6 +47,7 @@ import { VINPIN_DECODE_QUEUE, VinpinDecodeQueueProvider } from "./queues/vinpin-
SUBSCRIPTION_EXPIRY_QUEUE, SUBSCRIPTION_EXPIRY_QUEUE,
QUERY_CLEANUP_QUEUE, QUERY_CLEANUP_QUEUE,
CATALOG_PREFETCH_QUEUE, CATALOG_PREFETCH_QUEUE,
CATALOG_PREFETCH_FAST_QUEUE,
LIFECYCLE_EMAIL_QUEUE, LIFECYCLE_EMAIL_QUEUE,
EXPERT_REWARDS_QUEUE, EXPERT_REWARDS_QUEUE,
PART_PRICE_REFRESH_QUEUE, PART_PRICE_REFRESH_QUEUE,

View File

@@ -39,14 +39,17 @@ function makeDeps(opts: { waiting: number; limitResults: unknown[][] }) {
}; };
const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults const posthog = { payload: vi.fn(async () => ({})) }; // compiled-in defaults
const db = makeDb(opts.limitResults); const db = makeDb(opts.limitResults);
const fastQueue = { add: vi.fn(async (..._args: unknown[]) => ({ id: "fastjob" })) };
const service = new PrefetchWorkerService( const service = new PrefetchWorkerService(
queue as never, queue as never,
fastQueue as never,
{} as never, // categoriesService — unused by the scan {} as never, // categoriesService — unused by the scan
redis as never, redis as never,
posthog as never, posthog as never,
db as never, db as never,
); );
return { service, queue, redis, posthog, db };
return { service, queue, fastQueue, redis, posthog, db };
} }
describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => { describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
@@ -59,12 +62,12 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
}); });
describe("enqueueInit", () => { describe("enqueueInit", () => {
it("sets lifo + data.fast when fast", async () => { it("sets lifo + data.fast when fast (fast queue)", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] }); const { service, fastQueue } = makeDeps({ waiting: 0, limitResults: [] });
await ( await (
service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> } service as never as { enqueueInit: (a: string, b: string, c: boolean) => Promise<void> }
).enqueueInit("v1", "emex", true); ).enqueueInit("v1", "emex", true);
const [name, data, jobOpts] = queue.add.mock.calls[0]; const [name, data, jobOpts] = fastQueue.add.mock.calls[0];
expect(name).toBe("prefetch-init"); expect(name).toBe("prefetch-init");
expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true }); expect(data).toMatchObject({ vehicleId: "v1", source: "emex", fast: true });
expect(jobOpts).toMatchObject({ lifo: true }); expect(jobOpts).toMatchObject({ lifo: true });
@@ -82,8 +85,8 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
}); });
describe("addJob (child jobs)", () => { describe("addJob (child jobs)", () => {
it("sets lifo when data.fast is true", async () => { it("sets lifo when data.fast is true (fast queue)", async () => {
const { service, queue } = makeDeps({ waiting: 0, limitResults: [] }); const { service, fastQueue: queue } = makeDeps({ waiting: 0, limitResults: [] });
await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob( await (service as never as { addJob: (n: string, d: unknown) => Promise<void> }).addJob(
"prefetch-children", "prefetch-children",
{ {
@@ -114,7 +117,11 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => { it("runs Phase-1 in the fast lane even when the backlog is far over the ceiling", async () => {
// waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a // waiting 50k ≫ default maxBacklog 1000 → Phase-2 must be suspended, but a
// zero-parts vehicle must still be onboarded with lifo. // zero-parts vehicle must still be onboarded with lifo.
const { service, queue, redis } = makeDeps({ const {
service,
fastQueue: queue,
redis,
} = makeDeps({
waiting: 50_000, waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result limitResults: [[{ id: "empty1", source: "emex" }]], // Phase-1 query result
}); });
@@ -183,13 +190,17 @@ describe("PrefetchWorkerService — fast lane (lifo) + backlog gating", () => {
describe("runBackfillScan (in-process timer entry, NX-locked)", () => { describe("runBackfillScan (in-process timer entry, NX-locked)", () => {
it("runs the scan when the lock is acquired", async () => { it("runs the scan when the lock is acquired", async () => {
const { service, queue, redis } = makeDeps({ const {
service,
fastQueue: queue,
redis,
} = makeDeps({
waiting: 50_000, waiting: 50_000,
limitResults: [[{ id: "empty1", source: "emex" }]], limitResults: [[{ id: "empty1", source: "emex" }]],
}); });
await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan(); await (service as never as { runBackfillScan: () => Promise<void> }).runBackfillScan();
expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number)); expect(redis.setNx).toHaveBeenCalledWith("prefetch:backfill:lock", "1", expect.any(Number));
expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued expect(queue.add).toHaveBeenCalledTimes(1); // Phase-1 init enqueued (fast lane)
}); });
it("skips the scan when the lock is already held", async () => { it("skips the scan when the lock is already held", async () => {

View File

@@ -24,7 +24,10 @@ import {
updateProgress, updateProgress,
} from "./prefetch-utils"; } from "./prefetch-utils";
import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types"; import { PrefetchCategoryJobData, PrefetchInitJobData } from "./prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "./queues/catalog-prefetch.queue"; import {
CATALOG_PREFETCH_FAST_QUEUE,
CATALOG_PREFETCH_QUEUE,
} from "./queues/catalog-prefetch.queue";
/** /**
* Depth cap for the prefetch BFS — a safety ceiling, NOT the target depth. * Depth cap for the prefetch BFS — a safety ceiling, NOT the target depth.
@@ -134,11 +137,13 @@ const POISON_TTL_S = (Number(process.env.PREFETCH_POISON_TTL_DAYS) || 30) * 86_4
export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy { export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrefetchWorkerService.name); private readonly logger = new Logger(PrefetchWorkerService.name);
private worker: Worker | null = null; private worker: Worker | null = null;
private fastWorker?: Worker;
private backfillKickoff?: ReturnType<typeof setTimeout>; private backfillKickoff?: ReturnType<typeof setTimeout>;
private backfillInterval?: ReturnType<typeof setInterval>; private backfillInterval?: ReturnType<typeof setInterval>;
constructor( constructor(
@Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue, @Inject(CATALOG_PREFETCH_QUEUE) private queue: Queue,
@Inject(CATALOG_PREFETCH_FAST_QUEUE) private fastQueue: Queue,
private categoriesService: CategoriesService, private categoriesService: CategoriesService,
private redis: RedisService, private redis: RedisService,
private posthog: PostHogService, private posthog: PostHogService,
@@ -168,6 +173,27 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
this.logger.error(`[prefetch] Worker error: ${err.message}`); this.logger.error(`[prefetch] Worker error: ${err.message}`);
}); });
// FAST lane: own queue + worker so fresh decodes never compete with the
// deep-drill backlog (whose production>consumption regime starves lifo tails).
// Same process fn; the shared per-source redis rate gate still protects
// upstream across BOTH workers.
this.fastWorker = new Worker(
QUEUE_NAMES.CATALOG_PREFETCH_FAST,
(job, token) => backfillContext.run(true, () => this.process(job, token)),
{
connection: getBullConnection(),
concurrency: 4,
limiter: { max: 120, duration: 60_000 },
},
);
this.fastWorker.on("failed", (job, err) => {
this.logger.warn(`[prefetch:fast] Job ${job?.name} failed: ${err.message}`);
});
this.fastWorker.on("error", (err) => {
this.logger.error(`[prefetch:fast] Worker error: ${err.message}`);
});
this.logger.log("[prefetch] Fast worker started (concurrency=4, 120/min)");
this.logger.log( this.logger.log(
`[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` + `[prefetch] Worker started (concurrency=${WORKER_CONCURRENCY}, global ${WORKER_RATE_MAX}/min, ` +
`per-source ${JSON.stringify(SOURCE_RATE_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`, `per-source ${JSON.stringify(SOURCE_RATE_MAX)}, pcatPace=${PCAT_PACE_MS}ms)`,
@@ -198,6 +224,7 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
if (this.backfillKickoff) clearTimeout(this.backfillKickoff); if (this.backfillKickoff) clearTimeout(this.backfillKickoff);
if (this.backfillInterval) clearInterval(this.backfillInterval); if (this.backfillInterval) clearInterval(this.backfillInterval);
if (this.worker) { if (this.worker) {
await this.fastWorker?.close();
await this.worker.close(); await this.worker.close();
this.worker = null; this.worker = null;
} }
@@ -225,7 +252,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
job.name === "prefetch-children" || job.name === "prefetch-children" ||
job.name === "prefetch-parts") job.name === "prefetch-parts")
) { ) {
await this.checkSourceRate(data.source); await this.checkSourceRate(
data.source,
(job.data as { fast?: boolean }).fast ? "fast" : "main",
);
} }
if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) { if (data.source === "parts-catalogs" && PCAT_PACE_MS > 0) {
await new Promise((r) => setTimeout(r, PCAT_PACE_MS)); await new Promise((r) => setTimeout(r, PCAT_PACE_MS));
@@ -254,7 +284,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// global wait is correct: when it ends and cooldown is still active, // global wait is correct: when it ends and cooldown is still active,
// we'll pause again — at most ~1 cycle per retryAfterMs instead of // we'll pause again — at most ~1 cycle per retryAfterMs instead of
// cycling every queued job through. // cycling every queued job through.
await this.worker?.rateLimit(delayMs); const lane = (job.data as { fast?: boolean }).fast ? this.fastWorker : this.worker;
await lane?.rateLimit(delayMs);
this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`); this.logger.debug(`[prefetch] Worker paused ${delayMs}ms (cooldown ${job.data.source})`);
throw Worker.RateLimitError(); throw Worker.RateLimitError();
} }
@@ -620,7 +651,8 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
/** Queue a prefetch-init for a vehicle and set the in-flight guard. */ /** Queue a prefetch-init for a vehicle and set the in-flight guard. */
private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> { private async enqueueInit(vehicleId: string, source: string, fast = false): Promise<void> {
await this.queue.add( const q = fast ? this.fastQueue : this.queue;
await q.add(
"prefetch-init", "prefetch-init",
{ vehicleId, source: source as PrefetchInitJobData["source"], fast }, { vehicleId, source: source as PrefetchInitJobData["source"], fast },
{ {
@@ -746,6 +778,10 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
// OPPOSITE here and starve the job behind the backlog; lifo is correct.) // OPPOSITE here and starve the job behind the backlog; lifo is correct.)
...(data.fast ? { lifo: true } : {}), ...(data.fast ? { lifo: true } : {}),
}; };
if (data.fast) {
await this.fastQueue.add(name, data, opts);
return;
}
// parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the // parts-catalogs pacing is handled per-job in process() (PCAT_PACE_MS) + the
// limiter. The old cumulative `index * 20s` delay was pathological (the Nth // limiter. The old cumulative `index * 20s` delay was pathological (the Nth
@@ -831,12 +867,18 @@ export class PrefetchWorkerService implements OnModuleInit, OnModuleDestroy {
* ("source-rate") so process() defers THIS job to the window's end while other * ("source-rate") so process() defers THIS job to the window's end while other
* sources keep flowing. 0 / unknown source ceiling = unlimited. * sources keep flowing. 0 / unknown source ceiling = unlimited.
*/ */
private async checkSourceRate(source: string): Promise<void> { private async checkSourceRate(source: string, lane: "main" | "fast" = "main"): Promise<void> {
const max = SOURCE_RATE_MAX[source] ?? 0; // Per-LANE budgets: the main worker's deep-drill backlog would otherwise eat
if (max <= 0) return; // the whole per-source window and starve the fast lane with 18s defers
// (observed: fresh BMW init couldn't get a single pcat slot). Totals per
// source stay the same as before, so upstream load is unchanged.
const total = SOURCE_RATE_MAX[source] ?? 0;
if (total <= 0) return;
const fastShare = Math.max(1, Math.floor(total / 3));
const max = lane === "fast" ? fastShare : total - fastShare;
const windowMs = 60_000; const windowMs = 60_000;
const now = Date.now(); const now = Date.now();
const key = `prefetch:rate:${source}:${Math.floor(now / windowMs)}`; const key = `prefetch:rate:${lane}:${source}:${Math.floor(now / windowMs)}`;
const n = await this.redis.incr(key); const n = await this.redis.incr(key);
if (n === 1) await this.redis.expire(key, 61); if (n === 1) await this.redis.expire(key, 61);
if (n > max) { if (n > max) {

View File

@@ -307,6 +307,36 @@ export async function processCanonicalBackfill(
SELECT count(*)::int AS n FROM ins SELECT count(*)::int AS n FROM ins
`); `);
console.log(`[canonical-backfill] structure template rebuilt — ${tpl[0]?.n ?? 0} rows`); console.log(`[canonical-backfill] structure template rebuilt — ${tpl[0]?.n ?? 0} rows`);
// Entry-level template: the ENTRY names (bucket entry-point children — same
// level buildCanonicalInput serves) shared across the brand's vehicles.
// min 2 vehicles so one-off vehicle quirks don't become "the brand".
await db.execute(sql`DELETE FROM canonical_template_entries`);
const tent = await db.execute<{ n: number }>(sql`
WITH ins AS (
INSERT INTO canonical_template_entries
(source, brand, canonical_category_id, canonical_subcategory_id, name, vehicle_count)
SELECT c.source, coalesce(cv.brand_name, v.brand_name),
c.canonical_category_id, c.canonical_subcategory_id,
min(c.name),
count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id))::int
FROM categories c
LEFT JOIN catalog_vehicles cv ON c.catalog_vehicle_id = cv.id
LEFT JOIN vehicles v ON c.vehicle_id = v.id
LEFT JOIN categories p ON p.id = c.parent_id
WHERE c.canonical_category_id IS NOT NULL
AND coalesce(cv.brand_name, v.brand_name) IS NOT NULL
AND (c.parent_id IS NULL OR p.canonical_category_id IS DISTINCT FROM c.canonical_category_id
OR (p.parent_id IS NULL AND p.canonical_category_id = c.canonical_category_id))
GROUP BY c.source, coalesce(cv.brand_name, v.brand_name),
c.canonical_category_id, c.canonical_subcategory_id,
btrim(regexp_replace(translate(lower(c.name),'çşğıöü','csgiou'),'\\s+',' ','g'))
HAVING count(DISTINCT coalesce(c.catalog_vehicle_id, c.vehicle_id)) >= 2
RETURNING 1
)
SELECT count(*)::int AS n FROM ins
`);
console.log(`[canonical-backfill] template entries rebuilt — ${tent[0]?.n ?? 0} rows`);
} }
console.log( console.log(

View File

@@ -3,6 +3,7 @@ import { Queue } from "bullmq";
import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config"; import { QUEUE_NAMES, getBullConnection, getBullTelemetry } from "../bull.config";
export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE"; export const CATALOG_PREFETCH_QUEUE = "CATALOG_PREFETCH_QUEUE";
export const CATALOG_PREFETCH_FAST_QUEUE = "CATALOG_PREFETCH_FAST_QUEUE";
export const CatalogPrefetchQueueProvider: Provider = { export const CatalogPrefetchQueueProvider: Provider = {
provide: CATALOG_PREFETCH_QUEUE, provide: CATALOG_PREFETCH_QUEUE,
@@ -23,3 +24,20 @@ export const CatalogPrefetchQueueProvider: Provider = {
}); });
}, },
}; };
export const CatalogPrefetchFastQueueProvider: Provider = {
provide: CATALOG_PREFETCH_FAST_QUEUE,
useFactory: () => {
const telemetry = getBullTelemetry();
return new Queue(QUEUE_NAMES.CATALOG_PREFETCH_FAST, {
connection: getBullConnection(),
...(telemetry ? { telemetry } : {}),
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 30000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 },
},
});
},
};

View File

@@ -21,6 +21,7 @@ import {
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { CatalogService } from "../catalog/catalog.service";
import { CategoriesService } from "../categories/categories.service"; import { CategoriesService } from "../categories/categories.service";
import { Public } from "../common/decorators/public.decorator"; import { Public } from "../common/decorators/public.decorator";
import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard"; import { ApiKeyGuard, type VerifiedApiKey } from "../common/guards/api-key.guard";
@@ -55,6 +56,7 @@ export class PublicApiController {
constructor( constructor(
private readonly vehicles: VehiclesService, private readonly vehicles: VehiclesService,
private readonly categories: CategoriesService, private readonly categories: CategoriesService,
private readonly catalog: CatalogService,
private readonly p: PSourceDbService, private readonly p: PSourceDbService,
private readonly quota: PublicApiQuotaService, private readonly quota: PublicApiQuotaService,
) {} ) {}
@@ -312,6 +314,149 @@ export class PublicApiController {
return this.categories.searchCatalog(vehicleId, q ?? ""); return this.categories.searchCatalog(vehicleId, q ?? "");
} }
// ==========================================================================
// Katalog gezinme (VIN'siz araç ağacı). VIN decode ile AYNI kategori/parça
// akışına ulaşır; fark: araç, marka→model→(kasa/motor/şanzıman) seçimiyle
// belirlenir. Marka erişimi API-anahtarı sahibinin (user.id) aboneliğine
// bağlıdır. Gezinme kotadan düşmez (yalnız başarılı VIN identification düşer).
// ==========================================================================
/** Katalog markaları (anahtar sahibinin erişebildiği). */
@Get("catalog/brands")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Markalar", description: "Araç ağacı wizard'ı için marka listesi." })
async catalogBrands(@Req() req: ApiKeyRequest) {
return this.catalog.getBrands(req.user.id);
}
/** Bir markanın modelleri (araçları). */
@Get("catalog/brands/:brandName/models")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Modeller", description: "Seçilen markanın model/araç listesi." })
@ApiParam({ name: "brandName", example: "Fiat" })
@ApiQuery({ name: "service", required: false, description: "Katalog servis adı (opsiyonel filtre)" })
async catalogModels(
@Req() req: ApiKeyRequest,
@Param("brandName") brandName: string,
@Query("service") service?: string,
) {
return this.catalog.getModels(brandName, req.user.id, service);
}
/** Araç detayı (mimari + seçim adımları için meta). */
@Get("catalog/vehicles/:id")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Araç detayı" })
@ApiParam({ name: "id", format: "uuid" })
async catalogVehicle(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getVehicle(id, req.user.id);
}
/** Ford model konfigürasyonu (Ford mimarisi). */
@Get("catalog/vehicles/:id/ford-config")
@ApiTags("Katalog Gezinme")
async catalogFordConfig(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getFordModelConfig(id, req.user.id);
}
/** PSA kasa seçenekleri. */
@Get("catalog/vehicles/:id/psa-bodies")
@ApiTags("Katalog Gezinme")
async catalogPsaBodies(@Req() req: ApiKeyRequest, @Param("id") id: string) {
return this.catalog.getPsaBodies(id, req.user.id);
}
/** PSA motor seçenekleri (kasa seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-engines")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", description: "Seçilen kasa" })
async catalogPsaEngines(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
) {
return this.catalog.getPsaEngines(id, body, req.user.id);
}
/** PSA şanzıman seçenekleri (kasa + motor seçimine bağlı). */
@Get("catalog/vehicles/:id/psa-gearboxes")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body" })
@ApiQuery({ name: "engine" })
async catalogPsaGearboxes(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body: string,
@Query("engine") engine: string,
) {
return this.catalog.getPsaGearboxes(id, body, engine, req.user.id);
}
/**
* Seçilen aracın kategori ağacı (birleşik/kanonik). Public v1 kontratıyla
* aynı şekil ({ canonical, partCount, categories[], subgroups[] }); iç
* alanlar (source vb.) sanitize edilir. Kasa/motor/şanzıman gerektiren
* mimarilerde query ile verilir.
*/
@Get("catalog/vehicles/:id/categories")
@ApiTags("Katalog Gezinme")
@ApiOperation({ summary: "Kategori ağacı (araç seçiminden)" })
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
@ApiQuery({ name: "mgp", required: false, description: "mainGroupsPath" })
async catalogCategoryTree(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
@Query("mgp") mgp?: string,
) {
return sanitizeCanonicalTree(
(await this.catalog.getCanonicalTree(id, req.user.id, body, engine, gearbox, mgp)) as never,
);
}
/** Seçilen aracın kategori detayı + parçaları (OEM kodlarıyla). */
@Get("catalog/vehicles/:id/categories/:categoryId")
@ApiTags("Katalog Gezinme")
@ApiQuery({ name: "body", required: false })
@ApiQuery({ name: "engine", required: false })
@ApiQuery({ name: "gearbox", required: false })
async catalogCategoryParts(
@Req() req: ApiKeyRequest,
@Param("id") id: string,
@Param("categoryId") categoryId: string,
@Query("body") body?: string,
@Query("engine") engine?: string,
@Query("gearbox") gearbox?: string,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const detail: any = await this.catalog.getCategoryWithParts(
id,
categoryId,
req.user.id,
body,
engine,
gearbox,
);
return {
...detail,
parts: (detail.parts ?? []).map((p: Record<string, unknown>) => ({
id: p.id,
name: p.name,
description: p.description,
oemCode: p.oemCode,
position: p.position ?? p.hotspotIndex ?? null,
quantity: p.quantity,
remark: p.remark,
unavailable: p.unavailable,
categoryId: p.categoryId,
})),
};
}
private setQuotaHeaders(res: Response, state: DecodeQuotaState): void { private setQuotaHeaders(res: Response, state: DecodeQuotaState): void {
res.setHeader("X-Decode-Quota-Limit", String(state.limit)); res.setHeader("X-Decode-Quota-Limit", String(state.limit));
res.setHeader("X-Decode-Quota-Used", String(state.used)); res.setHeader("X-Decode-Quota-Used", String(state.used));

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { CatalogModule } from "../catalog/catalog.module";
import { CategoriesModule } from "../categories/categories.module"; import { CategoriesModule } from "../categories/categories.module";
import { ApiKeyGuard } from "../common/guards/api-key.guard"; import { ApiKeyGuard } from "../common/guards/api-key.guard";
import { PModule } from "../integrations/p/p.module"; import { PModule } from "../integrations/p/p.module";
@@ -12,7 +13,7 @@ import { WidgetController } from "./widget.controller";
* apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır. * apiKey plugin'i + ApiKeyGuard; iç servisler aynen yeniden kullanılır.
*/ */
@Module({ @Module({
imports: [VehiclesModule, CategoriesModule, PModule], imports: [VehiclesModule, CategoriesModule, CatalogModule, PModule],
controllers: [PublicApiController, WidgetController], controllers: [PublicApiController, WidgetController],
providers: [ApiKeyGuard, PublicApiQuotaService], providers: [ApiKeyGuard, PublicApiQuotaService],
}) })

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { catalogVehicles, queryLogs, vinpinDecodes } from "../database/schema/core";
import { VehiclesService } from "./vehicles.service"; import { VehiclesService } from "./vehicles.service";
vi.mock("@sase/shared", () => ({ vi.mock("@sase/shared", () => ({
@@ -312,12 +313,137 @@ describe("VehiclesService", () => {
expect(result).toMatchObject({ decoding: { vin: "NM435600006123456" } }); expect(result).toMatchObject({ decoding: { vin: "NM435600006123456" } });
expect(result).not.toHaveProperty("noCatalog"); expect(result).not.toHaveProperty("noCatalog");
expect(vinpinQueue.add).toHaveBeenCalledTimes(1); expect(vinpinQueue.add).toHaveBeenCalledTimes(1);
// Exactly ONE coverage-gap failure row at first sighting — the gap
// moment stays in query_logs, but only once per VIN.
expect(db.insert.mock.calls.filter((c: unknown[]) => c[0] === queryLogs)).toHaveLength(1);
} finally { } finally {
// Restore — "false" is functionally "off" (avoids the delete operator). // Restore — "false" is functionally "off" (avoids the delete operator).
process.env.VINPIN_ENABLED = prev ?? "false"; process.env.VINPIN_ENABLED = prev ?? "false";
} }
}); });
it("[VINPIN on] a poll of a PENDING decode logs NO query_logs row", async () => {
const prev = process.env.VINPIN_ENABLED;
process.env.VINPIN_ENABLED = "true";
try {
vi.mocked(isValidVin).mockReturnValue(true);
// Table-keyed select: the vinpin_decodes lookup finds an in-flight
// pending row; every other select (vehicles, brands, …) returns [].
let currentTable: unknown = null;
const selectChain: Record<string, any> = {};
selectChain.from = vi.fn().mockImplementation((t: unknown) => {
currentTable = t;
return selectChain;
});
selectChain.where = vi.fn().mockReturnValue(selectChain);
selectChain.limit = vi
.fn()
.mockImplementation(() =>
currentTable === vinpinDecodes
? [{ vin: "NM435600006123456", status: "pending" }]
: [],
);
const insertChain = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
onConflictDoNothing: vi.fn().mockReturnThis(),
};
const db = {
select: vi.fn().mockReturnValue(selectChain),
insert: vi.fn().mockReturnValue(insertChain),
};
const { service, corgiService, vinApiService, vinpinQueue } = createService(db);
corgiService.decodeVin.mockReturnValue({
isKnown: true,
brandName: "Fiat",
modelYear: 2018,
});
vinApiService.decodeVin.mockResolvedValue({
make: "FIAT",
model: "Tipo",
modelYear: "2018",
});
const result: any = await service.decodeVin("NM435600006123456", "u1");
expect(result).toMatchObject({ decoding: { vin: "NM435600006123456" } });
// No re-enqueue and, crucially, NO query_logs row for a poll —
// pre-fix every 3s poll wrote a "No catalog" failure row.
expect(vinpinQueue.add).not.toHaveBeenCalled();
expect(db.insert.mock.calls.filter((c: unknown[]) => c[0] === queryLogs)).toHaveLength(0);
} finally {
process.env.VINPIN_ENABLED = prev ?? "false";
}
});
it("[VINPIN on] a RESOLVED decode returns the catalog vehicle with ONE success row and no failure row", async () => {
const prev = process.env.VINPIN_ENABLED;
process.env.VINPIN_ENABLED = "true";
try {
vi.mocked(isValidVin).mockReturnValue(true);
const cv = {
id: "cv-1",
brandId: null, // null → skips checkBrandAccess
brandName: "Fiat",
model: "TIPO-EGEA",
year: 2022,
};
let currentTable: unknown = null;
const selectChain: Record<string, any> = {};
selectChain.from = vi.fn().mockImplementation((t: unknown) => {
currentTable = t;
return selectChain;
});
selectChain.where = vi.fn().mockReturnValue(selectChain);
selectChain.limit = vi.fn().mockImplementation(() => {
if (currentTable === vinpinDecodes)
return [{ vin: "NM435600006123456", status: "decoded", catalogVehicleId: "cv-1" }];
if (currentTable === catalogVehicles) return [cv];
return [];
});
const insertChain = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockReturnValue([]),
onConflictDoNothing: vi.fn().mockReturnThis(),
};
const db = {
select: vi.fn().mockReturnValue(selectChain),
insert: vi.fn().mockReturnValue(insertChain),
};
const { service, corgiService, vinApiService, vinpinQueue } = createService(db);
corgiService.decodeVin.mockReturnValue({
isKnown: true,
brandName: "Fiat",
modelYear: 2022,
});
vinApiService.decodeVin.mockResolvedValue({
make: "FIAT",
model: "Tipo",
modelYear: "2022",
});
const result: any = await service.decodeVin("NM435600006123456", "u1");
expect(result).toMatchObject({
catalogVehicle: { id: "cv-1", brandName: "Fiat", model: "TIPO-EGEA", year: 2022 },
});
expect(vinpinQueue.add).not.toHaveBeenCalled();
// Exactly ONE query_logs row: the vinpin success. Pre-fix this path
// wrote a failure row first, then the success → permanent fail+success
// pairs for every decode of an already-resolved VIN.
const logCalls = db.insert.mock.calls.filter((c: unknown[]) => c[0] === queryLogs);
expect(logCalls).toHaveLength(1);
const successArg = insertChain.values.mock.calls
.map((c: any[]) => c[0])
.find((v: any) => v && typeof v === "object" && "success" in v);
expect(successArg).toMatchObject({ success: true, source: "vinpin" });
} finally {
process.env.VINPIN_ENABLED = prev ?? "false";
}
});
it("keeps the informative dead-end (throws) when the identified brand has no browse catalog", async () => { it("keeps the informative dead-end (throws) when the identified brand has no browse catalog", async () => {
vi.mocked(isValidVin).mockReturnValue(true); vi.mocked(isValidVin).mockReturnValue(true);

View File

@@ -37,7 +37,7 @@ import { SERVICE_TO_BRAND } from "../integrations/pl24/pl24.types";
import { VinApiService } from "../integrations/vin-api/vin-api.service"; import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { isVinpinBrandAllowed } from "../integrations/vinpin/vinpin.constants"; import { isVinpinBrandAllowed } from "../integrations/vinpin/vinpin.constants";
import { PrefetchSource } from "../jobs/prefetch.types"; import { PrefetchSource } from "../jobs/prefetch.types";
import { CATALOG_PREFETCH_QUEUE } from "../jobs/queues/catalog-prefetch.queue"; import { CATALOG_PREFETCH_FAST_QUEUE } from "../jobs/queues/catalog-prefetch.queue";
import { VINPIN_DECODE_QUEUE } from "../jobs/queues/vinpin-decode.queue"; import { VINPIN_DECODE_QUEUE } from "../jobs/queues/vinpin-decode.queue";
import { RedisService } from "../redis/redis.service"; import { RedisService } from "../redis/redis.service";
import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys"; import { vinCandidateStashKey, vinResolveCacheKeys } from "./vin-cache-keys";
@@ -97,7 +97,7 @@ export class VehiclesService {
constructor( constructor(
@Inject(DATABASE) private db: Database, @Inject(DATABASE) private db: Database,
@Inject(CATALOG_PREFETCH_QUEUE) private prefetchQueue: Queue, @Inject(CATALOG_PREFETCH_FAST_QUEUE) private prefetchQueue: Queue,
@Inject(VINPIN_DECODE_QUEUE) private vinpinQueue: Queue, @Inject(VINPIN_DECODE_QUEUE) private vinpinQueue: Queue,
private corgiService: CorgiService, private corgiService: CorgiService,
private pl24Service: PL24Service, private pl24Service: PL24Service,
@@ -176,6 +176,25 @@ export class VehiclesService {
const ident = await this.identifyBasicStructured(vin); const ident = await this.identifyBasicStructured(vin);
if (ident) { if (ident) {
ctx.timings.identified_no_catalog = 1; ctx.timings.identified_no_catalog = 1;
// Vinpin ePER decode-oracle fallback (feature-flagged, Fiat-only for
// now). Strictly gated: when VINPIN_ENABLED is off OR the brand isn't
// allow-listed this is a no-op and the behaviour below is byte-identical
// to before. A returned response short-circuits the dead-end; null
// falls through to the existing noCatalog/throw path unchanged.
//
// Tried BEFORE the failure logQuery on purpose: a vinpin-resolved VIN
// answers with a real catalog vehicle (ONE success row, logged inside
// the fallback) and a poll of a pending decode is not a new decode
// failure. When the failure was logged first, every 3s frontend poll
// wrote a "No catalog" row — one vinpin-DECODED Egea produced 17
// failure rows and read as a 20pp raw success drop (2026-07-07).
// The fallback logs the coverage-gap failure row exactly once, at
// first sighting (job enqueue); not_found/failed/stale fall through
// to the failure log below, unchanged.
if (process.env.VINPIN_ENABLED === "true" && isVinpinBrandAllowed(ident.browseBrand)) {
const vinpinResp = await this.tryVinpinFallback(vin, ident, userId, ctx, startTime);
if (vinpinResp) return vinpinResp;
}
await this.logQuery( await this.logQuery(
userId, userId,
vin, vin,
@@ -186,15 +205,6 @@ export class VehiclesService {
`No catalog — identified as ${ident.display}`, `No catalog — identified as ${ident.display}`,
ctx.timings, ctx.timings,
); );
// Vinpin ePER decode-oracle fallback (feature-flagged, Fiat-only for
// now). Strictly gated: when VINPIN_ENABLED is off OR the brand isn't
// allow-listed this is a no-op and the behaviour below is byte-identical
// to before. A returned response short-circuits the dead-end; null
// falls through to the existing noCatalog/throw path unchanged.
if (process.env.VINPIN_ENABLED === "true" && isVinpinBrandAllowed(ident.browseBrand)) {
const vinpinResp = await this.tryVinpinFallback(vin, ident, userId, ctx, startTime);
if (vinpinResp) return vinpinResp;
}
// Brand is known AND has a browse catalog → offer the existing // Brand is known AND has a browse catalog → offer the existing
// /dashboard/catalog model-browse instead of dead-ending. The parts // /dashboard/catalog model-browse instead of dead-ending. The parts
// usually exist there (e.g. Fiat Egea NM4, old Renault VF1); they're // usually exist there (e.g. Fiat Egea NM4, old Renault VF1); they're
@@ -792,6 +802,10 @@ export class VehiclesService {
* - no row → insert a 'pending' row, enqueue a decode job, return `{ decoding }`. * - no row → insert a 'pending' row, enqueue a decode job, return `{ decoding }`.
* - failed / not_found / decoded-without-match → null → caller falls through to * - failed / not_found / decoded-without-match → null → caller falls through to
* the existing noCatalog/throw behaviour unchanged. * the existing noCatalog/throw behaviour unchanged.
*
* query_logs accounting: exactly ONE failure row per VIN, at first sighting
* (enqueue). Pending polls log nothing; a resolved decode logs one success
* row. The caller logs the failure row only when this returns null.
*/ */
private async tryVinpinFallback( private async tryVinpinFallback(
vin: string, vin: string,
@@ -843,6 +857,9 @@ export class VehiclesService {
} }
if (row.status === "pending") { if (row.status === "pending") {
// A poll of an in-flight decode — intentionally NOT logged to
// query_logs. The gap moment was logged once at enqueue below;
// logging every poll inflated failure counts (17 rows per VIN).
ctx.timings.vinpin_pending = 1; ctx.timings.vinpin_pending = 1;
return { decoding: { vin, display: ident.display }, vin }; return { decoding: { vin, display: ident.display }, vin };
} }
@@ -860,6 +877,19 @@ export class VehiclesService {
{ jobId: `vinpin-${vin}`, removeOnComplete: true, removeOnFail: false }, { jobId: `vinpin-${vin}`, removeOnComplete: true, removeOnFail: false },
); );
ctx.timings.vinpin_enqueued = 1; ctx.timings.vinpin_enqueued = 1;
// The ONE coverage-gap failure row for this VIN (the user-facing gap
// moment; keeps the coverage-backlog signal). Subsequent pending polls
// log nothing; resolution logs its own success row above.
await this.logQuery(
userId,
vin,
null,
"none",
false,
Date.now() - startTime,
`No catalog — identified as ${ident.display}`,
ctx.timings,
);
return { decoding: { vin, display: ident.display }, vin }; return { decoding: { vin, display: ident.display }, vin };
} catch (err) { } catch (err) {
// Never let the fallback break decode — fall through to existing behaviour. // Never let the fallback break decode — fall through to existing behaviour.

View File

@@ -2,7 +2,9 @@ import { api } from "@/lib/api-client";
import { KEYS_8 } from "@/lib/keys"; import { KEYS_8 } from "@/lib/keys";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Skeleton } from "@sase/ui"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { CategoryColumns } from "./category-columns";
import { CategoryGrid } from "./category-grid"; import { CategoryGrid } from "./category-grid";
import { CategoryTree } from "./category-tree";
// Shape returned by GET /categories/canonical/:vehicleId — the vehicle's // Shape returned by GET /categories/canonical/:vehicleId — the vehicle's
// part-holding leaf categories (or root categories on a fresh vehicle) folded // part-holding leaf categories (or root categories on a fresh vehicle) folded
@@ -12,6 +14,8 @@ interface Leaf {
name: string; name: string;
source: string; source: string;
partCount: number; partCount: number;
// template placeholder — CategoryGrid renders it disabled ("dolduruluyor")
unavailable?: boolean;
} }
interface Subgroup { interface Subgroup {
canonical: { id: string | null; slug: string; name: string }; canonical: { id: string | null; slug: string; name: string };
@@ -34,10 +38,28 @@ interface CanonicalGroup {
* bucket expands to a grid of its categories, which drill into parts via the * bucket expands to a grid of its categories, which drill into parts via the
* normal category route (reusing CategoryGrid's navigation). * normal category route (reusing CategoryGrid's navigation).
*/ */
export function CanonicalView({ vehicleId }: { vehicleId: string }) { export function CanonicalView({
vehicleId,
endpoint,
catalogMode,
brandName,
variantSearch,
viewMode = "grid",
}: {
vehicleId: string;
/** Override the fetch URL — the pl24 catalog flow uses /catalog/vehicles/:id/canonical. */
endpoint?: string;
catalogMode?: boolean;
brandName?: string;
variantSearch?: { body?: string; engine?: string; gearbox?: string; mgp?: string };
/** Görünüm: grid (varsayılan, akordeon) | tree | columns — kaynak görünümdeki
* değiştirici birleşikte de çalışsın diye kanonik veriden sentetik hiyerarşi
* kurulur; sentetik düğümler children'lı geldiğinden lazy-fetch tetiklenmez. */
viewMode?: "grid" | "tree" | "columns";
}) {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["canonical-tree", vehicleId], queryKey: ["canonical-tree", vehicleId, endpoint ?? ""],
queryFn: () => api.get<CanonicalGroup[]>(`/categories/canonical/${vehicleId}`), queryFn: () => api.get<CanonicalGroup[]>(endpoint ?? `/categories/canonical/${vehicleId}`),
enabled: !!vehicleId, enabled: !!vehicleId,
}); });
@@ -62,6 +84,52 @@ export function CanonicalView({ vehicleId }: { vehicleId: string }) {
// Open the buckets that already have parts; keep empty ones collapsed. // Open the buckets that already have parts; keep empty ones collapsed.
const defaultOpen = data.filter((g) => g.partCount > 0).map((g) => g.canonical.slug); const defaultOpen = data.filter((g) => g.partCount > 0).map((g) => g.canonical.slug);
if (viewMode === "tree" || viewMode === "columns") {
// Sentetik hiyerarşi: kova → (alt-yaprak) → gerçek kategoriler. Placeholder
// (tpl-*) girişler tıklanabilir olmadığından bu görünümlere alınmaz; gerçek
// kategoriler children'sız geçer ki tıklanınca normal lazy drill çalışsın.
const real = (cats: Leaf[]) => cats.filter((c) => !String(c.id).startsWith("tpl-"));
const roots = data
.map((g) => {
const subs = (g.subgroups ?? [])
.map((sg) => ({
id: `cnl-${g.canonical.slug}-${sg.canonical.slug}`,
name: sg.canonical.name,
partCount: sg.partCount,
children: real(sg.categories),
}))
.filter((n) => n.children.length > 0);
const flat = subs.length > 0 ? [] : real(g.categories);
const children = subs.length > 0 ? subs : flat;
return {
id: `cnl-${g.canonical.slug}`,
name: g.canonical.name,
partCount: g.partCount,
children,
};
})
.filter((n) => n.children.length > 0);
if (viewMode === "tree")
return (
<CategoryTree
categories={roots}
vehicleId={vehicleId}
catalogMode={catalogMode}
brandName={brandName}
variantSearch={variantSearch}
/>
);
return (
<CategoryColumns
categories={roots}
vehicleId={vehicleId}
catalogMode={catalogMode}
brandName={brandName}
variantSearch={variantSearch}
/>
);
}
return ( return (
<Accordion type="multiple" defaultValue={defaultOpen} className="space-y-1"> <Accordion type="multiple" defaultValue={defaultOpen} className="space-y-1">
{data.map((group) => ( {data.map((group) => (
@@ -74,9 +142,17 @@ export function CanonicalView({ vehicleId }: { vehicleId: string }) {
<div className="flex flex-1 items-center justify-between gap-3 pr-3"> <div className="flex flex-1 items-center justify-between gap-3 pr-3">
<span className="text-sm font-semibold">{group.canonical.name}</span> <span className="text-sm font-semibold">{group.canonical.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{group.partCount > 0 {(() => {
? `${group.partCount} parça · ${group.categories.length} kategori` // Unique count across flat + subgroups: template placeholders
: `${group.categories.length} kategori`} // live only inside subgroups, so flat length alone reads "0".
const ids = new Set(group.categories.map((c) => c.id));
for (const sg of group.subgroups ?? [])
for (const c of sg.categories) ids.add(c.id);
const n = ids.size;
return group.partCount > 0
? `${group.partCount} parça · ${n} kategori`
: `${n} kategori`;
})()}
</span> </span>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
@@ -101,7 +177,14 @@ export function CanonicalView({ vehicleId }: { vehicleId: string }) {
</span> </span>
) : null} ) : null}
</div> </div>
<CategoryGrid categories={sg.categories} vehicleId={vehicleId} hideFilter /> <CategoryGrid
categories={sg.categories}
vehicleId={vehicleId}
hideFilter
catalogMode={catalogMode}
brandName={brandName}
variantSearch={variantSearch}
/>
</div> </div>
))} ))}
</div> </div>
@@ -110,7 +193,14 @@ export function CanonicalView({ vehicleId }: { vehicleId: string }) {
// Fallback: flat source categories (un-drilled bucket — drilling // Fallback: flat source categories (un-drilled bucket — drilling
// fills the sub-leaves on the next load). // fills the sub-leaves on the next load).
return ( return (
<CategoryGrid categories={group.categories} vehicleId={vehicleId} hideFilter /> <CategoryGrid
categories={group.categories}
vehicleId={vehicleId}
hideFilter
catalogMode={catalogMode}
brandName={brandName}
variantSearch={variantSearch}
/>
); );
})()} })()}
</AccordionContent> </AccordionContent>

View File

@@ -3,6 +3,7 @@ import { FordVariantSelector } from "@/components/catalog/ford-variant-selector"
import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector"; import { P5RestrictionSelector } from "@/components/catalog/p5-restriction-selector";
import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector"; import { PsaVariantSelector } from "@/components/catalog/psa-variant-selector";
import { ViewModeToggle } from "@/components/catalog/view-mode-toggle"; import { ViewModeToggle } from "@/components/catalog/view-mode-toggle";
import { CanonicalView } from "@/components/categories/canonical-view";
import { CategoryColumns } from "@/components/categories/category-columns"; import { CategoryColumns } from "@/components/categories/category-columns";
import { CategoryGrid } from "@/components/categories/category-grid"; import { CategoryGrid } from "@/components/categories/category-grid";
import { CategoryTree } from "@/components/categories/category-tree"; import { CategoryTree } from "@/components/categories/category-tree";
@@ -40,6 +41,9 @@ function CatalogVehiclePage() {
const mgp = search.mgp; const mgp = search.mgp;
const hasVariant = !!(body || engine || gearbox || mgp); const hasVariant = !!(body || engine || gearbox || mgp);
// "source" (default) = ham pl24 ağacı; "canonical" = birleşik 20-kova görünüm
// (vehicles/$id sayfasıyla aynı desen; backend: /catalog/vehicles/:id/canonical).
const [browseMode, setBrowseMode] = useState<"source" | "canonical">("source");
const [viewMode, setViewMode] = useState<ViewMode>( const [viewMode, setViewMode] = useState<ViewMode>(
() => (getUserSettings().categoryViewMode as ViewMode | undefined) ?? "grid", () => (getUserSettings().categoryViewMode as ViewMode | undefined) ?? "grid",
); );
@@ -173,16 +177,56 @@ function CatalogVehiclePage() {
) : ( ) : (
<section className="space-y-3"> <section className="space-y-3">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-3">
{t("catalog.categories")} <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
</h2> {t("catalog.categories")}
{categoryTree && categoryTree.length > 0 && ( </h2>
<div className="inline-flex rounded-md border border-border p-0.5">
<button
type="button"
onClick={() => setBrowseMode("canonical")}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${browseMode === "canonical" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
Birleşik
</button>
<button
type="button"
onClick={() => setBrowseMode("source")}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${browseMode === "source" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
Orijinal Katalog
</button>
</div>
</div>
{browseMode === "source" && categoryTree && categoryTree.length > 0 && (
<span className="text-xs tabular-nums text-muted-foreground"> <span className="text-xs tabular-nums text-muted-foreground">
{t("catalog.categoryCount", { count: categoryCount })} {t("catalog.categoryCount", { count: categoryCount })}
</span> </span>
)} )}
</div> </div>
{categoriesLoading ? ( {browseMode === "canonical" ? (
<CanonicalView
vehicleId={modelId}
viewMode={
viewMode === "grid" || viewMode === "tree" || viewMode === "columns"
? viewMode
: "grid"
}
endpoint={`/catalog/vehicles/${modelId}/canonical${(() => {
const p = new URLSearchParams();
if (body) p.set("body", body);
if (engine) p.set("engine", engine);
if (gearbox) p.set("gearbox", gearbox);
if (mgp) p.set("mgp", mgp);
const qs = p.toString();
return qs ? `?${qs}` : "";
})()}`}
catalogMode
brandName={brandName}
variantSearch={variantSearch}
/>
) : null}
{browseMode !== "source" ? null : categoriesLoading ? (
<div className="space-y-2"> <div className="space-y-2">
{KEYS_8.map((__k) => ( {KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-10 w-full rounded-md" /> <Skeleton key={__k} className="h-10 w-full rounded-md" />

View File

@@ -46,6 +46,15 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// Short labels for the per-vehicle source switcher (eper-pekidi = the native ePER
// catalog; parts-catalogs = the broader fallback).
const SOURCE_LABELS: Record<string, string> = {
"eper-pekidi": "ePER",
"parts-catalogs": "Diğer",
emex: "Emex",
pl24: "PL24",
};
function VehicleDetailPage() { function VehicleDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { id } = Route.useParams(); const { id } = Route.useParams();
@@ -57,13 +66,17 @@ function VehicleDetailPage() {
// When a cross-tree catalog search is active, the normal browse (and its view // When a cross-tree catalog search is active, the normal browse (and its view
// toggle) are hidden in favour of the search results. // toggle) are hidden in favour of the search results.
const [searchActive, setSearchActive] = useState(false); const [searchActive, setSearchActive] = useState(false);
// "canonical" (default) = the catalog folded into the unified 20-bucket // "source" (default) = the raw per-brand OEM tree ("Orijinal Katalog");
// taxonomy; "source" = the raw per-brand OEM tree ("Orijinal Katalog"). // "canonical" = the unified 20-bucket taxonomy. Source is default again
// Canonical is default again (2026-07-08): the edge cases that forced the // (2026-07-08 akşam): on freshly-decoded vehicles the canonical view still
// temporary source default are fixed — entry-children serving (bucket → doğal // shows template-overlay buckets with no browsable content until the deep
// alt-kategori), duplicate-root merge + NULLS NOT DISTINCT index, template // drill fills them ("Aydınlatma — 0 kategori") — stays source-default until
// overlay, and fast-lane prefetch for fresh decodes. // per-bucket empty-state/drill-through UX is solved.
const [browseMode, setBrowseMode] = useState<"source" | "canonical">("canonical"); const [browseMode, setBrowseMode] = useState<"source" | "canonical">("source");
// Which per-source catalog "source" mode shows. Defaults to the vehicle's
// primary source — eper-pekidi first, the native ePER tree Fiat dealers know
// (fully populated + diagrams) over parts-catalogs' broader-but-lazy folders.
const [activeSource, setActiveSource] = useState<string | null>(null);
const changeViewMode = (mode: "grid" | "tree" | "columns") => { const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode); setViewMode(mode);
@@ -98,9 +111,25 @@ function VehicleDetailPage() {
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2, retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
}); });
const { data: catalogSources } = useQuery({
queryKey: ["catalog-sources", id],
queryFn: () => api.get<string[]>(`/categories/tree/${id}/sources`),
enabled: !!id && idValid,
});
// Once the source list is known, default the active source to the first
// (eper-pekidi when present). Never override a source the user picked.
useEffect(() => {
if (catalogSources && catalogSources.length > 0 && activeSource === null) {
setActiveSource(catalogSources[0]);
}
}, [catalogSources, activeSource]);
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({ const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["category-tree", id], queryKey: ["category-tree", id, activeSource],
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`), queryFn: () =>
api.get<CategoryNode[]>(
`/categories/tree/${id}${activeSource ? `?source=${encodeURIComponent(activeSource)}` : ""}`,
),
enabled: !!id && idValid, enabled: !!id && idValid,
}); });
@@ -339,9 +368,24 @@ function VehicleDetailPage() {
Orijinal Katalog Orijinal Katalog
</button> </button>
</div> </div>
{browseMode === "source" && ( {browseMode === "source" && (catalogSources?.length ?? 0) > 1 && (
<CategoryViewToggle value={viewMode} onChange={changeViewMode} /> <div className="inline-flex rounded-md border border-border p-0.5">
{catalogSources?.map((src) => (
<button
key={src}
type="button"
onClick={() => {
setActiveSource(src);
capture("catalog_source_changed", { vehicle_id: id, source: src });
}}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors ${activeSource === src ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
>
{SOURCE_LABELS[src] ?? src}
</button>
))}
</div>
)} )}
<CategoryViewToggle value={viewMode} onChange={changeViewMode} />
</div> </div>
)} )}
</CardHeader> </CardHeader>
@@ -368,7 +412,9 @@ function VehicleDetailPage() {
</div> </div>
)} )}
</div> </div>
{!searchActive && browseMode === "canonical" ? <CanonicalView vehicleId={id} /> : null} {!searchActive && browseMode === "canonical" ? (
<CanonicalView vehicleId={id} viewMode={viewMode} />
) : null}
{!searchActive && {!searchActive &&
browseMode === "source" && browseMode === "source" &&
(categoriesLoading ? ( (categoriesLoading ? (