dfb5ceb926
Cluj-Napoca sync failed with 62,307 removed features exceeding the limit. Batch deletions in chunks of 30,000 in both sync-service and no-geom-sync. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
802 lines
25 KiB
TypeScript
802 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) {
|
|
const BATCH = 30_000;
|
|
for (let i = 0; i < staleIds.length; i += BATCH) {
|
|
await prisma.gisFeature.deleteMany({
|
|
where: { id: { in: staleIds.slice(i, i + BATCH) } },
|
|
});
|
|
}
|
|
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;
|
|
}
|