041bfd4138
- eterra-client: detect server maxRecordCount cap in fetchAllLayerByWhere When server returns exactly 1000 (or other round cap) but we asked for 2000, recognize this as a server limit, adjust pageSize, and CONTINUE paginating. Previously: 1000 < 2000 -> break (lost all data beyond page 1). - no-geom-sync: count layers first, pass total to fetchAllLayer Belt-and-suspenders: even if cap detection misses, known total prevents early termination. Also use pageSize 1000 to match typical server cap. Clădiri count uses countLayer instead of fetching all OBJECTIDs. - UI: add include-no-geom checkbox in background sync section Users can toggle it independently of scan status. Shows '(scanare in curs)' hint when scan is still running.
799 lines
25 KiB
TypeScript
799 lines
25 KiB
TypeScript
/* 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();
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Workspace resolution (county → eTerra workspace PK) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/**
|
|
* Resolve the eTerra workspace PK for a SIRUTA.
|
|
* Chain: explicit param → GisUat DB row → ArcGIS layer query → null.
|
|
*/
|
|
async function resolveWorkspacePk(
|
|
client: EterraClient,
|
|
siruta: string,
|
|
explicitPk?: number | null,
|
|
): Promise<number | null> {
|
|
// 1. Explicit param
|
|
if (explicitPk && Number.isFinite(explicitPk) && explicitPk > 0) {
|
|
return explicitPk;
|
|
}
|
|
|
|
// 2. DB lookup
|
|
try {
|
|
const row = await prisma.gisUat.findUnique({
|
|
where: { siruta },
|
|
select: { workspacePk: true },
|
|
});
|
|
if (row?.workspacePk && row.workspacePk > 0) return row.workspacePk;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
// 3. ArcGIS layer query — fetch 1 feature from TERENURI_ACTIVE for this siruta
|
|
try {
|
|
const features = await client.listLayer(
|
|
{
|
|
id: "TERENURI_ACTIVE",
|
|
name: "TERENURI_ACTIVE",
|
|
endpoint: "aut",
|
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
|
},
|
|
siruta,
|
|
{ limit: 1, outFields: "WORKSPACE_ID" },
|
|
);
|
|
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
|
|
if (wsId != null) {
|
|
const num = Number(wsId);
|
|
if (Number.isFinite(num) && num > 0) {
|
|
// Persist for future lookups
|
|
prisma.gisUat
|
|
.update({ where: { siruta }, data: { workspacePk: num } })
|
|
.catch(() => {});
|
|
return num;
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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();
|
|
|
|
/** Quality breakdown of no-geometry immovables from scan */
|
|
export type NoGeomQuality = {
|
|
/** Have electronic cadRef (identifierDetails non-empty) */
|
|
withCadRef: number;
|
|
/** Have paper cadastral number */
|
|
withPaperCad: number;
|
|
/** Have paper LB / CF (carte funciară) number — field is paperLbNo in API */
|
|
withPaperLb: number;
|
|
/** Have hasLandbook=1 flag from eTerra */
|
|
withLandbook: number;
|
|
/** Have area > 0 (measuredArea or legalArea) */
|
|
withArea: number;
|
|
/** status=1 (active) in eTerra */
|
|
withActiveStatus: number;
|
|
/** "Useful" = active AND has identification or area */
|
|
useful: number;
|
|
/** Filtered out: inactive, or no identification AND no area */
|
|
empty: number;
|
|
};
|
|
|
|
export type NoGeomScanResult = {
|
|
totalImmovables: number;
|
|
/** Immovables that matched a remote GIS feature (cross-ref, may vary) */
|
|
withGeometry: number;
|
|
/** Total features in the remote ArcGIS TERENURI_ACTIVE layer (stable) */
|
|
remoteGisCount: number;
|
|
/** Total features in the remote ArcGIS CLADIRI_ACTIVE layer (stable) */
|
|
remoteCladiriCount: number;
|
|
noGeomCount: number;
|
|
/** Match quality: how many matched by cadastral ref vs immovable ID */
|
|
matchedByRef: number;
|
|
matchedById: number;
|
|
/** Quality breakdown of no-geometry items */
|
|
qualityBreakdown: NoGeomQuality;
|
|
/** Sample of immovable identifiers without geometry */
|
|
samples: Array<{
|
|
immovablePk: number;
|
|
identifierDetails: string;
|
|
paperCadNo?: string;
|
|
paperLbNo?: string;
|
|
status?: number;
|
|
hasLandbook?: number;
|
|
measuredArea?: number;
|
|
}>;
|
|
/** Total features already in local DB (geometry + no-geom) */
|
|
localDbTotal: number;
|
|
/** Geometry features already synced in local DB */
|
|
localDbWithGeom: number;
|
|
/** No-geometry features already imported in local DB */
|
|
localDbNoGeom: number;
|
|
/** How many are already enriched (magic) in local DB */
|
|
localDbEnriched: number;
|
|
/** How many enriched features have complete/current enrichment schema */
|
|
localDbEnrichedComplete: number;
|
|
/** Whether local sync is fresh (< 7 days) */
|
|
localSyncFresh: boolean;
|
|
/** Timestamp of the scan (for audit trail) */
|
|
scannedAt: string;
|
|
/** Error message if workspace couldn't be resolved */
|
|
error?: string;
|
|
};
|
|
|
|
export type NoGeomSyncResult = {
|
|
imported: number;
|
|
skipped: number;
|
|
cleaned: number;
|
|
errors: number;
|
|
status: "done" | "error";
|
|
error?: string;
|
|
};
|
|
|
|
/**
|
|
* Scan: count how many eTerra immovables for this UAT have no geometry
|
|
* in the remote GIS layer (TERENURI_ACTIVE).
|
|
*
|
|
* Cross-references the eTerra immovable list against the REMOTE ArcGIS
|
|
* layer (lightweight fetch, no geometry download). This works correctly
|
|
* regardless of whether the user has synced to local DB yet.
|
|
*
|
|
* 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;
|
|
workspacePk?: number | null;
|
|
},
|
|
): Promise<NoGeomScanResult> {
|
|
// 0. Resolve workspace
|
|
const wsPk = await resolveWorkspacePk(client, siruta, options?.workspacePk);
|
|
if (!wsPk) {
|
|
return {
|
|
totalImmovables: 0,
|
|
withGeometry: 0,
|
|
remoteGisCount: 0,
|
|
remoteCladiriCount: 0,
|
|
noGeomCount: 0,
|
|
matchedByRef: 0,
|
|
matchedById: 0,
|
|
qualityBreakdown: {
|
|
withCadRef: 0,
|
|
withPaperCad: 0,
|
|
withPaperLb: 0,
|
|
withLandbook: 0,
|
|
withArea: 0,
|
|
withActiveStatus: 0,
|
|
useful: 0,
|
|
empty: 0,
|
|
},
|
|
samples: [],
|
|
localDbTotal: 0,
|
|
localDbWithGeom: 0,
|
|
localDbNoGeom: 0,
|
|
localDbEnriched: 0,
|
|
localDbEnrichedComplete: 0,
|
|
localSyncFresh: false,
|
|
scannedAt: new Date().toISOString(),
|
|
error: `Nu s-a putut determina workspace-ul (județul) pentru SIRUTA ${siruta}`,
|
|
};
|
|
}
|
|
|
|
// 1. Fetch all immovables from eTerra immovable list API
|
|
const allImmovables = await fetchAllImmovables(
|
|
client,
|
|
siruta,
|
|
wsPk,
|
|
options?.onProgress,
|
|
);
|
|
|
|
// 2. Fetch remote GIS cadastral refs (lightweight — no geometry)
|
|
// This is the source of truth for "has geometry" regardless of local DB state.
|
|
// Count first so pagination knows the total and doesn't stop early.
|
|
const terenuriLayer = {
|
|
id: "TERENURI_ACTIVE",
|
|
name: "TERENURI_ACTIVE",
|
|
endpoint: "aut" as const,
|
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
|
};
|
|
const [terenuriCount, cladiriCount] = await Promise.all([
|
|
client.countLayer(terenuriLayer, siruta).catch(() => 0),
|
|
client
|
|
.countLayer(
|
|
{
|
|
id: "CLADIRI_ACTIVE",
|
|
name: "CLADIRI_ACTIVE",
|
|
endpoint: "aut" as const,
|
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
|
},
|
|
siruta,
|
|
)
|
|
.catch(() => 0),
|
|
]);
|
|
const remoteFeatures = await client.fetchAllLayer(terenuriLayer, siruta, {
|
|
returnGeometry: false,
|
|
outFields: "OBJECTID,NATIONAL_CADASTRAL_REFERENCE,IMMOVABLE_ID",
|
|
pageSize: 1000,
|
|
total: terenuriCount > 0 ? terenuriCount : undefined,
|
|
});
|
|
|
|
// 2b. Also fetch CLADIRI_ACTIVE features (lightweight, just OBJECTID)
|
|
const cladiriLayer = {
|
|
id: "CLADIRI_ACTIVE",
|
|
name: "CLADIRI_ACTIVE",
|
|
endpoint: "aut" as const,
|
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
|
};
|
|
let remoteCladiriCount = cladiriCount;
|
|
if (remoteCladiriCount === 0) {
|
|
try {
|
|
const cladiriFeatures = await client.fetchAllLayer(cladiriLayer, siruta, {
|
|
returnGeometry: false,
|
|
outFields: "OBJECTID",
|
|
pageSize: 1000,
|
|
});
|
|
remoteCladiriCount = cladiriFeatures.length;
|
|
} catch {
|
|
// Non-fatal — just won't show clădiri count
|
|
}
|
|
}
|
|
|
|
const remoteCadRefs = new Set<string>();
|
|
const remoteImmIds = new Set<string>();
|
|
for (const f of remoteFeatures) {
|
|
const cadRef = normalizeCadRef(
|
|
f.attributes?.NATIONAL_CADASTRAL_REFERENCE ?? "",
|
|
);
|
|
if (cadRef) remoteCadRefs.add(cadRef);
|
|
const immId = normalizeId(f.attributes?.IMMOVABLE_ID);
|
|
if (immId) remoteImmIds.add(immId);
|
|
}
|
|
|
|
// 3. Cross-reference: immovables NOT in remote GIS = no geometry
|
|
const noGeomItems: Array<{
|
|
immovablePk: number;
|
|
identifierDetails: string;
|
|
paperCadNo?: string;
|
|
paperLbNo?: string;
|
|
status?: number;
|
|
hasLandbook?: number;
|
|
measuredArea?: number;
|
|
legalArea?: number;
|
|
}> = [];
|
|
|
|
let matchedByRef = 0;
|
|
let matchedById = 0;
|
|
|
|
for (const item of allImmovables) {
|
|
const cadRef = normalizeCadRef(item.identifierDetails ?? "");
|
|
const immPk = Number(item.immovablePk ?? 0);
|
|
const immId = normalizeId(item.immovablePk);
|
|
|
|
// Present in remote GIS layer by cadastral ref? → has geometry
|
|
if (cadRef && remoteCadRefs.has(cadRef)) {
|
|
matchedByRef++;
|
|
continue;
|
|
}
|
|
|
|
// Present in remote GIS layer by IMMOVABLE_ID? → has geometry
|
|
if (immId && remoteImmIds.has(immId)) {
|
|
matchedById++;
|
|
continue;
|
|
}
|
|
|
|
noGeomItems.push({
|
|
immovablePk: immPk,
|
|
identifierDetails: String(item.identifierDetails ?? ""),
|
|
paperCadNo: item.paperCadNo ?? undefined,
|
|
paperLbNo: item.paperLbNo ?? undefined,
|
|
status: typeof item.status === "number" ? item.status : undefined,
|
|
hasLandbook:
|
|
typeof item.hasLandbook === "number" ? item.hasLandbook : undefined,
|
|
measuredArea:
|
|
typeof item.measuredArea === "number" ? item.measuredArea : undefined,
|
|
legalArea:
|
|
typeof item.legalArea === "number" ? item.legalArea : undefined,
|
|
});
|
|
}
|
|
|
|
// 4. Query local DB for context (what's already synced/imported)
|
|
// Also check enrichment completeness — do enriched features have
|
|
// the current schema? (e.g., PROPRIETARI_VECHI added later)
|
|
const ENRICHMENT_REQUIRED_KEYS = [
|
|
"NR_CAD",
|
|
"NR_CF",
|
|
"PROPRIETARI",
|
|
"PROPRIETARI_VECHI",
|
|
"ADRESA",
|
|
"CATEGORIE_FOLOSINTA",
|
|
"HAS_BUILDING",
|
|
];
|
|
|
|
const [localTotal, localNoGeom, enrichedFeatures, lastSyncRun] =
|
|
await Promise.all([
|
|
prisma.gisFeature.count({
|
|
where: { layerId: "TERENURI_ACTIVE", siruta },
|
|
}),
|
|
prisma.gisFeature.count({
|
|
where: {
|
|
layerId: "TERENURI_ACTIVE",
|
|
siruta,
|
|
geometrySource: "NO_GEOMETRY",
|
|
},
|
|
}),
|
|
prisma.gisFeature.findMany({
|
|
where: {
|
|
layerId: "TERENURI_ACTIVE",
|
|
siruta,
|
|
enrichedAt: { not: null },
|
|
},
|
|
select: { enrichment: true },
|
|
}),
|
|
prisma.gisSyncRun.findFirst({
|
|
where: { siruta, layerId: "TERENURI_ACTIVE", status: "done" },
|
|
orderBy: { completedAt: "desc" },
|
|
select: { completedAt: true },
|
|
}),
|
|
]);
|
|
|
|
const localEnriched = enrichedFeatures.length;
|
|
let localEnrichedComplete = 0;
|
|
for (const f of enrichedFeatures) {
|
|
const e = f.enrichment as Record<string, unknown> | null;
|
|
if (
|
|
e &&
|
|
ENRICHMENT_REQUIRED_KEYS.every((k) => k in e && e[k] !== undefined)
|
|
) {
|
|
localEnrichedComplete++;
|
|
}
|
|
}
|
|
|
|
const localWithGeom = localTotal - localNoGeom;
|
|
const syncFresh = lastSyncRun?.completedAt
|
|
? Date.now() - lastSyncRun.completedAt.getTime() < 168 * 60 * 60 * 1000
|
|
: false;
|
|
|
|
// withGeometry = immovables that MATCHED a GIS feature (always adds up)
|
|
const matchedCount = allImmovables.length - noGeomItems.length;
|
|
|
|
console.log(
|
|
`[no-geom-scan] Match quality: ${matchedCount} total (${matchedByRef} by cadRef, ${matchedById} by immId)` +
|
|
` | GIS layer: ${remoteFeatures.length} features | Immovables: ${allImmovables.length}` +
|
|
` | Unmatched GIS: ${remoteFeatures.length - matchedCount}`,
|
|
);
|
|
|
|
// Quality analysis of no-geom items
|
|
let qWithCadRef = 0;
|
|
let qWithPaperCad = 0;
|
|
let qWithPaperLb = 0;
|
|
let qWithLandbook = 0;
|
|
let qWithArea = 0;
|
|
let qWithActiveStatus = 0;
|
|
let qUseful = 0;
|
|
let qEmpty = 0;
|
|
for (const item of noGeomItems) {
|
|
const hasCad = !!item.identifierDetails?.trim();
|
|
const hasPaperCad = !!item.paperCadNo?.trim();
|
|
const hasPaperLb = !!item.paperLbNo?.trim();
|
|
const hasArea =
|
|
(item.measuredArea != null && item.measuredArea > 0) ||
|
|
(item.legalArea != null && item.legalArea > 0);
|
|
const isActive = item.status === 1;
|
|
const hasLb = item.hasLandbook === 1;
|
|
if (hasCad) qWithCadRef++;
|
|
if (hasPaperCad) qWithPaperCad++;
|
|
if (hasPaperLb) qWithPaperLb++;
|
|
if (hasLb) qWithLandbook++;
|
|
if (hasArea) qWithArea++;
|
|
if (isActive) qWithActiveStatus++;
|
|
// "Useful" = ACTIVE + HAS_LANDBOOK (imobil electronic) + has identification OR area
|
|
// Matches the import quality gate — only IE items are worth importing
|
|
const hasIdentification = hasCad || hasPaperLb || hasPaperCad;
|
|
if (isActive && hasLb && (hasIdentification || hasArea)) qUseful++;
|
|
else qEmpty++;
|
|
}
|
|
|
|
return {
|
|
totalImmovables: allImmovables.length,
|
|
withGeometry: matchedCount,
|
|
remoteGisCount: remoteFeatures.length,
|
|
remoteCladiriCount,
|
|
noGeomCount: noGeomItems.length,
|
|
matchedByRef,
|
|
matchedById,
|
|
qualityBreakdown: {
|
|
withCadRef: qWithCadRef,
|
|
withPaperCad: qWithPaperCad,
|
|
withPaperLb: qWithPaperLb,
|
|
withLandbook: qWithLandbook,
|
|
withArea: qWithArea,
|
|
withActiveStatus: qWithActiveStatus,
|
|
useful: qUseful,
|
|
empty: qEmpty,
|
|
},
|
|
samples: noGeomItems.slice(0, 20),
|
|
localDbTotal: localTotal,
|
|
localDbWithGeom: localWithGeom,
|
|
localDbNoGeom: localNoGeom,
|
|
localDbEnriched: localEnriched,
|
|
localDbEnrichedComplete: localEnrichedComplete,
|
|
localSyncFresh: syncFresh,
|
|
scannedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
workspacePk?: number | null;
|
|
},
|
|
): Promise<NoGeomSyncResult> {
|
|
try {
|
|
// 0. Resolve workspace
|
|
const wsPk = await resolveWorkspacePk(client, siruta, options?.workspacePk);
|
|
if (!wsPk) {
|
|
return {
|
|
imported: 0,
|
|
skipped: 0,
|
|
cleaned: 0,
|
|
errors: 0,
|
|
status: "error",
|
|
error: `Nu s-a putut determina workspace-ul pentru SIRUTA ${siruta}`,
|
|
};
|
|
}
|
|
|
|
// 1. Fetch all immovables
|
|
options?.onProgress?.(0, 1, "Descărcare listă imobile (fără geometrie)");
|
|
const allImmovables = await fetchAllImmovables(client, siruta, wsPk);
|
|
|
|
// 2. Cleanup: remove stale/orphan no-geom records from DB
|
|
// Build a set of valid immovablePks from the fresh immovable list.
|
|
// Any NO_GEOMETRY record whose immovablePk is NOT in the fresh list
|
|
// (or whose immovable is inactive/empty) gets deleted.
|
|
const validImmPks = new Set<number>();
|
|
for (const item of allImmovables) {
|
|
const pk = Number(item.immovablePk ?? 0);
|
|
if (pk > 0) {
|
|
const status = typeof item.status === "number" ? item.status : 1;
|
|
const cadRef = (item.identifierDetails ?? "").toString().trim();
|
|
const hasPaperLb = !!(item.paperLbNo ?? "").toString().trim();
|
|
const hasPaperCad = !!(item.paperCadNo ?? "").toString().trim();
|
|
const hasLandbook =
|
|
typeof item.hasLandbook === "number" ? item.hasLandbook : 0;
|
|
const hasArea =
|
|
(typeof item.measuredArea === "number" && item.measuredArea > 0) ||
|
|
(typeof item.legalArea === "number" && item.legalArea > 0);
|
|
const hasIdentification = !!cadRef || hasPaperLb || hasPaperCad;
|
|
|
|
// Only keep items that pass the quality gate (active + hasLandbook + identification/area)
|
|
if (
|
|
status === 1 &&
|
|
hasLandbook === 1 &&
|
|
(hasIdentification || hasArea)
|
|
) {
|
|
validImmPks.add(pk);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find stale no-geom records: objectId < 0 means no-geom (objectId = -immPk)
|
|
const existingNoGeom = await prisma.gisFeature.findMany({
|
|
where: {
|
|
layerId: "TERENURI_ACTIVE",
|
|
siruta,
|
|
geometrySource: "NO_GEOMETRY",
|
|
},
|
|
select: { id: true, objectId: true },
|
|
});
|
|
|
|
const staleIds: string[] = [];
|
|
for (const f of existingNoGeom) {
|
|
const immPk = -f.objectId; // objectId = -immovablePk for no-geom
|
|
if (!validImmPks.has(immPk)) {
|
|
staleIds.push(f.id);
|
|
}
|
|
}
|
|
|
|
if (staleIds.length > 0) {
|
|
await prisma.gisFeature.deleteMany({
|
|
where: { id: { in: staleIds } },
|
|
});
|
|
console.log(
|
|
`[no-geom-sync] Cleanup: removed ${staleIds.length} stale/invalid no-geom records`,
|
|
);
|
|
}
|
|
|
|
// 3. Get existing features from DB (after cleanup)
|
|
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);
|
|
}
|
|
|
|
// 4. Filter: not yet in DB + quality gate
|
|
// Quality: must be ACTIVE (status=1) AND hasLandbook=1 (IE) AND have identification OR area.
|
|
// Items without landbook are not electronic immovables — no CF data to extract.
|
|
let filteredOut = 0;
|
|
const candidates = allImmovables.filter((item) => {
|
|
const cadRef = normalizeCadRef(item.identifierDetails ?? "");
|
|
const immPk = Number(item.immovablePk ?? 0);
|
|
|
|
// Already in DB? → skip (not counted as filtered)
|
|
if (cadRef && existingCadRefs.has(cadRef)) return false;
|
|
if (immPk > 0 && existingObjIds.has(-immPk)) return false;
|
|
|
|
// Quality gate 1: must be active (status=1)
|
|
const status = typeof item.status === "number" ? item.status : 1;
|
|
if (status !== 1) {
|
|
filteredOut++;
|
|
return false;
|
|
}
|
|
|
|
// Quality gate 2: must be an electronic immovable (hasLandbook=1)
|
|
const hasLandbook =
|
|
typeof item.hasLandbook === "number" ? item.hasLandbook : 0;
|
|
if (hasLandbook !== 1) {
|
|
filteredOut++;
|
|
return false;
|
|
}
|
|
|
|
// Quality gate 3: must have identification OR area
|
|
const hasCadRef = !!cadRef;
|
|
const hasPaperLb = !!(item.paperLbNo ?? "").toString().trim();
|
|
const hasPaperCad = !!(item.paperCadNo ?? "").toString().trim();
|
|
const hasIdentification = hasCadRef || hasPaperLb || hasPaperCad;
|
|
|
|
// Area: measuredArea and legalArea are the only area fields on immovable/list
|
|
const hasArea =
|
|
(typeof item.measuredArea === "number" && item.measuredArea > 0) ||
|
|
(typeof item.legalArea === "number" && item.legalArea > 0);
|
|
|
|
if (!hasIdentification && !hasArea) {
|
|
filteredOut++;
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
return {
|
|
imported: 0,
|
|
skipped: filteredOut,
|
|
cleaned: staleIds.length,
|
|
errors: 0,
|
|
status: "done",
|
|
};
|
|
}
|
|
|
|
// 4. Import candidates in batches with retry
|
|
let imported = 0;
|
|
let skipped = 0;
|
|
let errors = 0;
|
|
const total = candidates.length;
|
|
const BATCH_SIZE = 50;
|
|
const MAX_RETRIES = 3;
|
|
|
|
for (
|
|
let batchStart = 0;
|
|
batchStart < candidates.length;
|
|
batchStart += BATCH_SIZE
|
|
) {
|
|
const batch = candidates.slice(batchStart, batchStart + BATCH_SIZE);
|
|
const ops: Array<ReturnType<typeof prisma.gisFeature.upsert>> = [];
|
|
|
|
for (const item of batch) {
|
|
const immPk = Number(item.immovablePk ?? 0);
|
|
if (immPk <= 0) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
const cadRef = String(item.identifierDetails ?? "").trim();
|
|
// Extract area — on immovable/list, the real fields are measuredArea and legalArea
|
|
const areaValue =
|
|
(typeof item.measuredArea === "number" && item.measuredArea > 0
|
|
? item.measuredArea
|
|
: null) ??
|
|
(typeof item.legalArea === "number" && item.legalArea > 0
|
|
? item.legalArea
|
|
: null);
|
|
|
|
const attributes: Record<string, unknown> = {
|
|
OBJECTID: -immPk,
|
|
IMMOVABLE_ID: immPk,
|
|
WORKSPACE_ID: item.workspace?.nomenPk ?? wsPk,
|
|
APPLICATION_ID: item.applicationId ?? null,
|
|
NATIONAL_CADASTRAL_REFERENCE: cadRef,
|
|
AREA_VALUE: areaValue,
|
|
IS_ACTIVE: item.status === 1 ? 1 : 0,
|
|
ADMIN_UNIT_ID: Number(siruta),
|
|
PAPER_CAD_NO: item.paperCadNo ?? null,
|
|
PAPER_LB_NO: item.paperLbNo ?? null,
|
|
HAS_LANDBOOK: item.hasLandbook ?? null,
|
|
TOP_NO: item.topNo ?? null,
|
|
IMMOVABLE_TYPE: item.immovableType ?? "P",
|
|
MEASURED_AREA: item.measuredArea ?? null,
|
|
LEGAL_AREA: item.legalArea ?? null,
|
|
NO_GEOMETRY_SOURCE: "ETERRA_IMMOVABLE_LIST",
|
|
};
|
|
|
|
ops.push(
|
|
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(),
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Execute batch with retry
|
|
if (ops.length > 0) {
|
|
let attempt = 0;
|
|
while (attempt < MAX_RETRIES) {
|
|
try {
|
|
await prisma.$transaction(ops);
|
|
imported += ops.length;
|
|
break;
|
|
} catch (err) {
|
|
attempt++;
|
|
if (attempt >= MAX_RETRIES) {
|
|
// Fall back to individual upserts for this batch
|
|
for (const op of ops) {
|
|
try {
|
|
await op;
|
|
imported++;
|
|
} catch {
|
|
errors++;
|
|
}
|
|
}
|
|
} else {
|
|
// Wait before retry (exponential backoff)
|
|
await new Promise((r) => setTimeout(r, 500 * attempt));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const done = Math.min(batchStart + BATCH_SIZE, total);
|
|
options?.onProgress?.(done, total, "Import parcele fără geometrie");
|
|
}
|
|
|
|
return {
|
|
imported,
|
|
skipped: skipped + filteredOut,
|
|
cleaned: staleIds.length,
|
|
errors,
|
|
status: "done",
|
|
};
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
return {
|
|
imported: 0,
|
|
skipped: 0,
|
|
cleaned: 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,
|
|
workspaceId: number,
|
|
onProgress?: (page: number, totalPages: number) => void,
|
|
): Promise<any[]> {
|
|
const all: any[] = [];
|
|
let page = 0;
|
|
let totalPages = 1;
|
|
let includeInscrisCF = true;
|
|
|
|
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;
|
|
}
|