feat: add parts catalogs integration, catalog prefetch worker, and vehicle select modal

Integrate external parts catalogs API with auth service, add BullMQ-based
catalog prefetch worker for background data caching, expand vehicles service
with shared vehicle support, and add vehicle select modal to frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sase Dev
2026-02-20 18:14:36 +00:00
parent f3f3f57756
commit f1a27810db
32 changed files with 2591 additions and 118 deletions

View File

@@ -3,9 +3,10 @@ import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
@Module({
imports: [PL24Module, EmexModule],
imports: [PL24Module, EmexModule, PartsCatalogsModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -18,7 +18,11 @@ function createService(db: any) {
const storage = {
upload: vi.fn().mockResolvedValue("https://storage.sase.tr/sase-schemas/test.png"),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, storage as any);
const partsCatalogsService = {
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);
return { service, db, redis, pl24Service };
}

View File

@@ -5,6 +5,7 @@ import { categories, vehicles, schemaPics, parts } from "../database/schema/core
import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { EmexService } from "../integrations/emex/emex.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
@@ -16,6 +17,7 @@ export class CategoriesService {
private redis: RedisService,
private pl24Service: PL24Service,
private emexService: EmexService,
private partsCatalogsService: PartsCatalogsService,
private storage: StorageService,
) {}
@@ -80,6 +82,46 @@ export class CategoriesService {
}
}
// If still no categories, try PartsCatalogs
if (dbCategories.length === 0 && vehicle.rawData) {
const rawData = vehicle.rawData as any;
if (rawData?.source === "parts-catalogs" && rawData.catalogId && rawData.carId) {
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
const groups = await this.partsCatalogsService.fetchGroups(
rawData.catalogId,
rawData.carId,
undefined,
carParams,
);
if (groups.length > 0) {
const insertData = groups.map((g) => ({
vehicleId,
name: g.name,
nameOriginal: g.name,
parentId: null as string | null,
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
source: "parts-catalogs" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
if (dbCategories.length < insertData.length) {
dbCategories = await this.db
.select()
.from(categories)
.where(eq(categories.vehicleId, vehicleId));
}
this.logger.log(`Stored ${dbCategories.length} PartsCatalogs categories for ${vehicle.vin}`);
}
} catch (err) {
this.logger.warn(`PartsCatalogs category fetch failed for ${vehicleId}: ${(err as Error).message}`);
}
}
}
// If still no categories, try EMEX fallback
if (dbCategories.length === 0 && vehicle.vin) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
@@ -219,6 +261,51 @@ export class CategoriesService {
const catalogInfo = rawData?.catalogInfo;
const linkPath = category.linkPath;
// PartsCatalogs subgroups (on-demand drill-down)
if (category.source === "parts-catalogs" && rawData?.source === "parts-catalogs" && category.externalId) {
try {
const carParams = this.buildPcatCarParams(rawData.parameters);
const subGroups = await this.partsCatalogsService.fetchGroups(
rawData.catalogId,
rawData.carId,
category.externalId,
carParams,
);
if (subGroups.length > 0) {
const insertData = subGroups.map((g) => ({
vehicleId: category.vehicleId,
name: g.name,
nameOriginal: g.name,
parentId: categoryId,
externalId: g.id,
linkPath: `pcat:${rawData.catalogId}:${rawData.carId}:${g.id}`,
linkWid: null as string | null,
source: "parts-catalogs" as const,
}));
children = await this.db
.insert(categories)
.values(insertData)
.onConflictDoNothing()
.returning();
if (children.length < insertData.length) {
children = await this.db
.select()
.from(categories)
.where(eq(categories.parentId, categoryId));
}
await this.redis.del(`cat:tree:${category.vehicleId}`);
}
} catch (err) {
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 (!catalogInfo?.serviceName || !linkPath) {
return [];
}
@@ -290,11 +377,16 @@ export class CategoriesService {
if (!category) throw new NotFoundException("Kategori bulunamadı");
// Check if this category has children
const children = await this.db
let 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,
@@ -333,7 +425,117 @@ export class CategoriesService {
.where(eq(vehicles.id, category.vehicleId))
.limit(1);
if (vehicle && category.source === "emex") {
if (vehicle && category.source === "parts-catalogs" && category.linkPath?.startsWith("pcat:")) {
// PartsCatalogs: fetch parts + schema image via API
try {
// Format: pcat:{catalogId}:{carId}:{groupId} — split only on first 3 colons
const withoutPrefix = category.linkPath.slice("pcat:".length);
const firstColon = withoutPrefix.indexOf(":");
const secondColon = withoutPrefix.indexOf(":", firstColon + 1);
const catalogId = withoutPrefix.slice(0, firstColon);
const carId = withoutPrefix.slice(firstColon + 1, secondColon);
const groupId = withoutPrefix.slice(secondColon + 1);
const vehicleRawData = vehicle.rawData as any;
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
const partsResult = await this.partsCatalogsService.fetchParts(
catalogId,
carId,
groupId,
carParams,
);
if (partsResult) {
// Flatten part groups into parts
if (needParts) {
const allParts: Array<typeof parts.$inferInsert> = [];
for (const pg of partsResult.partGroups) {
for (const p of pg.parts) {
if (!p.number) continue;
const posNum = p.positionNumber || pg.positionNumber || null;
allParts.push({
vehicleId: vehicle.id,
categoryId,
oemCode: p.number,
name: p.name || "Unknown",
nameOriginal: p.name || null,
description: p.notice || null,
quantity: null,
position: posNum,
hotspotIndex: posNum ? parseInt(posNum, 10) || null : null,
unavailable: false,
remark: null as string | null,
modelCodes: null as string | null,
presel: false,
source: "parts-catalogs" as const,
});
}
}
if (allParts.length > 0) {
dbParts = await this.db.insert(parts).values(allParts).returning();
this.logger.log(`Stored ${dbParts.length} PartsCatalogs parts for category ${categoryId}`);
}
}
// Download schema image and store hotspots
if (needImage && partsResult.img) {
try {
const imgUrl = partsResult.img.startsWith("//") ? `https:${partsResult.img}` : partsResult.img;
const imgResp = await fetch(imgUrl, {
signal: AbortSignal.timeout(15000),
});
if (imgResp.ok) {
const buf = Buffer.from(await imgResp.arrayBuffer());
const ext = partsResult.img.includes(".gif") ? "gif" : "png";
const key = `schemas/pcat-${categoryId}.${ext}`;
const minioUrl = await this.storage.upload(key, buf, `image/${ext}`);
const dims = this.getImageDimensions(buf, ext);
// Convert positions to hotspot format
const hotspotItems = partsResult.positions.map((pos) => ({
key: pos.number,
label: pos.number,
areas: [
{
left: pos.coordinates[0],
top: pos.coordinates[1],
width: pos.coordinates[2],
height: pos.coordinates[3],
},
],
}));
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: minioUrl,
hotspots: JSON.stringify({
width: dims.width,
height: dims.height,
items: hotspotItems,
}),
source: "parts-catalogs",
})
.returning();
pics.push(inserted);
this.logger.log(
`Stored PartsCatalogs schema image for category ${categoryId}: ${minioUrl}`,
);
}
} catch (imgErr) {
this.logger.warn(`Failed to download PC schema image: ${(imgErr as Error).message}`);
}
}
}
} catch (err) {
this.logger.error(`Failed to fetch PartsCatalogs parts for category ${categoryId}: ${(err as Error).message}`);
}
} else if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
try {
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
@@ -585,9 +787,12 @@ export class CategoriesService {
const dbChildCount = childCountMap.get(c.id) || 0;
// EMEX: leaf only if linkPath exists and no DB children
// PL24: leaf if BOM/servicepart-items linkPath, or no linkPath and no DB children
// PartsCatalogs: leaf if linkPath starts with "pcat:" and no DB children
const isLeaf = c.source === "emex"
? (!!c.linkPath && 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.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));
return {
...c,
schemaImageUrl: picMap.get(c.id) || null,
@@ -596,6 +801,21 @@ export class CategoriesService {
});
}
/**
* Build query params from PartsCatalogs car parameters.
* Parameters are [{key, idx, value}] — API expects {key: idx} as query params.
*/
private buildPcatCarParams(parameters?: Array<{ key: string; idx: string; value: string }>): Record<string, string> {
if (!parameters || !Array.isArray(parameters)) return {};
const params: Record<string, string> = {};
for (const p of parameters) {
if (p.key && p.idx) {
params[p.key] = p.idx;
}
}
return params;
}
private getImageDimensions(buf: Buffer, ext: string): { width: number; height: number } {
try {
if (ext === 'gif' && buf.length >= 10) {