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:
2026-06-01 00:35:45 +03:00
parent d9b14af96d
commit 4724a71113
10 changed files with 557 additions and 19 deletions

View File

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

View File

@@ -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 };
}

View File

@@ -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>();