feat: EMEX catalog integration — schema, data migration, backend & frontend
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
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:
@@ -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 {}
|
||||
|
||||
40
apps/api/src/catalog/emex-catalog.controller.ts
Normal file
40
apps/api/src/catalog/emex-catalog.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
388
apps/api/src/catalog/emex-catalog.service.ts
Normal file
388
apps/api/src/catalog/emex-catalog.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user