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
@@ -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;
}