feat(p): link only OEM codes with a cross-reference match

Every OEM code was a link → unmatched codes (the majority — fasteners,
clips, body parts TecDoc doesn't carry) opened an empty "no
equivalents" page. Now the schema page batch-checks its codes against
the snapshot (POST /p/matched, one indexed query) and links ONLY the
matched ones; unmatched codes render as plain text. Copy stays for all.
No more dead-end empty pages from the parts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:21:25 +03:00
parent 572337ba62
commit 4ffc3577a5
3 changed files with 80 additions and 6 deletions

View File

@@ -64,6 +64,39 @@ export class PSourceDbService implements OnModuleInit, OnModuleDestroy {
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);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Query } from "@nestjs/common";
import { Body, Controller, Get, Post, Query } from "@nestjs/common";
import { PSourceDbService } from "./p-source-db.service";
@Controller("p")
@@ -14,4 +14,15 @@ export class PController {
async oem(@Query("code") code: string) {
return this.p.lookupByOem(code ?? "");
}
/**
* Batch: which of these OEM codes have a P cross-reference. The schema page
* sends the codes it's about to render and links only the returned ones, so
* unmatched codes stay plain text and never open an empty detail page.
* `POST /p/matched { codes: [...] }` → { matched: [...] }.
*/
@Post("matched")
async matched(@Body("codes") codes: string[]) {
return { matched: await this.p.matchedCodes(Array.isArray(codes) ? codes : []) };
}
}

View File

@@ -37,6 +37,34 @@ export function PartsPanel({
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
// OEM codes that resolve to a non-empty cross-reference page. Only these are
// rendered as links — unmatched codes stay plain text so a click never lands
// on an empty "no equivalents" page. One batch lookup per parts list.
const [matchedOemCodes, setMatchedOemCodes] = useState<Set<string>>(new Set());
const oemCodes = useMemo(
() => [...new Set(parts.map((p) => p.oemCode).filter((c) => c && c !== "N/A"))],
[parts],
);
useEffect(() => {
if (oemCodes.length === 0) {
setMatchedOemCodes(new Set());
return;
}
let cancelled = false;
api
.post<{ matched: string[] }>("/p/matched", { codes: oemCodes })
.then((res) => {
if (!cancelled) setMatchedOemCodes(new Set(res?.matched ?? []));
})
.catch(() => {
if (!cancelled) setMatchedOemCodes(new Set());
});
return () => {
cancelled = true;
};
}, [oemCodes]);
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
// target illustration directly. Unresolved (target branch not seeded yet) →
@@ -365,11 +393,13 @@ export function PartsPanel({
)}
</button>
)}
{part.oemCode && part.oemCode !== "N/A" ? (
// Click → OEM detail page (P cross-reference) in a
// new tab so the catalog/schema context stays put. Plain
// anchor (not router Link) — a fresh load resolves the
// route and keeps this cell router-context-free.
{part.oemCode &&
part.oemCode !== "N/A" &&
matchedOemCodes.has(part.oemCode) ? (
// Link ONLY codes with a real cross-reference (new tab,
// keeps catalog/schema context). Plain anchor, not router
// Link — fresh load resolves the route, no router context
// needed here. Unmatched codes fall through to plain text.
<a
href={`/dashboard/oem/${encodeURIComponent(part.oemCode)}`}
target="_blank"