fix(geoportal-v2): siruta-aware parcela lookup (B1 round 2)

Previous fix searched by cadastralRef and picked the first
layerId-matching result. But cadastral refs collide across UATs:
"354686" exists in multiple counties. The Cluj-Napoca f9bf2ca4-...
parcel with full enrichment got passed over for a same-cad parcel
in another UAT that has no enrichment → panel rendered header +
"Caracteristici" with empty Intravilan, no "Date eTerra" section.

New server-side /api/gis/parcela/find?siruta&cad&layerId proxy:
- gisApi.search(cad) → filter by layerId → up to ~20 candidates
- For each candidate, parcela.get and check stored siruta
- Return the siruta-matching detail
- Fallback: first readable candidate (so the panel still has data
  even if siruta mismatch — better than empty)

Panel useEffect simplified: fast path = parcela.get by uuid when the
tile has one, slow path = parcela/find when not. 404 from find sets
the "not in central DB yet" empty state (user can hit Citește din
ANCPI to trigger orchestrator live-fetch).

Diagnostic logs: [gis-parcela-find] siruta=… cad=… layerId=…
candidates=N + per-hit "has_enrich=true keys=N" so we can tell from
container logs whether the right parcel resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 15:26:49 +03:00
parent b5eff5acc1
commit 7afba6e1a9
2 changed files with 121 additions and 25 deletions
+105
View File
@@ -0,0 +1,105 @@
import { NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth/require-auth";
import { gisApi, GisApiError } from "@/lib/gis-api-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Lookup the canonical GisFeature for a click that arrives without a uuid
// (PMTiles overview tiles only carry siruta / cadastral_ref / object_id).
//
// gis-api `/api/v1/search` indexes by cadastralRef trigram but does not
// return siruta per feature → two parcels in different UATs with the same
// cadref are indistinguishable from the search response alone. So we
// search, filter by layerId, then resolve each candidate via parcela.get
// and pick the one whose stored siruta matches the click. First match
// wins; if no candidate matches siruta we surface the closest hit (same
// cadref + layerId) so the panel still has something to render rather
// than a hard miss.
export async function GET(request: Request) {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const siruta = (searchParams.get("siruta") ?? "").trim();
const cad = (searchParams.get("cad") ?? "").trim();
const layerId = (searchParams.get("layerId") ?? "TERENURI_ACTIVE").trim();
if (!siruta || !cad) {
return NextResponse.json(
{ error: "missing_fields", required: ["siruta", "cad"] },
{ status: 400 },
);
}
try {
const sr = await gisApi.search(cad, 20);
const candidates = (sr.features ?? []).filter(
(f) => f.cadastralRef === cad && f.layerId === layerId,
);
console.log(
"[gis-parcela-find] siruta=%s cad=%s layerId=%s candidates=%d",
siruta,
cad,
layerId,
candidates.length,
);
if (candidates.length === 0) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
let fallback: unknown = null;
for (const c of candidates) {
try {
const detail = (await gisApi.parcela.get(c.id)) as {
siruta?: string;
[k: string]: unknown;
} | null;
if (!detail) continue;
if (typeof fallback !== "object" || fallback === null) fallback = detail;
if (String(detail.siruta ?? "") === siruta) {
const enr = (detail as { enrichment?: Record<string, unknown> }).enrichment ?? null;
console.log(
"[gis-parcela-find] hit id=%s has_enrich=%s keys=%d",
c.id.slice(0, 8),
!!enr,
enr ? Object.keys(enr).length : 0,
);
return NextResponse.json(detail);
}
} catch (e) {
// Skip candidates that 403 (scope) or fail individually.
if (e instanceof GisApiError && e.status === 403) continue;
throw e;
}
}
// No exact siruta match — return the best-effort candidate (first one
// we could read). Better than an empty panel; the user can verify
// via the Citește din ANCPI button if they think it's wrong.
if (fallback) {
console.log(
"[gis-parcela-find] no_siruta_match cad=%s — returning fallback",
cad,
);
return NextResponse.json(fallback);
}
return NextResponse.json({ error: "not_found" }, { status: 404 });
} catch (err) {
if (err instanceof GisApiError) {
console.log("[gis-parcela-find] gis-api %d %s", err.status, err.code);
return NextResponse.json(
{ error: err.code, status: err.status },
{ status: err.status },
);
}
const msg = err instanceof Error ? err.message : String(err);
console.error("[gis-parcela-find] internal:", msg);
return NextResponse.json(
{ error: "internal_error", hint: msg.slice(0, 200) },
{ status: 500 },
);
}
}
+14 -23
View File
@@ -165,34 +165,25 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
const run = async () => {
try {
let id = feature.id;
if (!id) {
const sr = await fetch(
`/api/gis/search?q=${encodeURIComponent(feature.cadastralRef)}&limit=20`,
let r: Response;
if (feature.id) {
// Fast path: tile carried the uuid.
r = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`);
} else {
// PMTiles overview: no uuid → use the server-side lookup that
// resolves siruta+cadref+layerId across candidate matches.
r = await fetch(
`/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` +
`&cad=${encodeURIComponent(feature.cadastralRef)}` +
`&layerId=${encodeURIComponent(feature.layerId)}`,
);
}
if (cancelled) return;
if (!sr.ok) {
setError("search_failed");
if (r.status === 404) {
// Parcel not in central DB yet — show header only; user can hit "Citește din ANCPI"
setLoading(false);
return;
}
const sd = (await sr.json()) as {
features?: Array<{ id: string; layerId: string; cadastralRef: string }>;
};
const match = (sd.features ?? []).find(
(f) =>
f.cadastralRef === feature.cadastralRef &&
f.layerId === feature.layerId,
);
if (!match) {
// Parcel not in central DB — show header only; user can press "Citește din ANCPI"
setLoading(false);
return;
}
id = match.id;
}
const r = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`);
if (cancelled) return;
if (r.status === 403) {
setError("forbidden");
setLoading(false);