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
This commit is contained in:
AI Assistant
2026-03-07 12:58:10 +02:00
parent d50b9ea0e2
commit 30915e8628
6 changed files with 604 additions and 22 deletions
+18 -16
View File
@@ -22,24 +22,25 @@ model KeyValueStore {
// ─── GIS: eTerra ParcelSync ──────────────────────────────────────── // ─── GIS: eTerra ParcelSync ────────────────────────────────────────
model GisFeature { model GisFeature {
id String @id @default(uuid()) id String @id @default(uuid())
layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE
siruta String siruta String
objectId Int // eTerra OBJECTID (unique per layer) objectId Int // eTerra OBJECTID (unique per layer); negative for no-geometry parcels (= -immovablePk)
inspireId String? inspireId String?
cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE
areaValue Float? areaValue Float?
isActive Boolean @default(true) isActive Boolean @default(true)
attributes Json // all raw eTerra attributes attributes Json // all raw eTerra attributes
geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) 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) // 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 // Prisma doesn't need to know about it — trigger auto-populates from geometry JSON
enrichment Json? // magic data: CF, owners, address, categories, etc. enrichment Json? // magic data: CF, owners, address, categories, etc.
enrichedAt DateTime? // when enrichment was last fetched enrichedAt DateTime? // when enrichment was last fetched
syncRunId String? syncRunId String?
projectId String? // link to project tag projectId String? // link to project tag
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id]) syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
@@ -48,6 +49,7 @@ model GisFeature {
@@index([cadastralRef]) @@index([cadastralRef])
@@index([layerId, siruta]) @@index([layerId, siruta])
@@index([projectId]) @@index([projectId])
@@index([geometrySource])
} }
model GisSyncRun { model GisSyncRun {
+17
View File
@@ -39,6 +39,17 @@ export async function GET() {
enrichedMap.set(`${e.siruta}:${e.layerId}`, e._count.id); 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<string, number>();
for (const ng of noGeomCounts) {
noGeomMap.set(`${ng.siruta}:${ng.layerId}`, ng._count.id);
}
// Latest sync run per siruta + layerId // Latest sync run per siruta + layerId
const latestRuns = await prisma.gisSyncRun.findMany({ const latestRuns = await prisma.gisSyncRun.findMany({
where: { status: "done" }, where: { status: "done" },
@@ -87,10 +98,12 @@ export async function GET() {
layerId: string; layerId: string;
count: number; count: number;
enrichedCount: number; enrichedCount: number;
noGeomCount: number;
lastSynced: string | null; lastSynced: string | null;
}[]; }[];
totalFeatures: number; totalFeatures: number;
totalEnriched: number; totalEnriched: number;
totalNoGeom: number;
} }
>(); >();
@@ -105,21 +118,25 @@ export async function GET() {
layers: [], layers: [],
totalFeatures: 0, totalFeatures: 0,
totalEnriched: 0, totalEnriched: 0,
totalNoGeom: 0,
}); });
} }
const uat = uatMap.get(fc.siruta)!; const uat = uatMap.get(fc.siruta)!;
const enriched = enrichedMap.get(`${fc.siruta}:${fc.layerId}`) ?? 0; 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}`); const runInfo = latestRunMap.get(`${fc.siruta}:${fc.layerId}`);
uat.layers.push({ uat.layers.push({
layerId: fc.layerId, layerId: fc.layerId,
count: fc._count.id, count: fc._count.id,
enrichedCount: enriched, enrichedCount: enriched,
noGeomCount: noGeom,
lastSynced: runInfo?.completedAt?.toISOString() ?? null, lastSynced: runInfo?.completedAt?.toISOString() ?? null,
}); });
uat.totalFeatures += fc._count.id; uat.totalFeatures += fc._count.id;
uat.totalEnriched += enriched; uat.totalEnriched += enriched;
uat.totalNoGeom += noGeom;
// Update UAT name if we got one from sync runs // Update UAT name if we got one from sync runs
if (uat.uatName.startsWith("UAT ") && runInfo?.uatName) { if (uat.uatName.startsWith("UAT ") && runInfo?.uatName) {
+90 -5
View File
@@ -31,6 +31,7 @@ import {
registerJob, registerJob,
unregisterJob, unregisterJob,
} from "@/modules/parcel-sync/services/session-store"; } 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"; import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -43,6 +44,7 @@ type ExportBundleRequest = {
jobId?: string; jobId?: string;
mode?: "base" | "magic"; mode?: "base" | "magic";
forceSync?: boolean; forceSync?: boolean;
includeNoGeometry?: boolean;
}; };
const validate = (body: ExportBundleRequest) => { const validate = (body: ExportBundleRequest) => {
@@ -57,12 +59,21 @@ const validate = (body: ExportBundleRequest) => {
const jobId = body.jobId ? String(body.jobId).trim() : undefined; const jobId = body.jobId ? String(body.jobId).trim() : undefined;
const mode = body.mode === "magic" ? "magic" : "base"; const mode = body.mode === "magic" ? "magic" : "base";
const forceSync = body.forceSync === true; const forceSync = body.forceSync === true;
const includeNoGeometry = body.includeNoGeometry === true;
if (!username) throw new Error("Email is required"); if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required"); if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric"); 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) => { const scheduleClear = (jobId?: string) => {
@@ -160,10 +171,15 @@ export async function POST(req: Request) {
if (jobId) registerJob(jobId); if (jobId) registerJob(jobId);
pushProgress(); pushProgress();
const hasNoGeom = validated.includeNoGeometry;
const weights = const weights =
validated.mode === "magic" validated.mode === "magic"
? { sync: 40, enrich: 35, gpkg: 15, zip: 10 } ? hasNoGeom
: { sync: 55, enrich: 0, gpkg: 30, zip: 15 }; ? { 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 */ /* Phase 1: Sync layers to local DB */
@@ -236,6 +252,45 @@ export async function POST(req: Request) {
} }
finishPhase(); 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) */ /* Phase 2: Enrich (magic mode only) */
/* ══════════════════════════════════════════════════════════ */ /* ══════════════════════════════════════════════════════════ */
@@ -286,7 +341,12 @@ export async function POST(req: Request) {
// Load features from DB // Load features from DB
const dbTerenuri = await prisma.gisFeature.findMany({ const dbTerenuri = await prisma.gisFeature.findMany({
where: { layerId: terenuriLayerId, siruta: validated.siruta }, 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({ const dbCladiri = await prisma.gisFeature.findMany({
@@ -378,6 +438,7 @@ export async function POST(req: Request) {
"CATEGORIE_FOLOSINTA", "CATEGORIE_FOLOSINTA",
"HAS_BUILDING", "HAS_BUILDING",
"BUILD_LEGAL", "BUILD_LEGAL",
"HAS_GEOMETRY",
]; ];
const csvRows: string[] = [headers.map(csvEscape).join(",")]; const csvRows: string[] = [headers.map(csvEscape).join(",")];
@@ -408,6 +469,11 @@ export async function POST(req: Request) {
(record.enrichment as FeatureEnrichment | null) ?? (record.enrichment as FeatureEnrichment | null) ??
({} as Partial<FeatureEnrichment>); ({} as Partial<FeatureEnrichment>);
const geom = record.geometry as GeoJsonFeature["geometry"] | null; 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<FeatureEnrichment>; const e = enrichment as Partial<FeatureEnrichment>;
if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1; if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1;
@@ -433,6 +499,7 @@ export async function POST(req: Request) {
e.CATEGORIE_FOLOSINTA ?? "", e.CATEGORIE_FOLOSINTA ?? "",
e.HAS_BUILDING ?? 0, e.HAS_BUILDING ?? 0,
e.BUILD_LEGAL ?? 0, e.BUILD_LEGAL ?? 0,
hasGeometry,
]; ];
csvRows.push(row.map(csvEscape).join(",")); csvRows.push(row.map(csvEscape).join(","));
@@ -476,12 +543,22 @@ export async function POST(req: Request) {
siruta: validated.siruta, siruta: validated.siruta,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
source: "local-db (sync-first)", 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 }, cladiri: { count: cladiriGeoFeatures.length },
syncSkipped: { syncSkipped: {
terenuri: !terenuriNeedsSync, terenuri: !terenuriNeedsSync,
cladiri: !cladiriNeedsSync, cladiri: !cladiriNeedsSync,
}, },
includeNoGeometry: hasNoGeom,
noGeomImported,
}; };
if (validated.mode === "magic" && magicGpkg && csvContent) { if (validated.mode === "magic" && magicGpkg && csvContent) {
@@ -502,7 +579,15 @@ export async function POST(req: Request) {
finishPhase(); finishPhase();
/* Done */ /* 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}`; message = `Finalizat 100% · Terenuri ${terenuriGeoFeatures.length} · Clădiri ${cladiriGeoFeatures.length}`;
if (noGeomInDb > 0) {
message += ` · Fără geometrie ${noGeomInDb}`;
}
if (!terenuriNeedsSync && !cladiriNeedsSync) { if (!terenuriNeedsSync && !cladiriNeedsSync) {
message += " (din cache local)"; message += " (din cache local)";
} }
+55
View File
@@ -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 });
}
}
@@ -351,10 +351,12 @@ export function ParcelSyncModule() {
layerId: string; layerId: string;
count: number; count: number;
enrichedCount: number; enrichedCount: number;
noGeomCount: number;
lastSynced: string | null; lastSynced: string | null;
}[]; }[];
totalFeatures: number; totalFeatures: number;
totalEnriched: number; totalEnriched: number;
totalNoGeom: number;
}; };
type DbSummary = { type DbSummary = {
uats: DbUatSummary[]; uats: DbUatSummary[];
@@ -380,6 +382,16 @@ export function ParcelSyncModule() {
const [loadingFeatures, setLoadingFeatures] = useState(false); const [loadingFeatures, setLoadingFeatures] = useState(false);
const [searchError, setSearchError] = useState(""); 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 */ /* Load UAT data + check server session on mount */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
@@ -594,6 +606,7 @@ export function ParcelSyncModule() {
siruta, siruta,
jobId, jobId,
mode, mode,
includeNoGeometry: includeNoGeom,
}), }),
}); });
@@ -658,9 +671,45 @@ export function ParcelSyncModule() {
// Refresh sync status — data was synced to DB // Refresh sync status — data was synced to DB
refreshSyncRef.current?.(); 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 */ /* Layer feature counts */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
@@ -2295,6 +2344,71 @@ export function ParcelSyncModule() {
</Card> </Card>
)} )}
{/* No-geometry option */}
{sirutaValid && session.connected && (
<Card className="border-dashed">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={includeNoGeom}
onChange={(e) => setIncludeNoGeom(e.target.checked)}
disabled={exporting}
className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
/>
<span className="text-sm">
Include parcele{" "}
<span className="font-medium">fără geometrie</span>
</span>
</label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={noGeomScanning || exporting}
onClick={() => void handleNoGeomScan()}
>
{noGeomScanning ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Search className="h-3 w-3 mr-1" />
)}
Scanare
</Button>
{noGeomScan && noGeomScanSiruta === siruta && (
<span className="text-xs text-muted-foreground">
{noGeomScan.noGeomCount > 0 ? (
<>
<span className="font-semibold text-amber-600 dark:text-amber-400">
{noGeomScan.noGeomCount.toLocaleString("ro-RO")}
</span>{" "}
parcele fără geometrie
<span className="ml-1 opacity-60">
(din{" "}
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "}
total eTerra,{" "}
{noGeomScan.totalInDb.toLocaleString("ro-RO")} în DB)
</span>
</>
) : (
<span className="text-emerald-600 dark:text-emerald-400">
Toate parcelele au geometrie
</span>
)}
</span>
)}
</div>
{includeNoGeom && (
<p className="text-[11px] text-muted-foreground mt-1.5 ml-6">
Parcelele fără geometrie vor apărea doar în CSV (coloana
HAS_GEOMETRY=0), nu în GPKG.
</p>
)}
</CardContent>
</Card>
)}
{/* Progress bar */} {/* Progress bar */}
{exportProgress && {exportProgress &&
exportProgress.status !== "unknown" && exportProgress.status !== "unknown" &&
@@ -2442,12 +2556,14 @@ export function ParcelSyncModule() {
{dbSummary.uats.map((uat) => { {dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {}; const catCounts: Record<string, number> = {};
let enrichedTotal = 0; let enrichedTotal = 0;
let noGeomTotal = 0;
let oldestSync: Date | null = null; let oldestSync: Date | null = null;
for (const layer of uat.layers) { for (const layer of uat.layers) {
const cat = const cat =
findLayerById(layer.layerId)?.category ?? "administrativ"; findLayerById(layer.layerId)?.category ?? "administrativ";
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count; catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
enrichedTotal += layer.enrichedCount; enrichedTotal += layer.enrichedCount;
noGeomTotal += layer.noGeomCount ?? 0;
if (layer.lastSynced) { if (layer.lastSynced) {
const d = new Date(layer.lastSynced); const d = new Date(layer.lastSynced);
if (!oldestSync || d < oldestSync) oldestSync = d; if (!oldestSync || d < oldestSync) oldestSync = d;
@@ -2523,6 +2639,16 @@ export function ParcelSyncModule() {
</span> </span>
</span> </span>
)} )}
{noGeomTotal > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-muted-foreground">
Fără geom:
</span>
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
{noGeomTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div> </div>
{/* Layer detail pills */} {/* Layer detail pills */}
@@ -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<NoGeomScanResult> {
// 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<string>();
const existingObjIds = new Set<number>();
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<NoGeomSyncResult> {
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<string>();
const existingObjIds = new Set<number>();
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<string, unknown> = {
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<any[]> {
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;
}