Compare commits
13 Commits
fix/activa
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
| c20fc5462a | |||
| 2bf7525fe2 | |||
| bf49900b57 | |||
| ff96dcac1c | |||
| 5a3e1a2299 | |||
| 24495d8970 | |||
| de960a0dd4 | |||
| cee11b5769 | |||
| 70d901f9f5 | |||
| 731ac93a55 | |||
|
|
0e8b2ab294 | ||
| 814b798b3f | |||
| c51b7b4e78 |
@@ -21,6 +21,14 @@ COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
|||||||
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
|
||||||
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
|
COPY --from=deps /app/packages/config/node_modules ./packages/config/node_modules
|
||||||
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
|
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
|
||||||
|
|
||||||
|
# Cache-bust: web-only commits were sticking to a cached build layer, so
|
||||||
|
# auto-deploys shipped a stale bundle (only --no-cache/force rebuilds picked
|
||||||
|
# them up). Referencing the commit SHA *before* the source COPY makes this layer
|
||||||
|
# (and therefore COPY + `pnpm build`) invalidate on every commit; the deps stage
|
||||||
|
# above stays cached on the lockfile, so installs aren't repeated.
|
||||||
|
ARG SOURCE_COMMIT=unknown
|
||||||
|
RUN echo "Building commit ${SOURCE_COMMIT}"
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite env vars (baked at build time)
|
# Vite env vars (baked at build time)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -50,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;
|
||||||
|
|
||||||
@@ -457,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
|
||||||
@@ -480,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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,20 +185,13 @@ export class SubscriptionsService {
|
|||||||
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
const allBrands = await this.db.select().from(brands).where(eq(brands.isActive, true));
|
||||||
|
|
||||||
if (allBrands.length > 0) {
|
if (allBrands.length > 0) {
|
||||||
// Full-plan trials already carry the complete userBrands set for this
|
await this.db.insert(userBrands).values(
|
||||||
// same subscription row; activating them must not re-insert (unique
|
|
||||||
// (userId, subscriptionId, brandId) would abort AFTER the status
|
|
||||||
// update above, leaving a half-activated sub and a 500 to the panel).
|
|
||||||
await this.db
|
|
||||||
.insert(userBrands)
|
|
||||||
.values(
|
|
||||||
allBrands.map((b) => ({
|
allBrands.map((b) => ({
|
||||||
userId: sub.userId,
|
userId: sub.userId,
|
||||||
subscriptionId: sub.id,
|
subscriptionId: sub.id,
|
||||||
brandId: b.id,
|
brandId: b.id,
|
||||||
})),
|
})),
|
||||||
)
|
);
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,24 @@ function VehicleDetailPage() {
|
|||||||
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fiat (any eper) vehicle: "Orijinal Katalog" shows the native ePER tree
|
||||||
|
// dealers know; "Birleşik" stays the canonical 20-bucket view (which folds the
|
||||||
|
// catalog into OUR structure), NOT the raw pcat tree. Non-eper vehicles keep
|
||||||
|
// the default source tree (treeSource undefined).
|
||||||
|
const { data: catalogSources } = useQuery({
|
||||||
|
queryKey: ["catalog-sources", id],
|
||||||
|
queryFn: () => api.get<string[]>(`/categories/tree/${id}/sources`),
|
||||||
|
enabled: !!id && idValid,
|
||||||
|
});
|
||||||
|
const hasEper = catalogSources?.includes("eper-pekidi") ?? false;
|
||||||
|
const treeSource = hasEper ? "eper-pekidi" : undefined;
|
||||||
|
|
||||||
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
||||||
queryKey: ["category-tree", id],
|
queryKey: ["category-tree", id, treeSource],
|
||||||
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`),
|
queryFn: () =>
|
||||||
|
api.get<CategoryNode[]>(
|
||||||
|
`/categories/tree/${id}${treeSource ? `?source=${encodeURIComponent(treeSource)}` : ""}`,
|
||||||
|
),
|
||||||
enabled: !!id && idValid,
|
enabled: !!id && idValid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
|
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
|
||||||
- VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT:-production}
|
- VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT:-production}
|
||||||
- VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE:-}
|
- VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE:-}
|
||||||
|
# Per-commit cache-bust (see Dockerfile) — Coolify substitutes the SHA so
|
||||||
|
# every commit rebuilds the bundle instead of reusing a stale cached layer.
|
||||||
|
- SOURCE_COMMIT=${SOURCE_COMMIT:-unknown}
|
||||||
- VITE_GOOGLE_ADS_ID=${VITE_GOOGLE_ADS_ID:-}
|
- VITE_GOOGLE_ADS_ID=${VITE_GOOGLE_ADS_ID:-}
|
||||||
- VITE_GOOGLE_ADS_SIGNUP_LABEL=${VITE_GOOGLE_ADS_SIGNUP_LABEL:-}
|
- VITE_GOOGLE_ADS_SIGNUP_LABEL=${VITE_GOOGLE_ADS_SIGNUP_LABEL:-}
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
Reference in New Issue
Block a user