feat(geoportal-v2): auto-fetch enrichment when DB only has tech keys

Parcel 328607 in Cluj-Napoca (and many others) is in gis_core with
only 10 enrichment keys, all tech-level (PARCEL_HAS_LANDBOOK,
PARCEL_IS_CONDOMINIUM, etc.) — no NR_CF, no ADRESA, no PROPRIETARI.
The panel renders correctly but with nothing of substance shown.
User had to manually click "Citește din ANCPI" to backfill.

Now auto-fires the fetch when:
  - Panel mounts a fresh detail (or after parcel switch)
  - AND enrichment lacks ALL three of NR_CF / ADRESA / PROPRIETARI
  - AND we haven't already auto-fetched for this parcel this tab
    session (sessionStorage dedupe keyed by uuid OR siruta+cad+layerId)

Visible feedback while it runs: a quiet "Se preiau date suplimentare
din ANCPI…" strip below the loading area. The user can keep reading
whatever is already on screen.

Side fixes:
- refreshFromAncpi → useCallback (stable deps) so it can sit in the
  auto-trigger useEffect's dep array without infinite loops.
- Refresh path now uses /api/gis/parcela/find as the fallback when no
  uuid is available, matching the initial-load logic. The old
  orchestrator-shape projection still exists as a last-resort fallback
  but is rarely hit.

Note: orchestrator's parcel/tech only re-populates PARCEL_* tech
fields. Truly enriching rich PII (NR_CF/ADRESA/PROPRIETARI) needs the
"deep enrich" orchestrator path which the gis-api proxy contract
doesn't expose yet — separate gis-api task. So parcels that only ever
got tech-level enrichment will stay at tech-level even after this
auto-fetch. The visible improvement is: parcels that DO have rich
data load it in the first second instead of needing a manual click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 19:52:00 +03:00
parent 342bdca648
commit 87f9d72e4f
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/react";
import {
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
@@ -409,7 +409,7 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
};
}, [isCladiri, feature.siruta, feature.cadastralRef, basic]);
const refreshFromAncpi = async () => {
const refreshFromAncpi = useCallback(async () => {
if (!feature.siruta || !feature.cadastralRef) {
setError("missing_siruta_or_cad");
return;
@@ -432,15 +432,26 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
return;
}
const techData = await res.json().catch(() => null);
// Refresh stored detail via parcela.get if uuid known.
if (detail?.id || feature.id) {
// After orchestrator updates the central DB, re-fetch via the
// server-side find/get path so we land on the canonical shape
// (and pick up rich enrichment that the tech response itself
// doesn't carry).
const id = detail?.id ?? feature.id;
const updated = await fetch(
`/api/gis/parcela/${encodeURIComponent(id)}`,
let updated: Response | null = null;
if (id) {
updated = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`);
} else {
updated = await fetch(
`/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` +
`&cad=${encodeURIComponent(feature.cadastralRef)}` +
`&layerId=${encodeURIComponent(feature.layerId)}`,
);
if (updated.ok) setDetail(await updated.json());
}
if (updated.ok) {
setDetail(await updated.json());
} else if (techData) {
// Project the orchestrator response into the panel directly.
// Final fallback — project the orchestrator response if we
// can't re-fetch the canonical record.
const inner =
(techData?.data as Record<string, unknown> | undefined) ?? techData;
setDetail({
@@ -456,7 +467,44 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
} finally {
setRefreshing(false);
}
};
}, [
feature.id,
feature.siruta,
feature.cadastralRef,
feature.layerId,
feature.areaValue,
detail?.id,
]);
// Auto-fetch from ANCPI when the central DB has only tech-level keys
// (no NR_CF / ADRESA / PROPRIETARI). The orchestrator caches its results
// for 30 days so repeat clicks on the same parcel don't re-spend quota.
// Dedupe per parcel-key via sessionStorage so a remount or React
// strict-mode double-fire doesn't double the call.
useEffect(() => {
if (basic || !detail || refreshing) return;
const e = (detail.enrichment ?? {}) as Record<string, unknown>;
const richPresent = Boolean(e.NR_CF || e.ADRESA || e.PROPRIETARI);
if (richPresent) return;
const parcelKey =
String(detail.id ?? "") ||
`${feature.siruta}:${feature.cadastralRef}:${feature.layerId}`;
const ssKey = `gis_auto_enrich_${parcelKey}`;
if (typeof sessionStorage === "undefined") return;
if (sessionStorage.getItem(ssKey)) return;
sessionStorage.setItem(ssKey, "1");
void refreshFromAncpi();
}, [
basic,
detail,
refreshing,
feature.siruta,
feature.cadastralRef,
feature.layerId,
refreshFromAncpi,
]);
const exportGpkg = () => {
const url = `https://eterra.live/harta?siruta=${encodeURIComponent(
@@ -585,6 +633,14 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
</div>
)}
{/* Auto-enrich is running in background (sparse DB record → fetched from ANCPI) */}
{refreshing && !loading && (
<div className="flex items-center gap-2 border-b bg-muted/20 px-3 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Se preiau date suplimentare din ANCPI</span>
</div>
)}
{/* Forbidden after a silent re-grant attempt already failed
once — likely Authentik scope misconfig. Stays discreet (no
call-to-action), only diag info for the operator. */}