feat: EMEX catalog integration — schema, data migration, backend & frontend
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

Redesign emex_* tables from vehicle-centric to catalog-centric structure with
junction tables (emex_vehicle_group_links, emex_vehicle_part_links). Migrate
92M+ rows from emex DB via postgres_fdw. Add EmexCatalogService for browse
and VIN pre-scraped matching, with Redis caching. Frontend catalog page now
has PL24/Emex tabs with full drill-down routes (brand → vehicle → group → parts).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 04:06:29 +00:00
parent 63f6309bf3
commit 1e8a049219
17 changed files with 1351 additions and 75 deletions

View File

@@ -1,14 +1,16 @@
import { Module } from "@nestjs/common";
import { CatalogController } from "./catalog.controller";
import { CatalogService } from "./catalog.service";
import { EmexCatalogController } from "./emex-catalog.controller";
import { EmexCatalogService } from "./emex-catalog.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { SubscriptionsModule } from "../subscriptions/subscriptions.module";
import { StorageModule } from "../storage/storage.module";
@Module({
imports: [PL24Module, SubscriptionsModule, StorageModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
controllers: [CatalogController, EmexCatalogController],
providers: [CatalogService, EmexCatalogService],
exports: [CatalogService, EmexCatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,40 @@
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("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,388 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { eq, and, sql, ilike, or } from "drizzle-orm";
import { DATABASE, Database } from "../database/database.provider";
import {
emexCatalogs,
emexVehicles,
emexPartGroups,
emexParts,
emexPartNumbers,
emexSchemaPics,
emexVehicleGroupLinks,
emexVehiclePartLinks,
} 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;
partNumber: string | null;
name: string;
nameOriginal: string | null;
description: string | null;
oemNumber: string | null;
hotspotIndex: number | null;
quantity: number | null;
position: string | null;
}
interface EmexSchemaPicDto {
id: string;
imageUrl: string | null;
localPath: string | null;
hotspots: unknown;
width: number | null;
height: number | null;
sortOrder: number | null;
}
export interface EmexGroupPartsDto {
group: EmexGroupDto;
parts: EmexPartDto[];
schemaPics: EmexSchemaPicDto[];
}
export interface EmexVehicleMatch {
vehicleId: string;
catalogCode: string;
vehicleName: string | null;
candidates: EmexVehicleDto[];
}
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);
const result: EmexGroupPartsDto = { group, parts, schemaPics };
await this.redis.setJson(cacheKey, result, CACHE_TTL.parts);
return result;
}
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;
}
}