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:
+300
-127
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user