feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)

- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ)
- Incremental sync engine (OBJECTID comparison, only downloads new features)
- PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models)
- 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status)
- Full UI with 3 tabs (Sincronizare, Parcele, Istoric)
- Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD)
- Real-time sync progress tracking with polling
This commit is contained in:
AI Assistant
2026-03-06 00:36:29 +02:00
parent 51dbfcb2bd
commit 7cdea66fa2
25 changed files with 3097 additions and 12 deletions
@@ -0,0 +1,82 @@
/**
* ESRI → GeoJSON conversion for eTerra features.
*/
import type { EsriFeature } from "./eterra-client";
export type GeoJsonPolygon = { type: "Polygon"; coordinates: number[][][] };
export type GeoJsonMultiPolygon = {
type: "MultiPolygon";
coordinates: number[][][][];
};
export type GeoJsonFeature = {
type: "Feature";
properties: Record<string, unknown>;
geometry: GeoJsonPolygon | GeoJsonMultiPolygon;
};
export type GeoJsonFeatureCollection = {
type: "FeatureCollection";
features: GeoJsonFeature[];
};
const ringArea = (ring: number[][]) => {
let area = 0;
for (let i = 0; i < ring.length - 1; i++) {
const curr = ring[i]!;
const next = ring[i + 1]!;
area += curr[0]! * next[1]! - next[0]! * curr[1]!;
}
return area / 2;
};
const isClockwise = (ring: number[][]) => ringArea(ring) < 0;
const closeRing = (ring: number[][]) => {
if (ring.length === 0) return ring;
const first = ring[0]!;
const last = ring[ring.length - 1]!;
return first[0] !== last[0] || first[1] !== last[1]
? [...ring, [first[0]!, first[1]!]]
: ring;
};
const ringsToPolygons = (rings: number[][][]) => {
const polygons: number[][][][] = [];
let current: number[][][] | null = null;
for (const ring of rings) {
const closed = closeRing(ring);
if (closed.length < 4) continue;
if (isClockwise(closed) || !current) {
if (current) polygons.push(current);
current = [closed];
} else {
current.push(closed);
}
}
if (current) polygons.push(current);
return polygons;
};
export const esriToGeojson = (
features: EsriFeature[],
): GeoJsonFeatureCollection => {
const geoFeatures: GeoJsonFeature[] = [];
for (const feature of features) {
const rings = feature.geometry?.rings;
if (!rings?.length) continue;
const polygons = ringsToPolygons(rings);
if (!polygons.length) continue;
const geometry: GeoJsonPolygon | GeoJsonMultiPolygon =
polygons.length === 1
? { type: "Polygon", coordinates: polygons[0]! }
: { type: "MultiPolygon", coordinates: polygons };
geoFeatures.push({
type: "Feature",
properties: feature.attributes ?? {},
geometry,
});
}
return { type: "FeatureCollection", features: geoFeatures };
};
@@ -0,0 +1,608 @@
/**
* eTerra ANCPI client — server-side only.
*
* Authenticates via form-post, keeps a JSESSIONID cookie jar,
* and exposes typed wrappers for count / list / fetchAll / parcel-detail
* queries against the eTerra ArcGIS-REST API.
*
* Ported from the standalone ParcelSync project and adapted for
* ArchiTools module architecture.
*/
import axios, { type AxiosError, type AxiosInstance } from "axios";
import crypto from "crypto";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type EsriGeometry = { rings: number[][][] };
export type EsriFeature = {
attributes: Record<string, unknown>;
geometry?: EsriGeometry;
};
export type LayerEndpoint = "aut" | "all";
export type LayerConfig = {
id: string;
name: string;
endpoint: LayerEndpoint;
subLayerId?: number;
whereTemplate?: string;
spatialFilter?: boolean;
};
type EsriQueryResponse = {
error?: { message?: string; details?: string[] };
count?: number;
objectIds?: number[];
objectIdFieldName?: string;
features?: EsriFeature[];
exceededTransferLimit?: boolean;
};
type EsriLayerInfo = {
fields?: { name: string }[];
error?: { message?: string; details?: string[] };
};
type ProgressCallback = (downloaded: number, total?: number) => void;
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const BASE_URL = "https://eterra.ancpi.ro/eterra";
const LOGIN_URL = `${BASE_URL}/api/authentication`;
const DEFAULT_TIMEOUT_MS = 40_000;
const DEFAULT_PAGE_SIZE = 2000;
const FALLBACK_PAGE_SIZE = 1000;
const MAX_RETRIES = 2;
const SESSION_TTL_MS = 9 * 60 * 1000;
const MAX_URL_LENGTH = 1500;
/* ------------------------------------------------------------------ */
/* Session cache (global singleton) */
/* ------------------------------------------------------------------ */
type SessionEntry = {
jar: CookieJar;
client: AxiosInstance;
createdAt: number;
lastUsed: number;
};
const globalStore = globalThis as {
__eterraSessionStore?: Map<string, SessionEntry>;
};
const sessionStore =
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
globalStore.__eterraSessionStore = sessionStore;
const makeCacheKey = (u: string, p: string) =>
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const isTransient = (error: unknown) => {
const err = error as AxiosError;
if (!err) return false;
if (
err.code === "ECONNRESET" ||
err.code === "ETIMEDOUT" ||
err.code === "ECONNABORTED"
)
return true;
const status = err.response?.status ?? 0;
return status >= 500;
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Client */
/* ------------------------------------------------------------------ */
export class EterraClient {
private client: AxiosInstance;
private jar: CookieJar;
private timeoutMs: number;
private maxRetries: number;
private username: string;
private password: string;
private reloginAttempted = false;
private layerFieldsCache = new Map<string, string[]>();
private constructor(
client: AxiosInstance,
jar: CookieJar,
timeoutMs: number,
username: string,
password: string,
maxRetries: number,
) {
this.client = client;
this.jar = jar;
this.timeoutMs = timeoutMs;
this.username = username;
this.password = password;
this.maxRetries = maxRetries;
}
/* ---- Factory --------------------------------------------------- */
static async create(
username: string,
password: string,
options?: { timeoutMs?: number; maxRetries?: number; useCache?: boolean },
) {
const useCache = options?.useCache !== false;
const now = Date.now();
const cacheKey = makeCacheKey(username, password);
const cached = sessionStore.get(cacheKey);
if (useCache && cached && now - cached.lastUsed < SESSION_TTL_MS) {
cached.lastUsed = now;
return new EterraClient(
cached.client,
cached.jar,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
username,
password,
options?.maxRetries ?? MAX_RETRIES,
);
}
const jar = new CookieJar();
const client = wrapper(
axios.create({
jar,
withCredentials: true,
maxContentLength: Infinity,
maxBodyLength: Infinity,
headers: {
Accept: "application/json, text/plain, */*",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
Referer: `${BASE_URL}/`,
},
}),
);
const instance = new EterraClient(
client,
jar,
options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
username,
password,
options?.maxRetries ?? MAX_RETRIES,
);
await instance.login(username, password);
sessionStore.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
return instance;
}
/* ---- Auth ------------------------------------------------------ */
private async login(u: string, p: string) {
const body = new URLSearchParams();
body.set("j_username", u);
body.set("j_password", p);
body.set("j_uuid", "undefined");
body.set("j_isRevoked", "undefined");
body.set("_spring_security_remember_me", "true");
body.set("submit", "Login");
try {
await this.requestWithRetry(() =>
this.client.post(LOGIN_URL, body.toString(), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: this.timeoutMs,
}),
);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401)
throw new Error("Login failed (invalid credentials)");
throw error;
}
const cookies = await this.jar.getCookies(LOGIN_URL);
if (!cookies.some((c) => c.key === "JSESSIONID"))
throw new Error("Login failed / session not set");
}
/* ---- High-level parcels --------------------------------------- */
async countParcels(siruta: string) {
return this.countLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
);
}
async fetchAllParcels(
siruta: string,
options?: {
pageSize?: number;
total?: number;
onProgress?: ProgressCallback;
delayMs?: number;
},
) {
return this.fetchAllLayer(
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
siruta,
options,
);
}
/* ---- Generic layer methods ------------------------------------ */
async countLayer(layer: LayerConfig, siruta: string) {
const where = await this.buildWhere(layer, siruta);
return this.countLayerByWhere(layer, where);
}
async countLayerByWhere(layer: LayerConfig, where: string) {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", where);
return this.countLayerWithParams(layer, params, false);
}
async countLayerByGeometry(layer: LayerConfig, geometry: EsriGeometry) {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", "1=1");
this.applyGeometryParams(params, geometry);
return this.countLayerWithParams(layer, params, true);
}
async listLayer(
layer: LayerConfig,
siruta: string,
options?: { limit?: number; outFields?: string },
) {
const where = await this.buildWhere(layer, siruta);
return this.listLayerByWhere(layer, where, options);
}
async listLayerByWhere(
layer: LayerConfig,
where: string,
options?: { limit?: number; outFields?: string },
) {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", where);
params.set("outFields", options?.outFields ?? "*");
params.set("returnGeometry", "false");
params.set("resultRecordCount", String(options?.limit ?? 200));
params.set("resultOffset", "0");
const data = await this.queryLayer(layer, params, false);
return data.features ?? [];
}
async fetchAllLayer(
layer: LayerConfig,
siruta: string,
options?: {
pageSize?: number;
total?: number;
onProgress?: ProgressCallback;
delayMs?: number;
},
) {
const where = await this.buildWhere(layer, siruta);
return this.fetchAllLayerByWhere(layer, where, options);
}
async fetchAllLayerByWhere(
layer: LayerConfig,
where: string,
options?: {
pageSize?: number;
total?: number;
onProgress?: ProgressCallback;
delayMs?: number;
outFields?: string;
returnGeometry?: boolean;
geometry?: EsriGeometry;
},
) {
let pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
const total = options?.total;
const onProgress = options?.onProgress;
const delayMs = options?.delayMs ?? 0;
const outFields = options?.outFields ?? "*";
const returnGeometry = options?.returnGeometry ?? true;
let offset = 0;
const all: EsriFeature[] = [];
while (true) {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", where);
if (options?.geometry) this.applyGeometryParams(params, options.geometry);
params.set("outFields", outFields);
params.set("returnGeometry", returnGeometry ? "true" : "false");
if (returnGeometry) params.set("outSR", "3844");
params.set("resultRecordCount", String(pageSize));
params.set("resultOffset", String(offset));
let data: EsriQueryResponse;
try {
data = await this.queryLayer(layer, params, Boolean(options?.geometry));
} catch {
if (pageSize > FALLBACK_PAGE_SIZE) {
pageSize = FALLBACK_PAGE_SIZE;
continue;
}
throw new Error(`Failed to fetch layer ${layer.name}`);
}
const features = data.features ?? [];
if (features.length === 0) {
if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) {
pageSize = FALLBACK_PAGE_SIZE;
continue;
}
break;
}
all.push(...features);
offset += features.length;
if (onProgress) onProgress(all.length, total);
if (total && all.length >= total) break;
if (features.length < pageSize) {
if (total && all.length < total && pageSize > FALLBACK_PAGE_SIZE) {
pageSize = FALLBACK_PAGE_SIZE;
continue;
}
break;
}
if (delayMs > 0) await sleep(delayMs);
}
return all;
}
async fetchAllLayerByGeometry(
layer: LayerConfig,
geometry: EsriGeometry,
options?: {
pageSize?: number;
total?: number;
onProgress?: ProgressCallback;
delayMs?: number;
outFields?: string;
returnGeometry?: boolean;
},
) {
return this.fetchAllLayerByWhere(layer, "1=1", { ...options, geometry });
}
async getLayerFieldNames(layer: LayerConfig) {
return this.getLayerFields(layer);
}
/* ---- Internals ------------------------------------------------ */
private layerQueryUrl(layer: LayerConfig) {
const suffix =
typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
return `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}/query`;
}
private applyGeometryParams(params: URLSearchParams, geometry: EsriGeometry) {
params.set("geometry", JSON.stringify(geometry));
params.set("geometryType", "esriGeometryPolygon");
params.set("spatialRel", "esriSpatialRelIntersects");
params.set("inSR", "3844");
}
private async buildWhere(layer: LayerConfig, siruta: string) {
const fields = await this.getLayerFields(layer);
const adminField = this.findAdminField(fields);
if (!adminField)
throw new Error(`Layer ${layer.name} has no SIRUTA/ADMIN_UNIT field`);
if (!layer.whereTemplate) return `${adminField}=${siruta}`;
const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE");
if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive)
return `${adminField}=${siruta}`;
return layer.whereTemplate
.replace(/\{\{adminField\}\}/g, adminField)
.replace(/\{\{siruta\}\}/g, siruta);
}
private findAdminField(fields: string[]) {
const preferred = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
const upper = fields.map((f) => f.toUpperCase());
for (const key of preferred) {
const idx = upper.indexOf(key);
if (idx >= 0) return fields[idx];
}
return null;
}
private async getLayerFields(layer: LayerConfig) {
const cacheKey = `${layer.endpoint}:${layer.name}`;
const cached = this.layerFieldsCache.get(cacheKey);
if (cached) return cached;
const suffix =
typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
const url = `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}?f=json`;
let response;
try {
response = await this.requestWithRetry(() =>
this.client.get(url, { timeout: this.timeoutMs }),
);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
await this.login(this.username, this.password);
response = await this.requestWithRetry(() =>
this.client.get(url, { timeout: this.timeoutMs }),
);
} else throw error;
}
const data = response.data as EsriLayerInfo | string;
if (typeof data === "string")
throw new Error(`Layer info invalid for ${layer.name}`);
if (data.error) {
const details = data.error.details?.join(" ") ?? "";
throw new Error(
`${data.error.message ?? "Layer error"}${details ? ` (${details})` : ""}`,
);
}
let fields = data.fields?.map((f) => f.name) ?? [];
if (!fields.length) {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", "1=1");
params.set("outFields", "*");
params.set("returnGeometry", "false");
params.set("resultRecordCount", "1");
params.set("resultOffset", "0");
const sample = await this.getJson(
`${this.layerQueryUrl(layer)}?${params}`,
);
fields = Object.keys(sample.features?.[0]?.attributes ?? {});
}
if (!fields.length)
throw new Error(`Layer ${layer.name} returned no fields`);
this.layerFieldsCache.set(cacheKey, fields);
return fields;
}
private async getJson(url: string): Promise<EsriQueryResponse> {
return this.requestJson(() =>
this.client.get(url, { timeout: this.timeoutMs }),
);
}
private async postJson(
url: string,
body: URLSearchParams,
): Promise<EsriQueryResponse> {
return this.requestJson(() =>
this.client.post(url, body.toString(), {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
timeout: this.timeoutMs,
}),
);
}
private async requestJson(
request: () => Promise<{
data: EsriQueryResponse | string;
status: number;
}>,
): Promise<EsriQueryResponse> {
let response;
try {
response = await this.requestWithRetry(request);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
await this.login(this.username, this.password);
response = await this.requestWithRetry(request);
} else if (err?.response?.status === 401) {
throw new Error("Session expired (401)");
} else throw error;
}
const data = response.data as EsriQueryResponse | string;
if (typeof data === "string")
throw new Error("Session expired or invalid response from eTerra");
if (data.error) {
const details = data.error.details?.join(" ") ?? "";
throw new Error(
`${data.error.message ?? "eTerra query error"}${details ? ` (${details})` : ""}`,
);
}
return data;
}
private async queryLayer(
layer: LayerConfig,
params: URLSearchParams,
usePost: boolean,
): Promise<EsriQueryResponse> {
const url = this.layerQueryUrl(layer);
const qs = params.toString();
const shouldPost = usePost || qs.length > MAX_URL_LENGTH;
if (shouldPost) return this.postJson(url, params);
try {
return await this.getJson(`${url}?${qs}`);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 414) return this.postJson(url, params);
throw error;
}
}
private async countLayerWithParams(
layer: LayerConfig,
params: URLSearchParams,
usePost: boolean,
) {
const cp = new URLSearchParams(params);
cp.set("returnCountOnly", "true");
const data = await this.queryLayer(layer, cp, usePost);
if (typeof data.count === "number") return data.count;
const message = data.error?.message;
throw new Error(
message
? `Count unavailable (${layer.name}): ${message}`
: `Count unavailable (${layer.name})`,
);
}
private async requestWithRetry<T>(request: () => Promise<T>) {
let attempt = 0;
while (true) {
try {
return await request();
} catch (error) {
if (attempt >= this.maxRetries || !isTransient(error)) throw error;
attempt += 1;
await sleep(300 * attempt);
}
}
}
}
@@ -0,0 +1,286 @@
/**
* eTerra layer catalog — all known layers grouped by category.
*/
import type { LayerConfig } from "./eterra-client";
export type LayerCategory =
| "terenuri"
| "documentatii"
| "cladiri"
| "administrativ";
export type LayerCatalogItem = LayerConfig & {
label: string;
category: LayerCategory;
};
export const LAYER_CATEGORY_LABELS: Record<LayerCategory, string> = {
terenuri: "Terenuri",
documentatii: "Documentații",
cladiri: "Clădiri",
administrativ: "Administrativ",
};
export const LAYER_CATALOG: LayerCatalogItem[] = [
// ── Terenuri ──
{
id: "TERENURI_ACTIVE",
name: "TERENURI_ACTIVE",
label: "Terenuri active",
category: "terenuri",
endpoint: "aut",
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
},
{
id: "TERENURI_NEINREGISTRATE",
name: "TERENURI_NEINREGISTRATE",
label: "Terenuri neînregistrate",
category: "terenuri",
endpoint: "aut",
},
{
id: "TERENURI_IN_LUCRU",
name: "TERENURI_IN_LUCRU",
label: "Terenuri în lucru",
category: "terenuri",
endpoint: "aut",
},
{
id: "TERENURI_RECEPTIONATE",
name: "TERENURI_RECEPTIONATE",
label: "Terenuri recepționate",
category: "terenuri",
endpoint: "aut",
},
{
id: "TERENURI_RESPINSE",
name: "TERENURI_RESPINSE",
label: "Terenuri respinse",
category: "terenuri",
endpoint: "aut",
},
{
id: "TERENURI_PE_CERERE",
name: "TERENURI_PE_CERERE",
label: "Terenuri pe cerere",
category: "terenuri",
endpoint: "aut",
},
{
id: "CADGEN_LAND_ACTIVE",
name: "CADGEN_LAND_ACTIVE",
label: "Cadgen land active",
category: "terenuri",
endpoint: "aut",
},
// ── Clădiri ──
{
id: "CLADIRI_ACTIVE",
name: "CLADIRI_ACTIVE",
label: "Clădiri active",
category: "cladiri",
endpoint: "aut",
},
{
id: "CLADIRI_RECEPTIONATE",
name: "CLADIRI_RECEPTIONATE",
label: "Clădiri recepționate",
category: "cladiri",
endpoint: "aut",
},
{
id: "CLADIRI_RESPINSE",
name: "CLADIRI_RESPINSE",
label: "Clădiri respinse",
category: "cladiri",
endpoint: "aut",
},
{
id: "CLADIRI_PE_CERERE",
name: "CLADIRI_PE_CERERE",
label: "Clădiri pe cerere",
category: "cladiri",
endpoint: "aut",
},
{
id: "CLADIRI_IN_LUCRU",
name: "CLADIRI_IN_LUCRU",
label: "Clădiri în lucru",
category: "cladiri",
endpoint: "aut",
},
{
id: "CLADIRI_NEINREGISTRATE",
name: "CLADIRI_NEINREGISTRATE",
label: "Clădiri neînregistrate",
category: "cladiri",
endpoint: "aut",
},
{
id: "CADGEN_BUILDING_ACTIVE",
name: "CADGEN_BUILDING_ACTIVE",
label: "Cadgen building active",
category: "cladiri",
endpoint: "aut",
},
// ── Documentații ──
{
id: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC",
name: "EXPERTIZA_CLADIRI_ACTIVE_DYNAMIC",
label: "Expertiză clădiri active",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC",
name: "EXPERTIZA_CLADIRI_INACTIVE_DYNAMIC",
label: "Expertiză clădiri inactive",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC",
name: "EXPERTIZA_TERENURI_ACTIVE_DYNAMIC",
label: "Expertiză terenuri active",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC",
name: "EXPERTIZA_TERENURI_INACTIVE_DYNAMIC",
label: "Expertiză terenuri inactive",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC",
name: "EXPERTIZA_JUDICIARA_ADMISE_DYNAMIC",
label: "Expertiză judiciară admise",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC",
name: "EXPERTIZA_JUDICIARA_RESPINSE_DYNAMIC",
label: "Expertiză judiciară respinse",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC",
name: "EXPERTIZA_JUDICIARA_NEINREGISTRATE_DYNAMIC",
label: "Expertiză judiciară neînreg.",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "ZONE_INTERES_ACTIVE_DYNAMIC",
name: "ZONE_INTERES_ACTIVE_DYNAMIC",
label: "Zone interes active",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "ZONE_INTERES_INACTIVE_DYNAMIC",
name: "ZONE_INTERES_INACTIVE_DYNAMIC",
label: "Zone interes inactive",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "ZONE_INTERES_PROPUSE_DYNAMIC",
name: "ZONE_INTERES_PROPUSE_DYNAMIC",
label: "Zone interes propuse",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "RECEPTII_TEHNICE_ADMISE_DYNAMIC",
name: "RECEPTII_TEHNICE_ADMISE_DYNAMIC",
label: "Recepții tehnice admise",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC",
name: "RECEPTII_TEHNICE_RESPINSE_DYNAMIC",
label: "Recepții tehnice respinse",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC",
name: "RECEPTII_TEHNICE_NEINREGISTRATE_DYNAMIC",
label: "Recepții tehnice neînreg.",
category: "documentatii",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
// ── Administrativ ──
{
id: "LIMITE_UAT",
name: "LIMITE_UAT",
label: "Limite UAT",
category: "administrativ",
endpoint: "all",
},
{
id: "LIMITE_INTRAV_DYNAMIC",
name: "LIMITE_INTRAV_DYNAMIC",
label: "Limite intravilan",
category: "administrativ",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "SPECIAL_AREAS_ACTIVE_DYNAMIC",
name: "SPECIAL_AREAS_ACTIVE_DYNAMIC",
label: "Arii speciale active",
category: "administrativ",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
{
id: "SPECIAL_AREAS_INACTIVE_DYNAMIC",
name: "SPECIAL_AREAS_INACTIVE_DYNAMIC",
label: "Arii speciale inactive",
category: "administrativ",
endpoint: "aut",
spatialFilter: true,
subLayerId: 0,
},
];
export const findLayerById = (id?: string) =>
LAYER_CATALOG.find((l) => l.id === id);
@@ -0,0 +1,25 @@
/**
* In-memory progress store for long-running sync/export jobs.
*/
export type SyncProgress = {
jobId: string;
downloaded: number;
total?: number;
status: "running" | "done" | "error";
phase?: string;
message?: string;
note?: string;
phaseCurrent?: number;
phaseTotal?: number;
};
type ProgressStore = Map<string, SyncProgress>;
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
g.__parcelSyncProgressStore = store;
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
export const getProgress = (jobId: string) => store.get(jobId);
export const clearProgress = (jobId: string) => store.delete(jobId);
@@ -0,0 +1,344 @@
/**
* Sync engine — downloads eTerra features and stores them in PostgreSQL.
*
* Supports incremental sync: compares remote OBJECTIDs with local DB,
* only downloads new features, marks removed ones.
*/
import { Prisma, PrismaClient } from "@prisma/client";
import { EterraClient } from "./eterra-client";
import type { LayerConfig } from "./eterra-client";
import { esriToGeojson } from "./esri-geojson";
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
import { fetchUatGeometry } from "./uat-geometry";
import {
setProgress,
clearProgress,
type SyncProgress,
} from "./progress-store";
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export type SyncResult = {
layerId: string;
siruta: string;
totalRemote: number;
totalLocal: number;
newFeatures: number;
removedFeatures: number;
status: "done" | "error";
error?: string;
};
/**
* Sync a single layer for a UAT into the local GIS database.
*
* 1. Count remote features
* 2. Get local OBJECTIDs already stored
* 3. Download only new OBJECTIDs (incremental)
* 4. Mark removed ones (present local, absent remote)
* 5. Store results + sync run metadata
*/
export async function syncLayer(
username: string,
password: string,
siruta: string,
layerId: string,
options?: {
uatName?: string;
jobId?: string;
forceFullSync?: boolean;
},
): Promise<SyncResult> {
const jobId = options?.jobId;
const layer = findLayerById(layerId);
if (!layer) throw new Error(`Layer ${layerId} not found`);
const push = (partial: Partial<SyncProgress>) => {
if (!jobId) return;
setProgress({
jobId,
downloaded: 0,
status: "running",
...partial,
} as SyncProgress);
};
// Create sync run record
const syncRun = await prisma.gisSyncRun.create({
data: {
siruta,
uatName: options?.uatName,
layerId,
status: "running",
},
});
try {
push({ phase: "Conectare eTerra", downloaded: 0 });
const client = await EterraClient.create(username, password);
// Get UAT geometry for spatial-filtered layers
let uatGeometry;
if (layer.spatialFilter) {
push({ phase: "Obținere geometrie UAT" });
uatGeometry = await fetchUatGeometry(client, siruta);
}
// Count remote features
push({ phase: "Numărare remote" });
let remoteCount: number;
try {
remoteCount = uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
} catch {
remoteCount = 0;
}
push({ phase: "Verificare locală", total: remoteCount });
// Get local OBJECTIDs for this layer+siruta
const localFeatures = await prisma.gisFeature.findMany({
where: { layerId, siruta },
select: { objectId: true },
});
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
// Fetch all remote features
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
const allRemote = uatGeometry
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
delayMs: 200,
})
: await client.fetchAllLayerByWhere(
layer,
await buildWhere(client, layer, siruta),
{
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
// Convert to GeoJSON for geometry storage
const geojson = esriToGeojson(allRemote);
const geojsonByObjId = new Map<number, (typeof geojson.features)[0]>();
for (const f of geojson.features) {
const objId = f.properties.OBJECTID as number | undefined;
if (objId != null) geojsonByObjId.set(objId, f);
}
// Determine which OBJECTIDs are new
const remoteObjIds = new Set<number>();
for (const f of allRemote) {
const objId = f.attributes.OBJECTID as number | undefined;
if (objId != null) remoteObjIds.add(objId);
}
const newObjIds = options?.forceFullSync
? remoteObjIds
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
const removedObjIds = [...localObjIds].filter(
(id) => !remoteObjIds.has(id),
);
push({
phase: "Salvare în baza de date",
downloaded: 0,
total: newObjIds.size,
});
// Insert new features in batches
let saved = 0;
const BATCH_SIZE = 100;
const newArray = [...newObjIds];
for (let i = 0; i < newArray.length; i += BATCH_SIZE) {
const batch = newArray.slice(i, i + BATCH_SIZE);
const creates = batch
.map((objId) => {
const feature = allRemote.find(
(f) => (f.attributes.OBJECTID as number) === objId,
);
if (!feature) return null;
const geoFeature = geojsonByObjId.get(objId);
const geom = geoFeature?.geometry;
return {
layerId,
siruta,
objectId: objId,
inspireId:
(feature.attributes.INSPIRE_ID as string | undefined) ?? null,
cadastralRef:
(feature.attributes.NATIONAL_CADASTRAL_REFERENCE as
| string
| undefined) ?? null,
areaValue:
typeof feature.attributes.AREA_VALUE === "number"
? feature.attributes.AREA_VALUE
: null,
isActive: feature.attributes.IS_ACTIVE !== 0,
attributes: feature.attributes as Prisma.InputJsonValue,
geometry: geom ? (geom as Prisma.InputJsonValue) : Prisma.JsonNull,
syncRunId: syncRun.id,
};
})
.filter(Boolean);
// Use upsert to handle potential conflicts (force sync)
for (const item of creates) {
if (!item) continue;
await prisma.gisFeature.upsert({
where: {
layerId_objectId: {
layerId: item.layerId,
objectId: item.objectId,
},
},
create: item,
update: {
...item,
updatedAt: new Date(),
},
});
}
saved += creates.length;
push({
phase: "Salvare în baza de date",
downloaded: saved,
total: newObjIds.size,
});
}
// Mark removed features
if (removedObjIds.length > 0) {
push({ phase: "Marcare șterse" });
await prisma.gisFeature.deleteMany({
where: {
layerId,
siruta,
objectId: { in: removedObjIds },
},
});
}
// Update sync run
const localCount = await prisma.gisFeature.count({
where: { layerId, siruta },
});
await prisma.gisSyncRun.update({
where: { id: syncRun.id },
data: {
status: "done",
totalRemote: remoteCount,
totalLocal: localCount,
newFeatures: newObjIds.size,
removedFeatures: removedObjIds.length,
completedAt: new Date(),
},
});
push({
phase: "Finalizat",
status: "done",
downloaded: remoteCount,
total: remoteCount,
});
if (jobId) setTimeout(() => clearProgress(jobId), 60_000);
return {
layerId,
siruta,
totalRemote: remoteCount,
totalLocal: localCount,
newFeatures: newObjIds.size,
removedFeatures: removedObjIds.length,
status: "done",
};
} catch (error) {
const msg = error instanceof Error ? error.message : "Unknown error";
await prisma.gisSyncRun.update({
where: { id: syncRun.id },
data: { status: "error", errorMessage: msg, completedAt: new Date() },
});
push({ phase: "Eroare", status: "error", message: msg });
if (jobId) setTimeout(() => clearProgress(jobId), 60_000);
return {
layerId,
siruta,
totalRemote: 0,
totalLocal: 0,
newFeatures: 0,
removedFeatures: 0,
status: "error",
error: msg,
};
}
}
/** Helper to build where clause outside the client */
async function buildWhere(
client: EterraClient,
layer: LayerConfig,
siruta: string,
) {
const fields = await client.getLayerFieldNames(layer);
const preferred = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
const upper = fields.map((f) => f.toUpperCase());
let adminField: string | null = null;
for (const key of preferred) {
const idx = upper.indexOf(key);
if (idx >= 0) {
adminField = fields[idx] ?? null;
break;
}
}
if (!adminField) return "1=1";
if (!layer.whereTemplate) return `${adminField}=${siruta}`;
const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE");
if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive)
return `${adminField}=${siruta}`;
return layer.whereTemplate
.replace(/\{\{adminField\}\}/g, adminField)
.replace(/\{\{siruta\}\}/g, siruta);
}
/**
* Get sync status for all layers for a given UAT.
*/
export async function getSyncStatus(siruta: string) {
const runs = await prisma.gisSyncRun.findMany({
where: { siruta },
orderBy: { startedAt: "desc" },
});
const counts = await prisma.gisFeature.groupBy({
by: ["layerId"],
where: { siruta },
_count: { id: true },
});
const countMap: Record<string, number> = {};
for (const c of counts) {
countMap[c.layerId] = c._count.id;
}
return { runs, localCounts: countMap };
}
@@ -0,0 +1,47 @@
/**
* UAT geometry fetcher — retrieves the boundary polygon for a SIRUTA
* from the LIMITE_UAT layer. Used as spatial filter for dynamic layers.
*/
import type { EsriGeometry } from "./eterra-client";
import { EterraClient } from "./eterra-client";
import { findLayerById } from "./eterra-layers";
const findAdminField = (fields: string[]) => {
const preferred = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
const upper = fields.map((f) => f.toUpperCase());
for (const key of preferred) {
const idx = upper.indexOf(key);
if (idx >= 0) return fields[idx];
}
return null;
};
export const fetchUatGeometry = async (
client: EterraClient,
siruta: string,
): Promise<EsriGeometry> => {
const layer = findLayerById("LIMITE_UAT");
if (!layer) throw new Error("LIMITE_UAT not configured");
const fields = await client.getLayerFieldNames(layer);
const adminField = findAdminField(fields);
if (!adminField) throw new Error("LIMITE_UAT missing admin field");
const where = `${adminField}=${siruta}`;
const features = await client.fetchAllLayerByWhere(layer, where, {
outFields: adminField,
returnGeometry: true,
pageSize: 1,
});
const geometry = features[0]?.geometry as EsriGeometry | undefined;
if (!geometry?.rings?.length) throw new Error("UAT geometry not found");
return geometry;
};