feat(parcel-sync): search by cadastral number with full details

Search tab now uses eTerra application API (same as the web UI):

- POST /api/eterra/search queries /api/immovable/list with exact
  identifierDetails filter + /api/documentation/data for full details
- Returns: nr cad, nr CF, CF vechi, nr topo, suprafata, intravilan,
  categorii folosinta, adresa, proprietari, solicitant
- Automatic workspace (county) resolution from SIRUTA with cache
- Support for multiple cadastral numbers (comma separated)

UI changes:
- Detail cards instead of flat ArcGIS feature table
- Copy details to clipboard button per parcel
- Add parcels to list + CSV export
- Search list with summary table + CSV download
- No more layer filter or pagination (not needed for app API)

New EterraClient methods:
- searchImmovableByIdentifier (exact cadaster lookup)
- fetchCounties / fetchAdminUnitsByCounty (workspace resolution)
This commit is contained in:
AI Assistant
2026-03-06 19:58:33 +02:00
parent c98ce81cb7
commit 540b02d8d2
3 changed files with 737 additions and 316 deletions
+300 -127
View File
@@ -1,29 +1,142 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
siruta?: string;
search?: string;
layerId?: string;
search?: string; // cadastral number(s), comma or newline separated
username?: string;
password?: string;
};
/* ------------------------------------------------------------------ */
/* Workspace (county) lookup cache */
/* ------------------------------------------------------------------ */
const globalRef = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
/**
* Live search eTerra by cadastral number / INSPIRE ID.
* Queries the remote eTerra ArcGIS REST API directly (not local DB).
* Resolve eTerra workspace nomenPk for a given SIRUTA.
* Strategy: fetch all counties, then for each county fetch its UATs
* until we find one whose nomenPk matches the SIRUTA.
* Results are cached globally (survives hot-reload).
*/
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached;
try {
const counties = await client.fetchCounties();
for (const county of counties) {
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id;
if (!countyPk) continue;
try {
const uats = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) {
const uatPk = String(uat?.nomenPk ?? uat?.pk ?? "");
if (uatPk) {
workspaceCache.set(uatPk, Number(countyPk));
}
}
// Check if our SIRUTA is now resolved
const resolved = workspaceCache.get(siruta);
if (resolved !== undefined) return resolved;
} catch {
continue;
}
}
} catch {
// fallback: can't fetch counties
}
return null;
}
/* ------------------------------------------------------------------ */
/* Helper formatters (same logic as export-bundle magic mode) */
/* ------------------------------------------------------------------ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatAddress(item?: any) {
const address = item?.immovableAddresses?.[0]?.address ?? null;
if (!address) return "";
const parts: string[] = [];
if (address.addressDescription) parts.push(address.addressDescription);
if (address.street) parts.push(`Str. ${address.street}`);
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
if (address.locality?.name) parts.push(address.locality.name);
return parts.length ? parts.join(", ") : "";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function normalizeIntravilan(values: string[]) {
const normalized = values
.map((v) => String(v ?? "").trim().toLowerCase())
.filter(Boolean);
const unique = new Set(normalized);
if (!unique.size) return "";
if (unique.size === 1)
return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt";
return "Mixt";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatCategories(entries: any[]) {
const map = new Map<string, number>();
for (const entry of entries) {
const key = String(entry?.categorieFolosinta ?? "").trim();
if (!key) continue;
const area = Number(entry?.suprafata ?? 0);
map.set(key, (map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0));
}
return Array.from(map.entries())
.map(([k, a]) => `${k}:${a.toFixed(2).replace(/\.00$/, "")}`)
.join("; ");
}
/* ------------------------------------------------------------------ */
/* Route handler */
/* ------------------------------------------------------------------ */
export type ParcelDetail = {
nrCad: string;
nrCF: string;
nrCFVechi: string;
nrTopo: string;
intravilan: string;
categorieFolosinta: string;
adresa: string;
proprietari: string;
suprafata: number | null;
solicitant: string;
immovablePk: string;
};
/**
* POST /api/eterra/search
*
* Search eTerra by cadastral number using the application API
* (same as the eTerra web UI). Returns full parcel details:
* nr. cadastral, CF, topo, intravilan, categorii folosință,
* adresă, proprietari.
*
* Accepts one or more cadastral numbers (comma/newline separated).
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
const search = (body.search ?? "").trim();
const layerId = (body.layerId ?? "").trim();
const rawSearch = (body.search ?? "").trim();
if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json(
@@ -31,9 +144,22 @@ export async function POST(req: Request) {
{ status: 400 },
);
}
if (!search) {
if (!rawSearch) {
return NextResponse.json(
{ error: "Termen de căutare obligatoriu" },
{ error: "Număr cadastral obligatoriu" },
{ status: 400 },
);
}
// Parse multiple cadastral numbers (comma, newline, space separated)
const cadNumbers = rawSearch
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (cadNumbers.length === 0) {
return NextResponse.json(
{ error: "Număr cadastral obligatoriu" },
{ status: 400 },
);
}
@@ -56,144 +182,191 @@ export async function POST(req: Request) {
const client = await EterraClient.create(username, password);
// Decide which layers to search
const searchLayers = layerId
? LAYER_CATALOG.filter((l) => l.id === layerId)
: LAYER_CATALOG.filter((l) =>
["TERENURI_ACTIVE", "CLADIRI_ACTIVE"].includes(l.id),
);
if (searchLayers.length === 0) {
return NextResponse.json({ features: [], total: 0 });
// Resolve workspace (county) for this SIRUTA
const workspaceId = await resolveWorkspace(client, siruta);
if (!workspaceId) {
return NextResponse.json(
{ error: "Nu s-a putut determina județul pentru UAT-ul selectat." },
{ status: 400 },
);
}
// Build the search WHERE — exact or LIKE depending on input
const isNumericOnly = /^\d+$/.test(search);
const escapedSearch = search.replace(/'/g, "''");
const results: ParcelDetail[] = [];
type FoundFeature = {
id: string;
layerId: string;
siruta: string;
objectId: number;
inspireId?: string;
cadastralRef?: string;
areaValue?: number;
isActive: boolean;
attributes: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
const allResults: FoundFeature[] = [];
for (const layer of searchLayers) {
for (const cadNr of cadNumbers) {
try {
// Get available fields for this layer
const fields = await client.getLayerFieldNames(layer);
const upperFields = fields.map((f) => f.toUpperCase());
// Find admin field for siruta filter
const adminFields = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
const adminField = adminFields.find((a) =>
upperFields.includes(a),
// 1. Search immovable by identifier (exact match)
const immResponse = await client.searchImmovableByIdentifier(
workspaceId,
siruta,
cadNr,
);
if (!adminField) continue;
// Get actual casing from layer fields
const adminFieldActual =
fields[upperFields.indexOf(adminField)] ?? adminField;
// Build search conditions depending on available fields
const conditions: string[] = [];
const hasCadRef = upperFields.includes(
"NATIONAL_CADASTRAL_REFERENCE",
);
const hasInspire = upperFields.includes("INSPIRE_ID");
const hasCadNr = upperFields.includes("NATIONAL_CADNR");
if (hasCadRef) {
const cadRefField =
fields[upperFields.indexOf("NATIONAL_CADASTRAL_REFERENCE")]!;
if (isNumericOnly) {
// Exact match for numeric cadastral numbers
conditions.push(`${cadRefField}='${escapedSearch}'`);
} else {
conditions.push(
`${cadRefField} LIKE '%${escapedSearch}%'`,
);
}
const items = immResponse?.content ?? [];
if (items.length === 0) {
// No result — add placeholder so user knows it wasn't found
results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
continue;
}
if (hasCadNr) {
const cadNrField =
fields[upperFields.indexOf("NATIONAL_CADNR")]!;
if (isNumericOnly) {
conditions.push(`${cadNrField}='${escapedSearch}'`);
} else {
conditions.push(`${cadNrField} LIKE '%${escapedSearch}%'`);
}
}
if (hasInspire) {
const inspireField =
fields[upperFields.indexOf("INSPIRE_ID")]!;
conditions.push(
`${inspireField} LIKE '%${escapedSearch}%'`,
for (const item of items) {
const immPk = item?.immovablePk;
const immPkStr = String(immPk ?? "");
// Basic data from immovable list
let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? "");
let nrCFVechi = "";
let nrTopo = String(
item?.topNo ?? item?.paperCadNo ?? "",
);
}
let addressText = formatAddress(item);
let proprietari = "";
let solicitant = "";
let intravilan = "";
let categorie = "";
let suprafata: number | null = null;
if (conditions.length === 0) continue;
const areaStr = item?.area ?? item?.areaValue;
if (areaStr != null) {
const parsed = Number(areaStr);
if (Number.isFinite(parsed)) suprafata = parsed;
}
const searchWhere = `${adminFieldActual}=${siruta} AND (${conditions.join(" OR ")})`;
// 2. Fetch documentation data (CF, proprietari)
if (immPk) {
try {
const docResponse = await client.fetchDocumentationData(
workspaceId,
[immPk],
);
const features = await client.listLayerByWhere(layer, searchWhere, {
limit: 50,
outFields: "*",
});
// Extract doc details
const docImm = (docResponse?.immovables ?? []).find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(d: any) => String(d?.immovablePk) === immPkStr,
);
if (docImm) {
if (docImm.landbookIE) {
const oldCF = nrCF;
nrCF = String(docImm.landbookIE);
if (oldCF && oldCF !== nrCF) nrCFVechi = oldCF;
}
if (docImm.topNo) nrTopo = String(docImm.topNo);
if (docImm.area != null) {
const docArea = Number(docImm.area);
if (Number.isFinite(docArea)) suprafata = docArea;
}
}
const now = new Date().toISOString();
for (const f of features) {
const attrs = f.attributes;
const objId =
typeof attrs.OBJECTID === "number"
? attrs.OBJECTID
: Number(attrs.OBJECTID ?? 0);
// Extract owners from partTwoRegs
const owners: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(docResponse?.partTwoRegs ?? []).forEach((reg: any) => {
if (
String(reg?.nodeType ?? "").toUpperCase() === "P" &&
reg?.nodeName
) {
const name = String(reg.nodeName).trim();
if (name) owners.push(name);
}
});
proprietari = Array.from(new Set(owners)).join("; ");
} catch {
// Documentation fetch failed — continue with basic data
}
}
allResults.push({
id: `live-${layer.id}-${objId}`,
layerId: layer.id,
siruta,
objectId: objId,
inspireId: (attrs.INSPIRE_ID as string | undefined) ?? undefined,
cadastralRef:
(attrs.NATIONAL_CADASTRAL_REFERENCE as string | undefined) ??
(attrs.NATIONAL_CADNR as string | undefined) ??
undefined,
areaValue:
typeof attrs.AREA_VALUE === "number"
? attrs.AREA_VALUE
: undefined,
isActive: attrs.IS_ACTIVE !== 0,
attributes: attrs as Record<string, unknown>,
createdAt: now,
updatedAt: now,
// 3. Fetch application data (solicitant, folosință, intravilan)
if (immPk) {
try {
const apps = await client.fetchImmAppsByImmovable(
immPk,
workspaceId,
);
// Pick most recent application
const chosen =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(apps ?? []).filter((a: any) => a?.dataCerere)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sort((a: any, b: any) =>
(b.dataCerere ?? 0) - (a.dataCerere ?? 0),
)[0] ?? apps?.[0];
if (chosen) {
solicitant = String(
chosen.solicitant ?? chosen.deponent ?? "",
);
const appId = chosen.applicationId;
if (appId) {
try {
const fol = await client.fetchParcelFolosinte(
workspaceId,
immPk,
appId,
);
intravilan = normalizeIntravilan(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fol ?? []).map((f: any) => f?.intravilan ?? ""),
);
categorie = formatCategories(fol ?? []);
} catch {
// folosinta fetch failed
}
}
}
} catch {
// immApps fetch failed
}
}
results.push({
nrCad: String(item?.identifierDetails ?? cadNr),
nrCF,
nrCFVechi,
nrTopo,
intravilan,
categorieFolosinta: categorie,
adresa: addressText,
proprietari,
suprafata,
solicitant,
immovablePk: immPkStr,
});
}
} catch {
// Skip layer on error and try next
continue;
// Error for this particular cadNr — add placeholder
results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
}
}
return NextResponse.json({
features: allResults,
total: allResults.length,
source: "eterra-live",
results,
total: results.length,
source: "eterra-app",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";