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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user