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:
+18
-16
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user