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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user