/* eslint-disable @typescript-eslint/no-explicit-any */ /** * No-geometry sync — imports eTerra immovables that have NO geometry * in the GIS layer (TERENURI_ACTIVE). * * These are parcels that exist in the eTerra immovable database but * have no spatial representation in the ArcGIS layer. They are stored * in GisFeature with: * - geometry = null * - geometrySource = "NO_GEOMETRY" * - objectId = negative immovablePk (to avoid collisions with real OBJECTIDs) * * The cross-reference works by: * 1. Fetch full immovable list from eTerra for the UAT (paginated) * 2. Load all existing cadastralRefs from DB for TERENURI_ACTIVE + siruta * 3. Immovables whose cadastralRef is NOT in DB → candidates * 4. Store each candidate as a GisFeature with geometry=null */ import { Prisma, PrismaClient } from "@prisma/client"; import { EterraClient } from "./eterra-client"; const prisma = new PrismaClient(); /* ------------------------------------------------------------------ */ /* Workspace resolution (county → eTerra workspace PK) */ /* ------------------------------------------------------------------ */ /** * Resolve the eTerra workspace PK for a SIRUTA. * Chain: explicit param → GisUat DB row → ArcGIS layer query → null. */ async function resolveWorkspacePk( client: EterraClient, siruta: string, explicitPk?: number | null, ): Promise { // 1. Explicit param if (explicitPk && Number.isFinite(explicitPk) && explicitPk > 0) { return explicitPk; } // 2. DB lookup try { const row = await prisma.gisUat.findUnique({ where: { siruta }, select: { workspacePk: true }, }); if (row?.workspacePk && row.workspacePk > 0) return row.workspacePk; } catch { /* ignore */ } // 3. ArcGIS layer query — fetch 1 feature from TERENURI_ACTIVE for this siruta 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 num = Number(wsId); if (Number.isFinite(num) && num > 0) { // Persist for future lookups prisma.gisUat .update({ where: { siruta }, data: { workspacePk: num } }) .catch(() => {}); return num; } } } catch { /* ignore */ } return null; } 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(); export type NoGeomScanResult = { totalImmovables: number; /** Features present in the remote ArcGIS TERENURI_ACTIVE layer (have geometry) */ withGeometry: number; noGeomCount: number; /** Sample of immovable identifiers without geometry */ samples: Array<{ immovablePk: number; identifierDetails: string; paperCadNo?: string; paperCfNo?: string; }>; /** Total features already in local DB (geometry + no-geom) */ localDbTotal: number; /** Geometry features already synced in local DB */ localDbWithGeom: number; /** No-geometry features already imported in local DB */ localDbNoGeom: number; /** How many are already enriched (magic) in local DB */ localDbEnriched: number; /** Whether local sync is fresh (< 7 days) */ localSyncFresh: boolean; /** Error message if workspace couldn't be resolved */ error?: string; }; export type NoGeomSyncResult = { imported: number; skipped: number; errors: number; status: "done" | "error"; error?: string; }; /** * Scan: count how many eTerra immovables for this UAT have no geometry * in the remote GIS layer (TERENURI_ACTIVE). * * Cross-references the eTerra immovable list against the REMOTE ArcGIS * layer (lightweight fetch, no geometry download). This works correctly * regardless of whether the user has synced to local DB yet. * * This does NOT write anything — it's a read-only operation. */ export async function scanNoGeometryParcels( client: EterraClient, siruta: string, options?: { onProgress?: (page: number, totalPages: number) => void; workspacePk?: number | null; }, ): Promise { // 0. Resolve workspace const wsPk = await resolveWorkspacePk(client, siruta, options?.workspacePk); if (!wsPk) { return { totalImmovables: 0, withGeometry: 0, noGeomCount: 0, samples: [], localDbTotal: 0, localDbWithGeom: 0, localDbNoGeom: 0, localDbEnriched: 0, localSyncFresh: false, error: `Nu s-a putut determina workspace-ul (județul) pentru SIRUTA ${siruta}`, }; } // 1. Fetch all immovables from eTerra immovable list API const allImmovables = await fetchAllImmovables( client, siruta, wsPk, options?.onProgress, ); // 2. Fetch remote GIS cadastral refs (lightweight — no geometry) // This is the source of truth for "has geometry" regardless of local DB state. // ~4 pages for 8k features with outFields only = very fast. const terenuriLayer = { id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut" as const, whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", }; const remoteFeatures = await client.fetchAllLayer(terenuriLayer, siruta, { returnGeometry: false, outFields: "OBJECTID,NATIONAL_CADASTRAL_REFERENCE,IMMOVABLE_ID", pageSize: 2000, }); const remoteCadRefs = new Set(); const remoteImmIds = new Set(); for (const f of remoteFeatures) { const cadRef = normalizeCadRef( f.attributes?.NATIONAL_CADASTRAL_REFERENCE ?? "", ); if (cadRef) remoteCadRefs.add(cadRef); const immId = normalizeId(f.attributes?.IMMOVABLE_ID); if (immId) remoteImmIds.add(immId); } // 3. Cross-reference: immovables NOT in remote GIS = no geometry const noGeomItems: Array<{ immovablePk: number; identifierDetails: string; paperCadNo?: string; paperCfNo?: string; }> = []; for (const item of allImmovables) { const cadRef = normalizeCadRef(item.identifierDetails ?? ""); const immPk = Number(item.immovablePk ?? 0); const immId = normalizeId(item.immovablePk); // Present in remote GIS layer by cadastral ref? → has geometry if (cadRef && remoteCadRefs.has(cadRef)) continue; // Present in remote GIS layer by IMMOVABLE_ID? → has geometry if (immId && remoteImmIds.has(immId)) continue; noGeomItems.push({ immovablePk: immPk, identifierDetails: String(item.identifierDetails ?? ""), paperCadNo: item.paperCadNo ?? undefined, paperCfNo: item.paperCfNo ?? undefined, }); } // 4. Query local DB for context (what's already synced/imported) const [localTotal, localNoGeom, localEnriched, lastSyncRun] = await Promise.all([ prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", siruta }, }), prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", siruta, geometrySource: "NO_GEOMETRY", }, }), prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", siruta, enrichedAt: { not: null }, }, }), prisma.gisSyncRun.findFirst({ where: { siruta, layerId: "TERENURI_ACTIVE", status: "done" }, orderBy: { completedAt: "desc" }, select: { completedAt: true }, }), ]); const localWithGeom = localTotal - localNoGeom; const syncFresh = lastSyncRun?.completedAt ? Date.now() - lastSyncRun.completedAt.getTime() < 168 * 60 * 60 * 1000 : false; return { totalImmovables: allImmovables.length, withGeometry: remoteFeatures.length, noGeomCount: noGeomItems.length, samples: noGeomItems.slice(0, 20), localDbTotal: localTotal, localDbWithGeom: localWithGeom, localDbNoGeom: localNoGeom, localDbEnriched: localEnriched, localSyncFresh: syncFresh, }; } /** * Import: store no-geometry immovables as GisFeature records. * * Uses negative immovablePk as objectId to avoid collision with * real OBJECTID values from the GIS layer (always positive). */ export async function syncNoGeometryParcels( client: EterraClient, siruta: string, options?: { onProgress?: (done: number, total: number, phase: string) => void; workspacePk?: number | null; }, ): Promise { try { // 0. Resolve workspace const wsPk = await resolveWorkspacePk(client, siruta, options?.workspacePk); if (!wsPk) { return { imported: 0, skipped: 0, errors: 0, status: "error", error: `Nu s-a putut determina workspace-ul pentru SIRUTA ${siruta}`, }; } // 1. Fetch all immovables options?.onProgress?.(0, 1, "Descărcare listă imobile (fără geometrie)"); const allImmovables = await fetchAllImmovables(client, siruta, wsPk); // 2. Get existing features from DB const existingFeatures = await prisma.gisFeature.findMany({ where: { layerId: "TERENURI_ACTIVE", siruta }, select: { cadastralRef: true, objectId: true }, }); const existingCadRefs = new Set(); const existingObjIds = new Set(); for (const f of existingFeatures) { if (f.cadastralRef) existingCadRefs.add(normalizeCadRef(f.cadastralRef)); existingObjIds.add(f.objectId); } // 3. Filter to only those not yet in DB const candidates = allImmovables.filter((item) => { const cadRef = normalizeCadRef(item.identifierDetails ?? ""); const immPk = Number(item.immovablePk ?? 0); if (cadRef && existingCadRefs.has(cadRef)) return false; if (immPk > 0 && existingObjIds.has(-immPk)) return false; return true; }); if (candidates.length === 0) { return { imported: 0, skipped: 0, errors: 0, status: "done" }; } // 4. Import candidates in batches with retry let imported = 0; let skipped = 0; let errors = 0; const total = candidates.length; const BATCH_SIZE = 50; const MAX_RETRIES = 3; for ( let batchStart = 0; batchStart < candidates.length; batchStart += BATCH_SIZE ) { const batch = candidates.slice(batchStart, batchStart + BATCH_SIZE); const ops: Array> = []; for (const item of batch) { const immPk = Number(item.immovablePk ?? 0); if (immPk <= 0) { skipped++; continue; } const cadRef = String(item.identifierDetails ?? "").trim(); const areaValue = typeof item.area === "number" ? item.area : null; const attributes: Record = { OBJECTID: -immPk, IMMOVABLE_ID: immPk, WORKSPACE_ID: item.workspacePk ?? wsPk, APPLICATION_ID: item.applicationId ?? null, NATIONAL_CADASTRAL_REFERENCE: cadRef, AREA_VALUE: areaValue, IS_ACTIVE: 1, ADMIN_UNIT_ID: Number(siruta), PAPER_CAD_NO: item.paperCadNo ?? null, PAPER_CF_NO: item.paperCfNo ?? null, PAPER_LB_NO: item.paperLbNo ?? null, TOP_NO: item.topNo ?? null, IMMOVABLE_TYPE: item.immovableType ?? "P", NO_GEOMETRY_SOURCE: "ETERRA_IMMOVABLE_LIST", }; ops.push( prisma.gisFeature.upsert({ where: { layerId_objectId: { layerId: "TERENURI_ACTIVE", objectId: -immPk, }, }, create: { layerId: "TERENURI_ACTIVE", siruta, objectId: -immPk, cadastralRef: cadRef || null, areaValue, isActive: true, attributes: attributes as Prisma.InputJsonValue, geometry: Prisma.JsonNull, geometrySource: "NO_GEOMETRY", }, update: { cadastralRef: cadRef || null, areaValue, attributes: attributes as Prisma.InputJsonValue, geometrySource: "NO_GEOMETRY", updatedAt: new Date(), }, }), ); } // Execute batch with retry if (ops.length > 0) { let attempt = 0; while (attempt < MAX_RETRIES) { try { await prisma.$transaction(ops); imported += ops.length; break; } catch (err) { attempt++; if (attempt >= MAX_RETRIES) { // Fall back to individual upserts for this batch for (const op of ops) { try { await op; imported++; } catch { errors++; } } } else { // Wait before retry (exponential backoff) await new Promise((r) => setTimeout(r, 500 * attempt)); } } } } const done = Math.min(batchStart + BATCH_SIZE, total); options?.onProgress?.(done, total, "Import parcele fără geometrie"); } return { imported, skipped, errors, status: "done" }; } catch (error) { const msg = error instanceof Error ? error.message : "Unknown error"; return { imported: 0, skipped: 0, errors: 0, status: "error", error: msg }; } } /** * Fetch all immovables from the eTerra immovable list for a UAT. * Paginated — fetches all pages. */ async function fetchAllImmovables( client: EterraClient, siruta: string, workspaceId: number, onProgress?: (page: number, totalPages: number) => void, ): Promise { const all: any[] = []; let page = 0; let totalPages = 1; let includeInscrisCF = true; while (page < totalPages) { const response = await client.fetchImmovableListByAdminUnit( workspaceId, siruta, page, 200, includeInscrisCF, ); // Retry without CF filter if first page is empty if (page === 0 && !(response?.content ?? []).length && includeInscrisCF) { includeInscrisCF = false; page = 0; totalPages = 1; continue; } totalPages = typeof response?.totalPages === "number" ? response.totalPages : totalPages; const content = response?.content ?? []; all.push(...content); page++; if (onProgress) { onProgress(page, totalPages); } } return all; }