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