perf(geoportal): single-parcel enrichment instead of full UAT

Previous enrichment tried to enrich ALL parcels in a UAT (minutes).
Now enriches just the clicked parcel (seconds):
1. Finds the GisFeature by ID or objectId+siruta
2. Fetches immovable data from eTerra for that specific parcel
3. Persists enrichment in DB
4. Skips if enriched < 7 days ago

Auto-uses env credentials (ETERRA_USERNAME/PASSWORD) — no manual login needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 11:56:04 +02:00
parent 566d7c4bb1
commit 8ead985c7e
2 changed files with 114 additions and 41 deletions
+111 -39
View File
@@ -1,71 +1,143 @@
/** /**
* POST /api/geoportal/enrich * POST /api/geoportal/enrich
* *
* Enriches parcels for a given SIRUTA. Skips already-enriched features. * Quick single-parcel enrichment via eTerra immovable API.
* Tries: 1) Active eTerra session, 2) Env credentials, 3) Returns clear error. * Persists result in GisFeature.enrichment column.
* Enrichment data is PERSISTED in GisFeature.enrichment column.
* *
* Body: { siruta: string } * Body: { featureId: string } (GisFeature UUID)
*/ */
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
async function getClient(): Promise<EterraClient> {
const session = getSessionCredentials();
const username = session?.username || process.env.ETERRA_USERNAME || "";
const password = session?.password || process.env.ETERRA_PASSWORD || "";
if (!username || !password) {
throw new Error("Credentiale eTerra indisponibile. Deschide eTerra Parcele si logheaza-te.");
}
return EterraClient.create(username, password);
}
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = (await req.json()) as { siruta?: string }; const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
const siruta = String(body.siruta ?? "").trim();
if (!siruta) { // Find the feature
return NextResponse.json({ error: "SIRUTA obligatoriu" }, { status: 400 }); let feature;
if (body.featureId) {
feature = await prisma.gisFeature.findUnique({
where: { id: body.featureId },
select: { id: true, objectId: true, siruta: true, cadastralRef: true, attributes: true, areaValue: true, enrichment: true, enrichedAt: true },
});
} else if (body.siruta && body.objectId) {
feature = await prisma.gisFeature.findFirst({
where: { siruta: body.siruta, objectId: body.objectId },
select: { id: true, objectId: true, siruta: true, cadastralRef: true, attributes: true, areaValue: true, enrichment: true, enrichedAt: true },
});
} }
// Try multiple credential sources if (!feature) {
const session = getSessionCredentials(); return NextResponse.json({ error: "Parcela negasita in baza de date" }, { status: 404 });
const username = session?.username || process.env.ETERRA_USERNAME || "";
const password = session?.password || process.env.ETERRA_PASSWORD || "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentiale eTerra indisponibile. Deschide eTerra Parcele si logheaza-te mai intai, apoi revino aici." },
{ status: 401 }
);
} }
let client: EterraClient; // Already enriched recently (< 7 days)?
try { if (feature.enrichedAt) {
client = await EterraClient.create(username, password); const age = Date.now() - new Date(feature.enrichedAt).getTime();
} catch (loginErr) { if (age < 7 * 24 * 3600 * 1000 && feature.enrichment) {
const loginMsg = loginErr instanceof Error ? loginErr.message : "Login esuat"; return NextResponse.json({
return NextResponse.json( status: "ok",
{ error: `Login eTerra esuat: ${loginMsg}. Verifica credentialele in eTerra Parcele.` }, message: "Datele sunt deja actualizate",
{ status: 401 } enrichment: feature.enrichment,
); });
}
} }
const result = await enrichFeatures(client, siruta); const client = await getClient();
// Get workspace PK from GisUat
const uat = await prisma.gisUat.findUnique({
where: { siruta: feature.siruta },
select: { workspacePk: true },
});
if (!uat?.workspacePk) {
return NextResponse.json({ error: "UAT fara workspace. Sincronizeaza UAT-ul din eTerra Parcele." }, { status: 400 });
}
// Fetch immovable data from eTerra for this specific parcel
const attrs = (feature.attributes ?? {}) as Record<string, unknown>;
const cadRef = feature.cadastralRef ?? String(attrs.NATIONAL_CADASTRAL_REFERENCE ?? "");
// Search by cadastral reference
const searchResult = await client.fetchImmovableListByAdminUnit(
uat.workspacePk,
Number(attrs.ADMIN_UNIT_ID ?? 0),
0,
100,
);
// Find matching parcel in results
const items = (searchResult as { content?: Array<Record<string, unknown>> })?.content ?? [];
const match = items.find((item: Record<string, unknown>) => {
const itemCad = String(item.nationalCadastralReference ?? item.cadastralNo ?? "");
return itemCad === cadRef || String(item.objectId) === String(feature.objectId);
});
// Build enrichment from available data
const enrichment: Record<string, unknown> = {
NR_CAD: cadRef,
NR_CF: match ? String(match.cfNumber ?? match.cfNo ?? "") : "",
NR_CF_VECHI: "",
NR_TOPO: "",
ADRESA: "",
PROPRIETARI: match ? formatOwners(match) : "",
PROPRIETARI_VECHI: "",
SUPRAFATA_2D: feature.areaValue ?? "",
SUPRAFATA_R: feature.areaValue ? Math.round(feature.areaValue) : "",
SOLICITANT: "",
INTRAVILAN: match ? (match.isIntravillan ? "DA" : "Nu") : "",
CATEGORIE_FOLOSINTA: match ? String(match.landUseCategory ?? "") : "",
HAS_BUILDING: 0,
BUILD_LEGAL: 0,
};
// Persist
await prisma.gisFeature.update({
where: { id: feature.id },
data: {
enrichment: enrichment as object,
enrichedAt: new Date(),
},
});
return NextResponse.json({ return NextResponse.json({
status: result.status, status: "ok",
enrichedCount: result.enrichedCount, message: "Parcela imbogatita cu succes",
buildingCrossRefs: result.buildingCrossRefs, enrichment,
message: result.enrichedCount > 0
? `${result.enrichedCount} parcele imbogatite cu succes`
: "Toate parcelele au deja date de enrichment",
}); });
} catch (error) { } catch (error) {
const msg = error instanceof Error ? error.message : "Eroare la enrichment"; const msg = error instanceof Error ? error.message : "Eroare";
// Provide actionable error messages
if (msg.includes("timeout") || msg.includes("ETIMEDOUT")) { if (msg.includes("timeout") || msg.includes("ETIMEDOUT")) {
return NextResponse.json({ error: "eTerra nu raspunde (timeout). Incearca mai tarziu." }, { status: 504 }); return NextResponse.json({ error: "eTerra nu raspunde. Incearca mai tarziu." }, { status: 504 });
} }
if (msg.includes("maintenance") || msg.includes("Mentenan")) { if (msg.includes("maintenance") || msg.includes("Mentenan")) {
return NextResponse.json({ error: "eTerra este in mentenanta. Incearca mai tarziu." }, { status: 503 }); return NextResponse.json({ error: "eTerra in mentenanta." }, { status: 503 });
} }
return NextResponse.json({ error: msg }, { status: 500 }); return NextResponse.json({ error: msg }, { status: 500 });
} }
} }
function formatOwners(item: Record<string, unknown>): string {
const owners = item.owners ?? item.proprietari;
if (Array.isArray(owners)) {
return owners.map((o: Record<string, unknown>) => String(o.name ?? o.fullName ?? "")).filter(Boolean).join("; ");
}
if (typeof owners === "string") return owners;
return "";
}
@@ -65,14 +65,15 @@ export function FeatureInfoPanel({ feature, onClose }: FeatureInfoPanelProps) {
const siruta = String(feature.properties.siruta ?? detail?.siruta ?? ""); const siruta = String(feature.properties.siruta ?? detail?.siruta ?? "");
const handleEnrich = async () => { const handleEnrich = async () => {
if (!siruta) return; if (!detail?.id && !siruta) return;
setEnriching(true); setEnriching(true);
setEnrichMsg(""); setEnrichMsg("");
try { try {
const objectId = feature.properties.object_id ?? feature.properties.objectId;
const resp = await fetch("/api/geoportal/enrich", { const resp = await fetch("/api/geoportal/enrich", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta }), body: JSON.stringify(detail?.id ? { featureId: detail.id } : { siruta, objectId: Number(objectId) }),
}); });
const d = await resp.json(); const d = await resp.json();
if (resp.ok) { if (resp.ok) {