Files
ArchiTools/src/app/api/eterra/search/route.ts
T
AI Assistant 79c45adc37 fix(search): address [object Object], suprafata from folosinte, owner tree separation
- Address: handle street/locality/county as objects (extract .name)
  Fixes 'Str. [object Object], Feleacu'  'Str. X, Feleacu'
- Suprafata: fallback to total area from folosinte endpoint when
  immovable list and documentation APIs return null
- Owners: use tree traversal (nodeId/parentNodeId) to detect radiated
  inscriptions. Walk up parent chain to check radiationDate/cancelled/
  isActive/closed/status on ancestor inscription nodes.
- Enhanced logging: first/last 3 partTwoRegs entries + node types
  for debugging owner structure in Dozzle
2026-03-06 22:06:28 +02:00

533 lines
18 KiB
TypeScript

import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
siruta?: string;
search?: string; // cadastral number(s), comma or newline separated
username?: string;
password?: string;
workspacePk?: number; // county workspace PK — if provided, skips resolution
};
/* ------------------------------------------------------------------ */
/* Workspace (county) lookup cache */
/* ------------------------------------------------------------------ */
const globalRef = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
/**
* Resolve eTerra workspace ID for a given SIRUTA.
*
* Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this
* SIRUTA, read the WORKSPACE_ID attribute.
*
* Uses `listLayer()` (not `listLayerByWhere`) so the admin field name
* (ADMIN_UNIT_ID, SIRUTA, UAT_ID…) is auto-discovered from layer metadata.
*
* SIRUTA ≠ eTerra nomenPk, so nomenclature API lookups don't help.
*/
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached;
try {
// listLayer auto-discovers the correct admin field via buildWhere
const features = await client.listLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
{ limit: 1, outFields: "WORKSPACE_ID" },
);
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
console.log(
"[resolveWorkspace] ArcGIS WORKSPACE_ID for",
siruta,
"→",
wsId,
);
if (wsId != null) {
const numWs = Number(wsId);
if (Number.isFinite(numWs)) {
workspaceCache.set(siruta, numWs);
// Persist to DB for future fast lookups
persistWorkspace(siruta, numWs);
return numWs;
}
}
} catch (e) {
console.log(
"[resolveWorkspace] ArcGIS query failed:",
e instanceof Error ? e.message : e,
);
}
return null;
}
/** Fire-and-forget: save WORKSPACE_ID to GisUat row */
function persistWorkspace(siruta: string, workspacePk: number) {
prisma.gisUat
.upsert({
where: { siruta },
update: { workspacePk },
create: { siruta, name: siruta, workspacePk },
})
.catch(() => {});
}
/* ------------------------------------------------------------------ */
/* 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[] = [];
// addressDescription may contain the full text address already
if (address.addressDescription) {
const desc = String(address.addressDescription).trim();
if (desc.length > 3 && !desc.includes("[object")) return desc;
}
// street can be a string OR an object { name: "..." }
const streetName =
typeof address.street === "string"
? address.street
: address.street?.name ?? "";
if (streetName) parts.push(`Str. ${streetName}`);
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
// locality can also be a string or object
const localityName =
typeof address.locality === "string"
? address.locality
: address.locality?.name ?? "";
if (localityName) parts.push(localityName);
const countyName =
typeof address.county === "string"
? address.county
: address.county?.name ?? "";
if (countyName) parts.push(`Jud. ${countyName}`);
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;
proprietariActuali: string;
proprietariVechi: 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 rawSearch = (body.search ?? "").trim();
if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA obligatoriu" },
{ status: 400 },
);
}
if (!rawSearch) {
return NextResponse.json(
{ 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 },
);
}
// Credential chain: body > session > env
const session = getSessionCredentials();
const username = String(
body.username || session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi." },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// Workspace resolution chain: body → DB → ArcGIS layer query
let workspaceId = body.workspacePk ?? null;
if (!workspaceId || !Number.isFinite(workspaceId)) {
try {
const dbUat = await prisma.gisUat.findUnique({
where: { siruta },
select: { workspacePk: true },
});
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
} catch {
// DB lookup failed
}
}
if (!workspaceId || !Number.isFinite(workspaceId)) {
workspaceId = await resolveWorkspace(client, siruta);
}
if (!workspaceId) {
return NextResponse.json(
{ error: "Nu s-a putut determina județul pentru UAT-ul selectat." },
{ status: 400 },
);
}
console.log("[search] siruta:", siruta, "workspaceId:", workspaceId);
const results: ParcelDetail[] = [];
for (const cadNr of cadNumbers) {
try {
// 1. Search immovable by identifier (exact match)
const immResponse = await client.searchImmovableByIdentifier(
workspaceId,
siruta,
cadNr,
);
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: "",
proprietariActuali: "",
proprietariVechi: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
continue;
}
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 proprietariActuali: string[] = [];
let proprietariVechi: string[] = [];
let solicitant = "";
let intravilan = "";
let categorie = "";
let suprafata: number | null = null;
// Try multiple area fields
const areaStr =
item?.area ?? item?.areaValue ?? item?.areaMP ?? item?.suprafata;
if (areaStr != null) {
const parsed = Number(areaStr);
if (Number.isFinite(parsed) && parsed > 0) suprafata = parsed;
}
// Log raw item keys once for debugging (first item only)
if (results.length === 0) {
console.log(
"[search] immovable item keys:",
Object.keys(item ?? {}),
);
console.log("[search] area fields:", {
area: item?.area,
areaValue: item?.areaValue,
areaMP: item?.areaMP,
});
}
// 2. Fetch documentation data (CF, proprietari)
if (immPk) {
try {
const docResponse = await client.fetchDocumentationData(
workspaceId,
[immPk],
);
// 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;
}
}
// Extract owners from partTwoRegs — separate active vs cancelled
// using tree structure: "P" (person) nodes are children of
// inscription nodes. If a parent inscription has radiationDate,
// its person entries are former owners.
const activeOwners: string[] = [];
const cancelledOwners: string[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const regs: any[] = docResponse?.partTwoRegs ?? [];
if (regs.length > 0 && results.length === 0) {
console.log(
"[search] partTwoRegs count:",
regs.length,
"types:",
[...new Set(regs.map((r: any) => r?.nodeType))],
);
// Log first 3 entries fully
regs.slice(0, 3).forEach((r: any, i: number) =>
console.log(`[search] partTwoRegs[${i}]:`, JSON.stringify(r).slice(0, 600)),
);
// Log last 3 entries
regs.slice(-3).forEach((r: any, i: number) =>
console.log(`[search] partTwoRegs[${regs.length - 3 + i}]:`, JSON.stringify(r).slice(0, 600)),
);
}
// Build nodeId → entry map for tree traversal
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const nodeMap = new Map<string | number, any>();
for (const reg of regs) {
const nid = reg?.nodeId ?? reg?.id;
if (nid != null) nodeMap.set(nid, reg);
}
// Check if an entry or any ancestor is radiated
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isRadiated = (entry: any): boolean => {
// Direct checks on the entry itself
if (entry?.radiationDate != null) return true;
if (entry?.cancelled === true) return true;
if (entry?.isActive === false) return true;
if (entry?.closed === true) return true;
const st = String(entry?.status ?? "").toUpperCase();
if (st === "RADIAT" || st === "CANCELLED" || st === "CLOSED") return true;
// Walk up to parent
const pid = entry?.parentNodeId ?? entry?.parentId;
if (pid != null) {
const parent = nodeMap.get(pid);
if (parent) return isRadiated(parent);
}
return false;
};
for (const reg of regs) {
if (
String(reg?.nodeType ?? "").toUpperCase() !== "P" ||
!reg?.nodeName
) continue;
const name = String(reg.nodeName).trim();
if (!name) continue;
if (isRadiated(reg)) {
cancelledOwners.push(name);
} else {
activeOwners.push(name);
}
}
proprietariActuali = Array.from(new Set(activeOwners));
proprietariVechi = Array.from(new Set(cancelledOwners)).filter(
(n) => !proprietariActuali.includes(n),
);
} catch {
// Documentation fetch failed — continue with basic data
}
}
// 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 ?? []);
// Extract total area from folosinte as fallback
if (suprafata == null || suprafata <= 0) {
let totalArea = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const f of (fol ?? []) as any[]) {
const a = Number(f?.suprafata ?? 0);
if (Number.isFinite(a)) totalArea += a;
}
if (totalArea > 0) suprafata = totalArea;
}
} catch {
// folosinta fetch failed
}
}
}
} catch {
// immApps fetch failed
}
}
const allOwners = [...proprietariActuali, ...proprietariVechi];
results.push({
nrCad: String(item?.identifierDetails ?? cadNr),
nrCF,
nrCFVechi,
nrTopo,
intravilan,
categorieFolosinta: categorie,
adresa: addressText,
proprietari: allOwners.join("; "),
proprietariActuali: proprietariActuali.join("; "),
proprietariVechi: proprietariVechi.join("; "),
suprafata,
solicitant,
immovablePk: immPkStr,
});
}
} catch {
// Error for this particular cadNr — add placeholder
results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
proprietariActuali: "",
proprietariVechi: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
}
}
return NextResponse.json({
results,
total: results.length,
source: "eterra-app",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}