feat: admin user creation, Vite migration, dialog fix, pl24 integration
- Add POST /admin/users endpoint with password hashing and role support - Add user creation dialog to admin users page - Migrate web from Next.js to Vite + TanStack Router - Fix Dialog component positioning for Tailwind CSS v4 - Add @source directive for @sase/ui package scanning - Add pl24 integration parsers and vehicle decode flow - Backup old Next.js app to apps/web-nj Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { Roles } from "../common/decorators/roles.decorator";
|
||||
|
||||
@@ -7,6 +7,13 @@ import { Roles } from "../common/decorators/roles.decorator";
|
||||
export class AdminController {
|
||||
constructor(private adminService: AdminService) {}
|
||||
|
||||
@Post("users")
|
||||
async createUser(
|
||||
@Body() body: { name: string; email: string; password: string; role?: string },
|
||||
) {
|
||||
return this.adminService.createUser(body);
|
||||
}
|
||||
|
||||
@Get("dashboard")
|
||||
async getDashboardStats() {
|
||||
return this.adminService.getDashboardStats();
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { and, count, desc, eq, gte, ilike, inArray, or, sql } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import {
|
||||
users,
|
||||
accounts,
|
||||
userSubscriptions,
|
||||
payments,
|
||||
queryLogs,
|
||||
brands,
|
||||
referrals,
|
||||
} from "../database/schema/core";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
@@ -16,6 +24,54 @@ export class AdminService {
|
||||
|
||||
constructor(@Inject(DATABASE) private db: Database) {}
|
||||
|
||||
async createUser(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
}) {
|
||||
// Check if email already exists
|
||||
const [existing] = await this.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.email, data.email))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException("Bu e-posta adresi zaten kullaniliyor");
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
|
||||
// Insert user
|
||||
const [newUser] = await this.db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
emailVerified: true,
|
||||
role: data.role || "user",
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
});
|
||||
|
||||
// Insert credential account for better-auth compatibility
|
||||
await this.db.insert(accounts).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: newUser.id,
|
||||
accountId: newUser.id,
|
||||
providerId: "credential",
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async getDashboardStats() {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { resolve } from "path";
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { ThrottlerModule, ThrottlerGuard } from "@nestjs/throttler";
|
||||
import configuration from "./config/configuration";
|
||||
@@ -34,6 +35,11 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [
|
||||
resolve(__dirname, "..", ".env"),
|
||||
resolve(__dirname, "..", "..", ".env"),
|
||||
".env",
|
||||
],
|
||||
load: [configuration],
|
||||
validate,
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,11 @@ export class CategoriesController {
|
||||
return this.categoriesService.getCategoryTree(vehicleId);
|
||||
}
|
||||
|
||||
@Get(":id/children")
|
||||
async getChildren(@Param("id") id: string) {
|
||||
return this.categoriesService.getChildren(id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async getById(@Param("id") id: string) {
|
||||
return this.categoriesService.getById(id);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { categories, vehicles, schemaPics } from "../database/schema/core";
|
||||
import { categories, vehicles, schemaPics, parts } from "../database/schema/core";
|
||||
import { RedisService } from "../redis/redis.service";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
private readonly logger = new Logger(CategoriesService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private redis: RedisService,
|
||||
@@ -36,19 +38,23 @@ export class CategoriesService {
|
||||
// If no categories in DB, fetch from PL24
|
||||
if (dbCategories.length === 0 && vehicle.rawData) {
|
||||
const rawData = vehicle.rawData as any;
|
||||
const pl24VehicleId = rawData.vehicleId;
|
||||
const catalogInfo = rawData.catalogInfo;
|
||||
|
||||
if (pl24VehicleId && vehicle.brandName) {
|
||||
const pl24Categories = await this.pl24Service.getCategories(pl24VehicleId, vehicle.brandName);
|
||||
if (catalogInfo?.serviceName && catalogInfo?.mainGroupsPath) {
|
||||
const pl24Categories = await this.pl24Service.fetchMainGroups(
|
||||
catalogInfo.serviceName,
|
||||
catalogInfo.mainGroupsPath,
|
||||
);
|
||||
|
||||
if (pl24Categories.length > 0) {
|
||||
// Save to DB
|
||||
const insertData = pl24Categories.map((c) => ({
|
||||
vehicleId,
|
||||
name: c.name,
|
||||
nameOriginal: c.name,
|
||||
name: c.nameTr || c.nameEn,
|
||||
nameOriginal: c.nameEn,
|
||||
parentId: null as string | null,
|
||||
externalId: c.groupId,
|
||||
externalId: c.code,
|
||||
linkPath: c.linkPath || null,
|
||||
linkWid: c.linkWid || null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
@@ -63,6 +69,244 @@ export class CategoriesService {
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sub-categories (sub-groups) for a category.
|
||||
* Fetches from PL24 on-demand if not cached.
|
||||
*/
|
||||
async getChildren(categoryId: string) {
|
||||
// Check DB first
|
||||
let children = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.parentId, categoryId));
|
||||
|
||||
if (children.length > 0) return children;
|
||||
|
||||
// Fetch from PL24
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.id, category.vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (!vehicle) throw new NotFoundException("Vehicle not found");
|
||||
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
const linkPath = category.linkPath;
|
||||
|
||||
if (!catalogInfo?.serviceName || !linkPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const subGroups = await this.pl24Service.fetchSubGroupsByPath(
|
||||
linkPath,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
if (subGroups.length > 0) {
|
||||
const insertData = subGroups.map((sg) => ({
|
||||
vehicleId: category.vehicleId,
|
||||
name: sg.name,
|
||||
nameOriginal: sg.name,
|
||||
parentId: categoryId,
|
||||
externalId: sg.code,
|
||||
linkPath: sg.linkPath || null,
|
||||
linkWid: sg.linkWid || null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
children = await this.db.insert(categories).values(insertData).returning();
|
||||
|
||||
// Invalidate tree cache since new subgroups were added
|
||||
await this.redis.del(`cat:tree:${category.vehicleId}`);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a category with its parts, schema images, and hotspots.
|
||||
* If the category is a parent (has children), returns children instead.
|
||||
* If it's a leaf with a BOM linkPath, fetches parts from PL24 on-demand.
|
||||
*/
|
||||
async getCategoryWithParts(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (!category) throw new NotFoundException("Category not found");
|
||||
|
||||
// Check if this category has children
|
||||
const children = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(eq(categories.parentId, categoryId));
|
||||
|
||||
if (children.length > 0) {
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
// Leaf category — get or fetch parts
|
||||
let dbParts = await this.db
|
||||
.select()
|
||||
.from(parts)
|
||||
.where(eq(parts.categoryId, categoryId));
|
||||
|
||||
const pics = await this.db
|
||||
.select()
|
||||
.from(schemaPics)
|
||||
.where(eq(schemaPics.categoryId, categoryId));
|
||||
|
||||
// If no parts in DB, fetch from PL24
|
||||
if (dbParts.length === 0 && category.linkPath) {
|
||||
const [vehicle] = await this.db
|
||||
.select()
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.id, category.vehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (vehicle) {
|
||||
const rawData = vehicle.rawData as any;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
|
||||
if (catalogInfo?.serviceName) {
|
||||
try {
|
||||
const pl24Result = await this.pl24Service.fetchPartsByPath(
|
||||
category.linkPath,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
// Store parts
|
||||
if (pl24Result.parts.length > 0) {
|
||||
const insertData = pl24Result.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: p.oemCode || "N/A",
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
dbParts = await this.db.insert(parts).values(insertData).returning();
|
||||
}
|
||||
|
||||
// Store schema image if available
|
||||
if (pl24Result.schemaImageUrl && pics.length === 0) {
|
||||
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 || [],
|
||||
};
|
||||
|
||||
const [inserted] = await this.db
|
||||
.insert(schemaPics)
|
||||
.values({
|
||||
categoryId,
|
||||
imageUrl: imageResult.imageUrl,
|
||||
hotspots: JSON.stringify(hotspotsData),
|
||||
source: "pl24",
|
||||
})
|
||||
.returning();
|
||||
|
||||
pics.push(inserted);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to fetch parts for category ${categoryId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse hotspots data (may include width/height metadata)
|
||||
let hotspots: any[] = [];
|
||||
let schemaWidth = 0;
|
||||
let schemaHeight = 0;
|
||||
|
||||
if (pics.length > 0) {
|
||||
const rawHotspots = pics[0].hotspots;
|
||||
let parsed: any = rawHotspots;
|
||||
if (typeof rawHotspots === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(rawHotspots);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && !Array.isArray(parsed) && parsed.items) {
|
||||
// New format: { width, height, items }
|
||||
schemaWidth = parsed.width || 0;
|
||||
schemaHeight = parsed.height || 0;
|
||||
hotspots = parsed.items || [];
|
||||
} else if (Array.isArray(parsed)) {
|
||||
hotspots = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Transform raw PL24 hotspots {key, areas} to frontend format
|
||||
const mappedHotspots = hotspots.flatMap(
|
||||
(hs: { key: string; areas?: Array<{ left: number; top: number; width: number; height: number }> }) =>
|
||||
(hs.areas || []).map((area, areaIdx) => ({
|
||||
id: `hs-${hs.key}-${areaIdx}`,
|
||||
key: hs.key,
|
||||
group: parseInt(hs.key, 10) || 0,
|
||||
shape: "rect" as const,
|
||||
coordinates: [area.left, area.top, area.width, area.height],
|
||||
label: hs.key,
|
||||
})),
|
||||
);
|
||||
|
||||
// Map schemaPics to the format the frontend expects
|
||||
const mappedPics = pics.map((pic) => ({
|
||||
id: pic.id,
|
||||
url: pic.imageUrl,
|
||||
width: schemaWidth,
|
||||
height: schemaHeight,
|
||||
label: category.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.nameOriginal || null,
|
||||
parts: dbParts,
|
||||
schemaPics: mappedPics,
|
||||
hotspots: mappedHotspots,
|
||||
};
|
||||
}
|
||||
|
||||
async getById(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
@@ -97,6 +341,14 @@ export class CategoriesService {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove empty children arrays so frontend can distinguish
|
||||
// leaf nodes (children: []) from unexplored nodes (children: undefined)
|
||||
for (const node of map.values()) {
|
||||
if (node.children.length === 0) {
|
||||
delete node.children;
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@ export default () => ({
|
||||
baseUrl: process.env.IYZICO_BASE_URL,
|
||||
},
|
||||
pl24: {
|
||||
apiUrl: process.env.PL24_API_URL,
|
||||
baseUrl: process.env.PL24_BASE_URL || "https://www.partslink24.com",
|
||||
companyCode: process.env.PL24_COMPANY_CODE,
|
||||
username: process.env.PL24_USERNAME,
|
||||
password: process.env.PL24_PASSWORD,
|
||||
},
|
||||
|
||||
@@ -251,6 +251,8 @@ export const categories = pgTable(
|
||||
nameOriginal: varchar("name_original", { length: 500 }),
|
||||
parentId: uuid("parent_id"),
|
||||
externalId: varchar("external_id", { length: 100 }),
|
||||
linkPath: text("link_path"),
|
||||
linkWid: varchar("link_wid", { length: 100 }),
|
||||
source: varchar("source", { length: 20 }).default("pl24").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
|
||||
/**
|
||||
* Base PL24 Parser
|
||||
*
|
||||
* Note: With the real PL24 API integration, parsing is done directly in
|
||||
* PL24Service. These parsers are kept for potential future brand-specific
|
||||
* response normalization.
|
||||
*/
|
||||
|
||||
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
||||
|
||||
export abstract class BasePL24Parser {
|
||||
abstract readonly brandName: string;
|
||||
|
||||
abstract parseVehicle(raw: Record<string, unknown>): ParsedVehicle;
|
||||
abstract parseCategories(raw: unknown[]): ParsedCategory[];
|
||||
abstract parseParts(raw: unknown[]): PL24PartResponse[];
|
||||
abstract parseVehicle(raw: Record<string, unknown>): Partial<PL24DecodedVehicle>;
|
||||
abstract parseCategories(raw: unknown[]): PL24DecodedCategory[];
|
||||
abstract parseParts(raw: unknown[]): PL24Part[];
|
||||
|
||||
protected safeString(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { GenericPL24Parser } from "./generic-parser";
|
||||
import { ParsedVehicle } from "../pl24.types";
|
||||
|
||||
export class BmwPL24Parser extends GenericPL24Parser {
|
||||
constructor() {
|
||||
super("BMW");
|
||||
}
|
||||
|
||||
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
|
||||
const base = super.parseVehicle(raw);
|
||||
// BMW-specific: extract series from model code (E90, F30, G20, etc.)
|
||||
if (base.modelCode && typeof raw.series === "string") {
|
||||
base.name = `${raw.series} ${base.modelCode}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
/**
|
||||
* Generic PL24 Parser
|
||||
*
|
||||
* Kept for backwards compatibility. With the real PL24 API integration,
|
||||
* parsing is done directly in PL24Service using the actual API response format.
|
||||
*/
|
||||
|
||||
import { BasePL24Parser } from "./base-parser";
|
||||
import { ParsedVehicle, ParsedCategory, PL24PartResponse } from "../pl24.types";
|
||||
import type { PL24DecodedVehicle, PL24DecodedCategory, PL24Part } from "../pl24.types";
|
||||
|
||||
export class GenericPL24Parser extends BasePL24Parser {
|
||||
readonly brandName: string;
|
||||
@@ -9,48 +16,43 @@ export class GenericPL24Parser extends BasePL24Parser {
|
||||
this.brandName = brandName;
|
||||
}
|
||||
|
||||
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
|
||||
parseVehicle(raw: Record<string, unknown>): Partial<PL24DecodedVehicle> {
|
||||
return {
|
||||
vehicleId: this.safeString(raw.vehicleId || raw.id),
|
||||
catalogId: this.safeString(raw.catalogId || raw.catalog_id),
|
||||
name: this.safeString(raw.name || raw.description),
|
||||
modelCode: this.safeString(raw.modelCode || raw.model_code || raw.model),
|
||||
engine: this.safeString(raw.engine || raw.engineCode),
|
||||
transmission: this.safeString(raw.transmission || raw.gearbox),
|
||||
bodyType: this.safeString(raw.bodyType || raw.body_type || raw.body),
|
||||
market: this.safeString(raw.market || raw.region),
|
||||
yearFrom: this.safeNumber(raw.yearFrom || raw.year_from || raw.prodFrom),
|
||||
yearTo: this.safeNumber(raw.yearTo || raw.year_to || raw.prodTo),
|
||||
brand: this.brandName,
|
||||
model: this.safeString(raw.model || raw.description),
|
||||
year: this.safeNumber(raw.modelYear || raw.year),
|
||||
series: this.safeString(raw.series) || null,
|
||||
bodyType: this.safeString(raw.bodyType) || null,
|
||||
engineCode: this.safeString(raw.engineCode) || null,
|
||||
engineType: null,
|
||||
engineVolume: null,
|
||||
transmission: this.safeString(raw.transmission) || null,
|
||||
driveType: this.safeString(raw.driveType) || null,
|
||||
colorCode: this.safeString(raw.colorCode) || null,
|
||||
raw,
|
||||
catalogInfo: null,
|
||||
categories: [],
|
||||
};
|
||||
}
|
||||
|
||||
parseCategories(raw: unknown[]): ParsedCategory[] {
|
||||
return raw.map((item: any, index: number) => ({
|
||||
groupId: this.safeString(item.groupId || item.id || item.group_id),
|
||||
name: this.safeString(item.name || item.description),
|
||||
parentGroupId: item.parentGroupId || item.parent_group_id || null,
|
||||
sortOrder: this.safeNumber(item.sortOrder || item.sort_order || index),
|
||||
hasSchemaPic: !!item.hasSchemaPic || !!item.has_schema || !!item.imageUrl,
|
||||
}));
|
||||
}
|
||||
|
||||
parseParts(raw: unknown[]): PL24PartResponse[] {
|
||||
parseCategories(raw: unknown[]): PL24DecodedCategory[] {
|
||||
return raw.map((item: any) => ({
|
||||
partId: this.safeString(item.partId || item.id || item.part_id),
|
||||
name: this.safeString(item.name || item.description),
|
||||
description: this.safeString(item.description || item.additionalInfo || ""),
|
||||
quantity: this.safeNumber(item.quantity || item.qty || 1),
|
||||
position: this.safeString(item.position || item.pos || ""),
|
||||
hotspotIndex: item.hotspotIndex ?? item.hotspot_index ?? item.callout ?? null,
|
||||
oemCodes: this.extractOemCodes(item),
|
||||
code: this.safeString(item.code || item.id),
|
||||
nameEn: this.safeString(item.name || item.description),
|
||||
description: this.safeString(item.description) || null,
|
||||
iconUrl: null,
|
||||
subGroups: [],
|
||||
}));
|
||||
}
|
||||
|
||||
private extractOemCodes(item: any): string[] {
|
||||
if (Array.isArray(item.oemCodes)) return item.oemCodes;
|
||||
if (Array.isArray(item.partNumbers)) return item.partNumbers.map((p: any) => p.code || p);
|
||||
if (item.oemCode) return [item.oemCode];
|
||||
if (item.partNumber) return [item.partNumber];
|
||||
return [];
|
||||
parseParts(raw: unknown[]): PL24Part[] {
|
||||
return raw.map((item: any) => ({
|
||||
id: this.safeString(item.id || item.partId),
|
||||
oemCode: this.safeString(item.oemCode || item.partNumber),
|
||||
name: this.safeString(item.name || item.description),
|
||||
description: this.safeString(item.description) || undefined,
|
||||
quantity: this.safeNumber(item.quantity || 1),
|
||||
positionCode: this.safeString(item.position) || undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { GenericPL24Parser } from "./generic-parser";
|
||||
import { ParsedVehicle } from "../pl24.types";
|
||||
|
||||
export class MercedesPL24Parser extends GenericPL24Parser {
|
||||
constructor() {
|
||||
super("Mercedes-Benz");
|
||||
}
|
||||
|
||||
parseVehicle(raw: Record<string, unknown>): ParsedVehicle {
|
||||
const base = super.parseVehicle(raw);
|
||||
// Mercedes-specific: extract class (W205, W213, etc.)
|
||||
if (typeof raw.baumuster === "string") {
|
||||
base.modelCode = raw.baumuster;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { BasePL24Parser } from "./base-parser";
|
||||
import { BmwPL24Parser } from "./bmw-parser";
|
||||
import { MercedesPL24Parser } from "./mercedes-parser";
|
||||
import { GenericPL24Parser } from "./generic-parser";
|
||||
import { PL24_WMI_SERVICE_MAP, isP5Modern } from "../pl24.types";
|
||||
|
||||
const PARSER_MAP: Record<string, () => BasePL24Parser> = {
|
||||
"BMW": () => new BmwPL24Parser(),
|
||||
BMW: () => new BmwPL24Parser(),
|
||||
"Mercedes-Benz": () => new MercedesPL24Parser(),
|
||||
};
|
||||
|
||||
@@ -13,3 +14,15 @@ export function createParser(brandName: string): BasePL24Parser {
|
||||
if (factory) return factory();
|
||||
return new GenericPL24Parser(brandName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service name from VIN's WMI (first 3 chars).
|
||||
* Only returns P5 Modern services.
|
||||
*/
|
||||
export function getServiceForVin(vin: string): string | null {
|
||||
if (!vin || vin.length < 3) return null;
|
||||
const wmi = vin.substring(0, 3).toUpperCase();
|
||||
const service = PL24_WMI_SERVICE_MAP[wmi];
|
||||
if (service && isP5Modern(service)) return service;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,308 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
/**
|
||||
* PartsLink24 Authentication Service
|
||||
*
|
||||
* Handles JWT authentication, token refresh, and session management
|
||||
* for the partslink24.com API. Tokens cached in-memory (short-lived).
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RedisService } from "../../redis/redis.service";
|
||||
import { PL24_DEFAULTS } from "./pl24.constants";
|
||||
import { PL24_ENDPOINTS } from "./pl24.constants";
|
||||
import type {
|
||||
PL24LoginRequest,
|
||||
PL24LoginResponse,
|
||||
PL24TokenData,
|
||||
PL24JWTPayload,
|
||||
PL24AuthorizeRequest,
|
||||
PL24AuthorizeResponse,
|
||||
} from "./pl24.types";
|
||||
|
||||
@Injectable()
|
||||
export class PL24AuthService {
|
||||
private readonly logger = new Logger(PL24AuthService.name);
|
||||
private readonly cacheKey = `${PL24_DEFAULTS.CACHE_PREFIX}auth_token`;
|
||||
private tokenData: PL24TokenData | null = null;
|
||||
private serviceTokens = new Map<
|
||||
string,
|
||||
{ token: string; expiresAt: Date }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private redis: RedisService,
|
||||
) {}
|
||||
private readonly baseUrl: string;
|
||||
private readonly companyCode: string;
|
||||
private readonly username: string;
|
||||
private readonly password: string;
|
||||
private readonly timeout: number;
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
// Check Redis cache
|
||||
const cached = await this.redis.get(this.cacheKey);
|
||||
if (cached) return cached;
|
||||
constructor(private configService: ConfigService) {
|
||||
this.baseUrl = this.configService.get<string>(
|
||||
"pl24.baseUrl",
|
||||
"https://www.partslink24.com",
|
||||
);
|
||||
this.companyCode = this.configService.get<string>("pl24.companyCode", "");
|
||||
this.username = this.configService.get<string>("pl24.username", "");
|
||||
this.password = this.configService.get<string>("pl24.password", "");
|
||||
this.timeout = 30000;
|
||||
|
||||
// Authenticate with PL24
|
||||
const token = await this.authenticate();
|
||||
await this.redis.set(this.cacheKey, token, PL24_DEFAULTS.AUTH_TOKEN_TTL);
|
||||
return token;
|
||||
if (!this.companyCode || !this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
"PL24 credentials not configured. Set PL24_BASE_URL, PL24_COMPANY_CODE, PL24_USERNAME, PL24_PASSWORD",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async authenticate(): Promise<string> {
|
||||
const apiUrl = this.configService.get<string>("pl24.apiUrl");
|
||||
const username = this.configService.get<string>("pl24.username");
|
||||
const password = this.configService.get<string>("pl24.password");
|
||||
|
||||
if (!apiUrl || !username || !password) {
|
||||
this.logger.warn("PL24 credentials not configured");
|
||||
throw new Error("PL24 credentials not configured");
|
||||
/**
|
||||
* Login to PL24 and get access token.
|
||||
* Uses squeezeOut=true to force logout other sessions.
|
||||
*/
|
||||
async login(forceNew = false): Promise<PL24TokenData> {
|
||||
if (!forceNew && this.tokenData && this.isTokenValid(this.tokenData)) {
|
||||
return this.tokenData;
|
||||
}
|
||||
|
||||
this.logger.log("Logging in to PL24...");
|
||||
|
||||
const loginRequest: PL24LoginRequest = {
|
||||
authentication: {
|
||||
account: this.companyCode,
|
||||
user: this.username,
|
||||
pwd: this.password,
|
||||
},
|
||||
device: {
|
||||
id: "0",
|
||||
os: "Windows 10",
|
||||
offset: "0",
|
||||
lang: "en-US",
|
||||
"os-version": "0",
|
||||
},
|
||||
"app-version": "",
|
||||
squeezeOut: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
signal: AbortSignal.timeout(PL24_DEFAULTS.REQUEST_TIMEOUT),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${PL24_ENDPOINTS.LOGIN}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
},
|
||||
body: JSON.stringify(loginRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`PL24 auth failed: ${response.status}`);
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token: string };
|
||||
this.logger.log("PL24 authenticated successfully");
|
||||
return data.token;
|
||||
const data = (await response.json()) as PL24LoginResponse;
|
||||
|
||||
if (data.status === "USER_ALREADY_LOGGED_IN") {
|
||||
this.logger.warn("User already logged in, session squeezed out");
|
||||
}
|
||||
|
||||
if (!data.token?.access_token) {
|
||||
this.logger.error(
|
||||
`PL24 login failed: ${data.status} - ${data.message || "No token returned"}`,
|
||||
);
|
||||
throw new UnauthorizedException(
|
||||
`PL24 giris basarisiz: ${data.message || data.status || "Token alinamadi"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract session cookie
|
||||
const setCookie = response.headers.get("set-cookie");
|
||||
const sessionCookie = this.extractSessionCookie(setCookie);
|
||||
|
||||
// Decode JWT for expiration + services
|
||||
const payload = this.decodeJWT(data.token.access_token);
|
||||
|
||||
this.tokenData = {
|
||||
accessToken: data.token.access_token,
|
||||
refreshToken: data.refreshToken || "",
|
||||
sessionCookie,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
services: payload.services || [],
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`PL24 login successful. Token expires at ${this.tokenData.expiresAt.toISOString()}`,
|
||||
);
|
||||
this.logger.log(
|
||||
`Available services: ${this.tokenData.services.length}`,
|
||||
);
|
||||
|
||||
return this.tokenData;
|
||||
} catch (error) {
|
||||
this.logger.error("PL24 authentication failed", error);
|
||||
throw error;
|
||||
const err = error as Error;
|
||||
if (err.name === "TimeoutError") {
|
||||
throw new UnauthorizedException("PL24 giris zaman asimina ugradi");
|
||||
}
|
||||
this.logger.error(`PL24 login error: ${err.message}`);
|
||||
throw new UnauthorizedException(`PL24 giris hatasi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async invalidateToken(): Promise<void> {
|
||||
await this.redis.del(this.cacheKey);
|
||||
/**
|
||||
* Get service-specific authorization token.
|
||||
* Required for accessing specific catalogs.
|
||||
*/
|
||||
async authorizeService(serviceName: string): Promise<string> {
|
||||
const cached = this.serviceTokens.get(serviceName);
|
||||
if (cached && cached.expiresAt > new Date()) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const mainToken = await this.getAccessToken();
|
||||
|
||||
this.logger.log(`Authorizing service: ${serviceName}`);
|
||||
|
||||
const authorizeRequest: PL24AuthorizeRequest = {
|
||||
serviceNames: [
|
||||
"cart",
|
||||
"pl24-full-vin-data",
|
||||
"pl24-orderbridge",
|
||||
"pl24-orderbridge-cart",
|
||||
"pl24-sendbtmail",
|
||||
"pl24-qparts",
|
||||
"orderBook",
|
||||
"pl24-usage",
|
||||
"pl24-tls-pilot",
|
||||
serviceName,
|
||||
],
|
||||
serviceCategoryNames: ["pl24-shop-universal", "pl24-shop-tools"],
|
||||
withLogin: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}${PL24_ENDPOINTS.AUTHORIZE}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${mainToken}`,
|
||||
Cookie: this.tokenData?.sessionCookie || "",
|
||||
},
|
||||
body: JSON.stringify(authorizeRequest),
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PL24AuthorizeResponse;
|
||||
|
||||
const accessToken = data.access_token || data.token?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error("No service token in response");
|
||||
}
|
||||
|
||||
const payload = this.decodeJWT(accessToken);
|
||||
this.serviceTokens.set(serviceName, {
|
||||
token: accessToken,
|
||||
expiresAt: new Date(payload.exp * 1000),
|
||||
});
|
||||
|
||||
this.logger.log(`Service ${serviceName} authorized successfully`);
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error(`Service authorization error: ${err.message}`);
|
||||
throw new UnauthorizedException(
|
||||
`Servis yetkilendirme hatasi: ${err.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.accessToken;
|
||||
}
|
||||
|
||||
async getSessionCookie(): Promise<string> {
|
||||
if (!this.tokenData || !this.isTokenValid(this.tokenData)) {
|
||||
await this.login();
|
||||
}
|
||||
return this.tokenData!.sessionCookie;
|
||||
}
|
||||
|
||||
getAvailableServices(): string[] {
|
||||
return this.tokenData?.services || [];
|
||||
}
|
||||
|
||||
hasService(serviceName: string): boolean {
|
||||
return this.tokenData?.services.includes(serviceName) || false;
|
||||
}
|
||||
|
||||
clearTokens(): void {
|
||||
this.tokenData = null;
|
||||
this.serviceTokens.clear();
|
||||
this.logger.log("All PL24 tokens cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authorization headers for API requests.
|
||||
*/
|
||||
async buildAuthHeaders(
|
||||
serviceName?: string,
|
||||
includeContentType = false,
|
||||
): Promise<Record<string, string>> {
|
||||
const token = serviceName
|
||||
? await this.authorizeService(serviceName)
|
||||
: await this.getAccessToken();
|
||||
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Cookie: sessionCookie,
|
||||
Accept: "application/json",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
};
|
||||
|
||||
if (includeContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
private isTokenValid(token: PL24TokenData): boolean {
|
||||
const bufferMs = 60 * 1000;
|
||||
return token.expiresAt.getTime() - bufferMs > Date.now();
|
||||
}
|
||||
|
||||
private decodeJWT(token: string): PL24JWTPayload {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
throw new Error("Invalid JWT format");
|
||||
}
|
||||
const payload = Buffer.from(parts[1], "base64").toString("utf-8");
|
||||
return JSON.parse(payload);
|
||||
} catch {
|
||||
this.logger.error("Failed to decode JWT");
|
||||
throw new Error("Invalid JWT token");
|
||||
}
|
||||
}
|
||||
|
||||
private extractSessionCookie(setCookie: string | null): string {
|
||||
if (!setCookie) return "";
|
||||
|
||||
const match = setCookie.match(/PL24TOKEN=([^;]+)/);
|
||||
if (match) {
|
||||
return `PL24TOKEN=${match[1]}`;
|
||||
}
|
||||
|
||||
return setCookie.split(";")[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,15 @@ export const PL24_DEFAULTS = {
|
||||
REQUEST_TIMEOUT: 30000,
|
||||
MAX_RETRIES: 3,
|
||||
} as const;
|
||||
|
||||
export const PL24_ENDPOINTS = {
|
||||
// Auth
|
||||
LOGIN: "/pl24-appgtw/ext/api/1.0/login",
|
||||
AUTHORIZE: "/auth/ext/api/1.1/authorize",
|
||||
|
||||
// Catalog
|
||||
MANUFACTURERS: "/pl24-manufacturer/ext/api/1.0/manufacturers/",
|
||||
|
||||
// Image server
|
||||
IMAGESERVER: "/imageserver/ext/api/images",
|
||||
} as const;
|
||||
|
||||
@@ -4,6 +4,6 @@ import { PL24AuthService } from "./pl24-auth.service";
|
||||
|
||||
@Module({
|
||||
providers: [PL24Service, PL24AuthService],
|
||||
exports: [PL24Service],
|
||||
exports: [PL24Service, PL24AuthService],
|
||||
})
|
||||
export class PL24Module {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,602 @@
|
||||
export interface PL24AuthResponse {
|
||||
token: string;
|
||||
expiresIn: number;
|
||||
/**
|
||||
* PartsLink24 (PL24) API Types
|
||||
*
|
||||
* Type definitions for partslink24.com VIN integration.
|
||||
* PL24 uses JWT authentication and provides P5 architecture for modern catalogs.
|
||||
*/
|
||||
|
||||
// ==================== AUTH TYPES ====================
|
||||
|
||||
export interface PL24LoginRequest {
|
||||
authentication: {
|
||||
account: string;
|
||||
user: string;
|
||||
pwd: string;
|
||||
};
|
||||
device: {
|
||||
id: string;
|
||||
os: string;
|
||||
offset: string;
|
||||
lang: string;
|
||||
"os-version": string;
|
||||
};
|
||||
"app-version": string;
|
||||
squeezeOut: boolean;
|
||||
}
|
||||
|
||||
export interface PL24VehicleResponse {
|
||||
export interface PL24LoginResponse {
|
||||
status:
|
||||
| "OK"
|
||||
| "USER_ALREADY_LOGGED_IN"
|
||||
| "INVALID_CREDENTIALS"
|
||||
| "ERROR"
|
||||
| null;
|
||||
message?: string;
|
||||
token?: {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
scope: string;
|
||||
};
|
||||
refreshToken?: string;
|
||||
securables?: unknown;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface PL24JWTPayload {
|
||||
iat: number;
|
||||
exp: number;
|
||||
sid: string;
|
||||
aid: number;
|
||||
uid: number;
|
||||
services: string[];
|
||||
licid: number;
|
||||
app: string;
|
||||
type: string;
|
||||
country: string;
|
||||
ulo: string;
|
||||
alo: string;
|
||||
}
|
||||
|
||||
export interface PL24TokenData {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionCookie: string;
|
||||
expiresAt: Date;
|
||||
services: string[];
|
||||
}
|
||||
|
||||
export interface PL24AuthorizeRequest {
|
||||
serviceNames: string[];
|
||||
serviceCategoryNames: string[];
|
||||
withLogin: boolean;
|
||||
}
|
||||
|
||||
export interface PL24AuthorizeResponse {
|
||||
access_token?: string;
|
||||
token_type?: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
session_status?: string;
|
||||
lcSessionId?: string | null;
|
||||
token?: {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CATALOG TYPES ====================
|
||||
|
||||
export type PL24ApiArchitecture =
|
||||
| "P5_MODERN"
|
||||
| "LEGACY_PSA"
|
||||
| "LEGACY_HYUNDAI_KIA"
|
||||
| "LEGACY_KIA"
|
||||
| "LEGACY_FORD"
|
||||
| "LEGACY_NISSAN"
|
||||
| "LEGACY_OPEL"
|
||||
| "LEGACY_VOLVO";
|
||||
|
||||
export interface PL24CatalogConfig {
|
||||
basePath: string;
|
||||
apiPath: string;
|
||||
architecture: PL24ApiArchitecture;
|
||||
}
|
||||
|
||||
export const PL24_SERVICE_CATALOGS: Record<string, PL24CatalogConfig> = {
|
||||
// ==================== P5 MODERN ARCHITECTURE ====================
|
||||
|
||||
// Volkswagen Group
|
||||
vw_parts: {
|
||||
basePath: "/pl24-app/vw_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
vwclassic_parts: {
|
||||
basePath: "/pl24-app/vwclassic_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
vn_parts: {
|
||||
basePath: "/pl24-app/vn_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
audi_parts: {
|
||||
basePath: "/pl24-app/audi_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
skoda_parts: {
|
||||
basePath: "/pl24-app/skoda_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
seat_parts: {
|
||||
basePath: "/pl24-app/seat_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
cupra_parts: {
|
||||
basePath: "/pl24-app/cupra_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
bentley_parts: {
|
||||
basePath: "/pl24-app/bentley_parts",
|
||||
apiPath: "/p5vwag",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// BMW Group
|
||||
bmw_parts: {
|
||||
basePath: "/pl24-app/bmw_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
bmwclassic_parts: {
|
||||
basePath: "/pl24-app/bmwclassic_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
bmwmotorrad_parts: {
|
||||
basePath: "/pl24-app/bmwmotorrad_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
bmwmotorradclassic_parts: {
|
||||
basePath: "/pl24-app/bmwmotorradclassic_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
mini_parts: {
|
||||
basePath: "/pl24-app/mini_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
miniclassic_parts: {
|
||||
basePath: "/pl24-app/miniclassic_parts",
|
||||
apiPath: "/p5bmw",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Mercedes Group
|
||||
mercedes_parts: {
|
||||
basePath: "/pl24-app/mercedes_parts",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
mercedesclassic_parts: {
|
||||
basePath: "/p5/latest",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
mercedesvans_parts: {
|
||||
basePath: "/pl24-app/mercedesvans_parts",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
mercedestrucks_parts: {
|
||||
basePath: "/pl24-app/mercedestrucks_parts",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
mercedesunimog_parts: {
|
||||
basePath: "/pl24-app/mercedesunimog_parts",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
smart_parts: {
|
||||
basePath: "/pl24-app/smart_parts",
|
||||
apiPath: "/p5daimler",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Porsche
|
||||
porsche_parts: {
|
||||
basePath: "/pl24-app/porsche_parts",
|
||||
apiPath: "/p5porsche",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
porscheclassic_parts: {
|
||||
basePath: "/pl24-app/porscheclassic_parts",
|
||||
apiPath: "/p5porsche",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Toyota/Lexus
|
||||
toyota_parts: {
|
||||
basePath: "/pl24-app/toyota_parts",
|
||||
apiPath: "/p5toyota",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
lexus_parts: {
|
||||
basePath: "/pl24-app/lexus_parts",
|
||||
apiPath: "/p5toyota",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Renault Group
|
||||
renault_parts: {
|
||||
basePath: "/pl24-app/renault_parts",
|
||||
apiPath: "/p5renault",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
dacia_parts: {
|
||||
basePath: "/pl24-app/dacia_parts",
|
||||
apiPath: "/p5renault",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
alpine_parts: {
|
||||
basePath: "/pl24-app/alpine_parts",
|
||||
apiPath: "/p5renault",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Jaguar Land Rover
|
||||
jaguar_parts: {
|
||||
basePath: "/pl24-app/jaguar_parts",
|
||||
apiPath: "/p5jlr",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
landrover_parts: {
|
||||
basePath: "/pl24-app/landrover_parts",
|
||||
apiPath: "/p5jlr",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// MAN
|
||||
man_parts: {
|
||||
basePath: "/pl24-app/man_parts",
|
||||
apiPath: "/p5man",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Mitsubishi
|
||||
mmc_parts: {
|
||||
basePath: "/pl24-app/mmc_parts",
|
||||
apiPath: "/p5mmc",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
|
||||
// Suzuki
|
||||
suzuki_parts: {
|
||||
basePath: "/pl24-app/suzuki_parts",
|
||||
apiPath: "/p5suzuki",
|
||||
architecture: "P5_MODERN",
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== HELPER FUNCTIONS ====================
|
||||
|
||||
export function getServiceApiPath(serviceName: string): string {
|
||||
const config = PL24_SERVICE_CATALOGS[serviceName];
|
||||
return config?.apiPath || "/p5vwag";
|
||||
}
|
||||
|
||||
export function getServiceConfig(
|
||||
serviceName: string,
|
||||
): PL24CatalogConfig | null {
|
||||
return PL24_SERVICE_CATALOGS[serviceName] || null;
|
||||
}
|
||||
|
||||
export function isP5Modern(serviceName: string): boolean {
|
||||
const config = PL24_SERVICE_CATALOGS[serviceName];
|
||||
return config?.architecture === "P5_MODERN";
|
||||
}
|
||||
|
||||
export function isLegacyArchitecture(serviceName: string): boolean {
|
||||
const config = PL24_SERVICE_CATALOGS[serviceName];
|
||||
if (!config) return false;
|
||||
return config.architecture !== "P5_MODERN";
|
||||
}
|
||||
|
||||
// ==================== WMI MAP ====================
|
||||
|
||||
export const PL24_WMI_SERVICE_MAP: Record<string, string> = {
|
||||
// Volkswagen
|
||||
WVW: "vw_parts",
|
||||
WVG: "vw_parts",
|
||||
"1VW": "vw_parts",
|
||||
"3VW": "vw_parts",
|
||||
"9BW": "vw_parts",
|
||||
|
||||
// Audi
|
||||
WAU: "audi_parts",
|
||||
TRU: "audi_parts",
|
||||
"93U": "audi_parts",
|
||||
|
||||
// Skoda
|
||||
TMB: "skoda_parts",
|
||||
TMP: "skoda_parts",
|
||||
|
||||
// SEAT / Cupra
|
||||
VSS: "seat_parts",
|
||||
VSE: "seat_parts",
|
||||
|
||||
// Bentley
|
||||
SCB: "bentley_parts",
|
||||
|
||||
// BMW
|
||||
WBA: "bmw_parts",
|
||||
WBS: "bmw_parts",
|
||||
WBY: "bmw_parts",
|
||||
WBX: "bmw_parts",
|
||||
|
||||
// BMW Motorrad
|
||||
WB1: "bmwmotorrad_parts",
|
||||
|
||||
// MINI
|
||||
WMW: "mini_parts",
|
||||
|
||||
// Mercedes-Benz
|
||||
WDB: "mercedes_parts",
|
||||
WDD: "mercedes_parts",
|
||||
WDC: "mercedes_parts",
|
||||
W1K: "mercedes_parts",
|
||||
W1N: "mercedes_parts",
|
||||
|
||||
// Mercedes Vans
|
||||
WDF: "mercedesvans_parts",
|
||||
WD3: "mercedesvans_parts",
|
||||
WD4: "mercedesvans_parts",
|
||||
|
||||
// smart
|
||||
WME: "smart_parts",
|
||||
TRD: "smart_parts",
|
||||
|
||||
// Porsche
|
||||
WP0: "porsche_parts",
|
||||
WP1: "porsche_parts",
|
||||
|
||||
// Toyota
|
||||
JTD: "toyota_parts",
|
||||
JTE: "toyota_parts",
|
||||
JTN: "toyota_parts",
|
||||
JTM: "toyota_parts",
|
||||
SB1: "toyota_parts",
|
||||
"1NX": "toyota_parts",
|
||||
"2T1": "toyota_parts",
|
||||
"4T1": "toyota_parts",
|
||||
"5TD": "toyota_parts",
|
||||
"5TF": "toyota_parts",
|
||||
NMT: "toyota_parts",
|
||||
MR0: "toyota_parts",
|
||||
|
||||
// Lexus
|
||||
JTH: "lexus_parts",
|
||||
JTJ: "lexus_parts",
|
||||
"2T2": "lexus_parts",
|
||||
|
||||
// Renault
|
||||
VF1: "renault_parts",
|
||||
VF6: "renault_parts",
|
||||
VNE: "renault_parts",
|
||||
|
||||
// Dacia
|
||||
UU1: "dacia_parts",
|
||||
UU6: "dacia_parts",
|
||||
|
||||
// Alpine
|
||||
VFA: "alpine_parts",
|
||||
|
||||
// Jaguar
|
||||
SAJ: "jaguar_parts",
|
||||
|
||||
// Land Rover
|
||||
SAL: "landrover_parts",
|
||||
|
||||
// MAN
|
||||
WMA: "man_parts",
|
||||
WMH: "man_parts",
|
||||
|
||||
// Mitsubishi
|
||||
JMB: "mmc_parts",
|
||||
JMY: "mmc_parts",
|
||||
MMB: "mmc_parts",
|
||||
ML3: "mmc_parts",
|
||||
|
||||
// Suzuki
|
||||
JS2: "suzuki_parts",
|
||||
JS3: "suzuki_parts",
|
||||
TSM: "suzuki_parts",
|
||||
MA3: "suzuki_parts",
|
||||
MBH: "suzuki_parts",
|
||||
};
|
||||
|
||||
// ==================== VEHICLE TYPES ====================
|
||||
|
||||
export interface PL24CatalogInfo {
|
||||
serviceName: string;
|
||||
vehicleId: string;
|
||||
catalogId: string;
|
||||
name: string;
|
||||
modelCode: string;
|
||||
engine: string;
|
||||
transmission: string;
|
||||
bodyType: string;
|
||||
market: string;
|
||||
yearFrom: number;
|
||||
yearTo: number;
|
||||
raw: Record<string, unknown>;
|
||||
catalogPath: string;
|
||||
baseUrl: string;
|
||||
mainGroupsPath?: string;
|
||||
}
|
||||
|
||||
export interface PL24CategoryResponse {
|
||||
groupId: string;
|
||||
export interface PL24MainGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
parentGroupId: string | null;
|
||||
sortOrder: number;
|
||||
hasSchemaPic: boolean;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
subGroups?: PL24SubGroup[];
|
||||
linkPath?: string;
|
||||
linkWid?: string;
|
||||
}
|
||||
|
||||
export interface PL24PartResponse {
|
||||
partId: string;
|
||||
export interface PL24SubGroup {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
position: string;
|
||||
hotspotIndex: number | null;
|
||||
oemCodes: string[];
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
partCount?: number;
|
||||
}
|
||||
|
||||
export interface PL24SchemaPicResponse {
|
||||
imageUrl: string;
|
||||
hotspots: PL24Hotspot[];
|
||||
export interface PL24Part {
|
||||
id: string;
|
||||
oemCode: string;
|
||||
formattedPartNo?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
remark?: string;
|
||||
quantity?: number;
|
||||
positionCode?: string;
|
||||
modelCodes?: string;
|
||||
notes?: string;
|
||||
superseded?: {
|
||||
oldCode: string;
|
||||
newCode: string;
|
||||
};
|
||||
restrictions?: string[];
|
||||
additionalInfo?: Record<string, string>;
|
||||
hotspotId?: string;
|
||||
linkPath?: string;
|
||||
}
|
||||
|
||||
export interface PL24HotspotArea {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
descr?: string | null;
|
||||
}
|
||||
|
||||
export interface PL24Hotspot {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
shape: "rect" | "circle" | "polygon";
|
||||
points?: { x: number; y: number }[];
|
||||
key: string;
|
||||
areas: PL24HotspotArea[];
|
||||
hotspotKeyLinks?: unknown[];
|
||||
masks?: unknown[];
|
||||
}
|
||||
|
||||
export interface ParsedVehicle {
|
||||
vehicleId: string;
|
||||
catalogId: string;
|
||||
name: string;
|
||||
modelCode: string;
|
||||
engine: string;
|
||||
transmission: string;
|
||||
bodyType: string;
|
||||
market: string;
|
||||
yearFrom: number;
|
||||
yearTo: number;
|
||||
}
|
||||
|
||||
export interface ParsedCategory {
|
||||
export interface PL24PartsResponse {
|
||||
success: boolean;
|
||||
groupId: string;
|
||||
name: string;
|
||||
parentGroupId: string | null;
|
||||
sortOrder: number;
|
||||
hasSchemaPic: boolean;
|
||||
groupName: string;
|
||||
schemaImageUrl?: string;
|
||||
schemaWidth?: number;
|
||||
schemaHeight?: number;
|
||||
parts: PL24Part[];
|
||||
hotspots?: PL24Hotspot[];
|
||||
}
|
||||
|
||||
export interface PL24ImageResponse {
|
||||
originalHeight: number;
|
||||
originalWidth: number;
|
||||
scaledHeight: number;
|
||||
scaledWidth: number;
|
||||
image: string;
|
||||
hotspots: PL24Hotspot[];
|
||||
}
|
||||
|
||||
// ==================== STANDARDIZED OUTPUT ====================
|
||||
|
||||
export interface PL24DecodedVehicle {
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number;
|
||||
series: string | null;
|
||||
bodyType: string | null;
|
||||
engineCode: string | null;
|
||||
engineType: string | null;
|
||||
engineVolume: string | null;
|
||||
transmission: string | null;
|
||||
driveType: string | null;
|
||||
colorCode: string | null;
|
||||
productionDate?: string | null;
|
||||
raw: Record<string, unknown>;
|
||||
catalogInfo: PL24CatalogInfo | null;
|
||||
categories: PL24DecodedCategory[];
|
||||
}
|
||||
|
||||
export interface PL24DecodedCategory {
|
||||
code: string;
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
iconUrl: string | null;
|
||||
subGroups: PL24DecodedSubGroup[];
|
||||
linkPath?: string;
|
||||
linkWid?: string;
|
||||
}
|
||||
|
||||
export interface PL24DecodedSubGroup {
|
||||
code: string;
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
schemaImageUrl: string | null;
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
export interface PL24DecodedPart {
|
||||
oemCode: string;
|
||||
alternativeOems?: string[];
|
||||
nameEn: string;
|
||||
nameTr?: string;
|
||||
description: string | null;
|
||||
positionCode?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
quantity?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// ==================== BRAND MAP ====================
|
||||
|
||||
export const SERVICE_TO_BRAND: Record<string, string> = {
|
||||
vw_parts: "Volkswagen",
|
||||
vwclassic_parts: "Volkswagen",
|
||||
vn_parts: "Volkswagen",
|
||||
audi_parts: "Audi",
|
||||
seat_parts: "SEAT",
|
||||
cupra_parts: "Cupra",
|
||||
skoda_parts: "Skoda",
|
||||
bentley_parts: "Bentley",
|
||||
bmw_parts: "BMW",
|
||||
bmwclassic_parts: "BMW",
|
||||
bmwmotorrad_parts: "BMW",
|
||||
bmwmotorradclassic_parts: "BMW",
|
||||
mini_parts: "MINI",
|
||||
miniclassic_parts: "MINI",
|
||||
mercedes_parts: "Mercedes-Benz",
|
||||
mercedesclassic_parts: "Mercedes-Benz",
|
||||
mercedesvans_parts: "Mercedes-Benz",
|
||||
mercedestrucks_parts: "Mercedes-Benz",
|
||||
mercedesunimog_parts: "Mercedes-Benz",
|
||||
smart_parts: "smart",
|
||||
porsche_parts: "Porsche",
|
||||
porscheclassic_parts: "Porsche",
|
||||
toyota_parts: "Toyota",
|
||||
lexus_parts: "Lexus",
|
||||
renault_parts: "Renault",
|
||||
dacia_parts: "Dacia",
|
||||
alpine_parts: "Alpine",
|
||||
jaguar_parts: "Jaguar",
|
||||
landrover_parts: "Land Rover",
|
||||
man_parts: "MAN",
|
||||
mmc_parts: "Mitsubishi",
|
||||
suzuki_parts: "Suzuki",
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
import { DATABASE, Database } from "../database/database.provider";
|
||||
import { parts, categories, vehicles } from "../database/schema/core";
|
||||
import { parts, categories, vehicles, schemaPics } from "../database/schema/core";
|
||||
import { PL24Service } from "../integrations/pl24/pl24.service";
|
||||
|
||||
@Injectable()
|
||||
export class PartsService {
|
||||
private readonly logger = new Logger(PartsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private pl24Service: PL24Service,
|
||||
@@ -38,44 +40,70 @@ export class PartsService {
|
||||
if (!vehicle) throw new NotFoundException("Vehicle not found");
|
||||
|
||||
const rawData = vehicle.rawData as any;
|
||||
const pl24VehicleId = rawData?.vehicleId;
|
||||
const groupId = category.externalId;
|
||||
const catalogInfo = rawData?.catalogInfo;
|
||||
const linkPath = category.linkPath;
|
||||
|
||||
if (pl24VehicleId && groupId && vehicle.brandName) {
|
||||
const pl24Parts = await this.pl24Service.getParts(pl24VehicleId, groupId, vehicle.brandName);
|
||||
if (!catalogInfo?.serviceName || !linkPath) {
|
||||
return dbParts;
|
||||
}
|
||||
|
||||
if (pl24Parts.length > 0) {
|
||||
const insertData = pl24Parts.flatMap((p) =>
|
||||
p.oemCodes.length > 0
|
||||
? p.oemCodes.map((oem) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: oem,
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.position || null,
|
||||
hotspotIndex: p.hotspotIndex ?? null,
|
||||
source: "pl24" as const,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: "N/A",
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.position || null,
|
||||
hotspotIndex: p.hotspotIndex ?? null,
|
||||
source: "pl24" as const,
|
||||
},
|
||||
],
|
||||
);
|
||||
// Use fetchPartsByPath which handles the actual PL24 BOM endpoint
|
||||
const pl24Result = await this.pl24Service.fetchPartsByPath(
|
||||
linkPath,
|
||||
catalogInfo.serviceName,
|
||||
);
|
||||
|
||||
dbParts = await this.db.insert(parts).values(insertData).returning();
|
||||
if (pl24Result.parts.length > 0) {
|
||||
const insertData = pl24Result.parts.map((p) => ({
|
||||
vehicleId: vehicle.id,
|
||||
categoryId,
|
||||
oemCode: p.oemCode || "N/A",
|
||||
name: p.name,
|
||||
nameOriginal: p.name,
|
||||
description: p.description || null,
|
||||
quantity: p.quantity || null,
|
||||
position: p.positionCode || null,
|
||||
hotspotIndex: p.hotspotId ? parseInt(p.hotspotId, 10) || null : null,
|
||||
source: "pl24" as const,
|
||||
}));
|
||||
|
||||
dbParts = await this.db.insert(parts).values(insertData).returning();
|
||||
}
|
||||
|
||||
// Store schema image if available
|
||||
if (pl24Result.schemaImageUrl) {
|
||||
const existingPics = await this.db
|
||||
.select()
|
||||
.from(schemaPics)
|
||||
.where(eq(schemaPics.categoryId, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (existingPics.length === 0) {
|
||||
try {
|
||||
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 || [],
|
||||
};
|
||||
|
||||
await this.db.insert(schemaPics).values({
|
||||
categoryId,
|
||||
imageUrl: imageResult.imageUrl,
|
||||
hotspots: JSON.stringify(hotspotsData),
|
||||
source: "pl24",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`Failed to store schema image for category ${categoryId}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Controller, Post, Get, Delete, Param, Body, Query } from "@nestjs/common";
|
||||
import { VehiclesService } from "./vehicles.service";
|
||||
import { CategoriesService } from "../categories/categories.service";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { VinValidationPipe } from "../common/pipes/vin-validation.pipe";
|
||||
|
||||
@Controller("vehicles")
|
||||
export class VehiclesController {
|
||||
constructor(private vehiclesService: VehiclesService) {}
|
||||
constructor(
|
||||
private vehiclesService: VehiclesService,
|
||||
private categoriesService: CategoriesService,
|
||||
) {}
|
||||
|
||||
@Post("decode")
|
||||
async decode(
|
||||
@@ -28,6 +32,17 @@ export class VehiclesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get(":vehicleId/categories/:categoryId")
|
||||
async getCategoryParts(
|
||||
@Param("vehicleId") vehicleId: string,
|
||||
@Param("categoryId") categoryId: string,
|
||||
@CurrentUser("id") userId: string,
|
||||
) {
|
||||
// Verify vehicle belongs to user
|
||||
await this.vehiclesService.getById(vehicleId, userId);
|
||||
return this.categoriesService.getCategoryWithParts(categoryId);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
async getById(@Param("id") id: string, @CurrentUser("id") userId: string) {
|
||||
return this.vehiclesService.getById(id, userId);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
import { VinApiModule } from "../integrations/vin-api/vin-api.module";
|
||||
import { EmexModule } from "../integrations/emex/emex.module";
|
||||
import { BrandsModule } from "../brands/brands.module";
|
||||
import { CategoriesModule } from "../categories/categories.module";
|
||||
|
||||
@Module({
|
||||
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, BrandsModule],
|
||||
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, BrandsModule, CategoriesModule],
|
||||
controllers: [VehiclesController],
|
||||
providers: [VehiclesService],
|
||||
exports: [VehiclesService],
|
||||
|
||||
@@ -72,9 +72,14 @@ export class VehiclesService {
|
||||
const brandId = brand[0].id;
|
||||
await this.checkBrandAccess(userId, brandId);
|
||||
|
||||
// 4. PL24 decode
|
||||
// 4. PL24 decode (real API)
|
||||
let source = "corgi";
|
||||
const pl24Vehicle = await this.pl24Service.decodeVin(vin, corgiResult.brandName);
|
||||
let pl24Vehicle = null;
|
||||
try {
|
||||
pl24Vehicle = await this.pl24Service.decodeVin(vin);
|
||||
} catch (err) {
|
||||
this.logger.warn(`PL24 decode failed for ${vin}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// 5. Fallback to EMEX if PL24 not available
|
||||
let emexVehicle = null;
|
||||
@@ -83,7 +88,6 @@ export class VehiclesService {
|
||||
try {
|
||||
emexVehicle = await this.emexService.getScrapedVehicle(vin);
|
||||
if (!emexVehicle) {
|
||||
// Queue an async scrape job for future requests
|
||||
await this.emexService.decodeVin(vin, userId);
|
||||
this.logger.log(`EMEX scrape job queued for ${vin}`);
|
||||
}
|
||||
@@ -109,12 +113,12 @@ export class VehiclesService {
|
||||
vin,
|
||||
brandId,
|
||||
brandName: corgiResult.brandName,
|
||||
model: pl24Vehicle?.modelCode || emexVehicle?.modelCode || vinApiData?.model || null,
|
||||
year: pl24Vehicle?.yearFrom || emexVehicle?.yearFrom || corgiResult.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
engine: pl24Vehicle?.engine || emexVehicle?.engine || vinApiData?.engineModel || null,
|
||||
model: pl24Vehicle?.model || emexVehicle?.modelCode || vinApiData?.model || null,
|
||||
year: pl24Vehicle?.year || emexVehicle?.yearFrom || corgiResult.modelYear || (vinApiData ? parseInt(vinApiData.modelYear) : null),
|
||||
engine: pl24Vehicle?.engineType || pl24Vehicle?.engineCode || emexVehicle?.engine || vinApiData?.engineModel || null,
|
||||
transmission: pl24Vehicle?.transmission || vinApiData?.transmissionStyle || null,
|
||||
bodyType: pl24Vehicle?.bodyType || vinApiData?.bodyClass || null,
|
||||
market: pl24Vehicle?.market || null,
|
||||
market: null as string | null,
|
||||
rawData: pl24Vehicle || emexVehicle?.rawData || vinApiData || null,
|
||||
source,
|
||||
updatedAt: new Date(),
|
||||
@@ -122,7 +126,6 @@ export class VehiclesService {
|
||||
|
||||
let savedVehicle;
|
||||
if (cached.length > 0) {
|
||||
// Update existing cache
|
||||
const [updated] = await this.db
|
||||
.update(vehicles)
|
||||
.set(vehicleData)
|
||||
@@ -130,7 +133,6 @@ export class VehiclesService {
|
||||
.returning();
|
||||
savedVehicle = updated;
|
||||
} else {
|
||||
// Insert new
|
||||
const [inserted] = await this.db.insert(vehicles).values(vehicleData).returning();
|
||||
savedVehicle = inserted;
|
||||
}
|
||||
@@ -173,7 +175,6 @@ export class VehiclesService {
|
||||
}
|
||||
|
||||
private async checkBrandAccess(userId: string, brandId: string) {
|
||||
// Check active subscription
|
||||
const [sub] = await this.db
|
||||
.select()
|
||||
.from(userSubscriptions)
|
||||
@@ -184,7 +185,6 @@ export class VehiclesService {
|
||||
throw new ForbiddenException("No active subscription. Please subscribe to access vehicle data.");
|
||||
}
|
||||
|
||||
// Check brand access
|
||||
const [access] = await this.db
|
||||
.select()
|
||||
.from(userBrands)
|
||||
|
||||
Reference in New Issue
Block a user