feat(tecdoc): OEM detail page with TecDoc cross-references

Resolve a catalog OEM code to its TecDoc equivalents on a new
/dashboard/oem/$code page: the aftermarket parts that carry it
(brand + article number + image + EAN), buyable supplier
substitutes, and OE cross-references (same part under other makes).

- API: TecdocModule (read-only postgres-js client to the imported
  `td` snapshot), GET /tecdoc/oem?code=. Normalisation-based match
  (TecDoc stores `1J0 973 702`, catalog gives `1J0973702`); exact
  match recovers ~1/10 vs normalised ~5/10 on real codes. Self-
  disables without TECDOC_DB_* env → { matched: false }.
- Web: OEM code in the parts panel is now a link (new tab) to the
  detail page; "N/A" stays plain text.
- Mirrors CatalogSourceDbModule (raw queries, no Drizzle modelling).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:52:51 +03:00
parent 5e1675f95a
commit fb2c28faf1
10 changed files with 1193 additions and 570 deletions

View File

@@ -29,6 +29,7 @@ import { DemoModule } from "./demo/demo.module";
import { EmailModule } from "./email/email.module";
import { HealthController } from "./health.controller";
import { EmexModule } from "./integrations/emex/emex.module";
import { TecdocModule } from "./integrations/tecdoc/tecdoc.module";
import { InternalAdminModule } from "./internal-admin/internal-admin.module";
import { JobsModule } from "./jobs/jobs.module";
import { MetaCapiModule } from "./meta-capi/meta-capi.module";
@@ -86,6 +87,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
CategoriesModule,
DemoModule,
PartsModule,
TecdocModule,
JobsModule,
EmexModule,
TranslationsModule,

View File

@@ -88,6 +88,14 @@ export default () => ({
.map((s) => s.trim())
.filter(Boolean),
},
tecdoc: {
// Read-only lookup against the imported TecDoc snapshot (db `td`). When
// enabled + url set, the OEM detail page resolves a part's OEM code to
// TecDoc aftermarket equivalents + OE cross-references. Disabled → endpoint
// returns { matched: false } and the UI shows an empty state.
enabled: process.env.TECDOC_DB_ENABLED === "true",
url: process.env.TECDOC_DB_URL,
},
otel: {
enabled: process.env.OTEL_ENABLED === "true",
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,

View File

@@ -0,0 +1,220 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import postgres, { type Sql } from "postgres";
import type {
TecdocArticle,
TecdocCompatible,
TecdocOeNumber,
TecdocOemResult,
} from "./tecdoc.types";
/**
* Read-only lookup against the imported TecDoc snapshot (db `td` — a selective
* copy of articles + OE numbers + aftermarket compatibility + images/eans; the
* 29 GB vehicle-fitment table is intentionally excluded). Given an OEM code from
* the sase catalog, returns the TecDoc articles that carry it as an OE number,
* with their aftermarket equivalents and OE cross-references.
*
* Matching is normalisation-based, not exact: TecDoc stores OE codes with
* spaces/dashes (`1J0 973 702`) while the catalog gives `1J0973702`, so both
* sides are reduced to `[A-Z0-9]` uppercase before comparison (a precomputed
* `code_norm` column, indexed, holds the TecDoc side). Exact matching recovers
* almost nothing — verified ~1/10 vs normalised ~5/10 on real catalog codes.
*
* Never throws: disabled feature, too-short code, connection blip or no match
* all collapse to `matched: false` so the UI has a single empty-state path.
*/
@Injectable()
export class TecdocSourceDbService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(TecdocSourceDbService.name);
private sql: Sql | null = null;
private enabled = false;
// Short normalised codes (e.g. "NA" from "N/A", single digits) collide across
// unrelated parts — refuse to match below this length. Real OE numbers are 5+.
private static readonly MIN_NORM_LEN = 5;
private static readonly MAX_ARTICLES = 60;
private static readonly MAX_AGG = 300;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
const enabled = this.config.get<boolean>("tecdoc.enabled");
const url = this.config.get<string>("tecdoc.url");
if (!enabled || !url) {
this.logger.log(`[tecdoc] disabled (enabled=${enabled}, urlSet=${Boolean(url)})`);
return;
}
this.sql = postgres(url, {
max: 5,
idle_timeout: 30,
connect_timeout: 10,
prepare: false,
});
this.enabled = true;
this.logger.log("[tecdoc] connected, OEM cross-reference lookup enabled");
}
async onModuleDestroy() {
if (this.sql) {
await this.sql.end({ timeout: 5 });
this.sql = null;
}
}
/** `1J0 973 702` / `1j0-973-702` → `1J0973702`. Used for both the input code
* and JS-side dedupe; the TecDoc side is matched against the stored
* `code_norm` (built with the identical rule at import time). */
private static norm(code: string): string {
return code.toUpperCase().replace(/[^A-Z0-9]/g, "");
}
async lookupByOem(rawCode: string): Promise<TecdocOemResult | null> {
const query = (rawCode ?? "").trim();
const queryNorm = TecdocSourceDbService.norm(query);
const miss: TecdocOemResult = {
query,
queryNorm,
matched: false,
articles: [],
aftermarketParts: [],
oeCrossReferences: [],
truncated: false,
};
if (!this.enabled || !this.sql) return miss;
if (queryNorm.length < TecdocSourceDbService.MIN_NORM_LEN) return miss;
try {
const rows = await this.sql<
Array<{
id: string;
brand: string;
article_number: string;
name: string | null;
spare_info: string | null;
oe_numbers: TecdocOeNumber[];
compatible: TecdocCompatible[];
images: Array<{ url: string; thumb: string | null }>;
eans: string[];
}>
>`
WITH hit AS (
SELECT DISTINCT article_id
FROM article_oe_numbers
WHERE code_norm = ${queryNorm}
LIMIT ${TecdocSourceDbService.MAX_ARTICLES}
)
SELECT
a.id::text AS id,
b.name AS brand,
a.article_number AS article_number,
a.name AS name,
a.spare_info AS spare_info,
COALESCE((
SELECT json_agg(json_build_object('brand', o.brand, 'code', o.code))
FROM (
SELECT DISTINCT brand, code FROM article_oe_numbers
WHERE article_id = a.id ORDER BY brand LIMIT 200
) o
), '[]') AS oe_numbers,
COALESCE((
SELECT json_agg(json_build_object('brand', c.compatible_brand, 'article', c.compatible_article))
FROM (
SELECT DISTINCT compatible_brand, compatible_article FROM article_compatibility
WHERE article_id = a.id ORDER BY compatible_brand LIMIT 200
) c
), '[]') AS compatible,
COALESCE((
SELECT json_agg(json_build_object('url', i.image_url, 'thumb', i.thumb_url) ORDER BY i.sort_order)
FROM (
SELECT image_url, thumb_url, sort_order FROM article_images
WHERE article_id = a.id ORDER BY sort_order LIMIT 8
) i
), '[]') AS images,
COALESCE((
SELECT json_agg(e.ean) FROM (
SELECT DISTINCT ean FROM article_ean_numbers WHERE article_id = a.id LIMIT 20
) e
), '[]') AS eans
FROM hit
JOIN articles a ON a.id = hit.article_id
JOIN article_brands b ON b.id = a.brand_id
ORDER BY b.name, a.article_number
`;
if (rows.length === 0) return miss;
let truncated = rows.length >= TecdocSourceDbService.MAX_ARTICLES;
const articles: TecdocArticle[] = rows.map((r) => {
if (r.oe_numbers.length >= 200 || r.compatible.length >= 200) truncated = true;
return {
id: r.id,
brand: r.brand,
articleNumber: r.article_number,
name: r.name,
spareInfo: r.spare_info,
images: r.images,
eans: r.eans,
oeNumbers: r.oe_numbers,
compatible: r.compatible,
};
});
// ── Aggregate: buyable aftermarket part numbers ──────────────────────
// The matched articles are themselves aftermarket parts; their
// compatibility rows add equivalent numbers from other supplier brands.
const afterSeen = new Set<string>();
const aftermarketParts: TecdocOemResult["aftermarketParts"] = [];
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
const key = `${brand.toUpperCase().trim()}${TecdocSourceDbService.norm(articleNumber)}`;
if (afterSeen.has(key) || !articleNumber.trim()) return;
afterSeen.add(key);
if (aftermarketParts.length < TecdocSourceDbService.MAX_AGG) {
aftermarketParts.push({ brand, articleNumber, thumb });
} else {
truncated = true;
}
};
for (const a of articles) {
pushAfter(a.brand, a.articleNumber, a.images[0]?.thumb ?? a.images[0]?.url ?? null);
}
for (const a of articles) {
for (const c of a.compatible) pushAfter(c.brand, c.article, null);
}
// ── Aggregate: OE cross-references (same part, other makes) ───────────
// Exclude restatements of the queried code itself (same normalised code).
const oeSeen = new Set<string>();
const oeCrossReferences: TecdocOeNumber[] = [];
for (const a of articles) {
for (const oe of a.oeNumbers) {
const codeNorm = TecdocSourceDbService.norm(oe.code);
if (codeNorm === queryNorm) continue;
const key = `${oe.brand.toUpperCase().trim()}${codeNorm}`;
if (oeSeen.has(key)) continue;
oeSeen.add(key);
if (oeCrossReferences.length < TecdocSourceDbService.MAX_AGG) {
oeCrossReferences.push(oe);
} else {
truncated = true;
}
}
}
return {
query,
queryNorm,
matched: true,
articles,
aftermarketParts,
oeCrossReferences,
truncated,
};
} catch (err) {
this.logger.warn(`[tecdoc] lookup failed (oem=${query}): ${(err as Error).message}`);
return miss;
}
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query } from "@nestjs/common";
import { TecdocSourceDbService } from "./tecdoc-source-db.service";
@Controller("tecdoc")
export class TecdocController {
constructor(private readonly tecdoc: TecdocSourceDbService) {}
/**
* Resolve an OEM code from the catalog to its TecDoc equivalents.
* `GET /tecdoc/oem?code=1J0973702` → { matched, articles, aftermarketParts,
* oeCrossReferences }. Always 200 with `matched: false` on any miss.
*/
@Get("oem")
async oem(@Query("code") code: string) {
return this.tecdoc.lookupByOem(code ?? "");
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { TecdocSourceDbService } from "./tecdoc-source-db.service";
import { TecdocController } from "./tecdoc.controller";
/**
* OEM cross-reference lookup against the imported TecDoc snapshot (db `td`).
* Raw read-only queries — intentionally no Drizzle schema modelling, mirroring
* CatalogSourceDbModule. Self-disables when TECDOC_DB_* env is unset.
*/
@Module({
controllers: [TecdocController],
providers: [TecdocSourceDbService],
exports: [TecdocSourceDbService],
})
export class TecdocModule {}

View File

@@ -0,0 +1,50 @@
/** An OE (original-equipment) number cross-reference: the same physical part as
* catalogued by a vehicle manufacturer (e.g. VAG `1J0 973 702`). */
export interface TecdocOeNumber {
brand: string;
code: string;
}
/** An aftermarket equivalent: a buyable part number from a supplier brand
* (e.g. FEBI BILSTEIN `171903`). */
export interface TecdocCompatible {
brand: string;
article: string;
}
export interface TecdocImage {
url: string;
thumb: string | null;
}
/** One TecDoc article whose OE number list contains the queried OEM code. */
export interface TecdocArticle {
id: string;
brand: string;
articleNumber: string;
name: string | null;
spareInfo: string | null;
images: TecdocImage[];
eans: string[];
oeNumbers: TecdocOeNumber[];
compatible: TecdocCompatible[];
}
/** Response of the OEM detail lookup. `matched: false` covers every miss —
* feature disabled, code too short, or no TecDoc article carries that OE
* number — so the UI has a single empty-state path. */
export interface TecdocOemResult {
query: string;
queryNorm: string;
matched: boolean;
/** Distinct articles whose OE list contains the queried code. */
articles: TecdocArticle[];
/** Deduped buyable aftermarket part numbers across all matched articles
* (the matched articles themselves + their compatibility entries). */
aftermarketParts: Array<{ brand: string; articleNumber: string; thumb: string | null }>;
/** Deduped OE cross-references across all matched articles, excluding the
* queried code itself — i.e. the same part's numbers under other makes. */
oeCrossReferences: TecdocOeNumber[];
/** True when any per-article list or the article set hit its cap. */
truncated: boolean;
}