Compare commits
29 Commits
feature/vi
...
feat/fiat-
| Author | SHA1 | Date | |
|---|---|---|---|
| de960a0dd4 | |||
| cee11b5769 | |||
| 70d901f9f5 | |||
| 731ac93a55 | |||
|
|
0e8b2ab294 | ||
| 814b798b3f | |||
| c51b7b4e78 | |||
| ff81b244b7 | |||
| 8dd2d223e9 | |||
| 36ed37d5c0 | |||
| c8803ada35 | |||
| ccc2cd75a5 | |||
| f2a37b78cb | |||
| 0e2871c964 | |||
| 116def2c79 | |||
| bcf8bb38cd | |||
| d38bcaa36f | |||
| 25965717c3 | |||
| 671e9af15b | |||
| da95b0484d | |||
| 5ed3959a12 | |||
| eb92c5c5f8 | |||
| 8a079d84f8 | |||
| 1c4c896d64 | |||
| cafe206001 | |||
| a803d9da10 | |||
|
|
8054e2faf3 | ||
| 006cf1fd63 | |||
| 454eab6b0e |
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal file
17
apps/api/drizzle/0031_canonical_template_entries.sql
Normal 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");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user