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

@@ -4,9 +4,10 @@ import { CategoriesService } from "./categories.service";
import { PL24Module } from "../integrations/pl24/pl24.module";
import { EmexModule } from "../integrations/emex/emex.module";
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
import { CatalogModule } from "../catalog/catalog.module";
@Module({
imports: [PL24Module, EmexModule, PartsCatalogsModule],
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule],
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],

View File

@@ -25,7 +25,11 @@ function createService(db: any) {
const pl24FordLegacyService = {
fetchCategoriesForPsaVin: vi.fn().mockResolvedValue([]),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
const emexCatalogService = {
matchByName: vi.fn().mockResolvedValue(null),
getVehicleGroups: vi.fn().mockResolvedValue([]),
};
const service = new CategoriesService(db as any, redis as any, pl24Service as any, emexService as any, emexCatalogService as any, partsCatalogsService as any, storage as any, pl24FordLegacyService as any);
return { service, db, redis, pl24Service };
}

View File

@@ -6,6 +6,7 @@ import { RedisService } from "../redis/redis.service";
import { PL24Service } from "../integrations/pl24/pl24.service";
import { PL24FordLegacyService } from "../integrations/pl24/pl24-ford-legacy.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 { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
import { StorageService } from "../storage/storage.service";
@@ -19,6 +20,7 @@ export class CategoriesService {
private redis: RedisService,
private pl24Service: PL24Service,
private emexService: EmexService,
private emexCatalogService: EmexCatalogService,
private partsCatalogsService: PartsCatalogsService,
private storage: StorageService,
private pl24FordLegacyService: PL24FordLegacyService,
@@ -203,9 +205,46 @@ export class CategoriesService {
}
}
// If still no categories, try EMEX fallback
// If still no categories, try EMEX pre-scraped match first, then on-demand fallback
if (dbCategories.length === 0 && vehicle.vin) {
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX fallback`);
this.logger.log(`No PL24 categories for ${vehicle.vin}, trying EMEX pre-scraped match`);
// Try pre-scraped emex catalog match
const vRawData = vehicle.rawData as Record<string, unknown> | null;
const catalogCode = (vRawData?.catalogCode as string) || this.emexService.getCatalogCode(vehicle.vin);
const vehicleName = (vRawData?.emexVehicleName as string) || (vRawData?.model as string);
if (catalogCode && vehicleName) {
try {
const match = await this.emexCatalogService.matchByName(catalogCode, vehicleName);
if (match) {
this.logger.log(`EMEX pre-scraped match found for ${vehicle.vin}: ${match.vehicleId}`);
const groups = await this.emexCatalogService.getVehicleGroups(match.vehicleId);
if (groups.length > 0) {
// Convert emex groups to categories format for tree building
const insertData = groups.map((g) => ({
vehicleId,
catalogVehicleId: null as string | null,
name: g.name,
nameOriginal: g.nameOriginal || g.name,
parentId: null as string | null,
externalId: g.groupId,
linkPath: `emex-catalog:${match.vehicleId}:${g.id}`,
linkWid: null as string | null,
source: "emex" as const,
}));
dbCategories = await this.db.insert(categories).values(insertData).onConflictDoNothing().returning();
this.logger.log(`Stored ${dbCategories.length} EMEX pre-scraped categories for ${vehicle.vin}`);
}
}
} catch (err) {
this.logger.warn(`EMEX pre-scraped match failed for ${vehicle.vin}: ${(err as Error).message}`);
}
}
// If pre-scraped match didn't work, try on-demand EMEX scrape
if (dbCategories.length === 0) {
this.logger.log(`No pre-scraped match for ${vehicle.vin}, trying EMEX on-demand fallback`);
try {
const emexResult = await this.emexService.decodeVin(vehicle.vin);
if (emexResult) {
@@ -302,6 +341,7 @@ export class CategoriesService {
} catch (emexErr) {
this.logger.warn(`EMEX category fallback failed for ${vehicle.vin}: ${(emexErr as Error).message}`);
}
} // end: if (dbCategories.length === 0) — on-demand fallback
}
// Build tree