docs: update INDEX.md + add catalog module, Ford/PSA legacy catalog, shared vehicles

- CatalogModule: VIN-less PL24 catalog browser (brands, models, categories, parts)
  - Supports P5 Modern (REST) and P4 Legacy (Ford, PSA) catalog architectures
  - Ford variant selector (model-year/engine/gearbox), PSA variant selector (body/engine/gearbox)
  - New API endpoints: ford-config, psa-bodies, psa-engines, psa-gearboxes, brands/:name/catalogs
- Shared vehicles: vehicles table decoupled from users via userVehicles junction table
- PL24 Ford Legacy service: comprehensive HTML-scraping for Ford/PSA/Hyundai/Kia/Nissan/Opel/Volvo
- PL24 types and service updated for P4 Legacy brand support
- Categories/parts service updated for dual FK (vehicleId + catalogVehicleId) pattern
- Catalog browser frontend routes and components
- docs/INDEX.md: updated with all new endpoints, components, hooks, routes (2026-03-02)
- docs/pl24-catalog/: per-brand catalog exploration docs
- scripts/migration-shared-vehicles.sql, pl24-catalog-explorer.js, posthog-dashboards.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-03-02 00:31:15 +00:00
parent f2189a014b
commit 72c0de65de
90 changed files with 12043 additions and 419 deletions

View File

@@ -24,6 +24,7 @@ import { EmexModule } from "./integrations/emex/emex.module";
import { TranslationsModule } from "./translations/translations.module";
import { AdminModule } from "./admin/admin.module";
import { AnalyticsModule } from "./analytics/analytics.module";
import { CatalogModule } from "./catalog/catalog.module";
import { HealthController } from "./health.controller";
import { AuthGuard } from "./common/guards/auth.guard";
import { RolesGuard } from "./common/guards/roles.guard";
@@ -70,6 +71,7 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
TranslationsModule,
AdminModule,
AnalyticsModule,
CatalogModule,
],
controllers: [HealthController],
providers: [

View File

@@ -0,0 +1,92 @@
import { Controller, Get, Param, Post, Query } from "@nestjs/common";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { CatalogService } from "./catalog.service";
@Controller("catalog")
export class CatalogController {
constructor(private catalogService: CatalogService) {}
@Get("brands")
getBrands(@CurrentUser() user: { id: string }) {
return this.catalogService.getBrands(user.id);
}
@Get("brands/:brandName/catalogs")
getCatalogs(@Param("brandName") brandName: string, @CurrentUser() user: { id: string }) {
return this.catalogService.getCatalogs(brandName, user.id);
}
@Get("brands/:brandName/models")
getModels(
@Param("brandName") brandName: string,
@Query("service") service: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getModels(brandName, user.id, service);
}
@Get("vehicles/:id")
getVehicle(@Param("id") id: string, @CurrentUser() user: { id: string }) {
return this.catalogService.getVehicle(id, user.id);
}
@Get("vehicles/:id/ford-config")
getFordModelConfig(@Param("id") id: string, @CurrentUser() user: { id: string }) {
return this.catalogService.getFordModelConfig(id, user.id);
}
@Get("vehicles/:id/psa-bodies")
getPsaBodies(@Param("id") id: string, @CurrentUser() user: { id: string }) {
return this.catalogService.getPsaBodies(id, user.id);
}
@Get("vehicles/:id/psa-engines")
getPsaEngines(
@Param("id") id: string,
@Query("body") body: string,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getPsaEngines(id, body, user.id);
}
@Get("vehicles/:id/psa-gearboxes")
getPsaGearboxes(
@Param("id") id: string,
@Query("body") body: string,
@Query("engine") engine: string,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getPsaGearboxes(id, body, engine, user.id);
}
@Get("vehicles/:id/categories")
getCategoryTree(
@Param("id") id: string,
@Query("body") body: string | undefined,
@Query("engine") engine: string | undefined,
@Query("gearbox") gearbox: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getCategoryTree(id, user.id, body, engine, gearbox);
}
@Get("vehicles/:id/categories/:categoryId")
getCategoryWithParts(
@Param("id") id: string,
@Param("categoryId") categoryId: string,
@Query("body") body: string | undefined,
@Query("engine") engine: string | undefined,
@Query("gearbox") gearbox: string | undefined,
@CurrentUser() user: { id: string },
) {
return this.catalogService.getCategoryWithParts(id, categoryId, user.id, body, engine, gearbox);
}
/** Admin: explore a PL24 service catalog structure for discovery */
@Post("explore/:serviceName")
@Roles("admin")
exploreService(@Param("serviceName") serviceName: string) {
return this.catalogService.exploreService(serviceName);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { CatalogController } from "./catalog.controller";
import { CatalogService } from "./catalog.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { StorageModule } from "../storage/storage.module";
@Module({
imports: [PL24Module, SubscriptionsModule, StorageModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
export class ExploreServiceDto {
serviceName!: string;
}

View File

@@ -22,7 +22,10 @@ function createService(db: any) {
fetchGroups: vi.fn().mockResolvedValue([]),
fetchParts: vi.fn().mockResolvedValue(null),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any);
const pl24FordLegacyService = {
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
return { service, db, redis, pl24Service };
}

View File

@@ -1,11 +1,13 @@
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { eq, inArray, sql } from "drizzle-orm";
import { eq, inArray, isNull, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.service";
import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import type { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { StorageService } from "../storage/storage.service";
@Injectable()
@@ -19,6 +21,7 @@ export class CategoriesService {
private emexService: EmexService,
private partsCatalogsService: PartsCatalogsService,
private storage: StorageService,
private pl24FordLegacyService: PL24FordLegacyService,
) {}
async getCategoryTree(vehicleId: string) {
@@ -65,6 +68,7 @@ export class CategoriesService {
const insertData = uniquePl24.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
@@ -82,6 +86,82 @@ export class CategoriesService {
}
}
// ── PSA (Citroën/Peugeot): gerçek scope'ları session ile çek ──
// rawData.categories'de nav linkler var (Portal, vehicle.action, vin-group) — gerçek parça
// kategorileri değil. Bu yüzden rawData bypass edip PSA session flow'u çağırıyoruz.
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
const catPath: string = rawData?.catalogInfo?.catalogPath ?? "";
const svcName: string = rawData?.catalogInfo?.serviceName ?? "";
if (catPath.startsWith("/psa/") && svcName && vehicle.vin) {
try {
const scopes = await this.pl24FordLegacyService.fetchCategoriesForPsaVin(svcName, vehicle.vin);
if (scopes.length > 0) {
const insertData = scopes.map((s) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: s.nameTr || s.nameEn,
nameOriginal: s.nameEn,
parentId: null as string | null,
externalId: s.code,
linkPath: s.linkPath || null,
linkWid: null as string | null,
source: "pl24" as const,
}));
dbCategories = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
this.logger.log(`Stored ${dbCategories.length} PSA scope categories for ${vehicle.vin}`);
}
} catch (err) {
this.logger.warn(`PSA scope fetch failed for ${vehicleId}: ${(err as Error).message}`);
}
}
}
// P4 Legacy: categories already decoded from VIN HTML (rawData.categories)
// PSA araçları üstte işlendi — burada /psa/ path'lerini atla
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
const catPath: string = rawData?.catalogInfo?.catalogPath ?? "";
if (!catPath.startsWith("/psa/")) {
const decodedCats = Array.isArray(rawData?.categories)
? (rawData.categories as Array<{ code: string; nameEn: string; nameTr?: string; linkPath?: string; linkWid?: string }>)
: null;
if (decodedCats && decodedCats.length > 0) {
try {
const seenNames = new Set<string>();
const uniqueCats = decodedCats.filter((c) => {
const name = c.nameTr || c.nameEn;
if (seenNames.has(name)) return false;
seenNames.add(name);
return true;
});
const insertData = uniqueCats.map((c) => ({
vehicleId,
catalogVehicleId: 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();
this.logger.log(`Stored ${dbCategories.length} P4 legacy categories for ${vehicle.vin}`);
} catch (err) {
this.logger.warn(`P4 legacy category insert failed for ${vehicleId}: ${(err as Error).message}`);
}
}
}
}
// If still no categories, try PartsCatalogs
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
@@ -98,6 +178,7 @@ export class CategoriesService {
if (groups.length > 0) {
const insertData = groups.map((g) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: g.name,
nameOriginal: g.name,
parentId: null as string | null,
@@ -154,6 +235,7 @@ export class CategoriesService {
.insert(categories)
.values({
vehicleId,
catalogVehicleId: null as string | null,
name: node.name,
nameOriginal: node.name,
parentId,
@@ -191,6 +273,7 @@ export class CategoriesService {
const insertData = uniqueCategories.map((c) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: c.nameTr || c.nameEn,
nameOriginal: c.nameEn,
parentId: null as string | null,
@@ -249,6 +332,8 @@ export class CategoriesService {
if (!category) throw new NotFoundException("Kategori bulunamadı");
if (!category.vehicleId) return [];
const [vehicle] = await this.db
.select()
.from(vehicles)
@@ -263,9 +348,10 @@ export class CategoriesService {
// PartsCatalogs subgroups (on-demand drill-down)
if (category.source === "parts-catalogs" && rawData?.source === "parts-catalogs" && category.externalId) {
let subGroups: PcatGroup[] = [];
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
const subGroups = await this.partsCatalogsService.fetchGroups(
subGroups = await this.partsCatalogsService.fetchGroups(
rawData.catalogId,
rawData.carId,
category.externalId,
@@ -273,8 +359,30 @@ export class CategoriesService {
);
if (subGroups.length > 0) {
const insertData = subGroups.map((g) => ({
// Guard: if the API returned root-level groups as a fallback (happens when
// the groupId is a leaf-item reference, not a real sub-group ID), all returned
// groups will match existing root-level category externalIds for this vehicle.
// In that case, skip insertion — this category has no real sub-groups.
const rootExtIds = await this.db
.select({ externalId: categories.externalId })
.from(categories)
.where(eq(categories.vehicleId, category.vehicleId!));
const rootOnlyIds = rootExtIds
.filter((r) => r.externalId)
.map((r) => r.externalId as string);
const rootExtSet = new Set(rootOnlyIds);
const realSubGroups = subGroups.filter((g) => !rootExtSet.has(g.id));
if (realSubGroups.length === 0) {
this.logger.warn(
`getChildren: API returned root-level fallback groups for ${categoryId} (externalId=${category.externalId}) — skipping insert`,
);
return [];
}
const insertData = realSubGroups.map((g) => ({
vehicleId: category.vehicleId,
catalogVehicleId: category.catalogVehicleId,
name: g.name,
nameOriginal: g.name,
parentId: categoryId,
@@ -303,7 +411,18 @@ export class CategoriesService {
this.logger.error(`PartsCatalogs subgroup fetch failed for ${categoryId} (externalId=${category.externalId}): ${(err as Error).message}`);
}
return children.length > 0 ? this.enrichWithSchemaImages(children) : children;
if (children.length > 0) {
const enriched = await this.enrichWithSchemaImages(children);
// Use the API's hasSubgroups flag to correctly mark leaves vs parents.
// enrichWithSchemaImages only checks dbChildCount (always 0 for fresh inserts),
// so without this override, all sub-groups would be misidentified as leaves.
const hasSubgroupsMap = new Map(subGroups.map((g) => [g.id, g.hasSubgroups]));
return enriched.map((c: any) => ({
...c,
children: hasSubgroupsMap.get(c.externalId) === false ? [] : c.children,
}));
}
return children;
}
if (!catalogInfo?.serviceName || !linkPath) {
@@ -311,7 +430,8 @@ export class CategoriesService {
}
// BOM / servicepart item links are leaf categories — they return parts, not subgroups
if (linkPath.includes("/bom/") || linkPath.includes("/bomdetails") || linkPath.includes("/partinfo/") || linkPath.includes("/servicepart/vin_items")) {
const lp = linkPath.toLowerCase();
if (lp.includes("/bom/") || lp.includes("/bomdetails") || lp.includes("/partinfo/") || lp.includes("/servicepart/vin_items")) {
return [];
}
@@ -331,6 +451,7 @@ export class CategoriesService {
const insertData = uniqueSubGroups.map((sg) => ({
vehicleId: category.vehicleId,
catalogVehicleId: category.catalogVehicleId,
name: sg.name,
nameOriginal: sg.name,
parentId: categoryId,
@@ -377,16 +498,11 @@ export class CategoriesService {
if (!category) throw new NotFoundException("Kategori bulunamadı");
// Check if this category has children
let children = await this.db
const children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
// For parts-catalogs categories with no DB children, fetch from API first
if (children.length === 0 && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
children = await this.getChildren(categoryId);
}
if (children.length > 0) {
return {
id: category.id,
@@ -400,7 +516,28 @@ export class CategoriesService {
};
}
// PSA parent nodes — always fetch children on-demand:
// • psa:: scope paths (top-level scopes)
// • json-illustrations.action paths (mid-level main groups → illustration lists)
const isPsaParent =
category.linkPath?.startsWith("psa::") ||
(category.linkPath?.includes("/psa/") && category.linkPath?.includes("json-illustrations.action"));
if (isPsaParent && category.vehicleId) {
const psaChildren = await this.getChildren(categoryId);
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
children: psaChildren,
};
}
// Leaf category — get or fetch parts
let discoveredChildren: any[] = [];
let dbParts = await this.db
.select()
.from(parts)
@@ -420,11 +557,13 @@ export class CategoriesService {
}
if ((needParts || needImage) && category.linkPath) {
const [vehicle] = await this.db
.select()
.from(vehicles)
.where(eq(vehicles.id, category.vehicleId))
.limit(1);
const [vehicle] = category.vehicleId
? await this.db
.select()
.from(vehicles)
.where(eq(vehicles.id, category.vehicleId))
.limit(1)
: [];
if (vehicle && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
// PartsCatalogs: fetch parts + schema image via API
@@ -536,13 +675,16 @@ export class CategoriesService {
} catch (err) {
const msg = (err as Error).message;
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${msg}`);
// HTTP 400 = upstream API has no parts for this group; mark unavailable to prevent infinite retries
// HTTP 400 = upstream API has no direct parts for this group — it may be a parent group
if (msg.includes("HTTP 400")) {
await this.db
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`);
discoveredChildren = await this.getChildren(categoryId);
if (discoveredChildren.length === 0) {
await this.db
.update(categories)
.set({ unavailable: true })
.where(eq(categories.id, categoryId));
this.logger.warn(`Marked category ${categoryId} as unavailable (empty group from PartsCatalogs)`);
}
}
}
} else if (vehicle && category.source === "emex") {
@@ -642,7 +784,7 @@ export class CategoriesService {
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity || null,
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10);
@@ -659,32 +801,79 @@ export class CategoriesService {
}
// Store schema image if available
if (needImage && pl24Result.schemaImageUrl) {
const imageResult = await this.pl24Service.getSchemaImage(
pl24Result.schemaImageUrl,
catalogInfo.serviceName,
);
// PSA: if cache hit returned no buffer, re-fetch fresh to get the image
const isPsaBoard = category.linkPath.includes("/psa/") && category.linkPath.includes("image-board.action");
if (needImage && isPsaBoard && !pl24Result.schemaImageBuffer) {
try {
const freshResult = await this.pl24FordLegacyService.fetchPsaParts(
category.linkPath, catalogInfo.serviceName, "_all_", "_all_", "_all_", true,
);
if (freshResult.schemaImageBuffer) {
(pl24Result as any).schemaImageBuffer = freshResult.schemaImageBuffer;
(pl24Result as any).schemaImageContentType = freshResult.schemaImageContentType;
(pl24Result as any).schemaWidth = freshResult.schemaWidth;
(pl24Result as any).schemaHeight = freshResult.schemaHeight;
(pl24Result as any).hotspots = freshResult.hotspots;
}
} catch (refetchErr) {
this.logger.warn(`PSA image re-fetch failed for ${categoryId}: ${(refetchErr as Error).message}`);
}
}
if (needImage && (pl24Result.schemaImageBuffer || (!isPsaBoard && pl24Result.schemaImageUrl))) {
// PSA: image already downloaded as buffer — upload directly to MinIO
if (pl24Result.schemaImageBuffer) {
try {
const ct = pl24Result.schemaImageContentType || "image/png";
const ext = ct.includes("gif") ? "gif" : "png";
const key = `schemas/psa-${categoryId}.${ext}`;
const minioUrl = await this.storage.upload(key, pl24Result.schemaImageBuffer, ct);
const hotspotsData = {
width: pl24Result.schemaWidth || null,
height: pl24Result.schemaHeight || null,
items: pl24Result.hotspots || [],
};
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: minioUrl,
hotspots: JSON.stringify(hotspotsData),
source: "pl24",
})
.returning();
pics.push(inserted);
this.logger.log(`Stored PSA schema image for category ${categoryId}: ${minioUrl}`);
} catch (imgErr) {
this.logger.warn(`Failed to upload PSA schema image: ${(imgErr as Error).message}`);
}
} else {
// P5 REST: download via getSchemaImage
const imageResult = await this.pl24Service.getSchemaImage(
pl24Result.schemaImageUrl!,
catalogInfo.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 || [],
};
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();
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: imageResult.imageUrl,
hotspots: JSON.stringify(hotspotsData),
source: "pl24",
})
.returning();
pics.push(inserted);
pics.push(inserted);
}
}
}
} catch (err) {
@@ -694,6 +883,20 @@ export class CategoriesService {
}
}
// If fetchParts revealed this is actually a parent group (HTTP 400 → sub-groups found), return children
if (discoveredChildren.length > 0) {
return {
id: category.id,
name: category.name,
description: category.nameOriginal || null,
parentId: category.parentId || null,
parts: [],
schemaPics: [],
hotspots: [],
children: discoveredChildren,
};
}
// Parse hotspots data (may include width/height metadata)
let hotspots: any[] = [];
let schemaWidth = 0;
@@ -803,7 +1006,7 @@ export class CategoriesService {
? (!!c.linkPath && dbChildCount === 0)
: c.source === "parts-catalogs"
? (!!c.linkPath?.startsWith("pcat:") && dbChildCount === 0)
: (c.linkPath?.includes("/bom/") || c.linkPath?.includes("/bomdetails") || c.linkPath?.includes("/partinfo/") || c.linkPath?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
: (c.linkPath?.toLowerCase()?.includes("/bom/") || c.linkPath?.toLowerCase()?.includes("/bomdetails") || c.linkPath?.toLowerCase()?.includes("/partinfo/") || c.linkPath?.toLowerCase()?.includes("/servicepart/vin_items") || (!c.linkPath && dbChildCount === 0));
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,

View File

@@ -231,6 +231,36 @@ export const oemCodeCopies = pgTable(
],
);
// ─── Catalog Vehicles (VIN-less catalog browse — one record per PL24 vehicleId) ──
export const catalogVehicles = pgTable(
"catalog_vehicles",
{
id: uuid("id").primaryKey().defaultRandom(),
source: varchar("source", { length: 20 }).default("pl24").notNull(),
serviceName: varchar("service_name", { length: 100 }).notNull(),
brandName: varchar("brand_name", { length: 100 }).notNull(),
brandId: uuid("brand_id").references(() => brands.id),
model: varchar("model", { length: 255 }).notNull(),
year: varchar("year", { length: 50 }),
engine: varchar("engine", { length: 255 }),
bodyType: varchar("body_type", { length: 100 }),
transmission: varchar("transmission", { length: 100 }),
market: varchar("market", { length: 100 }),
serviceVehicleId: varchar("service_vehicle_id", { length: 255 }).notNull(),
catalogPath: text("catalog_path"),
architecture: varchar("architecture", { length: 30 }),
metadata: jsonb("metadata"),
categoriesFetched: boolean("categories_fetched").default(false).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("catalog_vehicles_source_vid_idx").on(table.source, table.serviceVehicleId),
index("catalog_vehicles_brand_name_idx").on(table.brandName),
index("catalog_vehicles_service_name_idx").on(table.serviceName),
],
);
// ─── Vehicles (shared config — one record per VIN) ──
export const vehicles = pgTable(
"vehicles",
@@ -280,13 +310,12 @@ export const categories = pgTable(
"categories",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
parentId: uuid("parent_id"),
externalId: varchar("external_id", { length: 500 }),
externalId: text("external_id"),
linkPath: text("link_path"),
linkWid: varchar("link_wid", { length: 100 }),
unavailable: boolean("unavailable").default(false).notNull(),
@@ -295,8 +324,9 @@ export const categories = pgTable(
},
(table) => [
index("categories_vehicle_id_idx").on(table.vehicleId),
index("categories_catalog_vehicle_id_idx").on(table.catalogVehicleId),
index("categories_parent_id_idx").on(table.parentId),
uniqueIndex("categories_vehicle_name_source_idx").on(table.vehicleId, table.name, table.source),
uniqueIndex("categories_vehicle_name_source_idx").on(table.vehicleId, table.catalogVehicleId, table.name, table.source),
],
);
@@ -305,9 +335,8 @@ export const parts = pgTable(
"parts",
{
id: uuid("id").primaryKey().defaultRandom(),
vehicleId: uuid("vehicle_id")
.notNull()
.references(() => vehicles.id, { onDelete: "cascade" }),
vehicleId: uuid("vehicle_id").references(() => vehicles.id, { onDelete: "cascade" }),
catalogVehicleId: uuid("catalog_vehicle_id").references(() => catalogVehicles.id, { onDelete: "cascade" }),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
@@ -327,6 +356,7 @@ export const parts = pgTable(
},
(table) => [
index("parts_vehicle_id_idx").on(table.vehicleId),
index("parts_catalog_vehicle_id_idx").on(table.catalogVehicleId),
index("parts_category_id_idx").on(table.categoryId),
index("parts_oem_code_idx").on(table.oemCode),
],

View File

@@ -15,6 +15,7 @@ import {
parts,
schemaPics,
referrals,
catalogVehicles,
} from "./core";
export const usersRelations = relations(users, ({ many }) => ({
@@ -59,6 +60,13 @@ export const userBrandsRelations = relations(userBrands, ({ one }) => ({
export const brandsRelations = relations(brands, ({ many }) => ({
userBrands: many(userBrands),
catalogVehicles: many(catalogVehicles),
}));
export const catalogVehiclesRelations = relations(catalogVehicles, ({ one, many }) => ({
brand: one(brands, { fields: [catalogVehicles.brandId], references: [brands.id] }),
categories: many(categories),
parts: many(parts),
}));
export const paymentsRelations = relations(payments, ({ one }) => ({
@@ -88,6 +96,7 @@ export const userVehiclesRelations = relations(userVehicles, ({ one }) => ({
export const categoriesRelations = relations(categories, ({ one, many }) => ({
vehicle: one(vehicles, { fields: [categories.vehicleId], references: [vehicles.id] }),
catalogVehicle: one(catalogVehicles, { fields: [categories.catalogVehicleId], references: [catalogVehicles.id] }),
parent: one(categories, {
fields: [categories.parentId],
references: [categories.id],
@@ -100,6 +109,7 @@ export const categoriesRelations = relations(categories, ({ one, many }) => ({
export const partsRelations = relations(parts, ({ one }) => ({
vehicle: one(vehicles, { fields: [parts.vehicleId], references: [vehicles.id] }),
catalogVehicle: one(catalogVehicles, { fields: [parts.catalogVehicleId], references: [catalogVehicles.id] }),
category: one(categories, { fields: [parts.categoryId], references: [categories.id] }),
}));

View File

@@ -4,33 +4,43 @@ import postgres from "postgres";
import { brands, plans, users, accounts } from "./schema/core";
const BRANDS_DATA = [
{ name: "BMW", slug: "bmw" },
{ name: "Mercedes-Benz", slug: "mercedes-benz" },
// Mevcut markalar
{ name: "Audi", slug: "audi" },
{ name: "Volkswagen", slug: "volkswagen" },
{ name: "Fiat", slug: "fiat" },
{ name: "Renault", slug: "renault" },
{ name: "Peugeot", slug: "peugeot" },
{ name: "BMW", slug: "bmw" },
{ name: "Citroen", slug: "citroen" },
{ name: "Toyota", slug: "toyota" },
{ name: "Dacia", slug: "dacia" },
{ name: "Fiat", slug: "fiat" },
{ name: "Ford", slug: "ford" },
{ name: "Honda", slug: "honda" },
{ name: "Hyundai", slug: "hyundai" },
{ name: "Kia", slug: "kia" },
{ name: "Ford", slug: "ford" },
{ name: "Opel", slug: "opel" },
{ name: "Skoda", slug: "skoda" },
{ name: "Seat", slug: "seat" },
{ name: "Volvo", slug: "volvo" },
{ name: "Nissan", slug: "nissan" },
{ name: "Mazda", slug: "mazda" },
{ name: "Porsche", slug: "porsche" },
{ name: "Land Rover", slug: "land-rover" },
{ name: "Jaguar", slug: "jaguar" },
{ name: "Kia", slug: "kia" },
{ name: "Land Rover", slug: "land-rover" },
{ name: "Mazda", slug: "mazda" },
{ name: "Mercedes-Benz", slug: "mercedes-benz" },
{ name: "Mini", slug: "mini" },
{ name: "Dacia", slug: "dacia" },
{ name: "Mitsubishi", slug: "mitsubishi" },
{ name: "Nissan", slug: "nissan" },
{ name: "Opel", slug: "opel" },
{ name: "Peugeot", slug: "peugeot" },
{ name: "Porsche", slug: "porsche" },
{ name: "Renault", slug: "renault" },
{ name: "Seat", slug: "seat" },
{ name: "Skoda", slug: "skoda" },
{ name: "Subaru", slug: "subaru" },
{ name: "Suzuki", slug: "suzuki" },
{ name: "Mitsubishi", slug: "mitsubishi" },
{ name: "Toyota", slug: "toyota" },
{ name: "Volkswagen", slug: "volkswagen" },
{ name: "Volvo", slug: "volvo" },
// PL24 kataloglarında olan, sase.tr'ye eklenen markalar
{ name: "Alpine", slug: "alpine" },
{ name: "Bentley", slug: "bentley" },
{ name: "Cupra", slug: "cupra" },
{ name: "Infiniti", slug: "infiniti" },
{ name: "Lexus", slug: "lexus" },
{ name: "MAN", slug: "man" },
{ name: "Polestar", slug: "polestar" },
{ name: "Smart", slug: "smart" },
];
const PLANS_DATA = [

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ import {
getServiceApiPath,
getServiceConfig,
isP5Modern,
isLegacyArchitecture,
SERVICE_TO_BRAND,
} from "./pl24.types";
@@ -76,14 +77,12 @@ export class PL24Service {
);
}
// Dispatch legacy architectures to their dedicated services
// Dispatch P4 legacy architectures to the generic legacy service
if (!isP5Modern(serviceName)) {
if (serviceName === "fordt_parts") {
return this.fordLegacyService.decodeVin(cleanVin);
if (isLegacyArchitecture(serviceName)) {
return this.fordLegacyService.decodeVinForService(cleanVin, serviceName);
}
this.logger.warn(
`Legacy architecture not supported yet: ${serviceName}`,
);
this.logger.warn(`Unknown architecture for service: ${serviceName}`);
return null;
}
@@ -255,11 +254,18 @@ export class PL24Service {
async fetchPartsByPath(
linkPath: string,
serviceName: string,
body?: string,
engine?: string,
gearbox?: string,
): Promise<PL24PartsResponse> {
// Ford legacy dispatch
if (this.isFordLegacyPath(linkPath)) {
if (this.isP4LegacyPath(linkPath)) {
return this.fordLegacyService.fetchPartsByPath(linkPath, serviceName);
}
// PSA image-board dispatch
if (this.isPsaBoardPath(linkPath)) {
return this.fordLegacyService.fetchPsaParts(linkPath, serviceName, body, engine, gearbox);
}
await this.touchActivity();
const pathHash = createHash("sha256").update(linkPath).digest("hex").substring(0, 16);
@@ -378,11 +384,22 @@ export class PL24Service {
async fetchSubGroupsByPath(
linkPath: string,
serviceName: string,
body?: string,
engine?: string,
gearbox?: string,
): Promise<PL24MainGroup[]> {
// Ford legacy dispatch
if (this.isFordLegacyPath(linkPath)) {
if (this.isP4LegacyPath(linkPath)) {
return this.fordLegacyService.fetchSubGroupsByPath(linkPath, serviceName);
}
// PSA scope dispatch ("psa::{svc}::scope=..." → main groups)
if (this.isPsaPath(linkPath)) {
return this.fordLegacyService.fetchPsaSubGroups(linkPath, body, engine, gearbox);
}
// PSA illustrations dispatch (/psa/.../json-illustrations.action → illustrations)
if (this.isPsaIllusPath(linkPath)) {
return this.fordLegacyService.fetchPsaIllustrations(linkPath, serviceName, body, engine, gearbox);
}
this.logger.log(`Fetching sub-groups by path: ${linkPath}`);
await this.touchActivity();
@@ -406,6 +423,122 @@ export class PL24Service {
}
}
/**
* Fetch category tree scopes for a PSA legacy catalog vehicle.
* Delegates to fordLegacyService which handles PSA HTML scraping flow.
*/
async fetchMainGroupsForPsa(
serviceName: string,
familyId: string,
salesTypeId: string,
mode: string,
upds: string,
body?: string,
engine?: string,
gearbox?: string,
): Promise<PL24DecodedCategory[]> {
return this.fordLegacyService.fetchMainGroupsForPsa(
serviceName,
familyId,
salesTypeId,
mode,
upds,
body,
engine,
gearbox,
);
}
/**
* Fetch body types for a PSA catalog vehicle variant selector.
*/
async fetchPsaBodies(
svc: string,
familyId: string,
salesTypeId: string,
mode: string,
upds: string,
): Promise<{ code: string; name: string }[]> {
return this.fordLegacyService.fetchPsaBodies(svc, familyId, salesTypeId, mode, upds);
}
/**
* Fetch engines for a PSA catalog vehicle given a selected body code.
*/
async fetchPsaEnginesForBody(
svc: string,
familyId: string,
salesTypeId: string,
bodyCode: string,
mode: string,
upds: string,
): Promise<{ code: string; name: string }[]> {
return this.fordLegacyService.fetchPsaEnginesForBody(svc, familyId, salesTypeId, bodyCode, mode, upds);
}
/**
* Fetch gearboxes for a PSA catalog vehicle given selected body + engine codes.
*/
async fetchPsaGearboxes(
svc: string,
familyId: string,
salesTypeId: string,
bodyCode: string,
engineCode: string,
mode: string,
upds: string,
): Promise<{ code: string; name: string }[]> {
return this.fordLegacyService.fetchPsaGearboxes(svc, familyId, salesTypeId, bodyCode, engineCode, mode, upds);
}
/**
* Fetch model config (variant options) for a Ford catalog vehicle.
*/
async fetchFordModelConfig(
svc: string,
familyId: string,
mode: string,
upds: string,
): Promise<{
modelYears: { code: string; name: string }[];
engines: { code: string; name: string }[];
gearboxes: { code: string; name: string }[];
}> {
return this.fordLegacyService.fetchFordModelConfig(svc, familyId, mode, upds);
}
/**
* Fetch model config (year variant options) for a Volvo catalog vehicle.
*/
async fetchVolvoModelConfig(
svc: string,
mdlId: string,
mode: string,
upds: string,
): Promise<{
modelYears: { code: string; name: string }[];
engines: { code: string; name: string }[];
gearboxes: { code: string; name: string }[];
}> {
return this.fordLegacyService.fetchVolvoModelConfig(svc, mdlId, mode, upds);
}
/**
* Fetch main category groups for a Ford catalog vehicle variant.
*/
async fetchFordMainGroups(
svc: string,
familyId: string,
modelYear: string,
engine: string,
gearbox: string,
mode: string,
upds: string,
catCode?: string,
): Promise<PL24DecodedCategory[]> {
return this.fordLegacyService.fetchFordMainGroups(svc, familyId, modelYear, engine, gearbox, mode, upds, catCode);
}
/**
* Re-fetch main groups using a stored mainGroupsPath.
*/
@@ -414,6 +547,21 @@ export class PL24Service {
mainGroupsPath: string,
): Promise<PL24DecodedCategory[]> {
await this.touchActivity();
// Ford legacy catalog vehicles store the vehicle.action URL as catalogPath.
// Dispatch to fordLegacyService which fetches and extracts group links from the HTML.
if (this.isP4LegacyPath(mainGroupsPath)) {
const groups = await this.fordLegacyService.fetchSubGroupsByPath(mainGroupsPath, serviceName);
return groups.map((g) => ({
code: g.code,
nameEn: g.name,
nameTr: g.name,
description: g.description || null,
iconUrl: g.iconUrl || null,
subGroups: [],
linkPath: g.linkPath,
linkWid: g.linkWid,
}));
}
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
@@ -553,6 +701,15 @@ export class PL24Service {
return isP5Modern(serviceName) || serviceName === "fordt_parts";
}
/**
* Returns true if this VIN's WMI maps to any PL24 service (P5 Modern or P4 Legacy).
* Use this to gate VIN decode attempts; use isSupported() for catalog browser eligibility.
*/
isDecodeable(vin: string): boolean {
if (!vin || vin.length < 3) return false;
return !!this.getServiceName(vin);
}
/**
* Get supported brands list.
*/
@@ -1017,7 +1174,9 @@ export class PL24Service {
}
const partRecords = records.filter(
(record) => record.characteristic !== "sectionrow" && record.partno,
(record) =>
record.characteristic !== "sectionrow" &&
(record.partno || (record.values as Record<string, unknown>)?.partno),
);
return partRecords.map((part) => {
@@ -1152,7 +1311,9 @@ export class PL24Service {
const mercedesMatch = imageUrl.match(/[?&]illu=([a-zA-Z0-9_]+)/);
if (mercedesMatch) return mercedesMatch[1];
return null;
// PSA and other legacy brands use ticket-based URLs that don't match above patterns.
// Fall back to a hash of the URL so caching still works.
return createHash("sha256").update(imageUrl).digest("hex").substring(0, 24);
}
// ==================== PRIVATE: Brand-specific flows ====================
@@ -1183,8 +1344,20 @@ export class PL24Service {
return `${url.pathname}?${url.searchParams.toString()}`;
}
private isFordLegacyPath(linkPath: string): boolean {
return linkPath.includes("/ford/") && linkPath.includes(".action");
private isP4LegacyPath(linkPath: string): boolean {
return linkPath.includes(".action");
}
private isPsaPath(linkPath: string): boolean {
return linkPath.startsWith("psa::");
}
private isPsaIllusPath(linkPath: string): boolean {
return linkPath.includes("/psa/") && linkPath.includes("json-illustrations.action");
}
private isPsaBoardPath(linkPath: string): boolean {
return linkPath.includes("/psa/") && linkPath.includes("image-board.action");
}
private isDaimlerService(serviceName: string): boolean {
@@ -1540,6 +1713,237 @@ export class PL24Service {
}
}
// ==================== PUBLIC: Catalog browse (VIN-less) ====================
/**
* Fetch a list of vehicles/models for a service (VIN-less catalog browse).
* Tries P5 Modern selection wizard endpoints.
* Returns empty array if unavailable (requires discovery to find correct endpoint).
*/
async fetchVehicleList(serviceName: string): Promise<
Array<{
vehicleId: string;
model: string;
year?: string;
engine?: string;
bodyType?: string;
transmission?: string;
market?: string;
catalogPath?: string;
metadata?: Record<string, unknown>;
}>
> {
await this.touchActivity();
// Legacy architecture dispatch
const serviceConfig = getServiceConfig(serviceName);
if (serviceConfig?.architecture === "LEGACY_PSA") {
return this.fordLegacyService.fetchVehicleListForPsa(serviceName);
}
if (serviceConfig?.architecture === "LEGACY_FORD") {
return this.fordLegacyService.fetchVehicleListForFord(serviceName);
}
if (serviceConfig?.architecture === "LEGACY_HYUNDAI_KIA") {
return this.fordLegacyService.fetchVehicleListForHyundaiKia(serviceName);
}
if (serviceConfig?.architecture === "LEGACY_NISSAN") {
return this.fordLegacyService.fetchVehicleListForNissan(serviceName);
}
if (serviceConfig?.architecture === "LEGACY_OPEL") {
return this.fordLegacyService.fetchVehicleListForOpel(serviceName);
}
if (serviceConfig?.architecture === "LEGACY_VOLVO") {
return this.fordLegacyService.fetchVehicleListForVolvo(serviceName);
}
const cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}vehicle_list:${serviceName}`;
const cached = await this.redis.getJson<any[]>(cacheKey);
if (cached) return cached;
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const catalogBase = getServiceApiPath(serviceName);
// Discovered via Playwright explorer (scripts/pl24-catalog-explorer.js → docs/pl24-catalog/*.md)
// Each P5 backend uses a different initial model listing endpoint
const BACKEND_MODEL_PATH: Record<string, string> = {
p5vwag: "/extern/vehicle/modelfamilies", // VW, Audi, Skoda, SEAT, Cupra, Porsche, Bentley
p5bmw: "/extern/vehicle/models", // BMW, MINI, Motorrad
p5daimler: "/extern/vehicle/scope", // Mercedes-Benz, smart
p5renault: "/extern/vehicle/catalogs", // Renault, Dacia, Alpine
p5jlr: "/extern/vehicle/models", // Jaguar, Land Rover
p5toyota: "/extern/vehicle/modelFamilies", // Toyota, Lexus (capital F)
p5mitsubishi: "/extern/vehicles/vehiclesOverview", // Mitsubishi
p5suzuki: "/extern/vehicle/modelFamilies", // Suzuki
p5man: "/extern/model/categories", // MAN trucks
};
// catalogBase is like "/p5vwag" — strip leading slash for map lookup
const backendKey = catalogBase.replace(/^\//, "");
const modelPath = BACKEND_MODEL_PATH[backendKey] ?? "/extern/vehicle/modelfamilies";
const url = `${this.baseUrl}${catalogBase}${modelPath}?lang=${this.language}&serviceName=${serviceName}`;
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(15000),
});
if (response.ok) {
const data = (await response.json()) as Record<string, any>;
let vehicles = this.parseVehicleListResponse(data, serviceName);
// p5daimler "scope" endpoint returns 1 record whose link.path points to "modeltype" (Smart).
// Follow that link to retrieve the actual model type list (C450C454).
if (vehicles.length === 1 && vehicles[0].catalogPath?.includes("modeltype")) {
const modeltypePath = vehicles[0].catalogPath;
const modeltypeUrl = modeltypePath.startsWith("http")
? modeltypePath
: `${this.baseUrl}${modeltypePath}`;
try {
const modeltypeResp = await fetch(modeltypeUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(15000),
});
if (modeltypeResp.ok) {
const modeltypeData = (await modeltypeResp.json()) as Record<string, any>;
const modeltypeVehicles = this.parseVehicleListResponse(modeltypeData, serviceName);
if (modeltypeVehicles.length > 0) {
this.logger.log(`Smart: modeltype returned ${modeltypeVehicles.length} models`);
vehicles = modeltypeVehicles;
}
} else {
this.logger.warn(`Smart: modeltype HTTP ${modeltypeResp.status}`);
}
} catch (err) {
this.logger.warn(`Smart: modeltype fetch failed: ${(err as Error).message}`);
}
}
if (vehicles.length > 0) {
await this.redis.setJson(cacheKey, vehicles, 86400); // 24h
return vehicles;
}
}
this.logger.log(`No vehicle list available for ${serviceName} (HTTP ${response.status})`);
return [];
} catch (err) {
this.logger.warn(`fetchVehicleList failed for ${serviceName}: ${(err as Error).message}`);
return [];
}
}
private parseVehicleListResponse(
data: Record<string, any>,
serviceName: string,
): Array<{
vehicleId: string;
model: string;
year?: string;
engine?: string;
bodyType?: string;
transmission?: string;
market?: string;
catalogPath?: string;
metadata?: Record<string, unknown>;
}> {
let records: any[] = [];
if (Array.isArray(data)) {
records = data;
} else if (Array.isArray(data.data?.records)) {
records = data.data.records;
} else if (Array.isArray(data.vehicles)) {
records = data.vehicles;
} else if (Array.isArray(data.models)) {
records = data.models;
} else if (Array.isArray(data.data)) {
records = data.data;
}
return records
.filter((r) => r.id || r.vehicleId || r.vid)
.map((r) => {
const values = r.values || {};
const vehicleId = String(r.id || r.vehicleId || r.vid || "");
// modelfamilies uses values.caption; older formats use values.model / r.description
const model =
values.caption || values.model || values.description || r.description || r.name || vehicleId;
const link = r.link || {};
return {
vehicleId,
model,
year: values.year || values.modelYear || r.year || undefined,
engine: values.engine || values.engineCode || undefined,
bodyType: values.bodyType || values.body || undefined,
transmission: values.transmission || undefined,
market: values.market || undefined,
catalogPath: link.path || undefined,
metadata: r,
};
});
}
/**
* Explore a P5 Modern service catalog — tries various endpoints and returns raw results.
* Used by admin endpoint for discovery.
*/
async exploreP5Service(serviceName: string): Promise<Record<string, any>> {
await this.touchActivity();
const results: Record<string, any> = {};
try {
await this.authService.authorizeService(serviceName);
const headers = await this.authService.buildAuthHeaders(serviceName);
const catalogBase = getServiceApiPath(serviceName);
const probeEndpoints = [
"extern/vehicles",
"extern/vehicleList",
"extern/models",
"extern/selection/vehicles",
"extern/selection/rootNode",
"extern/catalogs",
"extern/modelSeries",
];
await Promise.all(
probeEndpoints.map(async (endpoint) => {
const url = `${this.baseUrl}${catalogBase}/${endpoint}?lang=${this.language}&serviceName=${serviceName}`;
try {
const response = await fetch(url, {
method: "GET",
headers,
signal: AbortSignal.timeout(8000),
});
const status = response.status;
let body: any = null;
if (response.ok) {
try {
body = await response.json();
} catch {
body = await response.text();
}
}
results[endpoint] = { status, body: body ? JSON.stringify(body).substring(0, 2000) : null };
} catch (err) {
results[endpoint] = { error: (err as Error).message };
}
}),
);
} catch (err) {
results._authError = (err as Error).message;
}
return results;
}
private parseJlrIllustrations(
response: unknown,
): Array<{ btnr: number; name: string; code: string }> {

View File

@@ -276,7 +276,7 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
// Mitsubishi
mmc_parts: {
basePath: "/pl24-app/mmc_parts",
apiPath: "/p5mmc",
apiPath: "/p5mitsubishi",
architecture: "P5_MODERN",
},
@@ -287,12 +287,84 @@ export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
architecture: "P5_MODERN",
},
// Ford
fordt_parts: {
basePath: "/ford/fordt_parts",
apiPath: "/ford/fordt_parts",
// ==================== LEGACY P4 ARCHITECTURE ====================
// PSA Group (Citroën, Peugeot)
citroen_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
},
citroenDs_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
},
peugeot_parts: {
basePath: "/psa",
apiPath: "/psa",
architecture: "LEGACY_PSA",
},
// Ford Group
fordp_parts: {
basePath: "/ford",
apiPath: "/ford",
architecture: "LEGACY_FORD",
},
fordt_parts: {
basePath: "/ford",
apiPath: "/ford",
architecture: "LEGACY_FORD",
},
// Hyundai-Kia Automotive Group
hyundai_parts: {
basePath: "/hyundai-kia-automotive-group",
apiPath: "/hyundai-kia-automotive-group",
architecture: "LEGACY_HYUNDAI_KIA",
},
kia_parts: {
basePath: "/hyundai-kia-automotive-group",
apiPath: "/hyundai-kia-automotive-group",
architecture: "LEGACY_HYUNDAI_KIA",
},
// Nissan/Infiniti
nissan_parts: {
basePath: "/nissan",
apiPath: "/nissan",
architecture: "LEGACY_NISSAN",
},
infiniti_parts: {
basePath: "/nissan",
apiPath: "/nissan",
architecture: "LEGACY_NISSAN",
},
// GM / Stellantis (Opel, Vauxhall)
opel_parts: {
basePath: "/opel",
apiPath: "/opel",
architecture: "LEGACY_OPEL",
},
vauxhall_parts: {
basePath: "/opel",
apiPath: "/opel",
architecture: "LEGACY_OPEL",
},
// Volvo/Polestar
volvo_parts: {
basePath: "/volvo",
apiPath: "/volvo",
architecture: "LEGACY_VOLVO",
},
polestar_parts: {
basePath: "/volvo",
apiPath: "/volvo",
architecture: "LEGACY_VOLVO",
},
};
// ==================== HELPER FUNCTIONS ====================
@@ -436,11 +508,43 @@ export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
MA3: "suzuki_parts",
MBH: "suzuki_parts",
// Ford
// Ford Commercial (Transit vans — Turkey Otosan, etc.)
NM0: "fordt_parts",
WF0: "fordt_parts",
"1FA": "fordt_parts",
"3FA": "fordt_parts",
// Ford Passenger (European passenger cars — Germany Cologne plant)
WF0: "fordp_parts",
"1FA": "fordp_parts",
"3FA": "fordp_parts",
// Hyundai
KMH: "hyundai_parts", // Hyundai Korea Motor House
TMK: "hyundai_parts", // Hyundai (Turkey/other markets)
// Kia
KNA: "kia_parts", // Kia (worldwide production)
U5Y: "kia_parts", // Kia Slovakia
// Nissan
JN1: "nissan_parts", // Nissan Japan (passenger)
JN6: "nissan_parts", // Nissan Japan (pickup/van)
JN8: "nissan_parts", // Nissan Japan (SUV)
VNK: "nissan_parts", // Nissan UK/Europe
// Infiniti
JNK: "infiniti_parts", // Infiniti (Japan/Korea)
// Opel / Vauxhall
W0L: "opel_parts", // Opel AG (Germany)
// Citroën (PSA)
VF7: "citroen_parts", // Citroën SA (France)
// Peugeot (PSA)
VF3: "peugeot_parts", // Peugeot SA (France)
// Volvo
YV1: "volvo_parts", // Volvo Cars (Sweden)
YV4: "volvo_parts", // Volvo Cars (specific models)
};
// ==================== VEHICLE TYPES ====================
@@ -451,6 +555,11 @@ export interface PL24CatalogInfo {
catalogPath: string;
baseUrl: string;
mainGroupsPath?: string;
// PSA VIN decode session parameters (set by decodeVinPsa)
psaFamilyId?: string;
psaSalesTypeId?: string;
psaMode?: string;
psaUpds?: string;
}
export interface PL24MainGroup {
@@ -518,6 +627,8 @@ export interface PL24PartsResponse {
groupId: string;
groupName: string;
schemaImageUrl?: string;
schemaImageBuffer?: Buffer; // pre-downloaded buffer (PSA: ticket URLs expire quickly)
schemaImageContentType?: string;
schemaWidth?: number;
schemaHeight?: number;
parts: PL24Part[];
@@ -589,37 +700,96 @@ export interface PL24DecodedPart {
// ==================== BRAND MAP ====================
export const SERVICE_TO_BRAND: Record<string, string> = {
// Volkswagen Group
vw_parts: "Volkswagen",
vwclassic_parts: "Volkswagen",
vn_parts: "Volkswagen",
audi_parts: "Audi",
seat_parts: "SEAT",
seat_parts: "Seat",
cupra_parts: "Cupra",
skoda_parts: "Skoda",
bentley_parts: "Bentley",
// BMW Group
bmw_parts: "BMW",
bmwclassic_parts: "BMW",
bmwmotorrad_parts: "BMW",
bmwmotorradclassic_parts: "BMW",
mini_parts: "MINI",
miniclassic_parts: "MINI",
mini_parts: "Mini",
miniclassic_parts: "Mini",
// Mercedes-Benz Group
mercedes_parts: "Mercedes-Benz",
mercedesclassic_parts: "Mercedes-Benz",
mercedesvans_parts: "Mercedes-Benz",
mercedestrucks_parts: "Mercedes-Benz",
mercedesunimog_parts: "Mercedes-Benz",
smart_parts: "smart",
smart_parts: "Smart",
// Porsche (VAG backend)
porsche_parts: "Porsche",
porscheclassic_parts: "Porsche",
// Toyota Group
toyota_parts: "Toyota",
lexus_parts: "Lexus",
// Renault Group
renault_parts: "Renault",
dacia_parts: "Dacia",
alpine_parts: "Alpine",
// Jaguar Land Rover
jaguar_parts: "Jaguar",
landrover_parts: "Land Rover",
// Other P5
man_parts: "MAN",
mmc_parts: "Mitsubishi",
suzuki_parts: "Suzuki",
// PSA Group
citroen_parts: "Citroen",
citroenDs_parts: "Citroen",
peugeot_parts: "Peugeot",
// Ford Group
fordp_parts: "Ford",
fordt_parts: "Ford",
// Hyundai-Kia Group
hyundai_parts: "Hyundai",
kia_parts: "Kia",
// Nissan/Infiniti
nissan_parts: "Nissan",
infiniti_parts: "Infiniti",
// GM / Stellantis
opel_parts: "Opel",
vauxhall_parts: "Opel",
// Volvo/Polestar
volvo_parts: "Volvo",
polestar_parts: "Polestar",
};
// Display names for services that share a brand (multi-catalog brands)
export const SERVICE_DISPLAY_NAMES: Record<string, string> = {
// BMW Group
bmw_parts: "BMW",
bmwclassic_parts: "BMW Classic",
bmwmotorrad_parts: "BMW Motorrad",
bmwmotorradclassic_parts: "BMW Motorrad Classic",
mini_parts: "Mini",
miniclassic_parts: "Mini Classic",
// Mercedes-Benz Group
mercedes_parts: "Mercedes-Benz",
mercedesclassic_parts: "Mercedes-Benz Classic",
mercedesvans_parts: "Mercedes-Benz Vans",
mercedestrucks_parts: "Mercedes-Benz Trucks",
mercedesunimog_parts: "Mercedes-Benz Unimog",
// Volkswagen Group
vw_parts: "Volkswagen",
vwclassic_parts: "Volkswagen Classic",
vn_parts: "Volkswagen Nfz",
porsche_parts: "Porsche",
porscheclassic_parts: "Porsche Classic",
// Renault Group
renault_parts: "Renault",
dacia_parts: "Dacia",
alpine_parts: "Alpine",
// Ford
fordp_parts: "Ford",
fordt_parts: "Ford Ticari",
// Citroen
citroen_parts: "Citroen",
citroenDs_parts: "Citroen DS",
};

View File

@@ -31,6 +31,10 @@ export class PartsService {
if (!category) throw new NotFoundException("Kategori bulunamadı");
if (!category.vehicleId) {
return dbParts;
}
const [vehicle] = await this.db
.select()
.from(vehicles)
@@ -61,7 +65,7 @@ export class PartsService {
name: p.name,
nameOriginal: p.name,
description: p.description || null,
quantity: p.quantity || null,
quantity: p.quantity ? (parseInt(String(p.quantity), 10) || null) : null,
position: p.positionCode || null,
hotspotIndex: p.hotspotId ? (() => {
const val = parseInt(p.hotspotId!, 10);

View File

@@ -284,7 +284,7 @@ export class SubscriptionsService {
const now = new Date();
const endDate = new Date(now);
endDate.setDate(endDate.getDate() + 3);
endDate.setDate(endDate.getDate() + 7);
// Create trial subscription
const [subscription] = await this.db

View File

@@ -165,13 +165,30 @@ export class VehiclesService {
/**
* Public VIN preview — no auth, no DB save, no brand access check.
* Uses resolveVin() which caches results in Redis for 5 minutes.
* Checks DB first, then Redis, then external API chain.
*/
async previewVin(vin: string) {
if (!isValidVin(vin)) {
throw new BadRequestException("Geçersiz şase numarası");
}
// DB'de varsa direkt dön — dış API çağrısına gerek yok
const [existing] = await this.db
.select({
brandName: vehicles.brandName,
model: vehicles.model,
year: vehicles.year,
engine: vehicles.engine,
source: vehicles.source,
})
.from(vehicles)
.where(eq(vehicles.vin, vin))
.limit(1);
if (existing) {
return existing;
}
const resolved = await this.resolveVin(vin);
if (!resolved) {
throw new BadRequestException("Şase numarası tanınamadı");
@@ -249,7 +266,7 @@ export class VehiclesService {
// 3. PL24 (if PC had multiple results, or PC failed entirely)
let pl24Vehicle: any = null;
if (this.pl24Service.isSupported(vin)) {
if (this.pl24Service.isDecodeable(vin)) {
try {
pl24Vehicle = await this.pl24Service.decodeVin(vin);
if (!brandName && pl24Vehicle) {