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:
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user