58442da355
Three bugs caused sync to return 0 features after 37 minutes: 1. reloginAttempted was instance-level flag — once set to true after first 401, all subsequent 401s threw immediately without retry. Moved to per-request scope so each request can independently relogin on 401. 2. Session lastUsed never updated during pagination — after ~10 min of paginating, the session store considered it expired and cleanup could evict it. Added touchSession() call before every request. 3. Single eTerra client shared across all cities/steps for hours — now creates a fresh client per city/step (session cache still avoids unnecessary logins when session is valid). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1162 lines
36 KiB
TypeScript
1162 lines
36 KiB
TypeScript
/**
|
|
* 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 = 120_000;
|
|
const DEFAULT_PAGE_SIZE = 1000;
|
|
const PAGE_SIZE_FALLBACKS = [500, 200];
|
|
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>;
|
|
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
|
|
};
|
|
const sessionStore =
|
|
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
|
globalStore.__eterraSessionStore = sessionStore;
|
|
|
|
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
|
if (!globalStore.__eterraCleanupTimer) {
|
|
globalStore.__eterraCleanupTimer = setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of sessionStore.entries()) {
|
|
if (now - entry.lastUsed > 9 * 60_000) {
|
|
sessionStore.delete(key);
|
|
}
|
|
}
|
|
}, 5 * 60_000);
|
|
}
|
|
|
|
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 cacheKey: string;
|
|
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;
|
|
this.cacheKey = makeCacheKey(username, password);
|
|
}
|
|
|
|
/* ---- 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");
|
|
|
|
// Activate RGI module context by loading the RGI page
|
|
// This may set additional session attributes needed for document downloads
|
|
try {
|
|
await this.client.get(`${BASE_URL}/`, { timeout: 10_000 });
|
|
} catch {
|
|
// Non-critical
|
|
}
|
|
}
|
|
|
|
/* ---- 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);
|
|
}
|
|
|
|
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
|
|
|
|
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
|
|
const where = await this.buildWhere(layer, siruta);
|
|
return this.fetchObjectIdsByWhere(layer, where);
|
|
}
|
|
|
|
async fetchObjectIdsByWhere(
|
|
layer: LayerConfig,
|
|
where: string,
|
|
): Promise<number[]> {
|
|
const params = new URLSearchParams();
|
|
params.set("f", "json");
|
|
params.set("where", where);
|
|
params.set("returnIdsOnly", "true");
|
|
const data = await this.queryLayer(layer, params, false);
|
|
return data.objectIds ?? [];
|
|
}
|
|
|
|
async fetchObjectIdsByGeometry(
|
|
layer: LayerConfig,
|
|
geometry: EsriGeometry,
|
|
): Promise<number[]> {
|
|
const params = new URLSearchParams();
|
|
params.set("f", "json");
|
|
params.set("where", "1=1");
|
|
params.set("returnIdsOnly", "true");
|
|
this.applyGeometryParams(params, geometry);
|
|
const data = await this.queryLayer(layer, params, true);
|
|
return data.objectIds ?? [];
|
|
}
|
|
|
|
/* ---- Fetch specific features by OBJECTID list ------------------- */
|
|
|
|
async fetchFeaturesByObjectIds(
|
|
layer: LayerConfig,
|
|
objectIds: number[],
|
|
options?: {
|
|
baseWhere?: string;
|
|
outFields?: string;
|
|
returnGeometry?: boolean;
|
|
onProgress?: ProgressCallback;
|
|
delayMs?: number;
|
|
},
|
|
): Promise<EsriFeature[]> {
|
|
if (objectIds.length === 0) return [];
|
|
const chunkSize = 500;
|
|
const all: EsriFeature[] = [];
|
|
const total = objectIds.length;
|
|
for (let i = 0; i < objectIds.length; i += chunkSize) {
|
|
const chunk = objectIds.slice(i, i + chunkSize);
|
|
const idList = chunk.join(",");
|
|
const idWhere = `OBJECTID IN (${idList})`;
|
|
const where = options?.baseWhere
|
|
? `(${options.baseWhere}) AND ${idWhere}`
|
|
: idWhere;
|
|
try {
|
|
const features = await this.fetchAllLayerByWhere(layer, where, {
|
|
outFields: options?.outFields ?? "*",
|
|
returnGeometry: options?.returnGeometry ?? true,
|
|
delayMs: options?.delayMs ?? 200,
|
|
});
|
|
all.push(...features);
|
|
} catch (err) {
|
|
// Log but continue with remaining chunks — partial results better than none
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
console.warn(
|
|
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
|
|
);
|
|
}
|
|
options?.onProgress?.(all.length, total);
|
|
}
|
|
return all;
|
|
}
|
|
|
|
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;
|
|
outFields?: string;
|
|
returnGeometry?: boolean;
|
|
},
|
|
) {
|
|
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[] = [];
|
|
|
|
// ArcGIS servers have a maxRecordCount (typically 1000).
|
|
// If we request 2000 but get exactly 1000, we hit the server cap.
|
|
// Track this so we continue paginating instead of stopping.
|
|
let serverMaxRecordCount: number | null = null;
|
|
let retried = false; // one retry per page for transient ArcGIS errors
|
|
|
|
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 (err) {
|
|
const cause = err instanceof Error ? err.message : String(err);
|
|
const isQueryError = cause.includes("Error performing query");
|
|
|
|
// ArcGIS "Error performing query" — retry same page size first
|
|
// (often a transient server-side timeout), then try smaller.
|
|
if (isQueryError && !retried) {
|
|
retried = true;
|
|
await sleep(2000);
|
|
continue;
|
|
}
|
|
retried = false;
|
|
|
|
// Try next smaller page size
|
|
const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
|
|
if (nextSize) {
|
|
pageSize = nextSize;
|
|
await sleep(1000);
|
|
continue;
|
|
}
|
|
throw new Error(`Failed to fetch layer ${layer.name}: ${cause}`);
|
|
}
|
|
retried = false; // reset on success
|
|
|
|
const features = data.features ?? [];
|
|
if (features.length === 0) {
|
|
const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
|
|
if (total && all.length < total && nextSize) {
|
|
pageSize = nextSize;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Detect server maxRecordCount cap:
|
|
// If we asked for more than we got AND the result is a round number
|
|
// (1000, 2000), the server likely capped us. Adjust pageSize to match.
|
|
if (
|
|
serverMaxRecordCount === null &&
|
|
features.length < pageSize &&
|
|
features.length > 0 &&
|
|
features.length % 500 === 0 // round cap: 500, 1000, 1500, 2000...
|
|
) {
|
|
serverMaxRecordCount = features.length;
|
|
pageSize = serverMaxRecordCount;
|
|
// Don't break — this is a full page at server's cap, continue
|
|
}
|
|
|
|
all.push(...features);
|
|
offset += features.length;
|
|
if (onProgress) onProgress(all.length, total);
|
|
if (total && all.length >= total) break;
|
|
|
|
// End of data: fewer features than the effective page size
|
|
const effectivePageSize = serverMaxRecordCount ?? pageSize;
|
|
if (features.length < effectivePageSize) {
|
|
const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
|
|
if (total && all.length < total && nextSize) {
|
|
pageSize = nextSize;
|
|
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);
|
|
}
|
|
|
|
/* ---- Magic-mode methods (eTerra application APIs) ------------- */
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchImmAppsByImmovable(
|
|
immovableId: string | number,
|
|
workspaceId: string | number,
|
|
): Promise<any[]> {
|
|
const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`;
|
|
return this.getRawJson(url);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchParcelFolosinte(
|
|
workspaceId: string | number,
|
|
immovableId: string | number,
|
|
applicationId: string | number,
|
|
page = 1,
|
|
): Promise<any[]> {
|
|
const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`;
|
|
return this.getRawJson(url);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchImmovableListByAdminUnit(
|
|
workspaceId: string | number,
|
|
adminUnitId: string | number,
|
|
page = 0,
|
|
size = 200,
|
|
includeInscrisCF = true,
|
|
): Promise<any> {
|
|
const url = `${BASE_URL}/api/immovable/list`;
|
|
const filters: Array<{
|
|
value: string | number;
|
|
type: "NUMBER" | "STRING";
|
|
key: string;
|
|
op: string;
|
|
}> = [
|
|
{
|
|
value: Number(workspaceId),
|
|
type: "NUMBER",
|
|
key: "workspace.nomenPk",
|
|
op: "=",
|
|
},
|
|
{
|
|
value: Number(adminUnitId),
|
|
type: "NUMBER",
|
|
key: "adminUnit.nomenPk",
|
|
op: "=",
|
|
},
|
|
{ value: "C", type: "STRING", key: "immovableType", op: "<>C" },
|
|
];
|
|
if (includeInscrisCF) {
|
|
filters.push({ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" });
|
|
}
|
|
const payload = { filters, nrElements: size, page, sorters: [] };
|
|
return this.requestRaw(() =>
|
|
this.client.post(url, payload, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchDocumentationData(
|
|
workspaceId: string | number,
|
|
immovableIds: Array<string | number>,
|
|
): Promise<any> {
|
|
const url = `${BASE_URL}/api/documentation/data/`;
|
|
const payload = {
|
|
workflowCode: "EXPLORE_DATABASE",
|
|
activityCode: "EXPLORE",
|
|
applicationId: 0,
|
|
workspaceId: Number(workspaceId),
|
|
immovables: immovableIds.map((id) => Number(id)).filter(Number.isFinite),
|
|
};
|
|
return this.requestRaw(() =>
|
|
this.client.post(url, payload, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchImmovableParcelDetails(
|
|
workspaceId: string | number,
|
|
immovableId: string | number,
|
|
page = 1,
|
|
size = 20,
|
|
): Promise<any[]> {
|
|
const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`;
|
|
return this.getRawJson(url);
|
|
}
|
|
|
|
/**
|
|
* Check if an immovable is an "Imobil Electronic" (IE) in eTerra.
|
|
* Uses the same endpoint the eTerra UI calls when searching by
|
|
* topNo + paperCfNo.
|
|
*
|
|
* @returns true if the immovable is registered as IE, false otherwise
|
|
*/
|
|
async checkIfIsIE(
|
|
adminUnitId: string | number,
|
|
paperCadNo: number | null,
|
|
topNo: string | number,
|
|
paperCfNo: string | number,
|
|
): Promise<boolean> {
|
|
const url = `${BASE_URL}/api/immovable/checkIfIsIE`;
|
|
const payload = {
|
|
adminUnitId: Number(adminUnitId),
|
|
paperCadNo: Number(paperCadNo ?? 0),
|
|
topNo:
|
|
typeof topNo === "string"
|
|
? Number(topNo.split(",")[0]) || 0
|
|
: Number(topNo),
|
|
paperCfNo: Number(paperCfNo),
|
|
};
|
|
try {
|
|
const result = await this.requestRaw<boolean | number | string>(() =>
|
|
this.client.post(url, payload, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
// API may return boolean, number (1/0), or string
|
|
return result === true || result === 1 || String(result) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the URL for downloading a CF (carte funciară) extract PDF.
|
|
* The eTerra UI calls this to get the landbook/CF PDF blob.
|
|
*
|
|
* @returns URL string (caller needs an authenticated session to fetch)
|
|
*/
|
|
getCfExtractUrl(
|
|
immovablePk: string | number,
|
|
workspaceId: string | number,
|
|
): string {
|
|
return `${BASE_URL}/api/cf/landbook/copycf/get/${immovablePk}/${workspaceId}/0/true`;
|
|
}
|
|
|
|
/**
|
|
* Search immovable list by exact cadastral number (identifierDetails).
|
|
* This is the eTerra application API that the web UI uses when you type
|
|
* a cadastral number in the search box.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async searchImmovableByIdentifier(
|
|
workspaceId: string | number,
|
|
adminUnitId: string | number,
|
|
identifierDetails: string,
|
|
page = 0,
|
|
size = 10,
|
|
): Promise<any> {
|
|
const url = `${BASE_URL}/api/immovable/list`;
|
|
const filters: Array<{
|
|
value: string | number;
|
|
type: "NUMBER" | "STRING";
|
|
key: string;
|
|
op: string;
|
|
}> = [
|
|
{
|
|
value: Number(workspaceId),
|
|
type: "NUMBER",
|
|
key: "workspace.nomenPk",
|
|
op: "=",
|
|
},
|
|
{
|
|
value: Number(adminUnitId),
|
|
type: "NUMBER",
|
|
key: "adminUnit.nomenPk",
|
|
op: "=",
|
|
},
|
|
{
|
|
value: identifierDetails,
|
|
type: "STRING",
|
|
key: "identifierDetails",
|
|
op: "=",
|
|
},
|
|
{ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
|
|
{ value: "P", type: "STRING", key: "immovableType", op: "<>C" },
|
|
];
|
|
const payload = { filters, nrElements: size, page, sorters: [] };
|
|
return this.requestRaw(() =>
|
|
this.client.post(url, payload, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Search immovable list by owner/titular name.
|
|
*
|
|
* Uses the same `/api/immovable/list` endpoint, with a `personName`
|
|
* filter key. eTerra supports partial matching on this field.
|
|
*
|
|
* Falls back to `titularName` key if personName returns empty.
|
|
*
|
|
* @returns Paginated response with `content[]`, `totalPages`, `totalElements`
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async searchImmovableByOwnerName(
|
|
workspaceId: string | number,
|
|
adminUnitId: string | number,
|
|
ownerName: string,
|
|
page = 0,
|
|
size = 20,
|
|
): Promise<any> {
|
|
const url = `${BASE_URL}/api/immovable/list`;
|
|
const baseFilters: Array<{
|
|
value: string | number;
|
|
type: "NUMBER" | "STRING";
|
|
key: string;
|
|
op: string;
|
|
}> = [
|
|
{
|
|
value: Number(workspaceId),
|
|
type: "NUMBER",
|
|
key: "workspace.nomenPk",
|
|
op: "=",
|
|
},
|
|
{
|
|
value: Number(adminUnitId),
|
|
type: "NUMBER",
|
|
key: "adminUnit.nomenPk",
|
|
op: "=",
|
|
},
|
|
{ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
|
|
];
|
|
|
|
// Try primary key: personName (used in some eTerra versions)
|
|
const keysToTry = ["personName", "titularName", "ownerName"];
|
|
for (const filterKey of keysToTry) {
|
|
try {
|
|
const filters = [
|
|
...baseFilters,
|
|
{
|
|
value: ownerName,
|
|
type: "STRING" as const,
|
|
key: filterKey,
|
|
op: "=",
|
|
},
|
|
];
|
|
const payload = { filters, nrElements: size, page, sorters: [] };
|
|
const result = await this.requestRaw(() =>
|
|
this.client.post(url, payload, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
// If we got content back (even empty), it means the key is valid
|
|
if (result && typeof result === "object" && "content" in result) {
|
|
console.log(
|
|
`[searchByOwner] Key "${filterKey}" worked — ${(result as any).totalElements ?? 0} results`,
|
|
);
|
|
return result;
|
|
}
|
|
} catch (e) {
|
|
console.log(
|
|
`[searchByOwner] Key "${filterKey}" failed:`,
|
|
e instanceof Error ? e.message : e,
|
|
);
|
|
// Try next key
|
|
}
|
|
}
|
|
|
|
// All keys failed — return empty result
|
|
return { content: [], totalElements: 0, totalPages: 0 };
|
|
}
|
|
|
|
/**
|
|
* Fetch all counties (workspaces) from eTerra nomenclature.
|
|
* Returns array of { nomenPk, name, parentNomenPk, ... }
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchCounties(): Promise<any[]> {
|
|
const url = `${BASE_URL}/api/adm/nomen/COUNTY/list`;
|
|
return this.getRawJson(url);
|
|
}
|
|
|
|
/**
|
|
* Fetch a single nomenclature entry by nomenPk.
|
|
* Returns { nomenPk, name, parentNomenPk, nomenType, ... } or null
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchNomenByPk(nomenPk: string | number): Promise<any> {
|
|
const url = `${BASE_URL}/api/adm/nomen/${nomenPk}`;
|
|
try {
|
|
return await this.getRawJson(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch administrative units (UATs) under a county workspace.
|
|
* Returns array of { nomenPk, name, parentNomenPk, ... }
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async fetchAdminUnitsByCounty(
|
|
countyNomenPk: string | number,
|
|
): Promise<any[]> {
|
|
const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`;
|
|
return this.getRawJson(url);
|
|
}
|
|
|
|
/* ---- 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) {
|
|
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 }),
|
|
);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private async getRawJson<T = any>(url: string): Promise<T> {
|
|
return this.requestRaw(() =>
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/** Touch session TTL in global store (prevents expiry during long pagination) */
|
|
private touchSession(): void {
|
|
const cached = sessionStore.get(this.cacheKey);
|
|
if (cached) cached.lastUsed = Date.now();
|
|
}
|
|
|
|
private async requestJson(
|
|
request: () => Promise<{
|
|
data: EsriQueryResponse | string;
|
|
status: number;
|
|
}>,
|
|
): Promise<EsriQueryResponse> {
|
|
this.touchSession();
|
|
let response;
|
|
try {
|
|
response = await this.requestWithRetry(request);
|
|
} catch (error) {
|
|
const err = error as AxiosError;
|
|
if (err?.response?.status === 401) {
|
|
// Always attempt relogin on 401 (session may expire multiple times during long syncs)
|
|
await this.login(this.username, this.password);
|
|
response = await this.requestWithRetry(request);
|
|
} 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;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private async requestRaw<T = any>(
|
|
request: () => Promise<{ data: T | string; status: number }>,
|
|
): Promise<T> {
|
|
this.touchSession();
|
|
let response;
|
|
try {
|
|
response = await this.requestWithRetry(request);
|
|
} catch (error) {
|
|
const err = error as AxiosError;
|
|
if (err?.response?.status === 401) {
|
|
await this.login(this.username, this.password);
|
|
response = await this.requestWithRetry(request);
|
|
} else throw error;
|
|
}
|
|
const data = response.data as T | string;
|
|
if (typeof data === "string") {
|
|
try {
|
|
return JSON.parse(data) as T;
|
|
} catch {
|
|
throw new Error("Session expired or invalid response from eTerra");
|
|
}
|
|
}
|
|
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})`,
|
|
);
|
|
}
|
|
|
|
/* ── RGI (Registrul General de Intrare) API ───────────────── */
|
|
|
|
/**
|
|
* Generic RGI POST request with JSON body.
|
|
* Uses the same JSESSIONID cookie jar as GIS queries.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async rgiPost<T = any>(path: string, body?: unknown): Promise<T> {
|
|
const url = `${BASE_URL}/api/${path}`;
|
|
return this.requestRaw(() =>
|
|
this.client.post(url, body ?? {}, {
|
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
|
timeout: this.timeoutMs,
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Generic RGI GET request.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
async rgiGet<T = any>(path: string): Promise<T> {
|
|
const url = `${BASE_URL}/api/${path}`;
|
|
return this.requestRaw(() =>
|
|
this.client.get(url, { timeout: this.timeoutMs }),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Download a file from RGI (returns Buffer).
|
|
*/
|
|
async rgiDownload(path: string): Promise<{ data: Buffer; contentType: string; filename: string }> {
|
|
const url = `${BASE_URL}/api/${path}`;
|
|
const doRequest = () =>
|
|
this.client.get(url, {
|
|
timeout: this.timeoutMs,
|
|
responseType: "arraybuffer",
|
|
// Override Accept header for binary download
|
|
headers: { Accept: "*/*" },
|
|
// Don't throw on any status — we'll check manually
|
|
validateStatus: () => true,
|
|
});
|
|
|
|
let response = await doRequest();
|
|
|
|
// If 401/302/404 (session expired → redirect to login), re-login and retry
|
|
if (response.status === 401 || response.status === 302 || response.status === 404) {
|
|
await this.login(this.username, this.password);
|
|
response = await doRequest();
|
|
}
|
|
|
|
if (response.status !== 200) {
|
|
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
}
|
|
|
|
const headers = response.headers as Record<string, string> | undefined;
|
|
const cd = headers?.["content-disposition"] ?? "";
|
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
|
return {
|
|
data: Buffer.from(response.data as ArrayBuffer),
|
|
contentType: headers?.["content-type"] ?? "application/octet-stream",
|
|
filename: match?.[1] ?? "document.pdf",
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|