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
+17
View File
@@ -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) {
+90 -5
View File
@@ -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)";
}
+55
View File
@@ -0,0 +1,55 @@
/**
* POST /api/eterra/no-geom-scan
*
* Scans eTerra immovable list for a UAT and counts how many parcels
* exist in the eTerra database but have no geometry in the GIS layer
* (i.e., they are NOT in the local TERENURI_ACTIVE DB).
*
* Body: { siruta: string }
* Returns: { totalImmovables, totalInDb, noGeomCount, samples }
*
* Requires active eTerra session.
*/
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { scanNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = (await req.json()) as { siruta?: string };
const siruta = String(body.siruta ?? "").trim();
if (!/^\d+$/.test(siruta)) {
return NextResponse.json(
{ error: "SIRUTA must be numeric" },
{ status: 400 },
);
}
const session = getSessionCredentials();
const username = String(
session?.username || process.env.ETERRA_USERNAME || "",
).trim();
const password = String(
session?.password || process.env.ETERRA_PASSWORD || "",
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Nu ești conectat la eTerra" },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
const result = await scanNoGeometryParcels(client, siruta);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}