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