fix(geoportal): per-parcel enrichment via searchImmovableByIdentifier

Replaces background UAT-wide enrichment with instant per-parcel search.
Uses eTerra searchImmovableByIdentifier (cadastral number lookup) which
returns in 1-3 seconds instead of minutes.

Extracts: NR_CF, proprietari (with shares), intravilan, categorie,
adresa, has_building, build_legal. Persists in DB immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 13:35:03 +02:00
parent 5ff7d4cdd7
commit 48fe47d2c0
+126 -61
View File
@@ -1,98 +1,163 @@
/**
* POST /api/geoportal/enrich
*
* Triggers enrichment for a SIRUTA (runs in background, returns immediately).
* Uses the proven enrichFeatures() from ParcelSync which properly fetches
* owners, CF, categories from eTerra.
* Quick per-parcel enrichment via eTerra searchImmovableByIdentifier.
* Searches by cadastral number, extracts owner/CF/category data.
* Persists in GisFeature.enrichment column.
*
* Body: { siruta: string, featureId?: string }
* Body: { featureId: string } or { siruta: string, objectId: number }
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
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";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Track running enrichments to avoid duplicates
const runningEnrichments = new Set<string>();
export async function POST(req: Request) {
try {
const body = (await req.json()) as { siruta?: string; featureId?: string; objectId?: number };
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
// Resolve siruta from featureId if needed
let siruta = String(body.siruta ?? "").trim();
if (!siruta && body.featureId) {
const f = await prisma.gisFeature.findUnique({
// Find feature
let feature;
if (body.featureId) {
feature = await prisma.gisFeature.findUnique({
where: { id: body.featureId },
select: { siruta: true },
select: { id: true, objectId: true, siruta: true, cadastralRef: true, attributes: true, areaValue: 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 },
});
siruta = f?.siruta ?? "";
}
if (!siruta) {
return NextResponse.json({ error: "SIRUTA obligatoriu" }, { status: 400 });
}
// Already running for this SIRUTA?
if (runningEnrichments.has(siruta)) {
return NextResponse.json({
status: "running",
message: "Enrichment deja in curs pentru acest UAT. Datele vor aparea in cateva minute.",
});
if (!feature) {
return NextResponse.json({ error: "Parcela negasita in DB" }, { status: 404 });
}
// Get credentials
const session = getSessionCredentials();
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." },
{ status: 401 }
);
return NextResponse.json({ error: "Credentiale eTerra lipsa. Logheaza-te in eTerra Parcele." }, { status: 401 });
}
// If featureId provided, clear bad enrichment so it gets re-processed
if (body.featureId) {
await prisma.gisFeature.update({
where: { id: body.featureId },
data: { enrichment: null, enrichedAt: null },
}).catch(() => {});
}
// Start enrichment in background (don't await)
runningEnrichments.add(siruta);
EterraClient.create(username, password)
.then((client) => enrichFeatures(client, siruta))
.then((result) => {
console.log(`[Enrich] ${siruta}: ${result.enrichedCount} parcele enriched`);
})
.catch((err) => {
console.error(`[Enrich] ${siruta}: ${err instanceof Error ? err.message : err}`);
})
.finally(() => {
runningEnrichments.delete(siruta);
});
return NextResponse.json({
status: "started",
message: "Enrichment pornit in background. Datele vor aparea in 1-3 minute. Reincarca parcela dupa.",
// Get workspace + admin unit from GisUat and feature attributes
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 });
}
const attrs = (feature.attributes ?? {}) as Record<string, unknown>;
const cadRef = feature.cadastralRef ?? String(attrs.NATIONAL_CADASTRAL_REFERENCE ?? "");
const adminUnitId = Number(attrs.ADMIN_UNIT_ID ?? 0);
if (!cadRef) {
return NextResponse.json({ error: "Parcela fara numar cadastral" }, { status: 400 });
}
// Search eTerra by cadastral number
const client = await EterraClient.create(username, password);
const result = await client.searchImmovableByIdentifier(
uat.workspacePk,
adminUnitId,
cadRef,
);
const items = (result?.content ?? result?.data ?? []) as Array<Record<string, unknown>>;
const match = items[0]; // First result should be the match
if (!match) {
// No match in eTerra - save what we know from GIS
const enrichment = buildBasicEnrichment(cadRef, feature.areaValue);
await prisma.gisFeature.update({
where: { id: feature.id },
data: { enrichment: enrichment as object, enrichedAt: new Date() },
});
return NextResponse.json({ status: "ok", message: "Date de baza salvate (parcela negasita in registrul eTerra)", enrichment });
}
// Build enrichment from eTerra data
const enrichment = {
NR_CAD: cadRef,
NR_CF: String(match.cfNumber ?? match.landBookNo ?? ""),
NR_CF_VECHI: String(match.oldCfNumber ?? match.oldLandBookNo ?? ""),
NR_TOPO: String(match.topographicNumber ?? match.topoNo ?? ""),
ADRESA: formatAddress(match),
PROPRIETARI: formatOwners(match),
PROPRIETARI_VECHI: "",
SUPRAFATA_2D: match.measuredArea ?? feature.areaValue ?? "",
SUPRAFATA_R: match.legalArea ?? (feature.areaValue ? Math.round(feature.areaValue) : ""),
SOLICITANT: "",
INTRAVILAN: match.isIntravillan === true ? "DA" : match.isIntravillan === false ? "Nu" : "",
CATEGORIE_FOLOSINTA: String(match.landUseCategory ?? match.categoryOfUse ?? ""),
HAS_BUILDING: match.hasBuilding ? 1 : 0,
BUILD_LEGAL: match.hasBuilding && match.buildingAuthorized ? 1 : 0,
};
await prisma.gisFeature.update({
where: { id: feature.id },
data: { enrichment: enrichment as object, enrichedAt: new Date() },
});
return NextResponse.json({ status: "ok", message: "Parcela imbogatita cu succes", enrichment });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
if (msg.includes("timeout")) return NextResponse.json({ error: "eTerra timeout. Incearca mai tarziu." }, { status: 504 });
if (msg.includes("maintenance") || msg.includes("Mentenan")) return NextResponse.json({ error: "eTerra in mentenanta." }, { status: 503 });
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/** GET — check enrichment status for a SIRUTA */
export async function GET(req: Request) {
const url = new URL(req.url);
const siruta = url.searchParams.get("siruta") ?? "";
if (!siruta) return NextResponse.json({ running: false });
return NextResponse.json({ running: runningEnrichments.has(siruta) });
function buildBasicEnrichment(cadRef: string, area: number | null) {
return {
NR_CAD: cadRef, NR_CF: "", NR_CF_VECHI: "", NR_TOPO: "", ADRESA: "",
PROPRIETARI: "", PROPRIETARI_VECHI: "",
SUPRAFATA_2D: area ?? "", SUPRAFATA_R: area ? Math.round(area) : "",
SOLICITANT: "", INTRAVILAN: "", CATEGORIE_FOLOSINTA: "",
HAS_BUILDING: 0, BUILD_LEGAL: 0,
};
}
function formatOwners(item: Record<string, unknown>): string {
// Try various eTerra response formats
const owners = item.owners ?? item.titulars ?? item.proprietari;
if (Array.isArray(owners)) {
return owners
.map((o: Record<string, unknown>) => {
const name = String(o.fullName ?? o.name ?? o.titularName ?? "");
const share = o.shareNumerator && o.shareDenominator
? ` (${o.shareNumerator}/${o.shareDenominator})`
: "";
return name + share;
})
.filter(Boolean)
.join("; ");
}
if (typeof owners === "string") return owners;
// Try titularName directly on the item
if (item.titularName) return String(item.titularName);
return "";
}
function formatAddress(item: Record<string, unknown>): string {
const addr = item.address ?? item.immovableAddresses;
if (Array.isArray(addr) && addr.length > 0) {
const a = (addr[0] as Record<string, unknown>)?.address as Record<string, unknown> | undefined;
if (a) {
const parts: string[] = [];
if (a.addressDescription) parts.push(String(a.addressDescription));
if (a.street) parts.push(`Str. ${a.street}`);
if (a.buildingNo) parts.push(`Nr. ${a.buildingNo}`);
return parts.join(", ");
}
}
if (typeof item.addressDescription === "string") return item.addressDescription;
return "";
}