From 30915e86281479924a679b52c8dfecaaece8d170 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 7 Mar 2026 12:58:10 +0200 Subject: [PATCH] feat(parcel-sync): import eTerra immovables without geometry - Add geometrySource field to GisFeature (NO_GEOMETRY marker) - New no-geom-sync service: scan + import parcels missing from GIS layer - Uses negative immovablePk as objectId to avoid @@unique collision - New /api/eterra/no-geom-scan endpoint for counting - Export-bundle: includeNoGeometry flag, imports before enrich - CSV export: new HAS_GEOMETRY column (0/1) - GPKG: still geometry-only (unchanged) - UI: checkbox + scan button on Export tab - Baza de Date tab: shows no-geometry counts per UAT - db-summary API: includes noGeomCount per layer --- prisma/schema.prisma | 34 +- src/app/api/eterra/db-summary/route.ts | 17 + src/app/api/eterra/export-bundle/route.ts | 95 +++++- src/app/api/eterra/no-geom-scan/route.ts | 55 ++++ .../components/parcel-sync-module.tsx | 128 +++++++- .../parcel-sync/services/no-geom-sync.ts | 297 ++++++++++++++++++ 6 files changed, 604 insertions(+), 22 deletions(-) create mode 100644 src/app/api/eterra/no-geom-scan/route.ts create mode 100644 src/modules/parcel-sync/services/no-geom-sync.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 400ad51..4bb1e7c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,24 +22,25 @@ model KeyValueStore { // ─── GIS: eTerra ParcelSync ──────────────────────────────────────── model GisFeature { - id String @id @default(uuid()) - layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE - siruta String - objectId Int // eTerra OBJECTID (unique per layer) - inspireId String? - cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE - areaValue Float? - isActive Boolean @default(true) - attributes Json // all raw eTerra attributes - geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + id String @id @default(uuid()) + layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE + siruta String + objectId Int // eTerra OBJECTID (unique per layer); negative for no-geometry parcels (= -immovablePk) + inspireId String? + cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE + areaValue Float? + isActive Boolean @default(true) + attributes Json // all raw eTerra attributes + geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + geometrySource String? // null = normal GIS sync, "NO_GEOMETRY" = eTerra immovable without GIS geometry // NOTE: native PostGIS column 'geom' is managed via SQL trigger (see prisma/postgis-setup.sql) // Prisma doesn't need to know about it — trigger auto-populates from geometry JSON - enrichment Json? // magic data: CF, owners, address, categories, etc. - enrichedAt DateTime? // when enrichment was last fetched - syncRunId String? - projectId String? // link to project tag - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + enrichment Json? // magic data: CF, owners, address, categories, etc. + enrichedAt DateTime? // when enrichment was last fetched + syncRunId String? + projectId String? // link to project tag + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id]) @@ -48,6 +49,7 @@ model GisFeature { @@index([cadastralRef]) @@index([layerId, siruta]) @@index([projectId]) + @@index([geometrySource]) } model GisSyncRun { diff --git a/src/app/api/eterra/db-summary/route.ts b/src/app/api/eterra/db-summary/route.ts index fef1d93..503156a 100644 --- a/src/app/api/eterra/db-summary/route.ts +++ b/src/app/api/eterra/db-summary/route.ts @@ -39,6 +39,17 @@ export async function GET() { enrichedMap.set(`${e.siruta}:${e.layerId}`, e._count.id); } + // No-geometry counts per siruta + layerId + const noGeomCounts = await prisma.gisFeature.groupBy({ + by: ["siruta", "layerId"], + where: { geometrySource: "NO_GEOMETRY" }, + _count: { id: true }, + }); + const noGeomMap = new Map(); + for (const ng of noGeomCounts) { + noGeomMap.set(`${ng.siruta}:${ng.layerId}`, ng._count.id); + } + // Latest sync run per siruta + layerId const latestRuns = await prisma.gisSyncRun.findMany({ where: { status: "done" }, @@ -87,10 +98,12 @@ export async function GET() { layerId: string; count: number; enrichedCount: number; + noGeomCount: number; lastSynced: string | null; }[]; totalFeatures: number; totalEnriched: number; + totalNoGeom: number; } >(); @@ -105,21 +118,25 @@ export async function GET() { layers: [], totalFeatures: 0, totalEnriched: 0, + totalNoGeom: 0, }); } const uat = uatMap.get(fc.siruta)!; const enriched = enrichedMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0; + const noGeom = noGeomMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0; const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`); uat.layers.push({ layerId: fc.layerId, count: fc._count.id, enrichedCount: enriched, + noGeomCount: noGeom, lastSynced: runInfo?.completedAt?.toISOString() ?? null, }); uat.totalFeatures += fc._count.id; uat.totalEnriched += enriched; + uat.totalNoGeom += noGeom; // Update UAT name if we got one from sync runs if (uat.uatName.startsWith("UAT ") && runInfo?.uatName) { diff --git a/src/app/api/eterra/export-bundle/route.ts b/src/app/api/eterra/export-bundle/route.ts index e0be9af..bd53658 100644 --- a/src/app/api/eterra/export-bundle/route.ts +++ b/src/app/api/eterra/export-bundle/route.ts @@ -31,6 +31,7 @@ import { registerJob, unregisterJob, } from "@/modules/parcel-sync/services/session-store"; +import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson"; export const runtime = "nodejs"; @@ -43,6 +44,7 @@ type ExportBundleRequest = { jobId?: string; mode?: "base" | "magic"; forceSync?: boolean; + includeNoGeometry?: boolean; }; const validate = (body: ExportBundleRequest) => { @@ -57,12 +59,21 @@ const validate = (body: ExportBundleRequest) => { const jobId = body.jobId ? String(body.jobId).trim() : undefined; const mode = body.mode === "magic" ? "magic" : "base"; const forceSync = body.forceSync === true; + const includeNoGeometry = body.includeNoGeometry === true; if (!username) throw new Error("Email is required"); if (!password) throw new Error("Password is required"); if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric"); - return { username, password, siruta, jobId, mode, forceSync }; + return { + username, + password, + siruta, + jobId, + mode, + forceSync, + includeNoGeometry, + }; }; const scheduleClear = (jobId?: string) => { @@ -160,10 +171,15 @@ export async function POST(req: Request) { if (jobId) registerJob(jobId); pushProgress(); + const hasNoGeom = validated.includeNoGeometry; const weights = validated.mode === "magic" - ? { sync: 40, enrich: 35, gpkg: 15, zip: 10 } - : { sync: 55, enrich: 0, gpkg: 30, zip: 15 }; + ? hasNoGeom + ? { sync: 35, noGeom: 10, enrich: 30, gpkg: 15, zip: 10 } + : { sync: 40, noGeom: 0, enrich: 35, gpkg: 15, zip: 10 } + : hasNoGeom + ? { sync: 45, noGeom: 15, enrich: 0, gpkg: 25, zip: 15 } + : { sync: 55, noGeom: 0, enrich: 0, gpkg: 30, zip: 15 }; /* ══════════════════════════════════════════════════════════ */ /* Phase 1: Sync layers to local DB */ @@ -236,6 +252,45 @@ export async function POST(req: Request) { } finishPhase(); + /* ══════════════════════════════════════════════════════════ */ + /* Phase 1b: Import no-geometry parcels (optional) */ + /* ══════════════════════════════════════════════════════════ */ + let noGeomImported = 0; + if (hasNoGeom && weights.noGeom > 0) { + setPhaseState("Import parcele fără geometrie", weights.noGeom, 1); + const noGeomClient = await EterraClient.create( + validated.username, + validated.password, + { timeoutMs: 120_000 }, + ); + + const noGeomResult = await syncNoGeometryParcels( + noGeomClient, + validated.siruta, + { + onProgress: (done, tot, ph) => { + phase = ph; + updatePhaseProgress(done, tot); + }, + }, + ); + + if (noGeomResult.status === "error") { + // Non-fatal: log but continue with export + note = `Avertisment: ${noGeomResult.error}`; + pushProgress(); + } else { + noGeomImported = noGeomResult.imported; + note = + noGeomImported > 0 + ? `${noGeomImported} parcele noi fără geometrie importate` + : "Nicio parcelă nouă fără geometrie"; + pushProgress(); + } + updatePhaseProgress(1, 1); + finishPhase(); + } + /* ══════════════════════════════════════════════════════════ */ /* Phase 2: Enrich (magic mode only) */ /* ══════════════════════════════════════════════════════════ */ @@ -286,7 +341,12 @@ export async function POST(req: Request) { // Load features from DB const dbTerenuri = await prisma.gisFeature.findMany({ where: { layerId: terenuriLayerId, siruta: validated.siruta }, - select: { attributes: true, geometry: true, enrichment: true }, + select: { + attributes: true, + geometry: true, + enrichment: true, + geometrySource: true, + }, }); const dbCladiri = await prisma.gisFeature.findMany({ @@ -378,6 +438,7 @@ export async function POST(req: Request) { "CATEGORIE_FOLOSINTA", "HAS_BUILDING", "BUILD_LEGAL", + "HAS_GEOMETRY", ]; const csvRows: string[] = [headers.map(csvEscape).join(",")]; @@ -408,6 +469,11 @@ export async function POST(req: Request) { (record.enrichment as FeatureEnrichment | null) ?? ({} as Partial); const geom = record.geometry as GeoJsonFeature["geometry"] | null; + const geomSource = ( + record as unknown as { geometrySource: string | null } + ).geometrySource; + const hasGeometry = + geom != null && geomSource !== "NO_GEOMETRY" ? 1 : 0; const e = enrichment as Partial; if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1; @@ -433,6 +499,7 @@ export async function POST(req: Request) { e.CATEGORIE_FOLOSINTA ?? "", e.HAS_BUILDING ?? 0, e.BUILD_LEGAL ?? 0, + hasGeometry, ]; csvRows.push(row.map(csvEscape).join(",")); @@ -476,12 +543,22 @@ export async function POST(req: Request) { siruta: validated.siruta, generatedAt: new Date().toISOString(), source: "local-db (sync-first)", - terenuri: { count: terenuriGeoFeatures.length }, + terenuri: { + count: terenuriGeoFeatures.length, + totalInDb: dbTerenuri.length, + noGeometryCount: dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }) + .geometrySource === "NO_GEOMETRY", + ).length, + }, cladiri: { count: cladiriGeoFeatures.length }, syncSkipped: { terenuri: !terenuriNeedsSync, cladiri: !cladiriNeedsSync, }, + includeNoGeometry: hasNoGeom, + noGeomImported, }; if (validated.mode === "magic" && magicGpkg && csvContent) { @@ -502,7 +579,15 @@ export async function POST(req: Request) { finishPhase(); /* Done */ + const noGeomInDb = dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }).geometrySource === + "NO_GEOMETRY", + ).length; message = `Finalizat 100% · Terenuri ${terenuriGeoFeatures.length} · Clădiri ${cladiriGeoFeatures.length}`; + if (noGeomInDb > 0) { + message += ` · Fără geometrie ${noGeomInDb}`; + } if (!terenuriNeedsSync && !cladiriNeedsSync) { message += " (din cache local)"; } diff --git a/src/app/api/eterra/no-geom-scan/route.ts b/src/app/api/eterra/no-geom-scan/route.ts new file mode 100644 index 0000000..9365c63 --- /dev/null +++ b/src/app/api/eterra/no-geom-scan/route.ts @@ -0,0 +1,55 @@ +/** + * POST /api/eterra/no-geom-scan + * + * Scans eTerra immovable list for a UAT and counts how many parcels + * exist in the eTerra database but have no geometry in the GIS layer + * (i.e., they are NOT in the local TERENURI_ACTIVE DB). + * + * Body: { siruta: string } + * Returns: { totalImmovables, totalInDb, noGeomCount, samples } + * + * Requires active eTerra session. + */ + +import { NextResponse } from "next/server"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; +import { scanNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as { siruta?: string }; + const siruta = String(body.siruta ?? "").trim(); + if (!/^\d+$/.test(siruta)) { + return NextResponse.json( + { error: "SIRUTA must be numeric" }, + { status: 400 }, + ); + } + + 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) { + return NextResponse.json( + { error: "Nu ești conectat la eTerra" }, + { status: 401 }, + ); + } + + const client = await EterraClient.create(username, password); + const result = await scanNoGeometryParcels(client, siruta); + + return NextResponse.json(result); + } 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 ea58283..3ce69a5 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -351,10 +351,12 @@ export function ParcelSyncModule() { layerId: string; count: number; enrichedCount: number; + noGeomCount: number; lastSynced: string | null; }[]; totalFeatures: number; totalEnriched: number; + totalNoGeom: number; }; type DbSummary = { uats: DbUatSummary[]; @@ -380,6 +382,16 @@ export function ParcelSyncModule() { const [loadingFeatures, setLoadingFeatures] = useState(false); const [searchError, setSearchError] = useState(""); + /* ── No-geometry import option ──────────────────────────────── */ + const [includeNoGeom, setIncludeNoGeom] = useState(false); + const [noGeomScanning, setNoGeomScanning] = useState(false); + const [noGeomScan, setNoGeomScan] = useState<{ + totalImmovables: number; + totalInDb: number; + noGeomCount: number; + } | null>(null); + const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done + /* ════════════════════════════════════════════════════════════ */ /* Load UAT data + check server session on mount */ /* ════════════════════════════════════════════════════════════ */ @@ -594,6 +606,7 @@ export function ParcelSyncModule() { siruta, jobId, mode, + includeNoGeometry: includeNoGeom, }), }); @@ -658,9 +671,45 @@ export function ParcelSyncModule() { // Refresh sync status — data was synced to DB refreshSyncRef.current?.(); }, - [siruta, exporting, startPolling], + [siruta, exporting, startPolling, includeNoGeom], ); + /* ════════════════════════════════════════════════════════════ */ + /* No-geometry scan */ + /* ════════════════════════════════════════════════════════════ */ + + const handleNoGeomScan = useCallback(async () => { + if (!siruta || noGeomScanning) return; + setNoGeomScanning(true); + setNoGeomScan(null); + try { + const res = await fetch("/api/eterra/no-geom-scan", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siruta }), + }); + const data = (await res.json()) as { + totalImmovables?: number; + totalInDb?: number; + noGeomCount?: number; + error?: string; + }; + if (data.error) { + setNoGeomScan(null); + } else { + setNoGeomScan({ + totalImmovables: data.totalImmovables ?? 0, + totalInDb: data.totalInDb ?? 0, + noGeomCount: data.noGeomCount ?? 0, + }); + setNoGeomScanSiruta(siruta); + } + } catch { + setNoGeomScan(null); + } + setNoGeomScanning(false); + }, [siruta, noGeomScanning]); + /* ════════════════════════════════════════════════════════════ */ /* Layer feature counts */ /* ════════════════════════════════════════════════════════════ */ @@ -2295,6 +2344,71 @@ export function ParcelSyncModule() { )} + {/* No-geometry option */} + {sirutaValid && session.connected && ( + + +
+ + + {noGeomScan && noGeomScanSiruta === siruta && ( + + {noGeomScan.noGeomCount > 0 ? ( + <> + + {noGeomScan.noGeomCount.toLocaleString("ro-RO")} + {" "} + parcele fără geometrie + + (din{" "} + {noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "} + total eTerra,{" "} + {noGeomScan.totalInDb.toLocaleString("ro-RO")} în DB) + + + ) : ( + + Toate parcelele au geometrie + + )} + + )} +
+ {includeNoGeom && ( +

+ Parcelele fără geometrie vor apărea doar în CSV (coloana + HAS_GEOMETRY=0), nu în GPKG. +

+ )} +
+
+ )} + {/* Progress bar */} {exportProgress && exportProgress.status !== "unknown" && @@ -2442,12 +2556,14 @@ export function ParcelSyncModule() { {dbSummary.uats.map((uat) => { const catCounts: Record = {}; let enrichedTotal = 0; + let noGeomTotal = 0; let oldestSync: Date | null = null; for (const layer of uat.layers) { const cat = findLayerById(layer.layerId)?.category ?? "administrativ"; catCounts[cat] = (catCounts[cat] ?? 0) + layer.count; enrichedTotal += layer.enrichedCount; + noGeomTotal += layer.noGeomCount ?? 0; if (layer.lastSynced) { const d = new Date(layer.lastSynced); if (!oldestSync || d < oldestSync) oldestSync = d; @@ -2523,6 +2639,16 @@ export function ParcelSyncModule() { )} + {noGeomTotal > 0 && ( + + + Fără geom: + + + {noGeomTotal.toLocaleString("ro-RO")} + + + )} {/* Layer detail pills */} diff --git a/src/modules/parcel-sync/services/no-geom-sync.ts b/src/modules/parcel-sync/services/no-geom-sync.ts new file mode 100644 index 0000000..539995a --- /dev/null +++ b/src/modules/parcel-sync/services/no-geom-sync.ts @@ -0,0 +1,297 @@ +/* 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(); + +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; + totalInDb: number; + noGeomCount: number; + /** Sample of immovable identifiers without geometry */ + samples: Array<{ + immovablePk: number; + identifierDetails: string; + paperCadNo?: string; + paperCfNo?: 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 local DB. + * + * 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; + }, +): Promise { + // 1. Fetch all immovables from eTerra + const allImmovables = await fetchAllImmovables( + client, + siruta, + options?.onProgress, + ); + + // 2. Get all existing cadastralRefs in DB for TERENURI_ACTIVE + 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. Find immovables not in DB + 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); + + // Already in DB by cadastral ref? + if (cadRef && existingCadRefs.has(cadRef)) continue; + + // Already in DB by negative objectId? + if (immPk > 0 && existingObjIds.has(-immPk)) continue; + + noGeomItems.push({ + immovablePk: immPk, + identifierDetails: String(item.identifierDetails ?? ""), + paperCadNo: item.paperCadNo ?? undefined, + paperCfNo: item.paperCfNo ?? undefined, + }); + } + + return { + totalImmovables: allImmovables.length, + totalInDb: existingFeatures.length, + noGeomCount: noGeomItems.length, + samples: noGeomItems.slice(0, 20), + }; +} + +/** + * 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; + }, +): Promise { + try { + // 1. Fetch all immovables + options?.onProgress?.(0, 1, "Descărcare listă imobile (fără geometrie)"); + const allImmovables = await fetchAllImmovables(client, siruta); + + // 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 + let imported = 0; + let skipped = 0; + let errors = 0; + const total = candidates.length; + + for (let i = 0; i < candidates.length; i++) { + const item = candidates[i]!; + 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; + + // Build synthetic attributes to match the eTerra GIS layer format + const attributes: Record = { + OBJECTID: -immPk, // synthetic negative + IMMOVABLE_ID: immPk, + WORKSPACE_ID: item.workspacePk ?? 65, + APPLICATION_ID: item.applicationId ?? null, + NATIONAL_CADASTRAL_REFERENCE: cadRef, + AREA_VALUE: areaValue, + IS_ACTIVE: 1, + ADMIN_UNIT_ID: Number(siruta), + // Metadata from immovable list + 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", + }; + + try { + await 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(), + }, + }); + imported++; + } catch { + errors++; + } + + if (i % 20 === 0 || i === total - 1) { + options?.onProgress?.(i + 1, 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, + onProgress?: (page: number, totalPages: number) => void, +): Promise { + const all: any[] = []; + let page = 0; + let totalPages = 1; + let includeInscrisCF = true; + + // The workspace ID for eTerra admin unit queries. + // Default to 65 (standard workspace); the eTerra API resolves by adminUnit. + const workspaceId = 65; + + 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; +}