feat(parcel-sync): owner name search (proprietar) in Search tab

- 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)
This commit is contained in:
AI Assistant
2026-03-08 03:48:23 +02:00
parent 8bb4a47ac5
commit 6558c690f5
3 changed files with 855 additions and 39 deletions
+358
View File
@@ -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<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
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<OwnerSearchResult[]> {
// 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<string, unknown> | 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<string, unknown>;
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<OwnerSearchResult[]> {
const searchPattern = `%${ownerName.trim()}%`;
const features = await prisma.$queryRaw<
Array<{
id: string;
cadastral_ref: string | null;
enrichment: Record<string, unknown> | 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<string, unknown>;
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<OwnerSearchResult[]> {
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 });
}
}