Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Önceki hibrit sadece derin parça-yapraklarını + kök/dal girişlerini gösteriyordu;
kısmen drill edilmiş kovada ("Fren sistemi" subtree'de 39 parça) sistem-kökü
skip ediliyor + 0-parçalı kardeşleri düşüyordu → Fren'de 14 yerine 2. Yeni model
(buildCanonicalInput): her kovanın "entry container" köklerinin AYNI-KOVA
çocuklarını SUBTREE parça sayısıyla göster (drilled olsun olmasın). Audi Fren →
11 alt-kategori (Disk frenler/Fren Kaliyeri/El fren…), orijinal katalog gibi.
On-the-fly bucketing (materialize edilmemiş taze kategoriler için).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1473 lines
49 KiB
TypeScript
1473 lines
49 KiB
TypeScript
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||
import { and, eq, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||
import {
|
||
buildCanonicalInput,
|
||
groupCategoriesByCanonical,
|
||
overlayTemplate,
|
||
} from "../categories/canonical-grouping";
|
||
import { DATABASE, type Database } from "../database/database.provider";
|
||
import {
|
||
brands,
|
||
canonicalCategories,
|
||
canonicalStructureTemplate,
|
||
catalogVehicles,
|
||
categories,
|
||
parts,
|
||
plans,
|
||
schemaPics,
|
||
userBrands,
|
||
userSubscriptions,
|
||
} from "../database/schema/core";
|
||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||
import {
|
||
type PL24DecodedCategory,
|
||
PL24_SERVICE_CATALOGS,
|
||
SERVICE_DISPLAY_NAMES,
|
||
SERVICE_TO_BRAND,
|
||
isP5Modern,
|
||
} from "../integrations/pl24/pl24.types";
|
||
import { RedisService } from "../redis/redis.service";
|
||
import { StorageService } from "../storage/storage.service";
|
||
|
||
@Injectable()
|
||
export class CatalogService {
|
||
private readonly logger = new Logger(CatalogService.name);
|
||
|
||
constructor(
|
||
@Inject(DATABASE) private db: Database,
|
||
private redis: RedisService,
|
||
private pl24Service: PL24Service,
|
||
private storage: StorageService,
|
||
) {}
|
||
|
||
/**
|
||
* Get all brands available in PL24 catalogs, with access flags.
|
||
* Returns static brand list from SERVICE_TO_BRAND, enriched with DB brand info.
|
||
*/
|
||
async getBrands(userId: string) {
|
||
// Get unique brand names from service catalog
|
||
const brandNames = new Set(Object.values(SERVICE_TO_BRAND));
|
||
|
||
// Fetch DB brands for logo URLs
|
||
const dbBrands = await this.db
|
||
.select({ id: brands.id, name: brands.name, slug: brands.slug, logoUrl: brands.logoUrl })
|
||
.from(brands)
|
||
.where(eq(brands.isActive, true));
|
||
|
||
// Case-insensitive name lookup to handle mismatches (e.g. "SEAT" vs "Seat", "MINI" vs "Mini")
|
||
const brandByName = new Map(dbBrands.map((b) => [b.name.toLowerCase(), b]));
|
||
|
||
// Check if user has full plan (brandCount=0 → unlimited brand access)
|
||
const [sub] = await this.db
|
||
.select({ brandCount: plans.brandCount })
|
||
.from(userSubscriptions)
|
||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||
.where(
|
||
and(
|
||
eq(userSubscriptions.userId, userId),
|
||
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||
),
|
||
)
|
||
.limit(1);
|
||
|
||
const isFullAccess = sub?.brandCount === 0;
|
||
const accessibleBrandIds = isFullAccess ? null : await this.getAccessibleBrandIds(userId);
|
||
|
||
// Build response
|
||
const result: Array<{
|
||
brandName: string;
|
||
brandId: string | null;
|
||
logoUrl: string | null;
|
||
serviceNames: string[];
|
||
hasAccess: boolean;
|
||
}> = [];
|
||
|
||
// Group services by brand
|
||
const brandServices = new Map<string, string[]>();
|
||
for (const [serviceName, brandName] of Object.entries(SERVICE_TO_BRAND)) {
|
||
if (!brandServices.has(brandName)) brandServices.set(brandName, []);
|
||
brandServices.get(brandName)?.push(serviceName);
|
||
}
|
||
|
||
for (const brandName of Array.from(brandNames).sort()) {
|
||
const dbBrand = brandByName.get(brandName.toLowerCase());
|
||
const hasAccess = isFullAccess
|
||
? true
|
||
: dbBrand
|
||
? (accessibleBrandIds?.has(dbBrand.id) ?? false)
|
||
: false;
|
||
|
||
result.push({
|
||
brandName,
|
||
brandId: dbBrand?.id || null,
|
||
logoUrl: dbBrand?.logoUrl || null,
|
||
serviceNames: brandServices.get(brandName) || [],
|
||
hasAccess,
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Get the list of sub-catalogs (services) available for a brand.
|
||
* Brands with a single service return a 1-item array; multi-service brands return all services.
|
||
*/
|
||
async getCatalogs(brandName: string, userId: string) {
|
||
const brandId = await this.getBrandIdByName(brandName);
|
||
if (brandId) {
|
||
await this.checkBrandAccess(userId, brandId);
|
||
}
|
||
|
||
const serviceNames = Object.entries(SERVICE_TO_BRAND)
|
||
.filter(([, b]) => b === brandName)
|
||
.map(([s]) => s);
|
||
|
||
if (serviceNames.length === 0) {
|
||
throw new NotFoundException(`Marka kataloğu bulunamadı: ${brandName}`);
|
||
}
|
||
|
||
return serviceNames.map((s) => ({
|
||
serviceName: s,
|
||
displayName: SERVICE_DISPLAY_NAMES[s] ?? s,
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Get models for a brand. Fetches from DB first, then PL24 if empty.
|
||
* Optional serviceName filters to a specific sub-catalog.
|
||
*/
|
||
async getModels(brandName: string, userId: string, serviceName?: string) {
|
||
// Check brand access
|
||
const brandId = await this.getBrandIdByName(brandName);
|
||
if (brandId) {
|
||
await this.checkBrandAccess(userId, brandId);
|
||
}
|
||
|
||
// All service names for this brand, or just the requested one
|
||
const allServiceNames = Object.entries(SERVICE_TO_BRAND)
|
||
.filter(([, b]) => b === brandName)
|
||
.map(([s]) => s);
|
||
|
||
if (allServiceNames.length === 0) {
|
||
throw new NotFoundException(`Marka kataloğu bulunamadı: ${brandName}`);
|
||
}
|
||
|
||
const serviceNames = serviceName ? [serviceName] : allServiceNames;
|
||
|
||
// Check DB first
|
||
const whereConditions = [
|
||
eq(catalogVehicles.brandName, brandName),
|
||
eq(catalogVehicles.source, "pl24"),
|
||
...(serviceName ? [eq(catalogVehicles.serviceName, serviceName)] : []),
|
||
];
|
||
|
||
const dbVehicles = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(and(...whereConditions));
|
||
|
||
if (dbVehicles.length > 0) {
|
||
return dbVehicles;
|
||
}
|
||
|
||
// Fetch from PL24 for each service
|
||
const allVehicles: (typeof catalogVehicles.$inferSelect)[] = [];
|
||
|
||
for (const svc of serviceNames) {
|
||
try {
|
||
const fetched = await this.pl24Service.fetchVehicleList(svc);
|
||
if (fetched.length === 0) continue;
|
||
|
||
const config = PL24_SERVICE_CATALOGS[svc];
|
||
const insertData = fetched.map((v) => ({
|
||
source: "pl24" as const,
|
||
serviceName: svc,
|
||
brandName,
|
||
brandId: brandId || null,
|
||
model: v.model,
|
||
year: v.year || null,
|
||
engine: v.engine || null,
|
||
bodyType: v.bodyType || null,
|
||
transmission: v.transmission || null,
|
||
market: v.market || null,
|
||
serviceVehicleId: v.vehicleId,
|
||
catalogPath: v.catalogPath || null,
|
||
architecture: config?.architecture || "P5_MODERN",
|
||
metadata: v.metadata || null,
|
||
categoriesFetched: false,
|
||
updatedAt: new Date(),
|
||
}));
|
||
|
||
const inserted = await this.db
|
||
.insert(catalogVehicles)
|
||
.values(insertData)
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
|
||
allVehicles.push(...inserted);
|
||
} catch (err) {
|
||
this.logger.warn(`Failed to fetch vehicle list for ${svc}: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
// Re-fetch from DB to include any previously inserted (onConflictDoNothing may skip)
|
||
if (allVehicles.length === 0) {
|
||
const refetched = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(and(...whereConditions));
|
||
return refetched;
|
||
}
|
||
|
||
return allVehicles;
|
||
}
|
||
|
||
/**
|
||
* Get a single catalog vehicle by ID.
|
||
*/
|
||
async getVehicle(id: string, userId: string) {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, id))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
|
||
if (vehicle.brandId) {
|
||
await this.checkBrandAccess(userId, vehicle.brandId);
|
||
}
|
||
|
||
return vehicle;
|
||
}
|
||
|
||
/**
|
||
* Get P5 Modern restriction options for a catalog vehicle.
|
||
* Fetches the first restriction level (from vehicle.catalogPath) or a subsequent level (from `nextPath`).
|
||
*/
|
||
async getP5Restrictions(
|
||
catalogVehicleId: string,
|
||
userId: string,
|
||
nextPath?: string,
|
||
): Promise<{ options: Array<{ code: string; name: string; path: string }>; isFinal: boolean }> {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||
|
||
const pathToFetch = nextPath || vehicle.catalogPath;
|
||
if (!pathToFetch) return { options: [], isFinal: false };
|
||
|
||
return this.pl24Service.fetchP5Restrictions(vehicle.serviceName, pathToFetch);
|
||
}
|
||
|
||
/**
|
||
* Get available body types for a PSA catalog vehicle (for variant selector UI).
|
||
*/
|
||
async getPsaBodies(
|
||
catalogVehicleId: string,
|
||
userId: string,
|
||
): Promise<{ code: string; name: string }[]> {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
|
||
|
||
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
|
||
if (!familyId || !salesTypeId) return [];
|
||
|
||
return this.pl24Service.fetchPsaBodies(vehicle.serviceName, familyId, salesTypeId, mode, upds);
|
||
}
|
||
|
||
/**
|
||
* Get available engines for a PSA catalog vehicle given a selected body code.
|
||
*/
|
||
async getPsaEngines(
|
||
catalogVehicleId: string,
|
||
body: string,
|
||
userId: string,
|
||
): Promise<{ code: string; name: string }[]> {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
|
||
|
||
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
|
||
if (!familyId || !salesTypeId) return [];
|
||
|
||
return this.pl24Service.fetchPsaEnginesForBody(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
salesTypeId,
|
||
body,
|
||
mode,
|
||
upds,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get available gearboxes for a PSA catalog vehicle given selected body + engine codes.
|
||
*/
|
||
async getPsaGearboxes(
|
||
catalogVehicleId: string,
|
||
body: string,
|
||
engine: string,
|
||
userId: string,
|
||
): Promise<{ code: string; name: string }[]> {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||
if (vehicle.architecture !== "LEGACY_PSA" || !vehicle.catalogPath) return [];
|
||
|
||
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
|
||
if (!familyId || !salesTypeId) return [];
|
||
|
||
return this.pl24Service.fetchPsaGearboxes(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
salesTypeId,
|
||
body,
|
||
engine,
|
||
mode,
|
||
upds,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get category tree for a catalog vehicle (on-demand fetch from PL24).
|
||
*/
|
||
/**
|
||
* Unified canonical browse for a catalog vehicle (pl24 flow) — the
|
||
* catalog_vehicle_id counterpart of CategoriesService.getCanonicalTree.
|
||
* Folds the vehicle's part-holding leaf categories into the 20 canonical
|
||
* buckets so pl24 brands (VW, Opel, Renault…) navigate through the same
|
||
* headings as the emex/pcat vehicle_id flow. Part counts are joined by
|
||
* category_id (pl24 parts often carry vehicle_id, not catalog_vehicle_id).
|
||
*/
|
||
async getCanonicalTree(
|
||
catalogVehicleId: string,
|
||
userId: string,
|
||
body = "_all_",
|
||
engine = "_all_",
|
||
gearbox = "_all_",
|
||
mainGroupsPath?: string,
|
||
) {
|
||
// Populate the raw source tree (lazy fetch + cache) and enforce brand access.
|
||
await this.getCategoryTree(catalogVehicleId, userId, body, engine, gearbox, mainGroupsPath);
|
||
|
||
const buckets = await this.db
|
||
.select({
|
||
id: canonicalCategories.id,
|
||
slug: canonicalCategories.slug,
|
||
name: canonicalCategories.name,
|
||
})
|
||
.from(canonicalCategories)
|
||
.where(isNull(canonicalCategories.parentId))
|
||
.orderBy(canonicalCategories.sortOrder);
|
||
const subLeaves = await this.db
|
||
.select({
|
||
id: canonicalCategories.id,
|
||
parentId: canonicalCategories.parentId,
|
||
slug: canonicalCategories.slug,
|
||
name: canonicalCategories.name,
|
||
})
|
||
.from(canonicalCategories)
|
||
.where(isNotNull(canonicalCategories.parentId))
|
||
.orderBy(canonicalCategories.sortOrder);
|
||
|
||
const cats = await this.db
|
||
.select({
|
||
id: categories.id,
|
||
name: categories.name,
|
||
source: categories.source,
|
||
canonicalId: categories.canonicalCategoryId,
|
||
subId: categories.canonicalSubcategoryId,
|
||
parentId: categories.parentId,
|
||
})
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||
|
||
const partCounts = new Map<string, number>();
|
||
const catIds = cats.map((c) => c.id);
|
||
if (catIds.length > 0) {
|
||
const rows = await this.db
|
||
.select({ categoryId: parts.categoryId, count: sql<number>`count(*)::int` })
|
||
.from(parts)
|
||
.where(inArray(parts.categoryId, catIds))
|
||
.groupBy(parts.categoryId);
|
||
for (const r of rows) partCounts.set(r.categoryId, r.count);
|
||
}
|
||
|
||
// Show each bucket's natural sub-category level (see buildCanonicalInput).
|
||
const {
|
||
input: shown,
|
||
counts: shownCounts,
|
||
hasChildren,
|
||
} = buildCanonicalInput(cats, partCounts, buckets);
|
||
const groups = groupCategoriesByCanonical(shown, shownCounts, buckets, {
|
||
requireParts: false,
|
||
subLeaves,
|
||
hasChildren,
|
||
});
|
||
|
||
// Overlay the per-brand structure template (see CategoriesService.getCanonicalTree).
|
||
const cv = await this.db
|
||
.select({ brand: catalogVehicles.brandName, source: catalogVehicles.source })
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
const brand = cv[0]?.brand;
|
||
const source = cats[0]?.source ?? cv[0]?.source;
|
||
if (!brand || !source) return groups;
|
||
const template = await this.db
|
||
.select({
|
||
bucketId: canonicalStructureTemplate.canonicalCategoryId,
|
||
subId: canonicalStructureTemplate.canonicalSubcategoryId,
|
||
})
|
||
.from(canonicalStructureTemplate)
|
||
.where(
|
||
and(
|
||
eq(canonicalStructureTemplate.source, source),
|
||
eq(canonicalStructureTemplate.brand, brand),
|
||
gte(canonicalStructureTemplate.vehicleCount, 2),
|
||
),
|
||
);
|
||
return overlayTemplate(groups, template, buckets, subLeaves);
|
||
}
|
||
|
||
async getCategoryTree(
|
||
catalogVehicleId: string,
|
||
userId: string,
|
||
body = "_all_",
|
||
engine = "_all_",
|
||
gearbox = "_all_",
|
||
mainGroupsPath?: string,
|
||
) {
|
||
const hasVariant = body !== "_all_" || engine !== "_all_" || gearbox !== "_all_";
|
||
const cacheKey = mainGroupsPath
|
||
? `cat:catalog:tree:${catalogVehicleId}:mgp=${Buffer.from(mainGroupsPath).toString("base64").slice(0, 40)}`
|
||
: hasVariant
|
||
? `cat:catalog:tree:${catalogVehicleId}:b=${body}:e=${engine}:g=${gearbox}`
|
||
: `cat:catalog:tree:${catalogVehicleId}`;
|
||
const cached = await this.redis.getJson<any[]>(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
|
||
if (vehicle.brandId) {
|
||
await this.checkBrandAccess(userId, vehicle.brandId);
|
||
}
|
||
|
||
// For LEGACY_PSA with specific variants, fetch directly from PL24.
|
||
// We also ensure the _all_ categories are persisted to DB so that
|
||
// getCategoryWithParts can resolve PSA codes to DB UUIDs via externalId.
|
||
if (vehicle.architecture === "LEGACY_PSA" && hasVariant && vehicle.catalogPath) {
|
||
try {
|
||
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
|
||
|
||
// Fetch variant-specific main groups
|
||
const pl24Categories = await this.pl24Service.fetchMainGroupsForPsa(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
salesTypeId,
|
||
mode,
|
||
upds,
|
||
body,
|
||
engine,
|
||
gearbox,
|
||
);
|
||
|
||
// Ensure _all_ categories are in DB so getCategoryWithParts can find UUIDs by externalId
|
||
let dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||
|
||
if (dbCategories.length === 0) {
|
||
// Fetch and persist the _all_ variant (once, as the canonical DB records)
|
||
const allCats = await this.pl24Service.fetchMainGroupsForPsa(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
salesTypeId,
|
||
mode,
|
||
upds,
|
||
);
|
||
if (allCats.length > 0) {
|
||
const seenNames = new Set<string>();
|
||
const unique = allCats.filter((c) => {
|
||
const name = c.nameTr || c.nameEn;
|
||
if (seenNames.has(name)) return false;
|
||
seenNames.add(name);
|
||
return true;
|
||
});
|
||
const insertData = unique.map((c) => ({
|
||
catalogVehicleId,
|
||
vehicleId: null as string | null,
|
||
name: c.nameTr || c.nameEn,
|
||
nameOriginal: c.nameEn,
|
||
parentId: null as string | null,
|
||
externalId: c.code,
|
||
linkPath: c.linkPath || null,
|
||
linkWid: c.linkWid || null,
|
||
source: "pl24" as const,
|
||
}));
|
||
const inserted = await this.db
|
||
.insert(categories)
|
||
.values(insertData)
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
dbCategories =
|
||
inserted.length > 0
|
||
? inserted
|
||
: await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||
await this.db
|
||
.update(catalogVehicles)
|
||
.set({ categoriesFetched: true, updatedAt: new Date() })
|
||
.where(eq(catalogVehicles.id, catalogVehicleId));
|
||
}
|
||
}
|
||
|
||
// Map PSA external codes → DB UUIDs so navigation uses real UUIDs
|
||
const externalIdToUuid = new Map(dbCategories.map((c) => [c.externalId, c.id]));
|
||
|
||
const tree = this.buildTree(
|
||
pl24Categories.map((c) => ({
|
||
id: externalIdToUuid.get(c.code) ?? c.code,
|
||
catalogVehicleId,
|
||
vehicleId: null,
|
||
name: c.nameTr || c.nameEn,
|
||
nameOriginal: c.nameEn,
|
||
parentId: null,
|
||
externalId: c.code,
|
||
linkPath: c.linkPath || null,
|
||
linkWid: c.linkWid || null,
|
||
source: "pl24",
|
||
})),
|
||
);
|
||
await this.redis.setJson(cacheKey, tree, 7200);
|
||
return tree;
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`PSA variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
|
||
);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// LEGACY_HYUNDAI_KIA / LEGACY_NISSAN / LEGACY_OPEL: no variant selector needed.
|
||
// Fetch main groups directly via fetchFordMainGroups (dispatches URL per architecture).
|
||
if (
|
||
vehicle.architecture === "LEGACY_HYUNDAI_KIA" ||
|
||
vehicle.architecture === "LEGACY_NISSAN" ||
|
||
vehicle.architecture === "LEGACY_OPEL"
|
||
) {
|
||
try {
|
||
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
|
||
if (familyId) {
|
||
const pl24Categories = await this.pl24Service.fetchFordMainGroups(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
"_all_",
|
||
"_all_",
|
||
"_all_",
|
||
mode,
|
||
upds,
|
||
);
|
||
|
||
if (pl24Categories.length > 0) {
|
||
const tree = await this.persistAndBuildLegacyTree(
|
||
catalogVehicleId,
|
||
pl24Categories,
|
||
cacheKey,
|
||
);
|
||
if (tree.length > 0) return tree;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`P4 direct category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
// DB fallback: return cached categories if PL24 failed or returned empty
|
||
return this.buildCategoryTreeFromDb(catalogVehicleId, cacheKey);
|
||
}
|
||
|
||
// LEGACY_FORD + LEGACY_VOLVO: fetch categories via group.action only when variant params are provided.
|
||
// Without params the FordVariantSelector on the frontend handles selection first.
|
||
// For LEGACY_FORD: body param = catCode (e.g. "CB7" for C-MAX Grand C-MAX).
|
||
// For LEGACY_VOLVO: body param = model year (e.g. "1638").
|
||
if (this.isP4FordLikeArch(vehicle.architecture)) {
|
||
if (!hasVariant) return []; // no variant selected yet → let frontend show variant selector
|
||
try {
|
||
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
|
||
if (familyId) {
|
||
let pl24Categories: Awaited<ReturnType<typeof this.pl24Service.fetchFordMainGroups>>;
|
||
|
||
if (vehicle.architecture === "LEGACY_FORD") {
|
||
// For Ford, body = catCode (generation identifier required by group.action).
|
||
// catCode is selected by the user via FordVariantSelector showing window.vehicles sub-models.
|
||
const fordCatCode = body && body !== "_nor_" && body !== "_all_" ? body : undefined;
|
||
pl24Categories = await this.pl24Service.fetchFordMainGroups(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
"_all_", // modelYear not used for Ford catCode-based lookup
|
||
"_all_", // engine not used
|
||
"_all_", // gearbox not used
|
||
mode,
|
||
upds,
|
||
fordCatCode,
|
||
);
|
||
} else {
|
||
// LEGACY_VOLVO: body = year, engine/gearbox as-is
|
||
const normFord = (v: string) => (v === "_nor_" ? "_all_" : v || "_all_");
|
||
pl24Categories = await this.pl24Service.fetchFordMainGroups(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
normFord(body ?? ""),
|
||
normFord(engine ?? ""),
|
||
normFord(gearbox ?? ""),
|
||
mode,
|
||
upds,
|
||
);
|
||
}
|
||
|
||
if (pl24Categories.length > 0) {
|
||
const tree = await this.persistAndBuildLegacyTree(
|
||
catalogVehicleId,
|
||
pl24Categories,
|
||
cacheKey,
|
||
);
|
||
if (tree.length > 0) return tree;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`Ford variant category fetch failed for ${catalogVehicleId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
// DB fallback: return cached categories if PL24 failed or returned empty
|
||
return this.buildCategoryTreeFromDb(catalogVehicleId, cacheKey);
|
||
}
|
||
|
||
// Check DB first
|
||
let dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||
|
||
// When mainGroupsPath is provided (P5 restriction flow), use it directly
|
||
const effectiveCatalogPath = mainGroupsPath || vehicle.catalogPath;
|
||
|
||
if (dbCategories.length === 0 && effectiveCatalogPath) {
|
||
try {
|
||
let pl24Categories: Awaited<ReturnType<PL24Service["fetchMainGroups"]>>;
|
||
|
||
if (vehicle.architecture === "LEGACY_PSA") {
|
||
// PSA vehicles: parse family/salesType from catalogPath, mode/upds from metadata
|
||
const { familyId, salesTypeId, mode, upds } = this.extractPsaMeta(vehicle);
|
||
pl24Categories = await this.pl24Service.fetchMainGroupsForPsa(
|
||
vehicle.serviceName,
|
||
familyId,
|
||
salesTypeId,
|
||
mode,
|
||
upds,
|
||
);
|
||
} else {
|
||
pl24Categories = await this.pl24Service.fetchMainGroups(
|
||
vehicle.serviceName,
|
||
effectiveCatalogPath,
|
||
);
|
||
}
|
||
|
||
if (pl24Categories.length > 0) {
|
||
const seenNames = new Set<string>();
|
||
const unique = pl24Categories.filter((c) => {
|
||
const name = c.nameTr || c.nameEn;
|
||
if (seenNames.has(name)) return false;
|
||
seenNames.add(name);
|
||
return true;
|
||
});
|
||
|
||
const insertData = unique.map((c) => ({
|
||
catalogVehicleId,
|
||
vehicleId: null as string | null,
|
||
name: c.nameTr || c.nameEn,
|
||
nameOriginal: c.nameEn,
|
||
parentId: null as string | null,
|
||
externalId: c.code,
|
||
linkPath: c.linkPath || null,
|
||
linkWid: c.linkWid || null,
|
||
source: "pl24" as const,
|
||
}));
|
||
|
||
dbCategories = await this.db
|
||
.insert(categories)
|
||
.values(insertData)
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
|
||
if (dbCategories.length < insertData.length) {
|
||
dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId));
|
||
}
|
||
|
||
// Mark categories as fetched
|
||
await this.db
|
||
.update(catalogVehicles)
|
||
.set({ categoriesFetched: true, updatedAt: new Date() })
|
||
.where(eq(catalogVehicles.id, catalogVehicleId));
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(
|
||
`PL24 category fetch failed for catalog vehicle ${catalogVehicleId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const tree = this.buildTree(dbCategories);
|
||
await this.redis.setJson(cacheKey, tree, 7200); // 2h TTL
|
||
return tree;
|
||
}
|
||
|
||
/**
|
||
* Build a category tree from DB for a catalog vehicle (used as fallback when PL24 is unreachable).
|
||
* Only returns root-level categories (parentId IS NULL).
|
||
*/
|
||
private async buildCategoryTreeFromDb(catalogVehicleId: string, cacheKey: string) {
|
||
const dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(
|
||
and(eq(categories.catalogVehicleId, catalogVehicleId), sql`${categories.parentId} IS NULL`),
|
||
);
|
||
if (dbCategories.length === 0) return [];
|
||
// Same nav-crumb read filter as persistAndBuildLegacyTree (self-heals
|
||
// previously-persisted Hyundai "Portal" / Nissan eu.nissan.biz junk rows).
|
||
const tree = this.buildTree(dbCategories.filter((c) => !this.isNavCrumbLink(c.linkPath)));
|
||
await this.redis.setJson(cacheKey, tree, 3600); // 1h cache for DB fallback
|
||
return tree;
|
||
}
|
||
|
||
/**
|
||
* Get sub-categories or parts for a category in a catalog vehicle.
|
||
* Adapts CategoriesService.getCategoryWithParts logic for catalogVehicleId.
|
||
*/
|
||
async getCategoryWithParts(
|
||
catalogVehicleId: string,
|
||
categoryIdInput: string,
|
||
userId: string,
|
||
body = "_all_",
|
||
engine = "_all_",
|
||
gearbox = "_all_",
|
||
) {
|
||
const [catalogVehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, catalogVehicleId))
|
||
.limit(1);
|
||
|
||
if (!catalogVehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
|
||
if (catalogVehicle.brandId) {
|
||
await this.checkBrandAccess(userId, catalogVehicle.brandId);
|
||
}
|
||
|
||
// PSA variant trees return PL24 codes (e.g. "_FCT0100") as IDs instead of UUIDs.
|
||
// Detect and resolve to DB UUID via externalId lookup.
|
||
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||
categoryIdInput,
|
||
);
|
||
let categoryRow: typeof categories.$inferSelect | undefined;
|
||
|
||
if (isUuid) {
|
||
[categoryRow] = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.id, categoryIdInput))
|
||
.limit(1);
|
||
} else {
|
||
// PSA external code — look up by externalId within this catalog vehicle
|
||
[categoryRow] = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(
|
||
and(
|
||
eq(categories.externalId, categoryIdInput),
|
||
eq(categories.catalogVehicleId, catalogVehicleId),
|
||
),
|
||
)
|
||
.limit(1);
|
||
}
|
||
|
||
if (!categoryRow) throw new NotFoundException("Kategori bulunamadı");
|
||
|
||
// Normalize to DB UUID for all downstream queries
|
||
const category = categoryRow;
|
||
const categoryId = category.id;
|
||
|
||
const linkPath = category.linkPath;
|
||
|
||
// Check if this category has children
|
||
let children = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.parentId, categoryId));
|
||
|
||
// Self-healing: drop nav-crumb junk children (e.g. "Portal"/eu.nissan.biz rows
|
||
// persisted by the old parser) and delete them so this node re-fetches the real
|
||
// subgroups instead of returning a junk-only child set.
|
||
const navCrumbChildren = children.filter((c) => this.isNavCrumbLink(c.linkPath));
|
||
if (navCrumbChildren.length > 0) {
|
||
await this.db.delete(categories).where(
|
||
inArray(
|
||
categories.id,
|
||
navCrumbChildren.map((c) => c.id),
|
||
),
|
||
);
|
||
children = children.filter((c) => !this.isNavCrumbLink(c.linkPath));
|
||
}
|
||
|
||
// Self-healing: if the linkPath is a leaf path but DB has children, those are
|
||
// stale records created by the previous case-insensitive bug. Delete and re-fetch.
|
||
if (children.length > 0 && linkPath && this.isLeafPath(linkPath)) {
|
||
this.logger.warn(
|
||
`Stale children detected on leaf category ${categoryId} (linkPath: ${linkPath}), cleaning up`,
|
||
);
|
||
await this.db.delete(categories).where(eq(categories.parentId, categoryId));
|
||
children = [];
|
||
}
|
||
|
||
if (children.length > 0) {
|
||
return {
|
||
id: category.id,
|
||
name: category.name,
|
||
description: category.nameOriginal || null,
|
||
parentId: category.parentId || null,
|
||
parts: [],
|
||
schemaPics: [],
|
||
hotspots: [],
|
||
children: await this.enrichWithSchemaImages(children),
|
||
};
|
||
}
|
||
|
||
if (linkPath && !this.isLeafPath(linkPath)) {
|
||
// Try to fetch subgroups
|
||
const subGroups = await this.pl24Service.fetchSubGroupsByPath(
|
||
linkPath,
|
||
catalogVehicle.serviceName,
|
||
body,
|
||
engine,
|
||
gearbox,
|
||
);
|
||
|
||
// After dropping nav-crumb junk the subgroup set can be empty (upstream
|
||
// returned only a "Portal"/external stub for this node). Guard the insert —
|
||
// drizzle .values([]) throws — and fall through to the leaf/parts path,
|
||
// which yields a clean empty node instead of a 500.
|
||
const seenNames = new Set<string>();
|
||
const unique = subGroups.filter((sg) => {
|
||
if (this.isNavCrumbLink(sg.linkPath)) return false;
|
||
if (seenNames.has(sg.name)) return false;
|
||
seenNames.add(sg.name);
|
||
return true;
|
||
});
|
||
|
||
if (unique.length > 0) {
|
||
const insertData = unique.map((sg) => ({
|
||
catalogVehicleId,
|
||
vehicleId: null as string | null,
|
||
name: sg.name,
|
||
nameOriginal: sg.name,
|
||
parentId: categoryId,
|
||
externalId: sg.code,
|
||
linkPath: sg.linkPath || null,
|
||
linkWid: sg.linkWid || null,
|
||
unavailable: sg.unavailable || false,
|
||
source: "pl24" as const,
|
||
}));
|
||
|
||
children = await this.db
|
||
.insert(categories)
|
||
.values(insertData)
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
|
||
if (children.length < insertData.length) {
|
||
children = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.parentId, categoryId));
|
||
}
|
||
|
||
await this.redis.del(`cat:catalog:tree:${catalogVehicleId}`);
|
||
|
||
if (children.length > 0) {
|
||
return {
|
||
id: category.id,
|
||
name: category.name,
|
||
description: category.nameOriginal || null,
|
||
parentId: category.parentId || null,
|
||
parts: [],
|
||
schemaPics: [],
|
||
hotspots: [],
|
||
children: await this.enrichWithSchemaImages(children),
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
// Leaf category — get or fetch parts
|
||
let dbParts = await this.db.select().from(parts).where(eq(parts.categoryId, categoryId));
|
||
|
||
const pics = await this.db
|
||
.select()
|
||
.from(schemaPics)
|
||
.where(eq(schemaPics.categoryId, categoryId));
|
||
|
||
const needParts = dbParts.length === 0;
|
||
const needImage = pics.length === 0;
|
||
|
||
if ((needParts || needImage) && linkPath && category.source === "pl24") {
|
||
try {
|
||
const pl24Result = await this.pl24Service.fetchPartsByPath(
|
||
linkPath,
|
||
catalogVehicle.serviceName,
|
||
body,
|
||
engine,
|
||
gearbox,
|
||
);
|
||
|
||
if (needParts && pl24Result.parts.length > 0) {
|
||
const insertData = pl24Result.parts.map((p) => ({
|
||
catalogVehicleId,
|
||
vehicleId: null as string | null,
|
||
categoryId,
|
||
oemCode: p.oemCode || "N/A",
|
||
name: p.name,
|
||
nameOriginal: p.name,
|
||
description: p.description || null,
|
||
quantity: p.quantity ? Number.parseInt(String(p.quantity), 10) || null : null,
|
||
position: p.positionCode || null,
|
||
hotspotIndex: ((): number | null => {
|
||
if (!p.hotspotId) return null;
|
||
const val = Number.parseInt(p.hotspotId, 10);
|
||
return val > 0 && val <= 2147483647 ? val : null;
|
||
})(),
|
||
unavailable: p.unavailable || false,
|
||
remark: p.remark || null,
|
||
modelCodes: p.modelCodes || null,
|
||
presel: p.presel || false,
|
||
price: p.price != null ? String(p.price) : null,
|
||
currency: p.price != null ? (p.currency ?? "EUR") : null,
|
||
source: "pl24" as const,
|
||
}));
|
||
|
||
dbParts = await this.db
|
||
.insert(parts)
|
||
.values(insertData)
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
}
|
||
|
||
if (needImage) {
|
||
// PSA provides a pre-downloaded buffer (ticket URLs expire immediately)
|
||
if (pl24Result.schemaImageBuffer) {
|
||
try {
|
||
const ext = (pl24Result.schemaImageContentType || "image/png").includes("jpeg")
|
||
? "jpg"
|
||
: "png";
|
||
const fileName = `catalog/${catalogVehicleId}/${categoryId}.${ext}`;
|
||
const uploadedUrl = await this.storage.upload(
|
||
fileName,
|
||
pl24Result.schemaImageBuffer,
|
||
pl24Result.schemaImageContentType || "image/png",
|
||
);
|
||
const hotspotsData = {
|
||
width: pl24Result.schemaWidth || null,
|
||
height: pl24Result.schemaHeight || null,
|
||
items: pl24Result.hotspots || [],
|
||
};
|
||
const [inserted] = await this.db
|
||
.insert(schemaPics)
|
||
.values({
|
||
categoryId,
|
||
imageUrl: uploadedUrl,
|
||
hotspots: JSON.stringify(hotspotsData),
|
||
source: "pl24",
|
||
})
|
||
.returning();
|
||
pics.push(inserted);
|
||
} catch (imgErr) {
|
||
this.logger.warn(
|
||
`PSA image upload failed for ${categoryId}: ${(imgErr as Error).message}`,
|
||
);
|
||
}
|
||
} else if (pl24Result.schemaImageUrl) {
|
||
const imageResult = await this.pl24Service.getSchemaImage(
|
||
pl24Result.schemaImageUrl,
|
||
catalogVehicle.serviceName,
|
||
);
|
||
|
||
if (imageResult) {
|
||
const hotspotsData = {
|
||
width: imageResult.width || pl24Result.schemaWidth || null,
|
||
height: imageResult.height || pl24Result.schemaHeight || null,
|
||
items:
|
||
imageResult.hotspots.length > 0
|
||
? imageResult.hotspots
|
||
: pl24Result.hotspots || [],
|
||
};
|
||
|
||
const [inserted] = await this.db
|
||
.insert(schemaPics)
|
||
.values({
|
||
categoryId,
|
||
imageUrl: imageResult.imageUrl,
|
||
hotspots: JSON.stringify(hotspotsData),
|
||
source: "pl24",
|
||
})
|
||
.returning();
|
||
|
||
pics.push(inserted);
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Parse hotspots
|
||
let hotspots: any[] = [];
|
||
let schemaWidth = 0;
|
||
let schemaHeight = 0;
|
||
|
||
if (pics.length > 0) {
|
||
const rawHotspots = pics[0].hotspots;
|
||
let parsed: any = rawHotspots;
|
||
if (typeof rawHotspots === "string") {
|
||
try {
|
||
parsed = JSON.parse(rawHotspots);
|
||
} catch {
|
||
parsed = [];
|
||
}
|
||
}
|
||
|
||
if (parsed && !Array.isArray(parsed) && parsed.items) {
|
||
schemaWidth = parsed.width || 0;
|
||
schemaHeight = parsed.height || 0;
|
||
hotspots = parsed.items || [];
|
||
} else if (Array.isArray(parsed)) {
|
||
hotspots = parsed;
|
||
}
|
||
}
|
||
|
||
const mappedHotspots = hotspots.flatMap(
|
||
(hs: {
|
||
key: string;
|
||
label?: string;
|
||
areas?: Array<{ left: number; top: number; width: number; height: number }>;
|
||
}) =>
|
||
(hs.areas || []).map((area, areaIdx) => ({
|
||
id: `hs-${hs.key}-${areaIdx}`,
|
||
key: hs.key,
|
||
group: Number.parseInt(hs.key, 10) || 0,
|
||
shape: "rect" as const,
|
||
coordinates: [area.left, area.top, area.width, area.height],
|
||
label: hs.label || hs.key,
|
||
})),
|
||
);
|
||
|
||
const mappedPics = pics.map((pic) => ({
|
||
id: pic.id,
|
||
url: pic.imageUrl,
|
||
width: schemaWidth,
|
||
height: schemaHeight,
|
||
label: category.name,
|
||
}));
|
||
|
||
return {
|
||
id: category.id,
|
||
name: category.name,
|
||
description: category.nameOriginal || null,
|
||
parentId: category.parentId || null,
|
||
parts: dbParts,
|
||
schemaPics: mappedPics,
|
||
hotspots: mappedHotspots,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Admin: explore a PL24 service to discover catalog structure.
|
||
* Tries various endpoint patterns and returns raw results.
|
||
*/
|
||
async exploreService(serviceName: string) {
|
||
const config = PL24_SERVICE_CATALOGS[serviceName];
|
||
if (!config) {
|
||
throw new NotFoundException(`Servis bulunamadı: ${serviceName}`);
|
||
}
|
||
|
||
this.logger.log(`Exploring PL24 service: ${serviceName} (${config.architecture})`);
|
||
|
||
const results: Record<string, any> = {
|
||
serviceName,
|
||
architecture: config.architecture,
|
||
basePath: config.basePath,
|
||
apiPath: config.apiPath,
|
||
endpoints: {},
|
||
};
|
||
|
||
if (isP5Modern(serviceName)) {
|
||
results.endpoints = await this.pl24Service.exploreP5Service(serviceName);
|
||
} else {
|
||
results.note = "Legacy architecture — manual exploration required";
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
// ─── Private helpers ────────────────────────────────────────────────────────
|
||
|
||
private extractPsaMeta(vehicle: typeof catalogVehicles.$inferSelect) {
|
||
const familyMatch = vehicle.catalogPath?.match(/::family=([^:]+)/);
|
||
const salesTypeMatch = vehicle.catalogPath?.match(/::salesType=([^:]+)/);
|
||
const meta = (vehicle.metadata || {}) as Record<string, string>;
|
||
return {
|
||
familyId: familyMatch?.[1] || "",
|
||
salesTypeId: salesTypeMatch?.[1] || "",
|
||
mode: meta.mode || "A0LW0TRTR",
|
||
upds: meta.upds || "",
|
||
};
|
||
}
|
||
|
||
private isP4FordLikeArch(arch: string | null): boolean {
|
||
return ["LEGACY_FORD", "LEGACY_VOLVO"].includes(arch ?? "");
|
||
}
|
||
|
||
private extractFordMeta(vehicle: typeof catalogVehicles.$inferSelect): {
|
||
familyId: string;
|
||
mode: string;
|
||
upds: string;
|
||
} {
|
||
const meta = (vehicle.metadata || {}) as Record<string, unknown>;
|
||
return {
|
||
familyId: (meta.familyId as string) || "",
|
||
mode: (meta.mode as string) || "",
|
||
upds: (meta.upds as string) || "",
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Get model config (variant options) for a Ford LEGACY catalog vehicle.
|
||
* Returns { modelYears, engines, gearboxes } for the Ford variant selector UI.
|
||
*/
|
||
async getFordModelConfig(
|
||
vehicleId: string,
|
||
userId: string,
|
||
): Promise<{
|
||
modelYears: { code: string; name: string }[];
|
||
engines: { code: string; name: string }[];
|
||
gearboxes: { code: string; name: string }[];
|
||
}> {
|
||
const [vehicle] = await this.db
|
||
.select()
|
||
.from(catalogVehicles)
|
||
.where(eq(catalogVehicles.id, vehicleId))
|
||
.limit(1);
|
||
|
||
if (!vehicle) throw new NotFoundException("Katalog aracı bulunamadı");
|
||
if (vehicle.brandId) await this.checkBrandAccess(userId, vehicle.brandId);
|
||
if (!this.isP4FordLikeArch(vehicle.architecture))
|
||
return { modelYears: [], engines: [], gearboxes: [] };
|
||
|
||
const { familyId, mode, upds } = this.extractFordMeta(vehicle);
|
||
if (!familyId) return { modelYears: [], engines: [], gearboxes: [] };
|
||
|
||
if (vehicle.architecture === "LEGACY_VOLVO") {
|
||
return this.pl24Service.fetchVolvoModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||
}
|
||
|
||
return this.pl24Service.fetchFordModelConfig(vehicle.serviceName, familyId, mode, upds);
|
||
}
|
||
|
||
private isLeafPath(linkPath: string): boolean {
|
||
const lower = linkPath.toLowerCase();
|
||
return (
|
||
lower.includes("/bom/") ||
|
||
lower.includes("/bomdetails") ||
|
||
lower.includes("/partinfo/") ||
|
||
// PL24 P5 leaf items endpoints — chemicals, servicepart, accessories,
|
||
// any /extern/<kind>/(vin|mdl)_items combination. These return parts,
|
||
// not subgroups, so they must short-circuit drill-down.
|
||
/\/extern\/[^/]+\/(vin_items|mdl_items)\b/.test(lower) ||
|
||
lower.includes("image-board.action") || // PSA illustration leaf
|
||
lower.includes("json-vin-bom-detail.action")
|
||
);
|
||
}
|
||
|
||
private async getBrandIdByName(brandName: string): Promise<string | null> {
|
||
const [brand] = await this.db
|
||
.select({ id: brands.id })
|
||
.from(brands)
|
||
.where(eq(brands.name, brandName))
|
||
.limit(1);
|
||
return brand?.id || null;
|
||
}
|
||
|
||
private async getAccessibleBrandIds(userId: string): Promise<Set<string>> {
|
||
const [sub] = await this.db
|
||
.select({ id: userSubscriptions.id, brandCount: plans.brandCount })
|
||
.from(userSubscriptions)
|
||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||
.where(
|
||
and(
|
||
eq(userSubscriptions.userId, userId),
|
||
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||
),
|
||
)
|
||
.limit(1);
|
||
|
||
if (!sub) return new Set();
|
||
|
||
// Full plan (brandCount=0) → access to all brands
|
||
if (sub.brandCount === 0) {
|
||
const allBrands = await this.db
|
||
.select({ id: brands.id })
|
||
.from(brands)
|
||
.where(eq(brands.isActive, true));
|
||
return new Set(allBrands.map((b) => b.id));
|
||
}
|
||
|
||
const subBrands = await this.db
|
||
.select({ brandId: userBrands.brandId })
|
||
.from(userBrands)
|
||
.where(and(eq(userBrands.userId, userId), eq(userBrands.subscriptionId, sub.id)));
|
||
|
||
return new Set(subBrands.map((b) => b.brandId));
|
||
}
|
||
|
||
private async checkBrandAccess(userId: string, brandId: string): Promise<void> {
|
||
const [sub] = await this.db
|
||
.select({ id: userSubscriptions.id, brandCount: plans.brandCount })
|
||
.from(userSubscriptions)
|
||
.innerJoin(plans, eq(userSubscriptions.planId, plans.id))
|
||
.where(
|
||
and(
|
||
eq(userSubscriptions.userId, userId),
|
||
or(eq(userSubscriptions.status, "active"), eq(userSubscriptions.status, "trial")),
|
||
),
|
||
)
|
||
.limit(1);
|
||
|
||
if (!sub) {
|
||
throw new ForbiddenException(
|
||
"Aktif aboneliğiniz yok. Katalog verilerine erişmek için abone olun.",
|
||
);
|
||
}
|
||
|
||
if (sub.brandCount === 0) return;
|
||
|
||
const [access] = await this.db
|
||
.select()
|
||
.from(userBrands)
|
||
.where(
|
||
and(
|
||
eq(userBrands.userId, userId),
|
||
eq(userBrands.subscriptionId, sub.id),
|
||
eq(userBrands.brandId, brandId),
|
||
),
|
||
)
|
||
.limit(1);
|
||
|
||
if (!access) {
|
||
throw new ForbiddenException("Bu markaya erişim yok. Aboneliğinizi güncelleyin.");
|
||
}
|
||
}
|
||
|
||
private async enrichWithSchemaImages(cats: (typeof categories.$inferSelect)[]) {
|
||
const catIds = cats.map((c) => c.id);
|
||
if (catIds.length === 0) return cats;
|
||
|
||
const pics = await this.db
|
||
.select({ categoryId: schemaPics.categoryId, imageUrl: schemaPics.imageUrl })
|
||
.from(schemaPics)
|
||
.where(inArray(schemaPics.categoryId, catIds));
|
||
|
||
const picMap = new Map(pics.map((p) => [p.categoryId, p.imageUrl]));
|
||
|
||
const childCounts = await this.db
|
||
.select({
|
||
parentId: categories.parentId,
|
||
count: sql<number>`count(*)::int`,
|
||
})
|
||
.from(categories)
|
||
.where(inArray(categories.parentId, catIds))
|
||
.groupBy(categories.parentId);
|
||
|
||
const childCountMap = new Map(childCounts.map((r) => [r.parentId, r.count]));
|
||
|
||
return cats.map((c) => {
|
||
const dbChildCount = childCountMap.get(c.id) || 0;
|
||
const isLeaf =
|
||
c.linkPath?.includes("/bom/") ||
|
||
c.linkPath?.includes("/bomdetails") ||
|
||
c.linkPath?.includes("/partinfo/") ||
|
||
c.linkPath?.includes("/servicepart/vin_items") ||
|
||
c.linkPath?.includes("image-board.action") || // PSA illustration leaf
|
||
(!c.linkPath && dbChildCount === 0);
|
||
return {
|
||
...c,
|
||
schemaImageUrl: picMap.get(c.id) || null,
|
||
children: isLeaf ? [] : undefined,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Header/breadcrumb nav links that PL24's group.action HTML leaks into the
|
||
* category table as pseudo-categories. They are not real part groups and drill
|
||
* to nothing. Mirrors the filter in categories.service (VIN-decode path).
|
||
* Keeps Volvo's `vin-group.action?...group1=...` real categories.
|
||
*/
|
||
private isNavCrumbLink(linkPath?: string | null): boolean {
|
||
if (!linkPath) return false;
|
||
if (/(portal|logout)\.action/i.test(linkPath)) return true;
|
||
if (/vehicle\.action/i.test(linkPath)) return true;
|
||
if (linkPath.includes("vin-group.action") && !linkPath.includes("group1=")) return true;
|
||
// External absolute URL embedded anywhere that is not a partslink .action
|
||
// endpoint. Nissan's "Repair & Maintenance Information" comes back as
|
||
// `/nissan/nissan_parts/https://eu.nissan.biz/` — the basePath prefix means an
|
||
// anchored ^https check misses it, so match `://` anywhere. Real PL24 linkPaths
|
||
// are relative (group.action / json-*.action / pcat: / emex:) and never contain `://`.
|
||
if (linkPath.includes("://") && !/\.action(\?|$)/i.test(linkPath)) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Persist freshly-fetched PL24 legacy main groups (Ford/Volvo/Opel/Hyundai-Kia/
|
||
* Nissan) and return the tree built FROM the DB rows — so every node id is a real
|
||
* UUID, never a raw upstream linkPath (which would 404 on drill). Filters nav-crumb
|
||
* junk and dedupes by name first (matching the PSA block's discipline), so
|
||
* onConflictDoNothing can't silently drop a colliding row and leak a raw-id node.
|
||
*/
|
||
private async persistAndBuildLegacyTree(
|
||
catalogVehicleId: string,
|
||
pl24Categories: PL24DecodedCategory[],
|
||
cacheKey: string,
|
||
): Promise<any[]> {
|
||
const seenNames = new Set<string>();
|
||
const clean = pl24Categories.filter((c) => {
|
||
if (this.isNavCrumbLink(c.linkPath)) return false;
|
||
const name = c.nameTr || c.nameEn;
|
||
if (!name || seenNames.has(name)) return false;
|
||
seenNames.add(name);
|
||
return true;
|
||
});
|
||
if (clean.length === 0) return [];
|
||
|
||
let dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId))
|
||
.orderBy(categories.createdAt, categories.id);
|
||
|
||
if (dbCategories.length === 0) {
|
||
await this.db
|
||
.insert(categories)
|
||
.values(
|
||
clean.map((c) => ({
|
||
catalogVehicleId,
|
||
vehicleId: null as string | null,
|
||
name: c.nameTr || c.nameEn,
|
||
nameOriginal: c.nameEn,
|
||
parentId: null as string | null,
|
||
externalId: c.code,
|
||
linkPath: c.linkPath || null,
|
||
linkWid: c.linkWid || null,
|
||
source: "pl24" as const,
|
||
})),
|
||
)
|
||
.onConflictDoNothing();
|
||
// Always re-select (never trust .returning(): onConflictDoNothing drops
|
||
// name-collision rows, which would otherwise leave gaps that fall back to
|
||
// raw-path ids). Deterministic order so the tree is stable across rebuilds.
|
||
dbCategories = await this.db
|
||
.select()
|
||
.from(categories)
|
||
.where(eq(categories.catalogVehicleId, catalogVehicleId))
|
||
.orderBy(categories.createdAt, categories.id);
|
||
await this.db
|
||
.update(catalogVehicles)
|
||
.set({ categoriesFetched: true, updatedAt: new Date() })
|
||
.where(eq(catalogVehicles.id, catalogVehicleId));
|
||
}
|
||
|
||
// Filter on read too — drops nav-crumb junk that was persisted before this
|
||
// fix (e.g. existing Hyundai "Portal" / Nissan eu.nissan.biz rows), so the
|
||
// response self-heals without a DB migration.
|
||
const tree = this.buildTree(dbCategories.filter((c) => !this.isNavCrumbLink(c.linkPath)));
|
||
await this.redis.setJson(cacheKey, tree, 7200);
|
||
return tree;
|
||
}
|
||
|
||
private buildTree(items: any[]): any[] {
|
||
const map = new Map<string, any>();
|
||
const roots: any[] = [];
|
||
|
||
for (const item of items) {
|
||
map.set(item.id, { ...item, children: [] });
|
||
}
|
||
|
||
for (const item of items) {
|
||
const node = map.get(item.id);
|
||
if (!node) continue;
|
||
if (item.parentId && map.has(item.parentId)) {
|
||
map.get(item.parentId)?.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
}
|
||
|
||
for (const node of map.values()) {
|
||
if (node.children.length === 0) {
|
||
node.children = undefined;
|
||
}
|
||
}
|
||
|
||
return roots;
|
||
}
|
||
}
|