/* eslint-disable @typescript-eslint/no-explicit-any */ /** * Enrich service — fetches CF/owner/address/building data from eTerra * and stores it in GisFeature.enrichment JSON column. * * Called after sync to add "magic" data to parcels. * Idempotent: re-running overwrites previous enrichment. */ import { Prisma } from "@prisma/client"; import { prisma } from "@/core/storage/prisma"; import { EterraClient } from "./eterra-client"; import { setProgress, clearProgress, type SyncProgress, } from "./progress-store"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); export type EnrichResult = { siruta: string; enrichedCount: number; totalFeatures?: number; unenrichedCount?: number; buildingCrossRefs: number; status: "done" | "error"; error?: string; }; /* ── Helpers (extracted from export-bundle) ──────────────────── */ const formatNumber = (value: number) => Number.isFinite(value) ? value.toFixed(2).replace(/\.00$/, "") : ""; const normalizeId = (value: unknown) => { if (value === null || value === undefined) return ""; const text = String(value).trim(); if (!text) return ""; return text.replace(/\.0$/, ""); }; const normalizeCadRef = (value: unknown) => normalizeId(value).replace(/\s+/g, "").toUpperCase(); const baseCadRef = (value: unknown) => { const ref = normalizeCadRef(value); if (!ref) return ""; return ref.includes("-") ? ref.split("-")[0]! : ref; }; const makeWorkspaceKey = (workspaceId: unknown, immovableId: unknown) => { const ws = normalizeId(workspaceId); const im = normalizeId(immovableId); if (!ws || !im) return ""; return `${ws}:${im}`; }; const isRetryable = (error: unknown) => { const err = error as { response?: { status?: number }; code?: string }; const status = err?.response?.status ?? 0; if ([429, 500, 502, 503, 504].includes(status)) return true; return err?.code === "ECONNRESET" || err?.code === "ETIMEDOUT"; }; const 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"; }; const formatCategories = (entries: any[]) => { const map = new Map(); for (const entry of entries) { // Support both API formats: // fetchParcelFolosinte (via app): categorieFolosinta + suprafata // fetchImmovableParcelDetails (direct): useCategory (no area) const key = String( entry?.categorieFolosinta ?? entry?.useCategory ?? "", ).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]) => (a > 0 ? `${k}:${formatNumber(a)}` : k)) .join("; "); }; const formatAddress = (item?: any) => { const address = item?.immovableAddresses?.[0]?.address ?? null; if (!address) return "-"; const parts: string[] = []; if (address.addressDescription) parts.push(address.addressDescription); 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); return parts.length ? parts.join(", ") : "-"; }; /** * Enrichment data stored per-feature in the `enrichment` JSON column. */ export type FeatureEnrichment = { NR_CAD: string; NR_CF: string; NR_CF_VECHI: string; NR_TOPO: string; ADRESA: string; PROPRIETARI: string; PROPRIETARI_VECHI: string; SUPRAFATA_2D: number | string; SUPRAFATA_R: number | string; SOLICITANT: string; INTRAVILAN: string; CATEGORIE_FOLOSINTA: string; HAS_BUILDING: number; BUILD_LEGAL: number; }; /** * Enrich all TERENURI_ACTIVE features for a given UAT. * * Reads features from DB, fetches extra data from eTerra (immovable list, * documentation, owners, folosinte), cross-references with CLADIRI_ACTIVE, * and stores the enrichment JSON on each GisFeature. */ export async function enrichFeatures( client: EterraClient, siruta: string, options?: { jobId?: string; onProgress?: (done: number, total: number, phase: string) => void; }, ): Promise { const jobId = options?.jobId; const push = (partial: Partial) => { if (!jobId) return; setProgress({ jobId, downloaded: 0, status: "running", ...partial, } as SyncProgress); }; try { // Load terenuri and cladiri from DB const terenuri = await prisma.gisFeature.findMany({ where: { layerId: "TERENURI_ACTIVE", siruta }, select: { id: true, objectId: true, attributes: true, cadastralRef: true, enrichedAt: true, enrichment: true, }, }); const cladiri = await prisma.gisFeature.findMany({ where: { layerId: "CLADIRI_ACTIVE", siruta }, select: { attributes: true }, }); if (terenuri.length === 0) { return { siruta, enrichedCount: 0, buildingCrossRefs: 0, status: "done", }; } // Resolve workspace PK from feature attributes or GisUat DB let resolvedWsPk: number | null = null; for (const f of terenuri) { const ws = (f.attributes as Record).WORKSPACE_ID; if (ws != null) { const n = Number(ws); if (Number.isFinite(n) && n > 0) { resolvedWsPk = n; break; } } } if (!resolvedWsPk) { try { const row = await prisma.gisUat.findUnique({ where: { siruta }, select: { workspacePk: true }, }); if (row?.workspacePk && row.workspacePk > 0) resolvedWsPk = row.workspacePk; } catch { /* ignore */ } } if (!resolvedWsPk) { // Last resort: try ArcGIS layer query for 1 feature 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 n = Number(wsId); if (Number.isFinite(n) && n > 0) resolvedWsPk = n; } } catch { /* ignore */ } } // If still null, enrichment will fail gracefully with empty lists const workspacePkForApi = resolvedWsPk ?? 65; console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`); push({ phase: "Pregătire îmbogățire", downloaded: 0, total: terenuri.length, }); // ── Throttled request helper ── let lastRequest = 0; const minInterval = 250; const throttled = async (fn: () => Promise) => { let attempt = 0; while (true) { const now = Date.now(); const wait = Math.max(0, lastRequest + minInterval - now); if (wait > 0) await sleep(wait); try { const result = await fn(); lastRequest = Date.now(); return result; } catch (error) { if (!isRetryable(error) || attempt >= 2) throw error; attempt += 1; const backoff = Math.min(5000, 1000 * attempt); await sleep(backoff); } } }; // ── Building cross-ref map (from local DB cladiri) ── const buildingMap = new Map(); for (const feature of cladiri) { const attrs = feature.attributes as Record; const immovableId = attrs.IMMOVABLE_ID ?? attrs.IMOVABLE_ID ?? null; const workspaceId = attrs.WORKSPACE_ID ?? null; const baseRef = baseCadRef(attrs.NATIONAL_CADASTRAL_REFERENCE ?? ""); const isLegal = Number(attrs.IS_LEGAL ?? 0) === 1 || String(attrs.IS_LEGAL ?? "").toLowerCase() === "true"; const add = (key: string) => { if (!key) return; const existing = buildingMap.get(key) ?? { has: false, legal: false }; existing.has = true; if (isLegal) existing.legal = true; buildingMap.set(key, existing); }; const immKey = normalizeId(immovableId); const wKey = makeWorkspaceKey(workspaceId, immovableId); if (immKey) add(immKey); if (wKey) add(wKey); if (baseRef) add(baseRef); } // ── Fetch immovable list from eTerra ── push({ phase: "Descărcare listă imobile", downloaded: 0 }); const immovableListById = new Map(); const immovableListByCad = new Map(); const ownersByLandbook = new Map>(); const cancelledOwnersByLandbook = new Map>(); const addOwner = (landbook: string, name: string, radiated = false) => { if (!landbook || !name) return; const targetMap = radiated ? cancelledOwnersByLandbook : ownersByLandbook; const existing = targetMap.get(landbook) ?? new Set(); existing.add(name); targetMap.set(landbook, existing); }; let listPage = 0; let listTotalPages = 1; let includeInscrisCF = true; while (listPage < listTotalPages) { const listResponse = await throttled(() => client.fetchImmovableListByAdminUnit( workspacePkForApi, siruta, listPage, 200, includeInscrisCF, ), ); if ( listPage === 0 && !(listResponse?.content ?? []).length && includeInscrisCF ) { includeInscrisCF = false; listPage = 0; listTotalPages = 1; continue; } listTotalPages = typeof listResponse?.totalPages === "number" ? listResponse.totalPages : listTotalPages; (listResponse?.content ?? []).forEach((item: any) => { const idKey = normalizeId(item?.immovablePk); if (idKey) immovableListById.set(idKey, item); const cadKey = normalizeCadRef(item?.identifierDetails ?? ""); if (cadKey) immovableListByCad.set(cadKey, item); }); listPage += 1; } // ── Fetch documentation/owner data ── push({ phase: "Descărcare documentații CF" }); const docByImmovable = new Map(); const immovableIds = Array.from(immovableListById.keys()); const docBatchSize = 50; for (let i = 0; i < immovableIds.length; i += docBatchSize) { const batch = immovableIds.slice(i, i + docBatchSize); const docResponse = await throttled(() => client.fetchDocumentationData(workspacePkForApi, batch), ); (docResponse?.immovables ?? []).forEach((item: any) => { const idKey = normalizeId(item?.immovablePk); if (idKey) docByImmovable.set(idKey, item); }); // Build nodeId → entry map for radiated detection const regs: any[] = docResponse?.partTwoRegs ?? []; const nodeMap = new Map(); for (const reg of regs) { if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg); } // Check if an entry or any ancestor "I" inscription is radiated const isRadiated = (entry: any, depth = 0): boolean => { if (!entry || depth > 10) return false; if (entry?.nodeStatus === -1) return true; const pid = entry?.parentId; if (pid != null) { const parent = nodeMap.get(Number(pid)); if (parent) return isRadiated(parent, depth + 1); } return false; }; for (const reg of regs) { if ( String(reg?.nodeType ?? "").toUpperCase() !== "P" || !reg?.landbookIE ) continue; const name = String(reg?.nodeName ?? "").trim(); if (name) addOwner(String(reg.landbookIE), name, isRadiated(reg)); } } // ── Enrich each teren feature ── push({ phase: "Îmbogățire parcele", downloaded: 0, total: terenuri.length, }); const folCache = new Map(); let enrichedCount = 0; let buildingCrossRefs = 0; const now = new Date(); for (let index = 0; index < terenuri.length; index += 1) { const feature = terenuri[index]!; const attrs = feature.attributes as Record; // Skip features with complete enrichment (resume after crash/interruption). // Re-enrich if enrichment schema is incomplete (e.g., missing PROPRIETARI_VECHI // added in a later version). if (feature.enrichedAt != null) { const enrichJson = feature.enrichment as Record | null; const isComplete = enrichJson != null && [ "NR_CAD", "NR_CF", "PROPRIETARI", "PROPRIETARI_VECHI", "ADRESA", "CATEGORIE_FOLOSINTA", "HAS_BUILDING", ].every((k) => k in enrichJson && enrichJson[k] !== undefined); if (isComplete) { enrichedCount += 1; if (index % 50 === 0) { options?.onProgress?.( index + 1, terenuri.length, "Îmbogățire parcele (skip enriched)", ); } continue; } // Stale enrichment — will be re-enriched below } const immovableId = attrs.IMMOVABLE_ID ?? ""; const workspaceId = attrs.WORKSPACE_ID ?? ""; const applicationId = (attrs.APPLICATION_ID as number) ?? null; // SOLICITANT skipped — saves ~500+ API calls; value was always "-" // for old CF records and rarely useful for modern ones. const solicitant = "-"; let intravilan = "-"; let categorie = "-"; let proprietari = "-"; let nrCF = "-"; let nrCFVechi = "-"; let nrTopo = "-"; let addressText = "-"; if (immovableId && workspaceId) { // ── Strategy: direct parcel details FIRST (1 call, no applicationId needed) ── // This endpoint works for both GIS features and no-geom imports. // Saves ~50-65% of API calls vs the old app-based flow. const immPkForDetails = immovableListById.get(normalizeId(immovableId))?.immovablePk ?? immovableId; const detKey = `${workspaceId}:${immPkForDetails}:details`; let details = folCache.get(detKey); if (!details) { try { details = await throttled(() => client.fetchImmovableParcelDetails( workspaceId as string | number, immPkForDetails as string | number, ), ); } catch { details = []; } folCache.set(detKey, details); } if (details && details.length > 0) { intravilan = normalizeIntravilan( details.map((d: any) => d?.intravilan ?? ""), ); categorie = formatCategories(details); } // ── Fallback: app-based flow (only if direct details returned nothing) ── // Uses applicationId from GIS feature → fetchParcelFolosinte. // This path adds 1 extra API call. if (categorie === "-" && applicationId) { const appId = Number(applicationId); if (appId > 0) { const folKey = `${workspaceId}:${immovableId}:${appId}`; let fol = folCache.get(folKey); if (!fol) { fol = await throttled(() => client.fetchParcelFolosinte( workspaceId as string | number, immovableId as string | number, appId, ), ); folCache.set(folKey, fol); } if (fol && fol.length > 0) { const folIntravilan = normalizeIntravilan( fol.map((item: any) => item?.intravilan ?? ""), ); const folCategorie = formatCategories(fol); if (folCategorie && folCategorie !== "-") categorie = folCategorie; if (folIntravilan && folIntravilan !== "-" && intravilan === "-") intravilan = folIntravilan; } } } } const cadRefRaw = (attrs.NATIONAL_CADASTRAL_REFERENCE ?? "") as string; const cadRef = normalizeCadRef(cadRefRaw); const immKey = normalizeId(immovableId); const listItem = (immKey ? immovableListById.get(immKey) : undefined) ?? (cadRef ? immovableListByCad.get(cadRef) : undefined); const docKey = listItem?.immovablePk ? normalizeId(listItem.immovablePk) : ""; const docItem = docKey ? docByImmovable.get(docKey) : undefined; const landbookIE = docItem?.landbookIE ?? ""; const owners = landbookIE && ownersByLandbook.get(String(landbookIE)) ? Array.from(ownersByLandbook.get(String(landbookIE)) ?? []) : []; const ownersByCad = cadRefRaw && ownersByLandbook.get(String(cadRefRaw)) ? Array.from(ownersByLandbook.get(String(cadRefRaw)) ?? []) : []; proprietari = Array.from(new Set([...owners, ...ownersByCad])).join("; ") || proprietari; // Cancelled/old owners const cancelledOwners = landbookIE && cancelledOwnersByLandbook.get(String(landbookIE)) ? Array.from(cancelledOwnersByLandbook.get(String(landbookIE)) ?? []) : []; const cancelledByCad = cadRefRaw && cancelledOwnersByLandbook.get(String(cadRefRaw)) ? Array.from(cancelledOwnersByLandbook.get(String(cadRefRaw)) ?? []) : []; const activeSet = new Set([...owners, ...ownersByCad]); const proprietariVechi = Array.from( new Set([...cancelledOwners, ...cancelledByCad]), ) .filter((n) => !activeSet.has(n)) .join("; "); nrCF = docItem?.landbookIE || listItem?.paperLbNo || listItem?.paperCadNo || nrCF; const nrCFVechiRaw = listItem?.paperLbNo || listItem?.paperCadNo || ""; nrCFVechi = docItem?.landbookIE && nrCFVechiRaw !== nrCF ? nrCFVechiRaw : nrCFVechi; nrTopo = listItem?.topNo || docItem?.topNo || listItem?.paperCadNo || nrTopo; addressText = listItem ? formatAddress(listItem) : addressText; const parcelRef = baseCadRef(cadRefRaw); const wKey = makeWorkspaceKey(workspaceId, immovableId); const build = (immKey ? buildingMap.get(immKey) : undefined) ?? (wKey ? buildingMap.get(wKey) : undefined) ?? (parcelRef ? buildingMap.get(parcelRef) : undefined) ?? { has: false, legal: false, }; const hasBuilding = build.has ? 1 : 0; const buildLegal = build.has ? (build.legal ? 1 : 0) : 0; if (hasBuilding) buildingCrossRefs += 1; // Area: prefer GIS AREA_VALUE, fall back to measuredArea/legalArea from // immovable list (important for no-geometry features where AREA_VALUE // may have been stored from measuredArea at import time, or may be null). let areaValue = typeof attrs.AREA_VALUE === "number" ? attrs.AREA_VALUE : null; if (areaValue == null && listItem) { areaValue = (typeof listItem.measuredArea === "number" && listItem.measuredArea > 0 ? listItem.measuredArea : null) ?? (typeof listItem.legalArea === "number" && listItem.legalArea > 0 ? listItem.legalArea : null); } const enrichment: FeatureEnrichment = { NR_CAD: cadRefRaw, NR_CF: nrCF, NR_CF_VECHI: nrCFVechi, NR_TOPO: nrTopo, ADRESA: addressText, PROPRIETARI: proprietari, PROPRIETARI_VECHI: proprietariVechi, SUPRAFATA_2D: areaValue !== null ? Number(areaValue.toFixed(2)) : "", SUPRAFATA_R: areaValue !== null ? Math.round(areaValue) : "", SOLICITANT: solicitant, INTRAVILAN: intravilan, CATEGORIE_FOLOSINTA: categorie, HAS_BUILDING: hasBuilding, BUILD_LEGAL: buildLegal, }; // Store enrichment in DB await prisma.gisFeature.update({ where: { id: feature.id }, data: { enrichment: enrichment as unknown as Prisma.InputJsonValue, enrichedAt: now, }, }); enrichedCount += 1; if (index % 10 === 0) { push({ phase: "Îmbogățire parcele", downloaded: index + 1, total: terenuri.length, }); options?.onProgress?.(index + 1, terenuri.length, "Îmbogățire parcele"); } } // ── Post-enrichment verification ── // Check that ALL features now have enrichment (no gaps) const unenriched = terenuri.length - enrichedCount; if (unenriched > 0) { console.warn( `[enrich] ${unenriched}/${terenuri.length} features remain unenriched for siruta=${siruta}`, ); } else { console.log( `[enrich] ✓ 100% enrichment: ${enrichedCount}/${terenuri.length} features for siruta=${siruta}`, ); } push({ phase: "Îmbogățire completă", status: "done", downloaded: terenuri.length, total: terenuri.length, }); if (jobId) setTimeout(() => clearProgress(jobId), 60_000); return { siruta, enrichedCount, totalFeatures: terenuri.length, unenrichedCount: unenriched, buildingCrossRefs, status: "done", }; } catch (error) { const msg = error instanceof Error ? error.message : "Unknown error"; push({ phase: "Eroare îmbogățire", status: "error", message: msg }); if (jobId) setTimeout(() => clearProgress(jobId), 60_000); return { siruta, enrichedCount: 0, buildingCrossRefs: 0, status: "error", error: msg, }; } } /** * Check data freshness for a UAT + layer. * Returns the most recent sync run's completedAt, or null if never synced. */ export async function getLayerFreshness( siruta: string, layerId: string, ): Promise<{ lastSynced: Date | null; featureCount: number; enrichedCount: number; }> { const lastRun = await prisma.gisSyncRun.findFirst({ where: { siruta, layerId, status: "done" }, orderBy: { completedAt: "desc" }, select: { completedAt: true }, }); const featureCount = await prisma.gisFeature.count({ where: { siruta, layerId }, }); const enrichedCount = await prisma.gisFeature.count({ where: { siruta, layerId, enrichedAt: { not: null } }, }); return { lastSynced: lastRun?.completedAt ?? null, featureCount, enrichedCount, }; } /** * Check if layer data is "fresh enough" (synced within maxAgeHours). */ export function isFresh(lastSynced: Date | null, maxAgeHours = 168): boolean { if (!lastSynced) return false; const ageMs = Date.now() - lastSynced.getTime(); return ageMs < maxAgeHours * 60 * 60 * 1000; }