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
@@ -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() {
</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 */}
{exportProgress &&
exportProgress.status !== "unknown" &&
@@ -2442,12 +2556,14 @@ export function ParcelSyncModule() {
{dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {};
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() {
</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>
{/* 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;
}