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
+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)";
}