Merge dev into main: pcat catalog + EMEX VIN decode + autonomy charter
Some checks failed
Deploy / Deploy to Production (push) Has been cancelled

Brings 35 dev branch commits onto main, integrating with the 32
main-side commits (Sentry, translation pipeline, deploy fixes).
Critical merge across VIN decode pipeline and catalog services.

# Conflicts:
#	.gitignore
#	apps/api/src/catalog/catalog.module.ts
#	apps/api/src/categories/categories.module.ts
#	apps/api/src/categories/categories.service.spec.ts
#	apps/api/src/common/filters/http-exception.filter.ts
#	apps/api/src/integrations/emex/emex.service.ts
#	apps/api/src/integrations/emex/emex.types.ts
#	apps/api/src/jobs/processors/emex-scrape.processor.ts
#	apps/api/src/vehicles/vehicles.controller.ts
#	apps/api/src/vehicles/vehicles.module.ts
#	apps/api/src/vehicles/vehicles.service.ts
#	apps/web/src/routes/dashboard.tsx
#	apps/web/src/routes/dashboard/catalog/index.tsx
This commit is contained in:
Semih
2026-05-10 23:57:09 +00:00
49 changed files with 5339 additions and 67 deletions

View File

@@ -59,6 +59,7 @@
"postgres": "^3.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"undici": "^7.22.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -73,7 +74,6 @@
"playwright": "^1.50.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"undici": "^7.22.0",
"vitest": "^3.0.0"
}
}

View File

@@ -4,11 +4,15 @@ import { StorageModule } from "../storage/storage.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { CatalogController } from "./catalog.controller";
import { CatalogService } from "./catalog.service";
import { EmexCatalogController } from "./emex-catalog.controller";
import { EmexCatalogService } from "./emex-catalog.service";
import { PcatCatalogController } from "./pcat-catalog.controller";
import { PcatCatalogService } from "./pcat-catalog.service";
@Module({
imports: [PL24Module, SubscriptionsModule, StorageModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
controllers: [CatalogController, EmexCatalogController, PcatCatalogController],
providers: [CatalogService, EmexCatalogService, PcatCatalogService],
exports: [CatalogService, EmexCatalogService, PcatCatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,51 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { EmexCatalogService } from "./emex-catalog.service";
@Controller("catalog/emex")
export class EmexCatalogController {
constructor(private emexCatalogService: EmexCatalogService) {}
@Get("brands")
getBrands() {
return this.emexCatalogService.getBrands();
}
@Get("brands/:code/vehicles")
getVehicles(@Param("code") code: string) {
return this.emexCatalogService.getVehicles(code);
}
@Get("brands/:code/wizard")
getWizard(@Param("code") code: string, @Query("ssd") ssd?: string) {
return this.emexCatalogService.getWizard(code, ssd || "");
}
@Get("brands/:code/wizard-vehicles")
getWizardVehicles(
@Param("code") code: string,
@Query("name") name: string,
@Query("model") model?: string,
) {
return this.emexCatalogService.getWizardVehicles(code, name, model);
}
@Get("vehicles/:id/groups")
getVehicleGroups(@Param("id") id: string) {
return this.emexCatalogService.getVehicleGroups(id);
}
@Get("vehicles/:id/groups/:groupId")
getGroupParts(@Param("id") id: string, @Param("groupId") groupId: string) {
return this.emexCatalogService.getGroupParts(id, groupId);
}
@Get("search")
searchByOem(@Query("oem") oem: string) {
return this.emexCatalogService.searchByOem(oem);
}
@Get("match")
matchByName(@Query("catalogCode") catalogCode: string, @Query("name") name: string) {
return this.emexCatalogService.matchByName(catalogCode, name);
}
}

View File

@@ -0,0 +1,696 @@
import { Inject, Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import {
emexCatalogs,
emexPartGroups,
emexPartNumbers,
emexParts,
emexSchemaPics,
emexVehicleGroupLinks,
emexVehiclePartLinks,
emexVehicleVins,
emexVehicles,
} from "../database/schema/emex";
import { RedisService } from "../redis/redis.service";
export interface EmexBrandDto {
id: string;
catalogId: string;
code: string | null;
brandName: string;
description: string | null;
}
export interface EmexVehicleDto {
id: string;
vehicleId: string;
name: string | null;
engine: string | null;
engineCode: string | null;
bodyType: string | null;
transmission: string | null;
driveType: string | null;
fuelType: string | null;
yearFrom: number | null;
yearTo: number | null;
optionsRaw: string | null;
}
export interface EmexGroupDto {
id: string;
groupId: string;
name: string;
nameOriginal: string | null;
parentGroupId: string | null;
sortOrder: number | null;
hasParts: boolean | null;
hasChildren: boolean | null;
}
interface EmexPartDto {
id: string;
name: string;
oemCode: string;
quantity: number;
position: string | null;
hotspotIndex: number | null;
}
interface EmexSchemaPicDto {
id: string;
url: string;
width: number;
height: number;
label: string;
}
export interface EmexGroupPartsDto {
group: EmexGroupDto;
parts: EmexPartDto[];
schemaPics: EmexSchemaPicDto[];
}
export interface EmexVehicleMatch {
vehicleId: string;
catalogCode: string;
vehicleName: string | null;
candidates: EmexVehicleDto[];
/** When DB has no match but wizard found the model, provides a QuickGroups URL for on-demand category fetch */
wizardQuickGroupsUrl?: string | null;
/** Brand name from emex_catalogs (populated by lookupVinCache) */
brandName?: string | null;
}
export interface EmexSearchResult {
partId: string;
partNumber: string | null;
name: string;
catalogCode: string | null;
brandName: string | null;
groupName: string | null;
}
const CACHE_TTL = {
brands: 86400, // 24h
vehicles: 43200, // 12h
groups: 21600, // 6h
parts: 7200, // 2h
match: 3600, // 1h
};
@Injectable()
export class EmexCatalogService {
private readonly logger = new Logger(EmexCatalogService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
) {}
// ── Katalog Browse ──────────────────────────────────
async getBrands(): Promise<EmexBrandDto[]> {
const cacheKey = "emex:brands";
const cached = await this.redis.getJson<EmexBrandDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db
.select({
id: emexCatalogs.id,
catalogId: emexCatalogs.catalogId,
code: emexCatalogs.code,
brandName: emexCatalogs.brandName,
description: emexCatalogs.description,
})
.from(emexCatalogs)
.orderBy(emexCatalogs.brandName);
await this.redis.setJson(cacheKey, rows, CACHE_TTL.brands);
return rows;
}
async getVehicles(catalogCode: string): Promise<EmexVehicleDto[]> {
const cacheKey = `emex:vehicles:${catalogCode}`;
const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey);
if (cached) return cached;
const catalog = await this.db
.select({ id: emexCatalogs.id })
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, catalogCode))
.limit(1);
if (catalog.length === 0) return [];
const rows = await this.db
.select({
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
})
.from(emexVehicles)
.where(eq(emexVehicles.catalogId, catalog[0].id))
.orderBy(emexVehicles.name);
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
return rows;
}
async getVehicleGroups(vehicleId: string): Promise<EmexGroupDto[]> {
const cacheKey = `emex:groups:${vehicleId}`;
const cached = await this.redis.getJson<EmexGroupDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db
.select({
id: emexPartGroups.id,
groupId: emexPartGroups.groupId,
name: emexPartGroups.name,
nameOriginal: emexPartGroups.nameOriginal,
parentGroupId: emexPartGroups.parentGroupId,
sortOrder: emexPartGroups.sortOrder,
hasParts: emexPartGroups.hasParts,
hasChildren: emexPartGroups.hasChildren,
})
.from(emexVehicleGroupLinks)
.innerJoin(emexPartGroups, eq(emexVehicleGroupLinks.emexGroupId, emexPartGroups.id))
.where(eq(emexVehicleGroupLinks.emexVehicleId, vehicleId))
.orderBy(emexPartGroups.sortOrder, emexPartGroups.name);
await this.redis.setJson(cacheKey, rows, CACHE_TTL.groups);
return rows;
}
async getGroupParts(vehicleId: string, groupId: string): Promise<EmexGroupPartsDto> {
const cacheKey = `emex:parts:${vehicleId}:${groupId}`;
const cached = await this.redis.getJson<EmexGroupPartsDto>(cacheKey);
if (cached) return cached;
// Get group info
const [group] = await this.db
.select({
id: emexPartGroups.id,
groupId: emexPartGroups.groupId,
name: emexPartGroups.name,
nameOriginal: emexPartGroups.nameOriginal,
parentGroupId: emexPartGroups.parentGroupId,
sortOrder: emexPartGroups.sortOrder,
hasParts: emexPartGroups.hasParts,
hasChildren: emexPartGroups.hasChildren,
})
.from(emexPartGroups)
.where(eq(emexPartGroups.id, groupId))
.limit(1);
if (!group) {
return { group: null as unknown as EmexGroupDto, parts: [], schemaPics: [] };
}
// Get parts for this vehicle+group via junction
const parts = await this.db
.select({
id: emexParts.id,
partNumber: emexParts.partNumber,
name: emexParts.name,
nameOriginal: emexParts.nameOriginal,
description: emexParts.description,
oemNumber: emexParts.oemNumber,
hotspotIndex: emexParts.hotspotIndex,
quantity: emexVehiclePartLinks.quantity,
position: emexVehiclePartLinks.position,
})
.from(emexVehiclePartLinks)
.innerJoin(emexParts, eq(emexVehiclePartLinks.emexPartId, emexParts.id))
.where(
and(
eq(emexVehiclePartLinks.emexVehicleId, vehicleId),
eq(emexVehiclePartLinks.emexGroupId, groupId),
),
)
.orderBy(emexParts.hotspotIndex, emexParts.name);
// Get schema pics for this group
const schemaPics = await this.db
.select({
id: emexSchemaPics.id,
imageUrl: emexSchemaPics.imageUrl,
localPath: emexSchemaPics.localPath,
hotspots: emexSchemaPics.hotspots,
width: emexSchemaPics.width,
height: emexSchemaPics.height,
sortOrder: emexSchemaPics.sortOrder,
})
.from(emexSchemaPics)
.where(eq(emexSchemaPics.groupId, groupId))
.orderBy(emexSchemaPics.sortOrder);
// Transform to SchemaViewer-compatible format
const formattedParts: EmexPartDto[] = parts.map((p) => ({
id: p.id,
name: p.name,
oemCode: p.partNumber || p.oemNumber || "",
quantity: p.quantity ?? 1,
position: p.position,
hotspotIndex: p.position ? Number.parseInt(p.position, 10) || null : null,
}));
const formattedPics: EmexSchemaPicDto[] = schemaPics.map((sp) => ({
id: sp.id,
url: sp.imageUrl || "",
width: sp.width ?? 0,
height: sp.height ?? 0,
label: group.name || "",
}));
const result: EmexGroupPartsDto = { group, parts: formattedParts, schemaPics: formattedPics };
await this.redis.setJson(cacheKey, result, CACHE_TTL.parts);
return result;
}
// ── Wizard (proxy emexdwc.ae GetWizard2) ─────────────
private static readonly EMEX_BASE = "https://emexdwc.ae";
private static readonly EMEX_HDR: Record<string, string> = {
Accept: "application/json",
Referer: "https://emexdwc.ae/CatalogParamSearch.aspx",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
};
async getWizard(catalogCode: string, ssd: string): Promise<unknown> {
const cacheKey = `emex:wizard:${catalogCode}:${ssd || "_root"}`;
const cached = await this.redis.getJson(cacheKey);
if (cached) return cached;
const params = new URLSearchParams({
catalogCode,
ssd,
_tstamp: String(Date.now()),
});
const url = `${EmexCatalogService.EMEX_BASE}/api/Catalog.svc/GetWizard2?${params}`;
try {
const res = await fetch(url, {
headers: EmexCatalogService.EMEX_HDR,
signal: AbortSignal.timeout(15000),
});
if (!res.ok) {
throw new Error(`EMEX wizard HTTP ${res.status}`);
}
const data = await res.json();
await this.redis.setJson(cacheKey, data, 3600); // 1h cache
return data;
} catch (err) {
this.logger.error(`getWizard failed: ${(err as Error).message}`);
throw new ServiceUnavailableException("EMEX wizard servisi kulanilamiyor");
}
}
/**
* Find vehicles in our DB that match a wizard selection.
* Tries: exact name match → name prefix → model-based fuzzy match.
* `name` is the "Sales Designation" from the wizard.
* `model` is the "Model" parameter from the wizard (optional).
*/
async getWizardVehicles(
catalogCode: string,
name: string,
model?: string,
): Promise<EmexVehicleDto[]> {
const hashInput = `${name}|${model || ""}`;
const cacheKey = `emex:wv:${catalogCode}:${Buffer.from(hashInput).toString("base64url").slice(0, 40)}`;
const cached = await this.redis.getJson<EmexVehicleDto[]>(cacheKey);
if (cached) return cached;
const [catalog] = await this.db
.select({ id: emexCatalogs.id })
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, catalogCode))
.limit(1);
if (!catalog) return [];
const vehicleCols = {
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
};
// 1. Exact match on Sales Designation name
let rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), eq(emexVehicles.name, name)))
.orderBy(emexVehicles.optionsRaw);
// 2. Prefix match on name
if (rows.length === 0) {
rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, `${name}%`)))
.orderBy(emexVehicles.optionsRaw)
.limit(100);
}
// 3. Model-based fuzzy match: extract short prefix from model name
// e.g. model="A3 Cabriolet" → search for names containing "A3 Cab"
if (rows.length === 0 && model) {
// Build search patterns from model name
// "A3 Cabriolet" → try "A3 Cab", "A3 Cabrio", "A3"
const words = model.split(/[\s/]+/);
const patterns: string[] = [];
if (words.length >= 2) {
patterns.push(`%${words[0]} ${words[1].slice(0, 3)}%`);
patterns.push(`%${words[0]} ${words[1].slice(0, 6)}%`);
}
patterns.push(`%${words[0]}%`);
for (const pattern of patterns) {
rows = await this.db
.select(vehicleCols)
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, pattern)))
.orderBy(emexVehicles.optionsRaw)
.limit(100);
if (rows.length > 0) break;
}
}
if (rows.length > 0) {
await this.redis.setJson(cacheKey, rows, CACHE_TTL.vehicles);
}
return rows;
}
/**
* Match a VIN-decoded vehicle to DB using wizard API flow.
* VIN-decode SSDs are too specific for GetWizard2 (returns HTML instead of JSON).
* So we start from root SSD ("") and walk wizard steps until we find a model
* option matching pathData (e.g. "Focus CB4 2008-2011"), then use getWizardVehicles
* to match motor variants against DB.
*/
async matchBySsd(
catalogCode: string,
_ssd: string,
pathData?: string,
): Promise<EmexVehicleMatch | null> {
const pathName = pathData?.replace(/^Name:\s*/i, "").trim();
if (!pathName) {
this.logger.log("matchBySsd: no pathData name, skipping");
return null;
}
const cacheKey = `emex:matchssd:${catalogCode}:${Buffer.from(pathName).toString("base64url").slice(0, 32)}`;
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
if (cached) return cached;
try {
// Walk wizard from root until we find pathName in model options
let currentSsd = "";
const maxSteps = 5;
for (let i = 0; i < maxSteps; i++) {
let wizardData: any;
try {
wizardData = await this.getWizard(catalogCode, currentSsd);
} catch {
this.logger.warn(`matchBySsd: wizard call failed at step ${i}`);
return null;
}
const steps = Array.isArray(wizardData) ? wizardData : [];
if (steps.length === 0) break;
let found = false;
let advanced = false;
for (const step of steps) {
const options = step.options as Array<{ key: string; value: string }> | undefined;
if (!options?.length) continue;
// Look for pathName in this step's options
const exact = options.find((o) => o.value === pathName);
if (exact) {
this.logger.log(`matchBySsd: found "${pathName}" in wizard step "${step.name}"`);
// Use getWizardVehicles which does DB matching with model name
const dbVehicles = await this.getWizardVehicles(catalogCode, pathName);
if (dbVehicles.length > 0) {
const match: EmexVehicleMatch = {
vehicleId: dbVehicles[0].id,
catalogCode,
vehicleName: pathName,
candidates: dbVehicles,
};
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
this.logger.log(
`matchBySsd: DB match via wizard — "${pathName}" → ${dbVehicles.length} candidates`,
);
return match;
}
// pathName found in wizard but no DB match — still no categories available
this.logger.log(`matchBySsd: "${pathName}" found in wizard but no DB match`);
return null;
}
// Not found in this step — if step is undetermined, try advancing
// Pick the first option that's likely correct (e.g. "Europe", "Passenger")
if (!step.determined && !advanced) {
// Heuristic: pick common values for region/vehicle type
const preferred = ["Europe", "Passenger"];
const pick = options.find((o) => preferred.includes(o.value)) || options[0];
currentSsd = pick.key;
advanced = true;
this.logger.log(`matchBySsd: advancing wizard "${step.name}" → "${pick.value}"`);
}
found = found || options.length > 0;
}
if (!advanced) break; // All steps determined, nowhere to advance
}
this.logger.log(`matchBySsd: "${pathName}" not found in wizard for c=${catalogCode}`);
return null;
} catch (err) {
this.logger.warn(`matchBySsd failed: ${(err as Error).message}`);
return null;
}
}
async searchByOem(query: string): Promise<EmexSearchResult[]> {
if (!query || query.length < 3) return [];
const cleanQuery = query.replace(/[-\s.]/g, "").toUpperCase();
// Search in emex_part_numbers
const results = await this.db
.select({
partId: emexParts.id,
partNumber: emexParts.partNumber,
name: emexParts.name,
catalogCode: emexCatalogs.code,
brandName: emexCatalogs.brandName,
groupName: emexPartGroups.name,
})
.from(emexPartNumbers)
.innerJoin(emexParts, eq(emexPartNumbers.emexPartId, emexParts.id))
.leftJoin(emexCatalogs, eq(emexParts.emexCatalogId, emexCatalogs.id))
.leftJoin(emexPartGroups, eq(emexParts.groupId, emexPartGroups.id))
.where(
or(
ilike(emexPartNumbers.oemCode, `%${cleanQuery}%`),
ilike(emexParts.partNumber, `%${cleanQuery}%`),
ilike(emexParts.oemNumber, `%${cleanQuery}%`),
),
)
.limit(50);
return results;
}
// ── VIN Decode Eslestirme ────────────────────────────
async matchByName(catalogCode: string, vehicleName: string): Promise<EmexVehicleMatch | null> {
const nameHash = Buffer.from(vehicleName).toString("base64url").slice(0, 32);
const cacheKey = `emex:match:${catalogCode}:${nameHash}`;
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
if (cached) return cached;
// Find catalog
const [catalog] = await this.db
.select({ id: emexCatalogs.id })
.from(emexCatalogs)
.where(eq(emexCatalogs.catalogId, catalogCode))
.limit(1);
if (!catalog) return null;
// Search vehicles by name (exact or contains)
const candidates = await this.db
.select({
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
})
.from(emexVehicles)
.where(and(eq(emexVehicles.catalogId, catalog.id), eq(emexVehicles.name, vehicleName)))
.orderBy(emexVehicles.optionsRaw);
if (candidates.length === 0) {
// Try partial match: extract model name before brackets
const modelMatch = vehicleName.match(/^([^\[]+)/);
if (modelMatch) {
const modelName = modelMatch[1].trim();
const partialCandidates = await this.db
.select({
id: emexVehicles.id,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
})
.from(emexVehicles)
.where(
and(eq(emexVehicles.catalogId, catalog.id), ilike(emexVehicles.name, `${modelName}%`)),
)
.orderBy(emexVehicles.optionsRaw)
.limit(50);
if (partialCandidates.length === 0) return null;
const match: EmexVehicleMatch = {
vehicleId: partialCandidates[0].id,
catalogCode,
vehicleName,
candidates: partialCandidates,
};
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
return match;
}
return null;
}
const match: EmexVehicleMatch = {
vehicleId: candidates[0].id,
catalogCode,
vehicleName,
candidates,
};
await this.redis.setJson(cacheKey, match, CACHE_TTL.match);
return match;
}
// ── VIN Cache ──────────────────────────────────────
/**
* Look up a VIN in the emex_vehicle_vins cache table.
* Returns the matched EmexVehicleMatch if found, null otherwise.
*/
async lookupVinCache(vin: string): Promise<EmexVehicleMatch | null> {
const cacheKey = `emex:vincache:${vin}`;
const cached = await this.redis.getJson<EmexVehicleMatch>(cacheKey);
if (cached) return cached;
const [row] = await this.db
.select({
emexVehicleId: emexVehicleVins.emexVehicleId,
vehicleId: emexVehicles.vehicleId,
name: emexVehicles.name,
engine: emexVehicles.engine,
engineCode: emexVehicles.engineCode,
bodyType: emexVehicles.bodyType,
transmission: emexVehicles.transmission,
driveType: emexVehicles.driveType,
fuelType: emexVehicles.fuelType,
yearFrom: emexVehicles.yearFrom,
yearTo: emexVehicles.yearTo,
optionsRaw: emexVehicles.optionsRaw,
catalogId: emexCatalogs.catalogId,
brandName: emexCatalogs.brandName,
})
.from(emexVehicleVins)
.innerJoin(emexVehicles, eq(emexVehicleVins.emexVehicleId, emexVehicles.id))
.innerJoin(emexCatalogs, eq(emexVehicles.catalogId, emexCatalogs.id))
.where(eq(emexVehicleVins.vin, vin))
.limit(1);
if (!row) return null;
const candidate: EmexVehicleDto = {
id: row.emexVehicleId!,
vehicleId: row.vehicleId,
name: row.name,
engine: row.engine,
engineCode: row.engineCode,
bodyType: row.bodyType,
transmission: row.transmission,
driveType: row.driveType,
fuelType: row.fuelType,
yearFrom: row.yearFrom,
yearTo: row.yearTo,
optionsRaw: row.optionsRaw,
};
const match: EmexVehicleMatch = {
vehicleId: row.emexVehicleId!,
catalogCode: row.catalogId,
vehicleName: row.name,
candidates: [candidate],
brandName: row.brandName,
};
await this.redis.setJson(cacheKey, match, 86400); // 24h
return match;
}
/**
* Save a VIN → emex_vehicle mapping to the cache table.
* Uses ON CONFLICT DO NOTHING to handle concurrent inserts.
*/
async saveVinCache(vin: string, emexVehicleId: string): Promise<void> {
try {
await this.db.insert(emexVehicleVins).values({ vin, emexVehicleId }).onConflictDoNothing();
this.logger.log(`VIN cache saved: ${vin}${emexVehicleId}`);
// Invalidate Redis cache so next lookup picks up the DB row
await this.redis.del(`emex:vincache:${vin}`);
} catch (err) {
this.logger.warn(`VIN cache save failed: ${(err as Error).message}`);
}
}
}

View File

@@ -0,0 +1,42 @@
import { Controller, Get, Param, Query } from "@nestjs/common";
import { PcatCatalogService } from "./pcat-catalog.service";
@Controller("catalog/pcat")
export class PcatCatalogController {
constructor(private pcatCatalogService: PcatCatalogService) {}
@Get("catalogs")
getCatalogs() {
return this.pcatCatalogService.getCatalogs();
}
@Get("catalogs/:catalogId/models")
getModels(@Param("catalogId") catalogId: string) {
return this.pcatCatalogService.getModels(catalogId);
}
@Get("catalogs/:catalogId/groups")
getGroups(@Param("catalogId") catalogId: string, @Query("parentId") parentId?: string) {
return this.pcatCatalogService.getCarGroups("", parentId);
}
@Get("catalogs/:catalogId/models/:modelId/cars")
getCars(@Param("modelId") modelId: string) {
return this.pcatCatalogService.getCars(modelId);
}
@Get("cars/:carId/groups")
getCarGroups(@Param("carId") carId: string, @Query("parentId") parentId?: string) {
return this.pcatCatalogService.getCarGroups(carId, parentId);
}
@Get("cars/:carId/groups/:groupId/schemas")
getSchemaImages(@Param("carId") carId: string, @Param("groupId") groupId: string) {
return this.pcatCatalogService.getSchemaImages(carId, groupId);
}
@Get("schemas/:schemaImageId")
getSchemaDetail(@Param("schemaImageId") schemaImageId: string) {
return this.pcatCatalogService.getSchemaDetail(schemaImageId);
}
}

View File

@@ -0,0 +1,404 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import { RedisService } from "../redis/redis.service";
export interface PcatCatalogDto {
id: string;
name: string;
brand: string | null;
imgUrl: string | null;
modelsCount: number;
carsCount: number;
}
export interface PcatModelDto {
id: string;
catalogId: string;
name: string;
imgUrl: string | null;
yearFrom: number | null;
yearTo: number | null;
carsCount: number;
}
export interface PcatCarDto {
id: string;
modelId: string;
name: string;
yearFrom: number | null;
yearTo: number | null;
engine: string | null;
transmission: string | null;
bodyType: string | null;
fuelType: string | null;
driveType: string | null;
steering: string | null;
schemasCount: number;
partsCount: number;
}
export interface PcatGroupDto {
id: string;
catalogId: string;
parentId: string | null;
name: string;
imgUrl: string | null;
hasSubgroups: boolean;
hasParts: boolean;
}
export interface PcatSchemaImageDto {
id: string;
name: string | null;
imgUrl: string | null;
partsCount: number;
}
export interface PcatSchemaDetailDto {
schemaImage: PcatSchemaImageDto;
parts: {
id: string;
name: string;
oemCode: string;
quantity: number;
position: string | null;
hotspotIndex: number | null;
}[];
schemaPics: { id: string; url: string; width: number; height: number; label: string }[];
hotspots: {
id: string;
key: string;
group: number;
shape: "rect";
coordinates: number[];
label: string;
}[];
}
const CACHE_PREFIX = "pcat2"; // bumped to invalidate stale cache from v1 queries
const CACHE_TTL = {
catalogs: 86400,
models: 43200,
cars: 21600,
groups: 21600,
schemas: 7200,
schemaDetail: 7200,
};
@Injectable()
export class PcatCatalogService {
private readonly logger = new Logger(PcatCatalogService.name);
constructor(
@Inject(DATABASE) private db: Database,
private redis: RedisService,
) {}
async getCatalogs(): Promise<PcatCatalogDto[]> {
const cacheKey = `${CACHE_PREFIX}:catalogs`;
const cached = await this.redis.getJson<PcatCatalogDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
name: string;
brand: string | null;
img_url: string | null;
models_count: number;
cars_count: number;
}>(
sql`SELECT id, name, brand, img_url, models_count, cars_count FROM pc.catalogs WHERE is_active = true ORDER BY name`,
);
const result: PcatCatalogDto[] = rows.map((r) => ({
id: r.id,
name: r.name,
brand: r.brand,
imgUrl: r.img_url,
modelsCount: r.models_count ?? 0,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.catalogs);
return result;
}
async getModels(catalogId: string): Promise<PcatModelDto[]> {
const cacheKey = `${CACHE_PREFIX}:models:${catalogId}`;
const cached = await this.redis.getJson<PcatModelDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
catalog_id: string;
name: string;
img_url: string | null;
year_from: number | null;
year_to: number | null;
cars_count: number;
}>(
sql`SELECT id, catalog_id, name, img_url, year_from, year_to, cars_count FROM pc.models WHERE catalog_id = ${catalogId} AND is_active = true ORDER BY name`,
);
const result: PcatModelDto[] = rows.map((r) => ({
id: r.id,
catalogId: r.catalog_id,
name: r.name,
imgUrl: r.img_url,
yearFrom: r.year_from,
yearTo: r.year_to,
carsCount: r.cars_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.models);
return result;
}
async getCars(modelId: string): Promise<PcatCarDto[]> {
const cacheKey = `${CACHE_PREFIX}:cars:${modelId}`;
const cached = await this.redis.getJson<PcatCarDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: string;
model_id: string;
name: string;
year_from: number | null;
year_to: number | null;
engine: string | null;
transmission: string | null;
body_type: string | null;
fuel_type: string | null;
drive_type: string | null;
steering: string | null;
schemas_count: number;
parts_count: number;
}>(
sql`SELECT id, model_id, name, year_from, year_to, engine, transmission, body_type, fuel_type, drive_type, steering, schemas_count, parts_count
FROM pc.cars WHERE model_id = ${modelId} AND is_active = true
ORDER BY year_from DESC NULLS LAST, name`,
);
const result: PcatCarDto[] = rows.map((r) => ({
id: r.id,
modelId: r.model_id,
name: r.name,
yearFrom: r.year_from,
yearTo: r.year_to,
engine: r.engine,
transmission: r.transmission,
bodyType: r.body_type,
fuelType: r.fuel_type,
driveType: r.drive_type,
steering: r.steering,
schemasCount: r.schemas_count ?? 0,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.cars);
return result;
}
async getCarGroups(carId: string, parentId?: string): Promise<PcatGroupDto[]> {
const cacheKey = `${CACHE_PREFIX}:car-groups:${carId}:${parentId || "root"}`;
const cached = await this.redis.getJson<PcatGroupDto[]>(cacheKey);
if (cached) return cached;
const rows = parentId
? await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
}>(
sql`WITH car_cats AS (
SELECT DISTINCT category_id FROM pc.schema_images WHERE car_id = ${carId}
)
SELECT g.id, g.catalog_id, g.parent_id, g.name, g.img_url,
EXISTS(SELECT 1 FROM car_cats cc JOIN pc.groups g2 ON g2.id = cc.category_id WHERE g2.parent_id = g.id) as has_subgroups,
true as has_parts
FROM car_cats cc
JOIN pc.groups g ON g.id = cc.category_id
WHERE g.parent_id = ${parentId}
ORDER BY g.name`,
)
: await this.db.execute<{
id: string;
catalog_id: string;
parent_id: string | null;
name: string;
img_url: string | null;
has_subgroups: boolean;
has_parts: boolean;
}>(
sql`WITH car_cats AS (
SELECT DISTINCT category_id FROM pc.schema_images WHERE car_id = ${carId}
)
SELECT g.id, g.catalog_id, g.parent_id, g.name, g.img_url,
EXISTS(SELECT 1 FROM car_cats cc JOIN pc.groups g2 ON g2.id = cc.category_id WHERE g2.parent_id = g.id) as has_subgroups,
true as has_parts
FROM car_cats cc
JOIN pc.groups g ON g.id = cc.category_id
WHERE g.parent_id IS NULL
ORDER BY g.name`,
);
const result: PcatGroupDto[] = rows.map((r) => ({
id: r.id,
catalogId: r.catalog_id,
parentId: r.parent_id,
name: r.name,
imgUrl: r.img_url,
hasSubgroups: r.has_subgroups ?? false,
hasParts: r.has_parts ?? false,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.groups);
return result;
}
async getSchemaImages(carId: string, groupId: string): Promise<PcatSchemaImageDto[]> {
const cacheKey = `${CACHE_PREFIX}:schemas:${carId}:${groupId}`;
const cached = await this.redis.getJson<PcatSchemaImageDto[]>(cacheKey);
if (cached) return cached;
const rows = await this.db.execute<{
id: number;
name: string | null;
img_url: string | null;
parts_count: number;
}>(
sql`SELECT id, name, img_url, parts_count FROM pc.schema_images
WHERE car_id = ${carId} AND category_id = ${groupId} AND is_active = true
ORDER BY name`,
);
const result: PcatSchemaImageDto[] = rows.map((r) => ({
id: String(r.id),
name: r.name,
imgUrl: r.img_url,
partsCount: r.parts_count ?? 0,
}));
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemas);
return result;
}
async getSchemaDetail(schemaImageId: string): Promise<PcatSchemaDetailDto> {
const cacheKey = `${CACHE_PREFIX}:schema-detail:${schemaImageId}`;
const cached = await this.redis.getJson<PcatSchemaDetailDto>(cacheKey);
if (cached) return cached;
const numId = Number.parseInt(schemaImageId, 10);
// Get schema image info
const [image] = await this.db.execute<{
id: number;
name: string | null;
img_url: string | null;
parts_count: number;
}>(
sql`SELECT id, name, img_url, parts_count FROM pc.schema_images WHERE id = ${numId} LIMIT 1`,
);
if (!image) {
return {
schemaImage: { id: schemaImageId, name: null, imgUrl: null, partsCount: 0 },
parts: [],
schemaPics: [],
hotspots: [],
};
}
// Get parts via schema_parts junction
const partRows = await this.db.execute<{
sp_id: number;
position_number: string | null;
quantity: number;
sp_description: string | null;
sp_notice: string | null;
part_id: number;
part_number: string;
part_name: string | null;
}>(
sql`SELECT sp.id as sp_id, sp.position_number, sp.quantity, sp.description as sp_description, sp.notice as sp_notice,
p.id as part_id, p.part_number, p.name as part_name
FROM pc.schema_parts sp JOIN pc.parts p ON p.id = sp.part_id
WHERE sp.schema_image_id = ${numId}
ORDER BY sp.position_number, p.name`,
);
// Get hotspots
const hotspotRows = await this.db.execute<{
id: number;
position_number: string;
x: number;
y: number;
width: number;
height: number;
}>(
sql`SELECT id, position_number, x, y, width, height FROM pc.part_hotspots
WHERE schema_image_id = ${numId} ORDER BY position_number`,
);
// Calculate image dimensions from hotspot bounds
let imgWidth = 0;
let imgHeight = 0;
for (const h of hotspotRows) {
imgWidth = Math.max(imgWidth, h.x + h.width);
imgHeight = Math.max(imgHeight, h.y + h.height);
}
// Add 10% padding
imgWidth = Math.round(imgWidth * 1.1) || 1000;
imgHeight = Math.round(imgHeight * 1.1) || 800;
const imgUrl = image.img_url?.startsWith("//") ? `https:${image.img_url}` : image.img_url || "";
const schemaImage: PcatSchemaImageDto = {
id: String(image.id),
name: image.name,
imgUrl: image.img_url,
partsCount: image.parts_count ?? 0,
};
const parts = partRows.map((r) => {
const pos = r.position_number ? Number.parseInt(r.position_number, 10) : null;
return {
id: String(r.sp_id),
name: r.part_name || r.sp_description || "",
oemCode: r.part_number,
quantity: r.quantity ?? 1,
position: r.position_number,
hotspotIndex: Number.isNaN(pos!) ? null : pos,
};
});
const schemaPics = [
{
id: String(image.id),
url: imgUrl,
width: imgWidth,
height: imgHeight,
label: image.name || "",
},
];
const hotspots = hotspotRows.map((h) => ({
id: String(h.id),
key: `hotspot-${h.id}`,
group: Number.parseInt(h.position_number, 10) || 0,
shape: "rect" as const,
coordinates: [h.x, h.y, h.width, h.height],
label: h.position_number,
}));
const result: PcatSchemaDetailDto = { schemaImage, parts, schemaPics, hotspots };
await this.redis.setJson(cacheKey, result, CACHE_TTL.schemaDetail);
return result;
}
}

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { CatalogModule } from "../catalog/catalog.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { PL24Module } from "../integrations/pl24/pl24.module";
@@ -7,7 +8,7 @@ import { CategoriesController } from "./categories.controller";
import { CategoriesService } from "./categories.service";
@Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule, TranslationsModule],
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -1,3 +1,4 @@
import { join } from "node:path";
import {
type ArgumentsHost,
Catch,
@@ -6,7 +7,6 @@ import {
HttpStatus,
Logger,
} from "@nestjs/common";
import { join } from "node:path";
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { SentryExceptionCaptured } from "@sentry/nestjs";
import { Request, Response } from "express";
@@ -43,7 +43,11 @@ export class HttpExceptionFilter implements ExceptionFilter {
}
// SPA fallback: serve index.html for non-API GET 404s
if (status === HttpStatus.NOT_FOUND && request.method === "GET" && !request.path.startsWith("/api")) {
if (
status === HttpStatus.NOT_FOUND &&
request.method === "GET" &&
!request.path.startsWith("/api")
) {
return response.sendFile(this.indexPath);
}

View File

@@ -17,11 +17,16 @@ export const emexCatalogs = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: varchar("catalog_id", { length: 100 }).notNull(),
code: varchar("code", { length: 50 }),
brandName: varchar("brand_name", { length: 100 }).notNull(),
description: text("description"),
sourceId: integer("source_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [uniqueIndex("emex_catalogs_catalog_id_idx").on(table.catalogId)],
(table) => [
uniqueIndex("emex_catalogs_catalog_id_idx").on(table.catalogId),
index("emex_catalogs_code_idx").on(table.code),
],
);
// ─── EMEX Vehicle ───────────────────────────────────
@@ -34,14 +39,24 @@ export const emexVehicles = pgTable(
name: varchar("name", { length: 500 }),
modelCode: varchar("model_code", { length: 100 }),
engine: varchar("engine", { length: 255 }),
engineCode: varchar("engine_code", { length: 100 }),
bodyType: varchar("body_type", { length: 100 }),
transmission: varchar("transmission", { length: 100 }),
driveType: varchar("drive_type", { length: 100 }),
fuelType: varchar("fuel_type", { length: 100 }),
yearFrom: integer("year_from"),
yearTo: integer("year_to"),
ssd: text("ssd"),
optionsRaw: text("options_raw"),
rawData: jsonb("raw_data"),
sourceId: integer("source_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("emex_vehicles_vehicle_id_idx").on(table.vehicleId),
index("emex_vehicles_catalog_id_idx").on(table.catalogId),
index("emex_vehicles_name_idx").on(table.name),
index("emex_vehicles_source_id_idx").on(table.sourceId),
],
);
@@ -57,7 +72,7 @@ export const emexVehicleVins = pgTable(
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_vins_vin_idx").on(table.vin),
uniqueIndex("emex_vehicle_vins_vin_idx").on(table.vin),
index("emex_vehicle_vins_vehicle_id_idx").on(table.emexVehicleId),
],
);
@@ -67,7 +82,7 @@ export const emexPartGroups = pgTable(
"emex_part_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
emexCatalogId: uuid("emex_catalog_id").references(() => emexCatalogs.id, {
onDelete: "cascade",
}),
groupId: varchar("group_id", { length: 100 }).notNull(),
@@ -75,11 +90,35 @@ export const emexPartGroups = pgTable(
nameOriginal: varchar("name_original", { length: 500 }),
parentGroupId: varchar("parent_group_id", { length: 100 }),
sortOrder: integer("sort_order"),
hasParts: boolean("has_parts").default(false),
hasChildren: boolean("has_children").default(false),
sourceId: integer("source_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_part_groups_vehicle_id_idx").on(table.emexVehicleId),
index("emex_part_groups_catalog_id_idx").on(table.emexCatalogId),
index("emex_part_groups_group_id_idx").on(table.groupId),
uniqueIndex("emex_part_groups_catalog_group_idx").on(table.emexCatalogId, table.groupId),
],
);
// ─── EMEX Vehicle-Group Link (junction) ─────────────
export const emexVehicleGroupLinks = pgTable(
"emex_vehicle_group_links",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id")
.references(() => emexVehicles.id, { onDelete: "cascade" })
.notNull(),
emexGroupId: uuid("emex_group_id")
.references(() => emexPartGroups.id, { onDelete: "cascade" })
.notNull(),
ssd: text("ssd"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
uniqueIndex("emex_vgl_vehicle_group_idx").on(table.emexVehicleId, table.emexGroupId),
index("emex_vgl_group_idx").on(table.emexGroupId),
],
);
@@ -88,23 +127,26 @@ export const emexParts = pgTable(
"emex_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
emexCatalogId: uuid("emex_catalog_id").references(() => emexCatalogs.id, {
onDelete: "cascade",
}),
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
partId: varchar("part_id", { length: 100 }),
partNumber: varchar("part_number", { length: 100 }),
name: varchar("name", { length: 500 }).notNull(),
nameOriginal: varchar("name_original", { length: 500 }),
description: text("description"),
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
oemNumber: varchar("oem_number", { length: 100 }),
hotspotIndex: integer("hotspot_index"),
rawData: jsonb("raw_data"),
sourceId: integer("source_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_parts_vehicle_id_idx").on(table.emexVehicleId),
index("emex_parts_catalog_id_idx").on(table.emexCatalogId),
index("emex_parts_group_id_idx").on(table.groupId),
index("emex_parts_part_number_idx").on(table.partNumber),
index("emex_parts_oem_number_idx").on(table.oemNumber),
],
);
@@ -124,38 +166,30 @@ export const emexPartNumbers = pgTable(
],
);
// ─── EMEX Vehicle Group ─────────────────────────────
export const emexVehicleGroups = pgTable(
"emex_vehicle_groups",
// ─── EMEX Vehicle-Part Link (junction) ──────────────
export const emexVehiclePartLinks = pgTable(
"emex_vehicle_part_links",
{
id: uuid("id").primaryKey().defaultRandom(),
catalogId: uuid("catalog_id").references(() => emexCatalogs.id, { onDelete: "cascade" }),
groupId: varchar("group_id", { length: 100 }).notNull(),
name: varchar("name", { length: 500 }).notNull(),
parentGroupId: varchar("parent_group_id", { length: 100 }),
emexVehicleId: uuid("emex_vehicle_id")
.references(() => emexVehicles.id, { onDelete: "cascade" })
.notNull(),
emexPartId: uuid("emex_part_id")
.references(() => emexParts.id, { onDelete: "cascade" })
.notNull(),
emexGroupId: uuid("emex_group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
quantity: integer("quantity"),
position: varchar("position", { length: 100 }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_groups_catalog_id_idx").on(table.catalogId),
index("emex_vehicle_groups_group_id_idx").on(table.groupId),
],
);
// ─── EMEX Vehicle Part ──────────────────────────────
export const emexVehicleParts = pgTable(
"emex_vehicle_parts",
{
id: uuid("id").primaryKey().defaultRandom(),
emexVehicleId: uuid("emex_vehicle_id").references(() => emexVehicles.id, {
onDelete: "cascade",
}),
emexPartId: uuid("emex_part_id").references(() => emexParts.id, { onDelete: "cascade" }),
fitmentInfo: text("fitment_info"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("emex_vehicle_parts_vehicle_id_idx").on(table.emexVehicleId),
index("emex_vehicle_parts_part_id_idx").on(table.emexPartId),
uniqueIndex("emex_vpl_vehicle_part_group_idx").on(
table.emexVehicleId,
table.emexPartId,
table.emexGroupId,
),
index("emex_vpl_part_idx").on(table.emexPartId),
index("emex_vpl_group_idx").on(table.emexGroupId),
],
);
@@ -165,14 +199,19 @@ export const emexSchemaPics = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
groupId: uuid("group_id").references(() => emexPartGroups.id, { onDelete: "cascade" }),
imageUrl: text("image_url").notNull(),
imageUrl: text("image_url"),
originalUrl: text("original_url"),
localPath: varchar("local_path", { length: 500 }),
hotspots: jsonb("hotspots").default("[]").notNull(),
width: integer("width"),
height: integer("height"),
sortOrder: integer("sort_order").default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [index("emex_schema_pics_group_id_idx").on(table.groupId)],
(table) => [
index("emex_schema_pics_group_id_idx").on(table.groupId),
uniqueIndex("emex_schema_pics_group_path_idx").on(table.groupId, table.localPath),
],
);
// ─── EMEX Part Image ────────────────────────────────

View File

@@ -131,6 +131,9 @@ export interface EmexScraperResponse {
catalogCode: string;
ssd?: string;
vehicle: EmexVehicleData;
vehicleLabel?: string;
vid?: string;
pathData?: string;
message?: string;
error?: string;
rawResponse?: Record<string, unknown>;
@@ -231,33 +234,103 @@ export interface CatalogEntry {
* WMI (World Manufacturer Identifier) to catalog mapping
*/
export const CATALOG_MAP: Record<string, CatalogEntry> = {
// BMW
WBA: { code: "BMW202501", brand: "BMW" },
WBS: { code: "BMW202501", brand: "BMW" },
WBY: { code: "BMW202501", brand: "BMW" },
// Mercedes-Benz
WDB: { code: "MB201810", brand: "Mercedes-Benz" },
WDD: { code: "MB201810", brand: "Mercedes-Benz" },
WDC: { code: "MB201810", brand: "Mercedes-Benz" },
WDF: { code: "MB201810", brand: "Mercedes-Benz" },
// Audi
WAU: { code: "AU1587", brand: "Audi" },
TRU: { code: "AU1587", brand: "Audi" },
// Volkswagen
WVW: { code: "VW1587", brand: "Volkswagen" },
WVG: { code: "VW1587", brand: "Volkswagen" },
WV2: { code: "VW1587", brand: "Volkswagen" },
// Renault
VF1: { code: "RENAULT201910", brand: "Renault" },
VF7: { code: "CPSA01", brand: "Peugeot" },
VF3: { code: "CPSA01", brand: "Peugeot" },
ZFA: { code: "CFIAT84", brand: "Fiat" },
// Peugeot
VF3: { code: "PEUGEOT00", brand: "Peugeot" },
// Citroen/Peugeot (VF7 shared — Peugeot more common)
VF7: { code: "PEUGEOT00", brand: "Peugeot" },
// Fiat
ZFA: { code: "FFIAT84", brand: "Fiat" },
// Alfa Romeo
ZAR: { code: "RFIAT84", brand: "Alfa Romeo" },
// Ford
WF0: { code: "FORD202201", brand: "Ford" },
NM0: { code: "FORD202201", brand: "Ford" },
// Toyota
JTD: { code: "TOYOTA00", brand: "Toyota" },
JTE: { code: "TOYOTA00", brand: "Toyota" },
SHH: { code: "HONDA00", brand: "Honda" },
KNM: { code: "HYUNDAI00", brand: "Hyundai" },
KNA: { code: "KIA00", brand: "Kia" },
JTN: { code: "TOYOTA00", brand: "Toyota" },
// Lexus
JTH: { code: "LEXUS00", brand: "Lexus" },
JTJ: { code: "LEXUS00", brand: "Lexus" },
// Honda
SHH: { code: "HONDA2017", brand: "Honda" },
// Hyundai
KMH: { code: "HYUNDAI202404", brand: "Hyundai" },
KNM: { code: "HYUNDAI202404", brand: "Hyundai" },
// Kia
KNA: { code: "KIA202404", brand: "Kia" },
KNE: { code: "KIA202404", brand: "Kia" },
// Porsche
WP0: { code: "PO799", brand: "Porsche" },
WP1: { code: "PO799", brand: "Porsche" },
// Subaru
JF1: { code: "SUBARU201802", brand: "Subaru" },
JF2: { code: "SUBARU201802", brand: "Subaru" },
// Mazda
JMZ: { code: "MAZDA2020", brand: "Mazda" },
JM1: { code: "MAZDA2020", brand: "Mazda" },
JM3: { code: "MAZDA2020", brand: "Mazda" },
// Mitsubishi
JMY: { code: "MMC202501", brand: "Mitsubishi" },
JMB: { code: "MMC202501", brand: "Mitsubishi" },
JA3: { code: "MMC202501", brand: "Mitsubishi" },
JA4: { code: "MMC202501", brand: "Mitsubishi" },
JA7: { code: "MMC202501", brand: "Mitsubishi" },
// Nissan
JN1: { code: "NISSAN201809", brand: "Nissan" },
JN8: { code: "NISSAN201809", brand: "Nissan" },
VSK: { code: "NISSAN201809", brand: "Nissan" },
// Volvo
YV1: { code: "VOLVO201410", brand: "Volvo" },
YV4: { code: "VOLVO201410", brand: "Volvo" },
// MINI
WMW: { code: "MINI202501", brand: "Mini" },
// Jaguar
SAJ: { code: "JAGUAR201701", brand: "Jaguar" },
// Land Rover
SAL: { code: "LRE201412", brand: "Land Rover" },
// Skoda
TMB: { code: "SK1119", brand: "Skoda" },
// SEAT
VSS: { code: "SE1113", brand: "Seat" },
// Dacia
UU1: { code: "DACIA201910", brand: "Dacia" },
// Suzuki
JSA: { code: "SUZUKI201905", brand: "Suzuki" },
TSM: { code: "SUZUKI201905", brand: "Suzuki" },
// Isuzu
JAA: { code: "ISUZU201702", brand: "Isuzu" },
// Opel
W0L: { code: "GM_OP201809", brand: "Opel" },
// Chevrolet
KL1: { code: "GM_C201809", brand: "Chevrolet" },
// SsangYong
KPT: { code: "SY201502", brand: "SsangYong" },
// Chrysler/Jeep/Dodge/RAM
"1C4": { code: "JEEP202402", brand: "Jeep" },
"3C4": { code: "CHRYSLER202402", brand: "Chrysler" },
// Rolls-Royce
SCA: { code: "RR202501", brand: "Rolls-Royce" },
// Smart
WME: { code: "MBS201810", brand: "Smart" },
// Infiniti
JNK: { code: "INFINITI201809", brand: "Infiniti" },
};

View File

@@ -7,6 +7,8 @@ import {
emexPartNumbers,
emexParts,
emexScrapeSessions,
emexVehicleGroupLinks,
emexVehiclePartLinks,
emexVehicleVins,
emexVehicles,
} from "../../database/schema/emex";
@@ -58,7 +60,6 @@ export async function processEmexScrape(
if (!emexVehicleRecord) {
// Vehicle not yet in EMEX tables — placeholder for scraper integration
// In production, this would call EmexScraperService.scrapeVehicle(vin)
console.log(`[emex-scrape] No cached vehicle for VIN ${vin}, scraper integration pending`);
if (session) {
@@ -79,20 +80,22 @@ export async function processEmexScrape(
await job.updateProgress(25);
console.log(`[emex-scrape] Vehicle resolved: ${emexVehicleRecord.vehicleId}`);
// ── Step 2: Fetch categories (part groups) ────────────
// ── Step 2: Fetch categories (part groups via junction) ──
const categoriesResult = await db
.select()
.from(emexPartGroups)
.where(eq(emexPartGroups.emexVehicleId, emexVehicleRecord.id));
.select({ id: emexPartGroups.id })
.from(emexVehicleGroupLinks)
.innerJoin(emexPartGroups, eq(emexVehicleGroupLinks.emexGroupId, emexPartGroups.id))
.where(eq(emexVehicleGroupLinks.emexVehicleId, emexVehicleRecord.id));
await job.updateProgress(50);
console.log(`[emex-scrape] Found ${categoriesResult.length} categories`);
// ── Step 3: Fetch parts ───────────────────────────────
// ── Step 3: Fetch parts (via junction) ──────────────────
const partsResult = await db
.select()
.from(emexParts)
.where(eq(emexParts.emexVehicleId, emexVehicleRecord.id));
.select({ id: emexParts.id })
.from(emexVehiclePartLinks)
.innerJoin(emexParts, eq(emexVehiclePartLinks.emexPartId, emexParts.id))
.where(eq(emexVehiclePartLinks.emexVehicleId, emexVehicleRecord.id));
await job.updateProgress(100);
console.log(`[emex-scrape] Found ${partsResult.length} parts`);

View File

@@ -1,8 +1,8 @@
import { Job } from "bullmq";
import { sql as drizzleSql, inArray } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import OpenAI from "openai";
import Redis from "ioredis";
import OpenAI from "openai";
import { emexCategoryTranslations } from "../../database/schema/core";
// Schema-loose local alias (matches the shape used by worker.ts which builds

View File

@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { BrandsModule } from "../brands/brands.module";
import { CatalogModule } from "../catalog/catalog.module";
import { CategoriesModule } from "../categories/categories.module";
import { CorgiModule } from "../integrations/corgi/corgi.module";
import { EmexModule } from "../integrations/emex/emex.module";
@@ -19,6 +20,7 @@ import { VehiclesService } from "./vehicles.service";
PartsCatalogsModule,
BrandsModule,
CategoriesModule,
CatalogModule,
JobsModule,
],
controllers: [VehiclesController],