feat(pl24): on-demand drill to resolve unseeded "bk. tablo:" references

When a reference's target illustration isn't seeded yet (load-time index
miss → categoryId null), clicking it now calls a new resolve endpoint that
drills the relevant main-group root (its external_id = the code's first
digit; the illustration is a direct child) and re-resolves. One PL24 call in
the common case, bounded + cached; falls back to pre-filled search if not
found. UI shows a spinner on the button while drilling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:41:02 +03:00
parent 232c9ccc7a
commit c440252aa3
3 changed files with 165 additions and 36 deletions

View File

@@ -1481,13 +1481,9 @@ export class CategoriesService {
private static readonly REF_CODE_RE = /\d{3}-\d{3}/g;
private static readonly REF_CODE_IN_NAME_RE = /\{(\d{3}-\d{3})\}/g;
private async resolvePartReferences<T extends { name: string; remark: string | null }>(
rows: T[],
vehicleId: string | null,
): Promise<(T & { references?: Array<{ code: string; categoryId: string | null }> })[]> {
const isRef = (p: T) => p.name?.includes("bk. tablo");
if (!vehicleId || !rows.some(isRef)) return rows;
// Build a code→categoryId index for a vehicle from the `{NNN-NNN}` illustration
// codes embedded in category names. Reflects only what's been seeded so far.
private async buildReferenceIndex(vehicleId: string): Promise<Map<string, string>> {
const cats = await this.db
.select({ id: categories.id, name: categories.name })
.from(categories)
@@ -1499,6 +1495,17 @@ export class CategoriesService {
if (!index.has(m[1])) index.set(m[1], c.id);
}
}
return index;
}
private async resolvePartReferences<T extends { name: string; remark: string | null }>(
rows: T[],
vehicleId: string | null,
): Promise<(T & { references?: Array<{ code: string; categoryId: string | null }> })[]> {
const isRef = (p: T) => p.name?.includes("bk. tablo");
if (!vehicleId || !rows.some(isRef)) return rows;
const index = await this.buildReferenceIndex(vehicleId);
return rows.map((p) => {
if (!isRef(p)) return p;
@@ -1516,6 +1523,83 @@ export class CategoriesService {
});
}
// On-demand resolution of a single "bk. tablo:" code. The target illustration
// lives as a direct (occasionally one-deeper) child of the main-group root
// whose number is the code's first digit — but that branch may not be drilled
// yet, so the load-time index missed it. Drill that root (one PL24 call seeds
// the whole illustration list), then re-resolve. Bounded: root + its group
// children only, capped, stopping on first hit. getChildren is DB-first, so
// repeat clicks on the same code are cheap.
private static readonly REF_DRILL_CALL_CAP = 25;
async resolveReferenceCode(
vehicleId: string,
rawCode: string,
): Promise<{ code: string; categoryId: string | null }> {
const code =
(rawCode ?? "")
.replace(/\\/g, "")
.trim()
.match(/\d{3}-\d{3}/)?.[0] ?? "";
if (!code) return { code: rawCode, categoryId: null };
// Already seeded? cheap path.
let index = await this.buildReferenceIndex(vehicleId);
if (index.has(code)) return { code, categoryId: index.get(code) ?? null };
// Locate the main-group root: its external_id is the code's leading digit.
const mainGroup = code[0];
const roots = await this.db
.select()
.from(categories)
.where(
and(
eq(categories.vehicleId, vehicleId),
isNull(categories.parentId),
eq(categories.externalId, mainGroup),
),
);
if (roots.length === 0) return { code, categoryId: null };
let calls = 0;
const drillAndCheck = async (categoryId: string): Promise<string | null> => {
if (calls >= CategoriesService.REF_DRILL_CALL_CAP) return null;
calls++;
try {
await this.getChildren(categoryId);
} catch (err) {
this.logger.warn(`[ref-drill] getChildren ${categoryId} failed: ${(err as Error).message}`);
return null;
}
index = await this.buildReferenceIndex(vehicleId);
return index.get(code) ?? null;
};
for (const root of roots) {
const hit = await drillAndCheck(root.id);
if (hit) return { code, categoryId: hit };
// Not a direct child — descend one level into freshly-seeded group nodes.
const subs = await this.db
.select({
id: categories.id,
linkWid: categories.linkWid,
hasSubgroups: categories.hasSubgroups,
})
.from(categories)
.where(eq(categories.parentId, root.id));
for (const sub of subs) {
const isGroup = sub.hasSubgroups === true || sub.linkWid?.includes("Group");
if (!isGroup) continue;
const subHit = await drillAndCheck(sub.id);
if (subHit) return { code, categoryId: subHit };
if (calls >= CategoriesService.REF_DRILL_CALL_CAP) break;
}
}
return { code, categoryId: null };
}
async getById(categoryId: string) {
const [category] = await this.db
.select()

View File

@@ -94,6 +94,15 @@ export class VehiclesController {
return this.categoriesService.getCategoryWithParts(categoryId);
}
// On-demand resolution of a "bk. tablo:" cross-reference code whose target
// illustration wasn't seeded at category-load time — drills the relevant
// main group, then returns the resolved category (or null if not found).
@Get(":vehicleId/references/resolve")
async resolveReference(@Param("vehicleId") vehicleId: string, @Query("code") code: string) {
await this.vehiclesService.getById(vehicleId);
return this.categoriesService.resolveReferenceCode(vehicleId, code ?? "");
}
@Get(":id")
async getById(@Param("id") id: string) {
return this.vehiclesService.getById(id);

View File

@@ -4,7 +4,7 @@ import { capture } from "@/lib/posthog";
import { useSchemaStore } from "@/stores/schema.store";
import { Button, Skeleton, cn } from "@sase/ui";
import { useNavigate } from "@tanstack/react-router";
import { ArrowRight, Check, Copy } from "lucide-react";
import { ArrowRight, Check, Copy, Loader2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
interface PartsPanelProps {
@@ -23,12 +23,14 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const viewedKeyRef = useRef<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [resolvingCode, setResolvingCode] = useState<string | null>(null);
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved → open the target
// illustration; unresolved (target branch not seeded yet) → pre-fill the
// vehicle catalog search with the code so the user lands on it once seeded.
// PL24 "bk. tablo:NNN-NNN" cross-reference jump. Resolved at load → open the
// target illustration directly. Unresolved (target branch not seeded yet) →
// ask the server to drill it on demand; if that finds it, jump there, else
// fall back to a pre-filled catalog search.
const goToReference = useCallback(
(ref: { code: string; categoryId: string | null }) => {
async (ref: { code: string; categoryId: string | null }) => {
if (!vehicleId) return;
capture("part_reference_clicked", {
code: ref.code,
@@ -36,18 +38,44 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
vehicle_id: vehicleId,
category_id: categoryId,
});
if (ref.categoryId) {
const openCategory = (targetId: string) =>
navigate({
to: "/dashboard/vehicles/$id/categories/$categoryId",
params: { id: vehicleId, categoryId: ref.categoryId },
});
} else {
navigate({
to: "/dashboard/vehicles/$id",
params: { id: vehicleId },
search: { q: ref.code },
params: { id: vehicleId, categoryId: targetId },
});
if (ref.categoryId) {
openCategory(ref.categoryId);
return;
}
// On-demand drill: seed the referenced illustration, then jump to it.
setResolvingCode(ref.code);
try {
const res = await api.get<{ code: string; categoryId: string | null }>(
`/vehicles/${vehicleId}/references/resolve?code=${encodeURIComponent(ref.code)}`,
);
capture("part_reference_drilled", {
code: ref.code,
resolved: res.categoryId != null,
vehicle_id: vehicleId,
});
if (res.categoryId) {
openCategory(res.categoryId);
return;
}
} catch {
// fall through to search
} finally {
setResolvingCode(null);
}
navigate({
to: "/dashboard/vehicles/$id",
params: { id: vehicleId },
search: { q: ref.code },
});
},
[vehicleId, categoryId, navigate],
);
@@ -189,22 +217,30 @@ export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPan
<td className="px-3 py-2" colSpan={hasPrices ? 5 : 4}>
{label && <span className="font-medium">{label}</span>}
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
{refs.map((ref) => (
<button
key={ref.code}
type="button"
onClick={() => goToReference(ref)}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
title={
ref.categoryId
? `Tabloya git: ${ref.code}`
: `Katalogda ara: ${ref.code}`
}
>
<ArrowRight className="size-3.5 shrink-0" />
bk. tablo: {ref.code}
</button>
))}
{refs.map((ref) => {
const isResolving = resolvingCode === ref.code;
return (
<button
key={ref.code}
type="button"
disabled={isResolving}
onClick={() => goToReference(ref)}
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline disabled:opacity-60"
title={
ref.categoryId
? `Tabloya git: ${ref.code}`
: `Tabloyu bul: ${ref.code}`
}
>
{isResolving ? (
<Loader2 className="size-3.5 shrink-0 animate-spin" />
) : (
<ArrowRight className="size-3.5 shrink-0" />
)}
bk. tablo: {ref.code}
</button>
);
})}
</span>
</td>
</tr>