feat: EMEX VIN decode → DB catalog entegrasyonu
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled

- CATALOG_MAP'e ~40 yeni WMI eklendi (Mitsubishi, Nissan, Volvo, vb.)
- brandFromCatalogCode listesi 55 DB kataloğuna genişletildi
- brand="UNKNOWN" olsa bile catalogCode varsa EMEX sonucu kabul edilir
- emex-catalog: linkPath'li kategorilerde parçalar DB'den yüklenir (scraper bug fix)
- VIN cache: emex_vehicle_vins tablosu ile tekrar VIN'lerde emexdwc.ae atlanır
- getCategoryTree Step 0: VIN cache lookup (~10ms)
- Match sonrası otomatik VIN cache doldurma
- Proaktif cache: ilk decode sonrası matchByName + saveVinCache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 08:32:34 +00:00
parent 79332003d2
commit 3710251117
8 changed files with 313 additions and 18 deletions

View File

@@ -4,6 +4,7 @@ import { DATABASE, Database } from "../database/database.provider";
import {
emexCatalogs,
emexVehicles,
emexVehicleVins,
emexPartGroups,
emexParts,
emexPartNumbers,
@@ -77,6 +78,8 @@ export interface EmexVehicleMatch {
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 {
@@ -610,4 +613,85 @@ export class EmexCatalogService {
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

@@ -205,7 +205,7 @@ export class CategoriesService {
}
}
// If still no categories, try EMEX: pathData → name → ssd → on-demand fallback
// If still no categories, try EMEX: VIN cache → pathData → name → ssd → on-demand fallback
if (dbCategories.length === 0 && vehicle.vin) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX catalog match`);
@@ -232,6 +232,27 @@ export class CategoriesService {
}
};
// Helper: save VIN→emex_vehicle cache after successful match
const cacheVinMatch = (match: { vehicleId: string }) => {
if (vehicle.vin) {
this.emexCatalogService.saveVinCache(vehicle.vin, match.vehicleId)
.catch((err) => this.logger.warn(`VIN cache save failed: ${(err as Error).message}`));
}
};
// Step 0: VIN → emex_vehicle_vins cache (DB-only, ~10ms)
if (dbCategories.length === 0) {
try {
const vinMatch = await this.emexCatalogService.lookupVinCache(vehicle.vin);
if (vinMatch) {
this.logger.log(`EMEX step 0: VIN cache hit → ${vinMatch.vehicleId}`);
await insertEmexMatch(vinMatch);
}
} catch (err) {
this.logger.warn(`EMEX VIN cache lookup failed: ${(err as Error).message}`);
}
}
// Step 1: pathData → extract vehicle name → matchByName (DB-only, ~50ms)
if (catalogCode && dbCategories.length === 0) {
const pathData = vRawData?.emexPathData as string | undefined;
@@ -244,6 +265,7 @@ export class CategoriesService {
if (match) {
this.logger.log(`EMEX pathData match found: ${match.vehicleId}`);
await insertEmexMatch(match);
cacheVinMatch(match);
}
} catch (err) {
this.logger.warn(`EMEX pathData match failed: ${(err as Error).message}`);
@@ -262,6 +284,7 @@ export class CategoriesService {
if (match) {
this.logger.log(`EMEX vehicleName match found: ${match.vehicleId}`);
await insertEmexMatch(match);
cacheVinMatch(match);
}
} catch (err) {
this.logger.warn(`EMEX vehicleName match failed: ${(err as Error).message}`);
@@ -283,6 +306,7 @@ export class CategoriesService {
if (match) {
this.logger.log(`EMEX SSD match found: ${match.vehicleId}`);
await insertEmexMatch(match);
cacheVinMatch(match);
}
} catch (err) {
this.logger.warn(`EMEX SSD match failed: ${(err as Error).message}`);
@@ -812,7 +836,52 @@ export class CategoriesService {
}
}
} else if (vehicle && category.source === "emex") {
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
if (category.linkPath?.startsWith("emex-catalog:")) {
// ── DB-backed path: emex-catalog:{emexVehicleId}:{emexGroupId} ──
try {
const [, emexVehicleId, emexGroupId] = category.linkPath.split(":");
const groupParts = await this.emexCatalogService.getGroupParts(emexVehicleId, emexGroupId);
if (needParts && groupParts.parts.length > 0) {
const insertData = groupParts.parts.map((p) => ({
vehicleId: vehicle.id,
categoryId,
oemCode: p.oemCode || "N/A",
name: p.name || "Unknown",
nameOriginal: p.name || null,
description: null as string | null,
quantity: p.quantity ?? null,
position: p.position || null,
hotspotIndex: p.hotspotIndex ?? null,
source: "emex" as const,
}));
dbParts = await this.db.insert(parts).values(insertData).returning();
this.logger.log(`Stored ${dbParts.length} EMEX DB parts for category ${categoryId}`);
}
// Schema pics — images are already on storage.sase.tr
if (needImage && groupParts.schemaPics.length > 0) {
for (const sp of groupParts.schemaPics) {
if (!sp.url) continue;
const [inserted] = await this.db
.insert(schemaPics)
.values({
categoryId,
imageUrl: sp.url,
hotspots: JSON.stringify({ width: sp.width || 0, height: sp.height || 0, items: [] }),
source: "emex",
})
.returning();
pics.push(inserted);
}
this.logger.log(`Stored ${pics.length} EMEX DB schema pics for category ${categoryId}`);
}
} catch (err) {
this.logger.error(`Failed to fetch EMEX DB parts for category ${categoryId}: ${(err as Error).message}`);
}
} else {
// ── URL-based path: fetch parts + schema image via Puppeteer from QuickDetails URL ──
try {
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
@@ -887,6 +956,7 @@ export class CategoriesService {
} catch (err) {
this.logger.error(`Failed to fetch EMEX parts for category ${categoryId}: ${(err as Error).message}`);
}
} // end: URL-based EMEX path
} else if (vehicle) {
// PL24: fetch parts + schema image via PL24 API
const rawData = vehicle.rawData as any;

View File

@@ -72,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),
],
);

View File

@@ -332,13 +332,23 @@ export class EmexService {
if (!c) return null;
const upper = c.toUpperCase();
const prefixes: [string, string][] = [
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['AU', 'Audi'],
['VW', 'Volkswagen'], ['FFIAT', 'Fiat'], ['RFIAT', 'Alfa Romeo'],
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['TOYOTA', 'Toyota'],
['BMW', 'BMW'], ['MB', 'Mercedes-Benz'], ['MBS', 'Smart'],
['AU', 'Audi'], ['VW', 'Volkswagen'],
['FFIAT', 'Fiat'], ['CFIAT', 'Abarth'], ['RFIAT', 'Alfa Romeo'],
['LFIAT', 'Lancia'], ['TFIAT', 'Fiat'],
['FORD', 'Ford'], ['RENAULT', 'Renault'], ['DACIA', 'Dacia'],
['TOYOTA', 'Toyota'], ['LEXUS', 'Lexus'],
['HONDA', 'Honda'], ['KIA', 'Kia'], ['HYUNDAI', 'Hyundai'],
['PORSCHE', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
['CPSA', 'Citroën/Peugeot'], ['VOLVO', 'Volvo'], ['NISSAN', 'Nissan'],
['OPEL', 'Opel'],
['PO', 'Porsche'], ['SUBARU', 'Subaru'], ['MAZDA', 'Mazda'],
['MMC', 'Mitsubishi'], ['NISSAN', 'Nissan'], ['INFINITI', 'Infiniti'],
['PEUGEOT', 'Peugeot'], ['CITROEN', 'Citroen'],
['VOLVO', 'Volvo'], ['JAGUAR', 'Jaguar'], ['LRE', 'Land Rover'],
['MINI', 'Mini'], ['RR', 'Rolls-Royce'],
['GM_OP', 'Opel'], ['GM_VX', 'Vauxhall'], ['GM_C', 'Chevrolet'],
['GM_B', 'Buick'], ['GM_K', 'Cadillac'], ['GM_G', 'GMC'],
['SK', 'Skoda'], ['SE', 'Seat'], ['SY', 'SsangYong'],
['ISUZU', 'Isuzu'], ['SUZUKI', 'Suzuki'],
['CHRYSLER', 'Chrysler'], ['DODGE', 'Dodge'], ['JEEP', 'Jeep'], ['RAM', 'Ram'],
];
for (const [prefix, brand] of prefixes) {
if (upper.startsWith(prefix)) return brand;

View File

@@ -234,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

@@ -8,10 +8,11 @@ import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { BrandsModule } from "../brands/brands.module";
import { CategoriesModule } from "../categories/categories.module";
import { CatalogModule } from "../catalog/catalog.module";
import { JobsModule } from "../jobs/jobs.module";
@Module({
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, PartsCatalogsModule, BrandsModule, CategoriesModule, JobsModule],
imports: [CorgiModule, PL24Module, VinApiModule, EmexModule, PartsCatalogsModule, BrandsModule, CategoriesModule, CatalogModule, JobsModule],
controllers: [VehiclesController],
providers: [VehiclesService],
exports: [VehiclesService],

View File

@@ -66,6 +66,12 @@ function createService(dbOrOverrides: any = {}) {
add: vi.fn().mockResolvedValue(undefined),
};
const emexCatalogService = {
lookupVinCache: vi.fn().mockResolvedValue(null),
saveVinCache: vi.fn().mockResolvedValue(undefined),
matchByName: vi.fn().mockResolvedValue(null),
};
const service = new VehiclesService(
db as any,
prefetchQueue as any,
@@ -73,6 +79,7 @@ function createService(dbOrOverrides: any = {}) {
pl24Service as any,
vinApiService as any,
emexService as any,
emexCatalogService as any,
partsCatalogsService as any,
redisService as any,
);

View File

@@ -25,6 +25,7 @@ import { CorgiService } from "../integrations/corgi/corgi.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { VinApiService } from "../integrations/vin-api/vin-api.service";
import { EmexService } from "../integrations/emex/emex.service";
import { EmexCatalogService } from "../catalog/emex-catalog.service";
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
import type { PcatCar } from "../integrations/parts-catalogs/parts-catalogs.types";
import type { EmexCandidate } from "../integrations/emex/emex.service";
@@ -59,6 +60,7 @@ export class VehiclesService {
private pl24Service: PL24Service,
private vinApiService: VinApiService,
private emexService: EmexService,
private emexCatalogService: EmexCatalogService,
private partsCatalogsService: PartsCatalogsService,
private redis: RedisService,
) {}
@@ -326,9 +328,42 @@ export class VehiclesService {
BYPASS: EMEX test — pcat/p24 devre dışı */
const pl24Vehicle: any = null; // BYPASS: pl24Vehicle null — EMEX bloğu çalışsın
// 4. EMEX fallback (single HTTP call — no double fetch)
// 4. EMEX fallback — first check VIN cache, then emexdwc.ae
let emexVehicle: import("../integrations/emex/emex.types").DecodedVehicle | null = null;
if (!pl24Vehicle) {
// 4a. VIN cache: skip emexdwc.ae entirely for repeat VINs
try {
const vinCacheMatch = await this.emexCatalogService.lookupVinCache(vin);
if (vinCacheMatch && vinCacheMatch.candidates.length > 0) {
const candidate = vinCacheMatch.candidates[0];
if (!brandName) brandName = vinCacheMatch.brandName || null;
const result: VinResolveResult = {
brandName,
model: candidate.name || null,
year: candidate.yearFrom || corgiResult?.modelYear || null,
engine: candidate.engineCode || candidate.engine || null,
transmission: candidate.transmission || null,
bodyType: candidate.bodyType || null,
rawData: {
source: "emex",
catalogCode: vinCacheMatch.catalogCode,
emexVehicleId: vinCacheMatch.vehicleId,
emexPathData: `Name: ${candidate.name}`,
emexVehicleName: candidate.name,
},
source: "emex",
corgiKnown,
corgiResult: corgiResult || null,
};
await this.redis.setJson(cacheKey, result, 300);
this.logger.log(`EMEX VIN cache hit for ${vin}${vinCacheMatch.vehicleId}`);
return result;
}
} catch (err) {
this.logger.warn(`EMEX VIN cache lookup failed: ${(err as Error).message}`);
}
// 4b. emexdwc.ae HTTP (single call — no double fetch)
try {
const emexResult = await this.emexService.decodeVinOrCandidates(vin);
if (emexResult.type === 'candidates') {
@@ -347,7 +382,7 @@ export class VehiclesService {
emexCandidates: emexResult.candidates,
};
}
if (emexResult.type === 'vehicle' && emexResult.vehicle.brand !== "UNKNOWN") {
if (emexResult.type === 'vehicle' && (emexResult.vehicle.brand !== "UNKNOWN" || (emexResult.vehicle.raw as any)?.catalogCode)) {
emexVehicle = emexResult.vehicle;
if (!brandName) brandName = emexResult.vehicle.brand || null;
} else if (emexResult.type === 'error') {
@@ -361,6 +396,24 @@ export class VehiclesService {
} catch (err) {
this.logger.warn(`EMEX fallback failed for ${vin}: ${(err as Error).message}`);
}
// 4c. Proactive VIN cache: if emex decoded, try matchByName and cache for next time
if (emexVehicle) {
const rawCatalogCode = (emexVehicle.raw as any)?.catalogCode as string | undefined;
const rawPathData = (emexVehicle.raw as any)?.emexPathData as string | undefined;
if (rawCatalogCode && rawPathData) {
const pathName = rawPathData.replace(/^Name:\s*/i, "").trim();
if (pathName) {
this.emexCatalogService.matchByName(rawCatalogCode, pathName)
.then((match) => {
if (match) {
return this.emexCatalogService.saveVinCache(vin, match.vehicleId);
}
})
.catch((err) => this.logger.warn(`Proactive VIN cache failed: ${(err as Error).message}`));
}
}
}
}
// Nothing recognized this VIN