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:
@@ -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);
|
||||
|
||||
@@ -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 : []) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user