96859dde4f
- NoGeomScanResult now includes: localDbTotal, localDbWithGeom, localDbNoGeom, localDbEnriched, localSyncFresh (parallel DB queries, fast) - Scan card shows 'Baza de date locala: X cu geometrie + Y fara + Z imbogatite' - Workflow preview shows numbered steps with smart estimates: step 1 shows 'skip (date proaspete)' when sync is fresh step 2 shows '~N noi de importat' or 'deja importate' for no-geom step 3 shows '~N de procesat (~M min)' or 'deja imbogatite' for enrichment - All-geometry card also shows local DB summary - User can see exactly what will happen before pressing Magic
486 lines
14 KiB
TypeScript
486 lines
14 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();
|
|
|
|
export type NoGeomScanResult = {
|
|
totalImmovables: number;
|
|
/** Features present in the remote ArcGIS TERENURI_ACTIVE layer (have geometry) */
|
|
withGeometry: number;
|
|
noGeomCount: number;
|
|
/** Sample of immovable identifiers without geometry */
|
|
samples: Array<{
|
|
immovablePk: number;
|
|
identifierDetails: string;
|
|
paperCadNo?: string;
|
|
paperCfNo?: string;
|
|
}>;
|
|
/** 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;
|
|
/** Whether local sync is fresh (< 7 days) */
|
|
localSyncFresh: boolean;
|
|
/** Error message if workspace couldn't be resolved */
|
|
error?: 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 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,
|
|
noGeomCount: 0,
|
|
samples: [],
|
|
localDbTotal: 0,
|
|
localDbWithGeom: 0,
|
|
localDbNoGeom: 0,
|
|
localDbEnriched: 0,
|
|
localSyncFresh: false,
|
|
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.
|
|
// ~4 pages for 8k features with outFields only = very fast.
|
|
const terenuriLayer = {
|
|
id: "TERENURI_ACTIVE",
|
|
name: "TERENURI_ACTIVE",
|
|
endpoint: "aut" as const,
|
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
|
};
|
|
const remoteFeatures = await client.fetchAllLayer(terenuriLayer, siruta, {
|
|
returnGeometry: false,
|
|
outFields: "OBJECTID,NATIONAL_CADASTRAL_REFERENCE,IMMOVABLE_ID",
|
|
pageSize: 2000,
|
|
});
|
|
|
|
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;
|
|
paperCfNo?: string;
|
|
}> = [];
|
|
|
|
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)) continue;
|
|
|
|
// Present in remote GIS layer by IMMOVABLE_ID? → has geometry
|
|
if (immId && remoteImmIds.has(immId)) continue;
|
|
|
|
noGeomItems.push({
|
|
immovablePk: immPk,
|
|
identifierDetails: String(item.identifierDetails ?? ""),
|
|
paperCadNo: item.paperCadNo ?? undefined,
|
|
paperCfNo: item.paperCfNo ?? undefined,
|
|
});
|
|
}
|
|
|
|
// 4. Query local DB for context (what's already synced/imported)
|
|
const [localTotal, localNoGeom, localEnriched, 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.count({
|
|
where: {
|
|
layerId: "TERENURI_ACTIVE",
|
|
siruta,
|
|
enrichedAt: { not: null },
|
|
},
|
|
}),
|
|
prisma.gisSyncRun.findFirst({
|
|
where: { siruta, layerId: "TERENURI_ACTIVE", status: "done" },
|
|
orderBy: { completedAt: "desc" },
|
|
select: { completedAt: true },
|
|
}),
|
|
]);
|
|
|
|
const localWithGeom = localTotal - localNoGeom;
|
|
const syncFresh = lastSyncRun?.completedAt
|
|
? Date.now() - lastSyncRun.completedAt.getTime() < 168 * 60 * 60 * 1000
|
|
: false;
|
|
|
|
return {
|
|
totalImmovables: allImmovables.length,
|
|
withGeometry: remoteFeatures.length,
|
|
noGeomCount: noGeomItems.length,
|
|
samples: noGeomItems.slice(0, 20),
|
|
localDbTotal: localTotal,
|
|
localDbWithGeom: localWithGeom,
|
|
localDbNoGeom: localNoGeom,
|
|
localDbEnriched: localEnriched,
|
|
localSyncFresh: syncFresh,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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. 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 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();
|
|
const areaValue = typeof item.area === "number" ? item.area : null;
|
|
|
|
const attributes: Record<string, unknown> = {
|
|
OBJECTID: -immPk,
|
|
IMMOVABLE_ID: immPk,
|
|
WORKSPACE_ID: item.workspacePk ?? wsPk,
|
|
APPLICATION_ID: item.applicationId ?? null,
|
|
NATIONAL_CADASTRAL_REFERENCE: cadRef,
|
|
AREA_VALUE: areaValue,
|
|
IS_ACTIVE: 1,
|
|
ADMIN_UNIT_ID: Number(siruta),
|
|
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",
|
|
};
|
|
|
|
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, 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,
|
|
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;
|
|
}
|