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