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:
@@ -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<string, number>();
|
||||
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) {
|
||||
|
||||
@@ -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<FeatureEnrichment>);
|
||||
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>;
|
||||
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)";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
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