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:
@@ -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: [
|
||||
|
||||
92
apps/api/src/catalog/catalog.controller.ts
Normal file
92
apps/api/src/catalog/catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/catalog/catalog.module.ts
Normal file
14
apps/api/src/catalog/catalog.module.ts
Normal 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 {}
|
||||
1270
apps/api/src/catalog/catalog.service.ts
Normal file
1270
apps/api/src/catalog/catalog.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
apps/api/src/catalog/dto/catalog-query.dto.ts
Normal file
3
apps/api/src/catalog/dto/catalog-query.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class ExploreServiceDto {
|
||||
serviceName!: string;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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] }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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 (C450–C454).
|
||||
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 }> {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user