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:
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user