6eae4fa1c5
- Proprietari split into proprietariActuali + proprietariVechi (radiati) based on cancelled/isActive/radiat/status/radiationDate fields - UI shows owners separated: actuali bold, vechi strikethrough - CSV export has separate PROPRIETARI_ACTUALI / PROPRIETARI_VECHI columns - Address: use addressDescription directly when present (>3 chars) - Add county to address fallback - Try area/areaValue/areaMP/suprafata fields for surface - Debug logging: log immovable item keys + partTwoRegs sample on first search
468 lines
16 KiB
TypeScript
468 lines
16 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 it looks like a complete address (has comma or street), use it directly
|
|
if (desc.length > 3) return desc;
|
|
}
|
|
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);
|
|
if (address.county?.name) parts.push(`Jud. ${address.county.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;
|
|
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
|
|
const activeOwners: string[] = [];
|
|
const cancelledOwners: string[] = [];
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const regs = docResponse?.partTwoRegs ?? [];
|
|
if (regs.length > 0) {
|
|
console.log("[search] partTwoRegs[0] keys:", Object.keys(regs[0]));
|
|
console.log("[search] partTwoRegs sample:", JSON.stringify(regs[0]).slice(0, 500));
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
regs.forEach((reg: any) => {
|
|
if (
|
|
String(reg?.nodeType ?? "").toUpperCase() === "P" &&
|
|
reg?.nodeName
|
|
) {
|
|
const name = String(reg.nodeName).trim();
|
|
if (!name) return;
|
|
// Check if this entry is cancelled/radiated
|
|
const isCancelled =
|
|
reg?.cancelled === true ||
|
|
reg?.isActive === false ||
|
|
reg?.radiat === true ||
|
|
String(reg?.status ?? "").toUpperCase() === "RADIAT" ||
|
|
String(reg?.status ?? "").toUpperCase() === "CANCELLED" ||
|
|
reg?.radiationDate != null;
|
|
if (isCancelled) {
|
|
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 ?? []);
|
|
} 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 });
|
|
}
|
|
}
|