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