OEM detail "slow load" was two things, not the query (DB 135ms / API 13-120ms): 1. The parts-panel link opened a NEW TAB → full SPA cold boot every click. Switch to in-app client navigation on plain click (real href kept, so ctrl/cmd/middle-click still opens a new tab). 2. The /p/oem response shipped each article's oeNumbers + compatible lists (up to 200 each × 60 articles) that the UI never renders — 96% of a 370 KB payload. Ship lean articles; aggregates already carry the cross-refs. 60-article code: 370 KB → ~12 KB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import postgres, { type Sql } from "postgres";
|
|
import type { PArticle, PCompatible, POeNumber, POemResult } from "./p.types";
|
|
|
|
/**
|
|
* Read-only lookup against the imported P 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 P articles that carry it as an OE number,
|
|
* with their aftermarket equivalents and OE cross-references.
|
|
*
|
|
* Matching is normalisation-based, not exact: P 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 P 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 PSourceDbService implements OnModuleInit, OnModuleDestroy {
|
|
private readonly logger = new Logger(PSourceDbService.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>("p.enabled");
|
|
const url = this.config.get<string>("p.url");
|
|
if (!enabled || !url) {
|
|
this.logger.log(`[p] 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("[p] 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 P 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, "");
|
|
}
|
|
|
|
/**
|
|
* Batch membership test for the schema page: given the OEM codes in a parts
|
|
* list, return the subset that has at least one OE cross-reference in the
|
|
* snapshot. The panel links only these (and renders the rest as plain text),
|
|
* so a click never lands on an empty "no equivalents" detail page. One indexed
|
|
* query (`code_norm` btree). Fail-open to [] (→ no links) when disabled / on
|
|
* error, echoing back the caller's original spelling for the matched codes.
|
|
*/
|
|
async matchedCodes(rawCodes: string[]): Promise<string[]> {
|
|
if (!this.enabled || !this.sql || !rawCodes?.length) return [];
|
|
const rawByNorm = new Map<string, string>();
|
|
for (const raw of rawCodes) {
|
|
const n = PSourceDbService.norm(raw ?? "");
|
|
if (n.length >= PSourceDbService.MIN_NORM_LEN && !rawByNorm.has(n)) rawByNorm.set(n, raw);
|
|
}
|
|
if (rawByNorm.size === 0) return [];
|
|
try {
|
|
const norms = [...rawByNorm.keys()];
|
|
const rows = await this.sql<Array<{ code_norm: string }>>`
|
|
SELECT DISTINCT code_norm FROM article_oe_numbers
|
|
WHERE code_norm IN ${this.sql(norms)}
|
|
`;
|
|
return rows
|
|
.map((r) => rawByNorm.get(r.code_norm))
|
|
.filter((c): c is string => c !== undefined);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`[p] matchedCodes failed (${rawByNorm.size} codes): ${(err as Error).message}`,
|
|
);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async lookupByOem(rawCode: string): Promise<POemResult | null> {
|
|
const query = (rawCode ?? "").trim();
|
|
const queryNorm = PSourceDbService.norm(query);
|
|
const miss: POemResult = {
|
|
query,
|
|
queryNorm,
|
|
matched: false,
|
|
articles: [],
|
|
aftermarketParts: [],
|
|
oeCrossReferences: [],
|
|
truncated: false,
|
|
};
|
|
|
|
if (!this.enabled || !this.sql) return miss;
|
|
if (queryNorm.length < PSourceDbService.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: POeNumber[];
|
|
compatible: PCompatible[];
|
|
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 ${PSourceDbService.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 (
|
|
-- Only publicly-resolvable URLs. The current snapshot stores
|
|
-- scrape-local '/_debug/...' paths (404 off-host) — filtering them
|
|
-- here keeps the API contract honest so the UI shows no broken
|
|
-- thumbnails; real CDN URLs surface automatically once present.
|
|
SELECT image_url, thumb_url, sort_order FROM article_images
|
|
WHERE article_id = a.id AND image_url LIKE 'http%'
|
|
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 >= PSourceDbService.MAX_ARTICLES;
|
|
|
|
const articles: PArticle[] = 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: POemResult["aftermarketParts"] = [];
|
|
const pushAfter = (brand: string, articleNumber: string, thumb: string | null) => {
|
|
const key = `${brand.toUpperCase().trim()}␟${PSourceDbService.norm(articleNumber)}`;
|
|
if (afterSeen.has(key) || !articleNumber.trim()) return;
|
|
afterSeen.add(key);
|
|
if (aftermarketParts.length < PSourceDbService.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: POeNumber[] = [];
|
|
for (const a of articles) {
|
|
for (const oe of a.oeNumbers) {
|
|
const codeNorm = PSourceDbService.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 < PSourceDbService.MAX_AGG) {
|
|
oeCrossReferences.push(oe);
|
|
} else {
|
|
truncated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
query,
|
|
queryNorm,
|
|
matched: true,
|
|
// Ship lean articles — the per-article oeNumbers/compatible lists were
|
|
// only needed above to build the aggregates; sending them too bloated
|
|
// heavy responses ~30x (370 KB → ~12 KB for a 60-article code).
|
|
articles: articles.map((a) => ({
|
|
id: a.id,
|
|
brand: a.brand,
|
|
articleNumber: a.articleNumber,
|
|
name: a.name,
|
|
spareInfo: a.spareInfo,
|
|
images: a.images,
|
|
eans: a.eans,
|
|
})),
|
|
aftermarketParts,
|
|
oeCrossReferences,
|
|
truncated,
|
|
};
|
|
} catch (err) {
|
|
this.logger.warn(`[p] lookup failed (oem=${query}): ${(err as Error).message}`);
|
|
return miss;
|
|
}
|
|
}
|
|
}
|