From 6558c690f50fc3e983ad6e4ca02002c9ac33c7fd Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 8 Mar 2026 03:48:23 +0200 Subject: [PATCH] feat(parcel-sync): owner name search (proprietar) in Search tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New search mode toggle: Nr. Cadastral / Proprietar - Owner search queries: 1. Local DB first (enrichment PROPRIETARI/PROPRIETARI_VECHI ILIKE) 2. eTerra API fallback (tries personName/titularName/ownerName filter keys) - DB search works offline (no eTerra connection needed) — uses enriched data - New API route: POST /api/eterra/search-owner - New eterra-client method: searchImmovableByOwnerName() - Owner results show source badge (DB local / eTerra online) - Results can be added to saved list and exported as CSV - Relaxed search tab guard: only requires UAT selection (not eTerra connection) - Cadastral search still requires eTerra connection (shows hint when offline) --- src/app/api/eterra/search-owner/route.ts | 358 ++++++++++++++ .../components/parcel-sync-module.tsx | 456 ++++++++++++++++-- .../parcel-sync/services/eterra-client.ts | 80 +++ 3 files changed, 855 insertions(+), 39 deletions(-) create mode 100644 src/app/api/eterra/search-owner/route.ts diff --git a/src/app/api/eterra/search-owner/route.ts b/src/app/api/eterra/search-owner/route.ts new file mode 100644 index 0000000..f5b3eb2 --- /dev/null +++ b/src/app/api/eterra/search-owner/route.ts @@ -0,0 +1,358 @@ +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; + ownerName?: string; + workspacePk?: number; + /** "db" = local DB only, "eterra" = eTerra API only, "both" = try both (default) */ + source?: "db" | "eterra" | "both"; +}; + +export type OwnerSearchResult = { + nrCad: string; + nrCF: string; + proprietari: string; + proprietariVechi: string; + adresa: string; + suprafata: number | string; + intravilan: string; + categorieFolosinta: string; + source: "db" | "eterra"; + immovablePk: string; +}; + +/* ------------------------------------------------------------------ */ +/* Workspace resolution (same as search route) */ +/* ------------------------------------------------------------------ */ + +const globalRef = globalThis as { + __eterraWorkspaceCache?: Map; +}; +const workspaceCache = + globalRef.__eterraWorkspaceCache ?? new Map(); +globalRef.__eterraWorkspaceCache = workspaceCache; + +async function resolveWorkspace( + client: EterraClient, + siruta: string, +): Promise { + const cached = workspaceCache.get(siruta); + if (cached !== undefined) return cached; + + try { + 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; + if (wsId != null) { + const numWs = Number(wsId); + if (Number.isFinite(numWs)) { + workspaceCache.set(siruta, numWs); + prisma.gisUat + .upsert({ + where: { siruta }, + update: { workspacePk: numWs }, + create: { siruta, name: siruta, workspacePk: numWs }, + }) + .catch(() => {}); + return numWs; + } + } + } catch { + // ArcGIS query failed + } + return null; +} + +/* ------------------------------------------------------------------ */ +/* DB search — search enrichment JSON for owner name */ +/* ------------------------------------------------------------------ */ + +async function searchOwnerInDb( + siruta: string, + ownerName: string, + limit = 50, +): Promise { + // Normalize search: remove diacritics for broader matching + const normalizedSearch = ownerName + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); + + // Search in both PROPRIETARI and PROPRIETARI_VECHI fields of enrichment JSON + // Use raw SQL for ILIKE on JSON text + const features = await prisma.$queryRaw< + Array<{ + id: string; + cadastral_ref: string | null; + enrichment: Record | null; + area_value: number | null; + }> + >` + SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + AND ( + unaccent(enrichment->>'PROPRIETARI') ILIKE unaccent(${"%" + normalizedSearch + "%"}) + OR unaccent(enrichment->>'PROPRIETARI_VECHI') ILIKE unaccent(${"%" + normalizedSearch + "%"}) + ) + ORDER BY "cadastralRef" ASC + LIMIT ${limit} + `; + + return features.map((f) => { + const e = (f.enrichment ?? {}) as Record; + return { + nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""), + nrCF: String(e.NR_CF ?? ""), + proprietari: String(e.PROPRIETARI ?? ""), + proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""), + adresa: String(e.ADRESA ?? ""), + suprafata: + typeof e.SUPRAFATA_2D === "number" + ? e.SUPRAFATA_2D + : (f.area_value ?? ""), + intravilan: String(e.INTRAVILAN ?? ""), + categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""), + source: "db" as const, + immovablePk: "", + }; + }); +} + +/* ------------------------------------------------------------------ */ +/* DB search fallback — without unaccent (if extension not installed) */ +/* ------------------------------------------------------------------ */ + +async function searchOwnerInDbSimple( + siruta: string, + ownerName: string, + limit = 50, +): Promise { + const searchPattern = `%${ownerName.trim()}%`; + + const features = await prisma.$queryRaw< + Array<{ + id: string; + cadastral_ref: string | null; + enrichment: Record | null; + area_value: number | null; + }> + >` + SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value + FROM "GisFeature" + WHERE siruta = ${siruta} + AND "layerId" = 'TERENURI_ACTIVE' + AND enrichment IS NOT NULL + AND ( + enrichment->>'PROPRIETARI' ILIKE ${searchPattern} + OR enrichment->>'PROPRIETARI_VECHI' ILIKE ${searchPattern} + ) + ORDER BY "cadastralRef" ASC + LIMIT ${limit} + `; + + return features.map((f) => { + const e = (f.enrichment ?? {}) as Record; + return { + nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""), + nrCF: String(e.NR_CF ?? ""), + proprietari: String(e.PROPRIETARI ?? ""), + proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""), + adresa: String(e.ADRESA ?? ""), + suprafata: + typeof e.SUPRAFATA_2D === "number" + ? e.SUPRAFATA_2D + : (f.area_value ?? ""), + intravilan: String(e.INTRAVILAN ?? ""), + categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""), + source: "db" as const, + immovablePk: "", + }; + }); +} + +/* ------------------------------------------------------------------ */ +/* eTerra API search — search by owner name via immovable list */ +/* ------------------------------------------------------------------ */ + +async function searchOwnerOnEterra( + client: EterraClient, + workspaceId: number, + siruta: string, + ownerName: string, +): Promise { + const response = await client.searchImmovableByOwnerName( + workspaceId, + siruta, + ownerName, + 0, + 50, + ); + + const items = response?.content ?? []; + if (items.length === 0) return []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return items.map((item: any) => ({ + nrCad: String(item?.identifierDetails ?? ""), + nrCF: String(item?.paperLbNo ?? item?.paperCadNo ?? ""), + proprietari: "", // Not available from immovable list directly + proprietariVechi: "", + adresa: "", + suprafata: item?.measuredArea ?? item?.legalArea ?? item?.area ?? "", + intravilan: "", + categorieFolosinta: "", + source: "eterra" as const, + immovablePk: String(item?.immovablePk ?? ""), + })); +} + +/* ------------------------------------------------------------------ */ +/* Route handler */ +/* ------------------------------------------------------------------ */ + +/** + * POST /api/eterra/search-owner + * + * Search by owner (proprietar/titular) name. + * + * Priority: 1) Local DB (enriched features), 2) eTerra API. + * DB search is fast and rich. eTerra search is slower but works pre-sync. + */ +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const siruta = String(body.siruta ?? "").trim(); + const ownerName = String(body.ownerName ?? "").trim(); + const source = body.source ?? "both"; + + if (!siruta || !/^\d+$/.test(siruta)) { + return NextResponse.json( + { error: "SIRUTA obligatoriu" }, + { status: 400 }, + ); + } + if (!ownerName || ownerName.length < 2) { + return NextResponse.json( + { error: "Numele proprietarului trebuie să aibă min. 2 caractere." }, + { status: 400 }, + ); + } + + const results: OwnerSearchResult[] = []; + let dbSearched = false; + let eterraSearched = false; + let eterraNote = ""; + + /* ── Step 1: DB search ──────────────────────────── */ + if (source !== "eterra") { + try { + const dbResults = await searchOwnerInDb(siruta, ownerName); + results.push(...dbResults); + dbSearched = true; + } catch { + // unaccent extension might not be installed — try simple ILIKE + try { + const dbResults = await searchOwnerInDbSimple(siruta, ownerName); + results.push(...dbResults); + dbSearched = true; + } catch (e2) { + console.log( + "[search-owner] DB search failed:", + e2 instanceof Error ? e2.message : e2, + ); + } + } + } + + /* ── Step 2: eTerra API search (if no DB results or source=both) ─ */ + if (source !== "db" && results.length === 0) { + const session = getSessionCredentials(); + const username = String( + session?.username || process.env.ETERRA_USERNAME || "", + ).trim(); + const password = String( + session?.password || process.env.ETERRA_PASSWORD || "", + ).trim(); + + if (username && password) { + try { + const client = await EterraClient.create(username, password); + + // Resolve workspace + 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) { + const eterraResults = await searchOwnerOnEterra( + client, + workspaceId, + siruta, + ownerName, + ); + // Deduplicate against DB results by nrCad + const existingCads = new Set(results.map((r) => r.nrCad)); + for (const r of eterraResults) { + if (!existingCads.has(r.nrCad)) { + results.push(r); + } + } + eterraSearched = true; + } else { + eterraNote = + "Nu s-a putut determina County/workspace pentru eTerra."; + } + } catch (e) { + eterraNote = `eTerra: ${e instanceof Error ? e.message : "eroare"}`; + } + } else { + eterraNote = + results.length === 0 + ? "Conectează-te la eTerra pentru a căuta online." + : ""; + } + } + + return NextResponse.json({ + results, + total: results.length, + dbSearched, + eterraSearched, + eterraNote, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 152ef82..564eee1 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -53,6 +53,8 @@ import { type LayerCatalogItem, } from "../services/eterra-layers"; import type { ParcelDetail } from "@/app/api/eterra/search/route"; +import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route"; +import { User } from "lucide-react"; /* ------------------------------------------------------------------ */ /* Types */ @@ -377,11 +379,18 @@ export function ParcelSyncModule() { } | null>(null); /* ── Parcel search tab ──────────────────────────────────────── */ + const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral"); const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); const [featuresSearch, setFeaturesSearch] = useState(""); const [loadingFeatures, setLoadingFeatures] = useState(false); const [searchError, setSearchError] = useState(""); + /* owner search */ + const [ownerSearch, setOwnerSearch] = useState(""); + const [ownerResults, setOwnerResults] = useState([]); + const [ownerLoading, setOwnerLoading] = useState(false); + const [ownerError, setOwnerError] = useState(""); + const [ownerNote, setOwnerNote] = useState(""); /* ── No-geometry import option ──────────────────────────────── */ const [includeNoGeom, setIncludeNoGeom] = useState(false); @@ -1407,6 +1416,85 @@ export function ParcelSyncModule() { [handleSearch], ); + /* ── Owner search handler ────────────────────────────────── */ + const handleOwnerSearch = useCallback(async () => { + if (!siruta || !/^\d+$/.test(siruta)) return; + if (!ownerSearch.trim() || ownerSearch.trim().length < 2) { + setOwnerError("Minim 2 caractere."); + return; + } + setOwnerLoading(true); + setOwnerError(""); + setOwnerNote(""); + try { + const res = await fetch("/api/eterra/search-owner", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + ownerName: ownerSearch.trim(), + ...(workspacePk ? { workspacePk } : {}), + }), + }); + const data = (await res.json()) as { + results?: OwnerSearchResult[]; + total?: number; + dbSearched?: boolean; + eterraSearched?: boolean; + eterraNote?: string; + error?: string; + }; + if (data.error) { + setOwnerResults([]); + setOwnerError(data.error); + } else { + setOwnerResults(data.results ?? []); + const notes: string[] = []; + if (data.dbSearched) notes.push("DB local"); + if (data.eterraSearched) notes.push("eTerra API"); + if (data.eterraNote) notes.push(data.eterraNote); + setOwnerNote( + notes.length > 0 + ? `Surse: ${notes.join(" + ")}${data.total ? ` · ${data.total} rezultate` : ""}` + : "", + ); + } + } catch { + setOwnerError("Eroare de rețea."); + } + setOwnerLoading(false); + }, [siruta, ownerSearch, workspacePk]); + + const handleOwnerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleOwnerSearch(); + } + }, + [handleOwnerSearch], + ); + + /** Convert an OwnerSearchResult → ParcelDetail so it can be added to the list */ + const ownerResultToParcelDetail = useCallback( + (r: OwnerSearchResult): ParcelDetail => ({ + nrCad: r.nrCad, + nrCF: r.nrCF, + nrCFVechi: "", + nrTopo: "", + intravilan: r.intravilan, + categorieFolosinta: r.categorieFolosinta, + adresa: r.adresa, + proprietari: r.proprietari || r.proprietariVechi, + proprietariActuali: r.proprietari, + proprietariVechi: r.proprietariVechi, + suprafata: typeof r.suprafata === "number" ? r.suprafata : null, + solicitant: "", + immovablePk: r.immovablePk, + }), + [], + ); + // Add result(s) to list for CSV export const addToList = useCallback((item: ParcelDetail) => { setSearchList((prev) => { @@ -1643,58 +1731,133 @@ export function ParcelSyncModule() { {/* Tab 1: Parcel search */} {/* ═══════════════════════════════════════════════════════ */} - {!sirutaValid || !session.connected ? ( + {!sirutaValid ? ( -

- {!session.connected - ? "Conectează-te la eTerra și selectează un UAT." - : "Selectează un UAT mai sus pentru a căuta parcele."} -

+

Selectează un UAT mai sus pentru a căuta parcele.

) : ( <> - {/* Search input */} + {/* Search input — mode toggle + input */} - -
-
- -
- - setFeaturesSearch(e.target.value)} - onKeyDown={handleSearchKeyDown} - /> -
-
- + > + + Nr. Cadastral + +
- {searchError && ( -

{searchError}

+ + {/* Cadastral search input */} + {searchMode === "cadastral" && ( +
+
+ +
+ + setFeaturesSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + disabled={!session.connected} + /> +
+ {!session.connected && ( +

+ Necesită conexiune eTerra. Folosește modul Proprietar pentru a căuta offline în DB. +

+ )} +
+ +
+ )} + + {/* Owner search input */} + {searchMode === "owner" && ( +
+
+ +
+ + setOwnerSearch(e.target.value)} + onKeyDown={handleOwnerKeyDown} + /> +
+
+ +
+ )} + + {searchMode === "cadastral" && searchError && ( +

{searchError}

+ )} + {searchMode === "owner" && ownerError && ( +

{ownerError}

+ )} + {searchMode === "owner" && ownerNote && ( +

{ownerNote}

)}
- {/* Results */} - {loadingFeatures && searchResults.length === 0 && ( + {/* ─── Cadastral search results ────────────── */} + {searchMode === "cadastral" && ( + <> + {/* Results */} + {loadingFeatures && searchResults.length === 0 && ( @@ -1947,7 +2110,7 @@ export function ParcelSyncModule() { )} {/* Empty state when no search has been done */} - {searchResults.length === 0 && !loadingFeatures && !searchError && ( + {searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && ( @@ -1959,6 +2122,221 @@ export function ParcelSyncModule() { )} + + )} + + {/* ─── Owner search results ────────────────── */} + {searchMode === "owner" && ( + <> + {ownerLoading && ownerResults.length === 0 && ( + + + +

Se caută proprietar...

+

+ Caută mai întâi în DB local (date îmbogățite), apoi pe + eTerra. +

+
+
+ )} + + {ownerResults.length > 0 && ( + <> +
+ + {ownerResults.length} rezultat + {ownerResults.length > 1 ? "e" : ""} pentru " + {ownerSearch}" + +
+ + +
+
+ +
+ {ownerResults.map((r, idx) => ( + + +
+
+

+ Nr. Cad. {r.nrCad} +

+ + {r.source === "db" + ? "din baza de date" + : "eTerra online"} + +
+
+ + +
+
+ +
+ {r.nrCF && ( +
+ + Nr. CF + + {r.nrCF} +
+ )} + {r.suprafata && ( +
+ + Suprafață + + + {typeof r.suprafata === "number" + ? formatArea(r.suprafata) + : `${r.suprafata} mp`} + +
+ )} + {r.intravilan && ( +
+ + Intravilan + + + {r.intravilan} + +
+ )} + {r.categorieFolosinta && ( +
+ + Categorii folosință + + + {r.categorieFolosinta} + +
+ )} + {r.adresa && ( +
+ + Adresă + + {r.adresa} +
+ )} + {r.proprietari && ( +
+ + Proprietari actuali + + + {r.proprietari} + +
+ )} + {r.proprietariVechi && ( +
+ + Proprietari anteriori + + + {r.proprietariVechi} + +
+ )} +
+
+
+ ))} +
+ + )} + + {ownerResults.length === 0 && !ownerLoading && !ownerError && ( + + + +

Introdu numele proprietarului și apasă Caută.

+

+ Caută în datele îmbogățite (DB local) și pe eTerra. +
+ Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export. +

+
+
+ )} + + )} {/* Saved list */} {searchList.length > 0 && ( diff --git a/src/modules/parcel-sync/services/eterra-client.ts b/src/modules/parcel-sync/services/eterra-client.ts index 11d507d..f7e26bb 100644 --- a/src/modules/parcel-sync/services/eterra-client.ts +++ b/src/modules/parcel-sync/services/eterra-client.ts @@ -643,6 +643,86 @@ export class EterraClient { ); } + /** + * Search immovable list by owner/titular name. + * + * Uses the same `/api/immovable/list` endpoint, with a `personName` + * filter key. eTerra supports partial matching on this field. + * + * Falls back to `titularName` key if personName returns empty. + * + * @returns Paginated response with `content[]`, `totalPages`, `totalElements` + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async searchImmovableByOwnerName( + workspaceId: string | number, + adminUnitId: string | number, + ownerName: string, + page = 0, + size = 20, + ): Promise { + const url = `${BASE_URL}/api/immovable/list`; + const baseFilters: Array<{ + value: string | number; + type: "NUMBER" | "STRING"; + key: string; + op: string; + }> = [ + { + value: Number(workspaceId), + type: "NUMBER", + key: "workspace.nomenPk", + op: "=", + }, + { + value: Number(adminUnitId), + type: "NUMBER", + key: "adminUnit.nomenPk", + op: "=", + }, + { value: -1, type: "NUMBER", key: "inscrisCF", op: "=" }, + ]; + + // Try primary key: personName (used in some eTerra versions) + const keysToTry = ["personName", "titularName", "ownerName"]; + for (const filterKey of keysToTry) { + try { + const filters = [ + ...baseFilters, + { + value: ownerName, + type: "STRING" as const, + key: filterKey, + op: "=", + }, + ]; + const payload = { filters, nrElements: size, page, sorters: [] }; + const result = await this.requestRaw(() => + this.client.post(url, payload, { + headers: { "Content-Type": "application/json;charset=UTF-8" }, + timeout: this.timeoutMs, + }), + ); + // If we got content back (even empty), it means the key is valid + if (result && typeof result === "object" && "content" in result) { + console.log( + `[searchByOwner] Key "${filterKey}" worked — ${(result as any).totalElements ?? 0} results`, + ); + return result; + } + } catch (e) { + console.log( + `[searchByOwner] Key "${filterKey}" failed:`, + e instanceof Error ? e.message : e, + ); + // Try next key + } + } + + // All keys failed — return empty result + return { content: [], totalElements: 0, totalPages: 0 }; + } + /** * Fetch all counties (workspaces) from eTerra nomenclature. * Returns array of { nomenPk, name, parentNomenPk, ... }