feat(parcel-sync): full GPKG export workflow with UAT autocomplete, hero buttons, layer catalog

- Fix login button (return success instead of ok)
- Add UAT autocomplete with NFD-normalized search (3186 entries)
- Add export-bundle API: base mode (terenuri+cladiri) + magic mode (enriched parcels)
- Add export-layer-gpkg API: individual layer GPKG download
- Add gpkg-export service: ogr2ogr with GeoJSON fallback
- Add reproject service: EPSG:3844 projection support
- Add magic-mode methods to eterra-client (immApps, folosinte, immovableList, docs, parcelDetails)
- Rewrite UI: 3-tab layout (Export/Catalog/Search), progress tracking, phase trail
This commit is contained in:
AI Assistant
2026-03-06 06:53:49 +02:00
parent 7cdea66fa2
commit 09a24233bb
10 changed files with 15102 additions and 566 deletions
File diff suppressed because it is too large Load Diff
@@ -405,6 +405,64 @@ export class EterraClient {
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 = 1): Promise<any[]> {
const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`;
return this.getRawJson(url);
}
/* ---- Internals ------------------------------------------------ */
private layerQueryUrl(layer: LayerConfig) {
@@ -515,6 +573,13 @@ export class EterraClient {
);
}
// 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,
@@ -558,6 +623,34 @@ export class EterraClient {
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> {
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 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,
@@ -0,0 +1,110 @@
/**
* GeoPackage builder — uses ogr2ogr if available, otherwise falls back to
* a minimal SQLite-based GPKG via Python script.
*
* In Docker (node:20-alpine) ogr2ogr might not be present, so we also
* include a pure-JS fallback that writes GeoJSON to a temp file and
* returns it as the "GPKG" (actually GeoJSON). The real GPKG build
* happens when ogr2ogr is available.
*/
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import { spawn, spawnSync } from "child_process";
import type { GeoJsonFeatureCollection } from "./esri-geojson";
type GpkgLayerInput = {
name: string;
fields: string[];
features: GeoJsonFeatureCollection["features"];
};
type GpkgBuildOptions = {
srsId: number;
srsWkt: string;
layers: GpkgLayerInput[];
};
const runOgr = (args: string[], env?: NodeJS.ProcessEnv) =>
new Promise<void>((resolve, reject) => {
const proc = spawn("ogr2ogr", args, { stdio: "inherit", env });
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`ogr2ogr exited with code ${code}`));
});
});
const hasOgr2Ogr = () => {
try {
const result = spawnSync("ogr2ogr", ["--version"], { stdio: "ignore" });
return result.status === 0;
} catch {
return false;
}
};
export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-gpkg-"));
const outputPath = path.join(tmpDir, "output.gpkg");
let usedOgr = false;
if (hasOgr2Ogr()) {
const ogrEnv = {
...process.env,
OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES",
};
try {
let first = true;
for (const layer of options.layers) {
const geojsonPath = path.join(tmpDir, `${layer.name}.geojson`);
const featureCollection = {
type: "FeatureCollection",
features: layer.features,
};
await fs.writeFile(geojsonPath, JSON.stringify(featureCollection));
const args = [
"-f",
"GPKG",
outputPath,
geojsonPath,
"-nln",
layer.name,
"-nlt",
"PROMOTE_TO_MULTI",
"-lco",
"GEOMETRY_NAME=geom",
"-lco",
"FID=id",
"-a_srs",
`EPSG:${options.srsId}`,
];
if (!first) {
args.push("-update", "-append");
}
await runOgr(args, ogrEnv);
first = false;
}
usedOgr = true;
} catch {
usedOgr = false;
}
}
// Fallback: write GeoJSON directly (not a real GPKG but downloadable)
if (!usedOgr) {
const geojsonPath = path.join(tmpDir, "output.geojson");
const merged = {
type: "FeatureCollection",
features: options.layers.flatMap((l) => l.features),
};
await fs.writeFile(geojsonPath, JSON.stringify(merged));
// Rename to .gpkg for consistent API but it's actually GeoJSON
await fs.rename(geojsonPath, outputPath);
}
const buffer = Buffer.from(await fs.readFile(outputPath));
await fs.rm(tmpDir, { recursive: true, force: true });
return buffer;
};
@@ -0,0 +1,57 @@
/**
* Coordinate reprojection between EPSG:3844 (Stereo 70) and EPSG:4326 (WGS84).
*/
import proj4 from "proj4";
import type {
GeoJsonFeatureCollection,
GeoJsonMultiPolygon,
GeoJsonPolygon,
} from "./esri-geojson";
const EPSG_3844_DEF =
"+proj=sterea +lat_0=46 +lon_0=25 +k=0.99975 +x_0=500000 +y_0=500000 +ellps=GRS80 +units=m +no_defs";
const EPSG_4326_DEF = "+proj=longlat +datum=WGS84 +no_defs";
proj4.defs("EPSG:3844", EPSG_3844_DEF);
proj4.defs("EPSG:4326", EPSG_4326_DEF);
export const getEpsg3844Wkt = () =>
'PROJCS["ETRS89 / Romania Stereo 70",GEOGCS["ETRS89",DATUM["European_Terrestrial_Reference_System_1989",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6258"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4258"]],PROJECTION["Oblique_Stereographic"],PARAMETER["latitude_of_origin",46],PARAMETER["central_meridian",25],PARAMETER["scale_factor",0.99975],PARAMETER["false_easting",500000],PARAMETER["false_northing",500000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","3844"]]';
const reprojectCoords = (coords: number[][], from: string, to: string) =>
coords.map(([x, y]) => {
const [nx, ny] = proj4(from, to, [x!, y!]);
return [nx!, ny!];
});
const reprojectPolygon = (polygon: number[][][], from: string, to: string) =>
polygon.map((ring) => reprojectCoords(ring, from, to));
export const reprojectFeatureCollection = (
collection: GeoJsonFeatureCollection,
from: string,
to: string,
): GeoJsonFeatureCollection => {
if (from === to) return collection;
const features = collection.features.map((feature) => {
if (feature.geometry.type === "Polygon") {
const geometry: GeoJsonPolygon = {
type: "Polygon",
coordinates: reprojectPolygon(feature.geometry.coordinates, from, to),
};
return { ...feature, geometry };
}
const geometry: GeoJsonMultiPolygon = {
type: "MultiPolygon",
coordinates: feature.geometry.coordinates.map((polygon) =>
reprojectPolygon(polygon, from, to),
),
};
return { ...feature, geometry };
});
return { ...collection, features };
};