Files
ArchiTools/src/modules/parcel-sync/services/no-geom-sync.ts
T
AI Assistant 96859dde4f feat(parcel-sync): scan shows local DB context + Magic workflow preview
- 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
2026-03-07 17:50:34 +02:00

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