feat(api): source-DB lookup-first for pcat/emex catalog fetches
Adds an optional local-dump lookup layer in front of the live PartsCatalogs and EMEX scrapes. When enabled, getCategoryWithPartsInner queries a Postgres (pcat) or MariaDB (emex) dump for the requested schema/group's parts and hotspots; on miss it falls through to the existing upstream call unchanged. Hits avoid the live API, its cooldown, and its rate-limits — direct DB latency. - New CatalogSourceDbModule with PcatSourceDbService + EmexSourceDbService (raw SQL, no Drizzle schema modeling — dump shapes are frozen snapshots). - pcat lookup keys on schema_images.schema_ext_id (the dump's column that matches sase's pcat groupId; observed ~7% hit rate on prod's 6596 unique groupIds, of which ~10% have schema_parts → ~3-5% net parts coverage). Joins schema_parts → parts directly; the dump's part_groups+part_group_items linkage covers 0 of our hits, so we skip that path entirely. - emex lookup uses (catalog_id, ssd) → vehicles.id then (vehicle_id, group_id) → vehicle_parts → parts + part_images. The ssd is already persisted into vehicles.rawData.ssd by the existing emex.mapper, no extra capture needed. Gated behind CATALOG_SOURCE_DB_ENABLED + PCAT_SOURCE_DB_URL / EMEX_SOURCE_DB_URL. All three default unset, so this commit is a no-op until prod env is configured. Adds mysql2 dep for the MariaDB client. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CatalogModule } from "../catalog/catalog.module";
|
||||
import { CatalogSourceDbModule } from "../integrations/catalog-source-db/catalog-source-db.module";
|
||||
import { EmexModule } from "../integrations/emex/emex.module";
|
||||
import { PartsCatalogsModule } from "../integrations/parts-catalogs/parts-catalogs.module";
|
||||
import { PL24Module } from "../integrations/pl24/pl24.module";
|
||||
@@ -8,7 +9,14 @@ import { CategoriesController } from "./categories.controller";
|
||||
import { CategoriesService } from "./categories.service";
|
||||
|
||||
@Module({
|
||||
imports: [PL24Module, EmexModule, PartsCatalogsModule, CatalogModule, TranslationsModule],
|
||||
imports: [
|
||||
PL24Module,
|
||||
EmexModule,
|
||||
PartsCatalogsModule,
|
||||
CatalogModule,
|
||||
TranslationsModule,
|
||||
CatalogSourceDbModule,
|
||||
],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
|
||||
@@ -37,6 +37,8 @@ function createService(db: any) {
|
||||
Promise.resolve(new Map<string, string>(texts.map((t) => [t, t]))),
|
||||
),
|
||||
};
|
||||
const pcatSourceDb = { fetchParts: vi.fn().mockResolvedValue(null) };
|
||||
const emexSourceDb = { fetchCategoryParts: vi.fn().mockResolvedValue(null) };
|
||||
const service = new CategoriesService(
|
||||
db as any,
|
||||
redis as any,
|
||||
@@ -46,6 +48,8 @@ function createService(db: any) {
|
||||
storage as any,
|
||||
pl24FordLegacyService as any,
|
||||
translationsService as any,
|
||||
pcatSourceDb as any,
|
||||
emexSourceDb as any,
|
||||
);
|
||||
return { service, db, redis, pl24Service, translationsService };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { and, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, parts, schemaPics, vehicles } from "../database/schema/core";
|
||||
import { EmexSourceDbService } from "../integrations/catalog-source-db/emex-source-db.service";
|
||||
import { PcatSourceDbService } from "../integrations/catalog-source-db/pcat-source-db.service";
|
||||
import { EmexService } from "../integrations/emex/emex.service";
|
||||
import { PartsCatalogsService } from "../integrations/parts-catalogs/parts-catalogs.service";
|
||||
import { PcatGroup } from "../integrations/parts-catalogs/parts-catalogs.types";
|
||||
@@ -24,6 +26,8 @@ export class CategoriesService {
|
||||
private storage: StorageService,
|
||||
private pl24FordLegacyService: PL24FordLegacyService,
|
||||
private translationsService: TranslationsService,
|
||||
private pcatSourceDb: PcatSourceDbService,
|
||||
private emexSourceDb: EmexSourceDbService,
|
||||
) {}
|
||||
|
||||
async getCategoryTree(vehicleId: string) {
|
||||
@@ -928,12 +932,19 @@ export class CategoriesService {
|
||||
const vehicleRawData = vehicle.rawData as any;
|
||||
const carParams = this.buildPcatCarParams(vehicleRawData?.parameters);
|
||||
|
||||
const partsResult = await this.partsCatalogsService.fetchParts(
|
||||
catalogId,
|
||||
carId,
|
||||
groupId,
|
||||
carParams,
|
||||
);
|
||||
// Local dump first; live upstream fallback. Logs hit/miss so prod
|
||||
// verification can measure source-DB coverage.
|
||||
let partsResult = await this.pcatSourceDb.fetchParts(catalogId, carId, groupId);
|
||||
if (partsResult) {
|
||||
this.logger.debug(`[source-db hit pcat] car=${carId} group=${groupId}`);
|
||||
} else {
|
||||
partsResult = await this.partsCatalogsService.fetchParts(
|
||||
catalogId,
|
||||
carId,
|
||||
groupId,
|
||||
carParams,
|
||||
);
|
||||
}
|
||||
|
||||
if (partsResult) {
|
||||
// Flatten part groups into parts
|
||||
@@ -1081,7 +1092,20 @@ export class CategoriesService {
|
||||
} else if (vehicle && category.source === "emex") {
|
||||
// EMEX: fetch parts + schema image via Puppeteer from QuickDetails URL
|
||||
try {
|
||||
const emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
// Local dump first (uses vehicles.rawData.ssd persisted by the
|
||||
// emex mapper). Falls back to live upstream on miss.
|
||||
const emexSsd = (vehicle.rawData as { ssd?: string } | null)?.ssd;
|
||||
let emexResult = await this.emexSourceDb.fetchCategoryParts(
|
||||
emexSsd,
|
||||
category.linkPath ?? "",
|
||||
);
|
||||
if (emexResult) {
|
||||
this.logger.debug(
|
||||
`[source-db hit emex] ssd=${emexSsd?.slice(0, 12)}... gid=${category.externalId}`,
|
||||
);
|
||||
} else {
|
||||
emexResult = await this.emexService.fetchCategoryParts(category.linkPath);
|
||||
}
|
||||
|
||||
// Build position code → sequential integer mapping for hotspot linking
|
||||
const posCodeToIndex = new Map<string, number>();
|
||||
|
||||
Reference in New Issue
Block a user