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
+881
View File
@@ -0,0 +1,881 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import JSZip from "jszip";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { esriToGeojson } from "@/modules/parcel-sync/services/esri-geojson";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import {
clearProgress,
setProgress,
} from "@/modules/parcel-sync/services/progress-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type ExportBundleRequest = {
username?: string;
password?: string;
siruta?: string | number;
jobId?: string;
mode?: "base" | "magic";
};
const validate = (body: ExportBundleRequest) => {
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "").trim();
const siruta = String(body.siruta ?? "").trim();
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
const mode = body.mode === "magic" ? "magic" : "base";
if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
return { username, password, siruta, jobId, mode };
};
const sleep = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
const scheduleClear = (jobId?: string) => {
if (!jobId) return;
setTimeout(() => clearProgress(jobId), 60_000);
};
const isRetryable = (error: unknown) => {
const err = error as { response?: { status?: number }; code?: string };
const status = err?.response?.status ?? 0;
if ([429, 500, 502, 503, 504].includes(status)) return true;
return err?.code === "ECONNRESET" || err?.code === "ETIMEDOUT";
};
const formatNumber = (value: number) =>
Number.isFinite(value) ? value.toFixed(2).replace(/\.00$/, "") : "";
const normalizeId = (value: unknown) => {
if (value === null || value === undefined) return "";
const text = String(value).trim();
if (!text) return "";
return text.replace(/\.0$/, "");
};
const normalizeCadRef = (value: unknown) =>
normalizeId(value).replace(/\s+/g, "").toUpperCase();
const baseCadRef = (value: unknown) => {
const ref = normalizeCadRef(value);
if (!ref) return "";
return ref.includes("-") ? ref.split("-")[0]! : ref;
};
const makeWorkspaceKey = (workspaceId: unknown, immovableId: unknown) => {
const ws = normalizeId(workspaceId);
const im = normalizeId(immovableId);
if (!ws || !im) return "";
return `${ws}:${im}`;
};
export async function POST(req: Request) {
let jobId: string | undefined;
let message: string | undefined;
let phase = "Initializare";
let note: string | undefined;
let status: "running" | "done" | "error" = "running";
let downloaded = 0;
let total: number | undefined;
let completedWeight = 0;
let currentWeight = 0;
let phaseTotal: number | undefined;
let phaseCurrent: number | undefined;
const pushProgress = () => {
if (!jobId) return;
setProgress({
jobId,
downloaded,
total,
status,
phase,
note,
message,
phaseCurrent,
phaseTotal,
});
};
const updateOverall = (fraction = 0) => {
const overall = completedWeight + currentWeight * fraction;
const clipped = Math.min(100, Math.max(0, overall));
downloaded = Number(clipped.toFixed(1));
total = 100;
pushProgress();
};
const setPhaseState = (
next: string,
weight: number,
nextTotal?: number,
) => {
phase = next;
currentWeight = weight;
phaseTotal = nextTotal;
phaseCurrent = nextTotal ? 0 : undefined;
note = undefined;
updateOverall(0);
};
const updatePhaseProgress = (value: number, nextTotal?: number) => {
if (typeof nextTotal === "number") phaseTotal = nextTotal;
if (phaseTotal && phaseTotal > 0) {
phaseCurrent = value;
updateOverall(Math.min(1, value / phaseTotal));
} else {
phaseCurrent = undefined;
updateOverall(0);
}
};
const finishPhase = () => {
completedWeight += currentWeight;
currentWeight = 0;
phaseTotal = undefined;
phaseCurrent = undefined;
note = undefined;
updateOverall(0);
};
const withHeartbeat = async <T,>(task: () => Promise<T>) => {
let tick = 0.1;
updatePhaseProgress(tick, 1);
const interval = setInterval(() => {
tick = Math.min(0.9, tick + 0.05);
updatePhaseProgress(tick, 1);
}, 1200);
try {
return await task();
} finally {
clearInterval(interval);
}
};
try {
const body = (await req.json()) as ExportBundleRequest;
const validated = validate(body);
jobId = validated.jobId;
pushProgress();
const terenuriLayer = findLayerById("TERENURI_ACTIVE");
const cladiriLayer = findLayerById("CLADIRI_ACTIVE");
if (!terenuriLayer || !cladiriLayer)
throw new Error("Missing layer configuration");
const weights =
validated.mode === "magic"
? {
auth: 3,
count: 2,
terenuri: 23,
cladiri: 13,
detalii: 34,
gpkgT: 8,
gpkgC: 7,
gpkgM: 5,
zip: 5,
}
: {
auth: 3,
count: 2,
terenuri: 40,
cladiri: 22,
detalii: 0,
gpkgT: 10,
gpkgC: 10,
gpkgM: 0,
zip: 13,
};
/* Auth */
setPhaseState("Autentificare", weights.auth, 1);
const client = await EterraClient.create(
validated.username,
validated.password,
{ timeoutMs: 120_000 },
);
updatePhaseProgress(1, 1);
finishPhase();
/* Count */
const safeCount = async (layerId: "terenuri" | "cladiri") => {
try {
return await client.countLayer(
layerId === "terenuri" ? terenuriLayer : cladiriLayer,
validated.siruta,
);
} catch (error) {
const msg =
error instanceof Error ? error.message : "Unexpected server error";
if (msg.toLowerCase().includes("count unavailable")) return undefined;
throw error;
}
};
setPhaseState("Numarare", weights.count, 2);
const terenuriCount = await safeCount("terenuri");
updatePhaseProgress(1, 2);
const cladiriCount = await safeCount("cladiri");
updatePhaseProgress(2, 2);
finishPhase();
const calcPageSize = (cnt?: number) => {
if (!cnt || cnt <= 0) return 1000;
if (cnt <= 200) return Math.max(50, Math.ceil(cnt / 2));
return Math.min(1000, Math.max(200, Math.ceil(cnt / 8)));
};
/* Download terenuri */
setPhaseState("Descarcare terenuri", weights.terenuri, terenuriCount);
const terenuriFeatures = await client.fetchAllLayer(
terenuriLayer,
validated.siruta,
{
total: terenuriCount,
pageSize: calcPageSize(terenuriCount),
delayMs: 250,
onProgress: (count, totalCount) =>
updatePhaseProgress(count, totalCount),
},
);
finishPhase();
/* Download cladiri */
setPhaseState("Descarcare cladiri", weights.cladiri, cladiriCount);
const cladiriFeatures = await client.fetchAllLayer(
cladiriLayer,
validated.siruta,
{
total: cladiriCount,
pageSize: calcPageSize(cladiriCount),
delayMs: 250,
onProgress: (count, totalCount) =>
updatePhaseProgress(count, totalCount),
},
);
finishPhase();
const terenuriGeo = esriToGeojson(terenuriFeatures);
const cladiriGeo = esriToGeojson(cladiriFeatures);
const srsWkt = getEpsg3844Wkt();
const terenuriFields = await client.getLayerFieldNames(terenuriLayer);
const cladiriFields = await client.getLayerFieldNames(cladiriLayer);
let terenuriGpkg: Buffer | null = null;
let cladiriGpkg: Buffer | null = null;
let magicGpkg: Buffer | null = null;
let csvContent: string | null = null;
let hasBuildingCount = 0;
let legalBuildingCount = 0;
if (validated.mode === "magic") {
/* ── Magic mode: enrich parcels ─────────────────────────── */
setPhaseState("Detalii parcele", weights.detalii, terenuriCount);
const immAppsCache = new Map<string, any[]>();
const folCache = new Map<string, any[]>();
let lastRequest = 0;
const minInterval = 250;
const throttled = async <T,>(fn: () => Promise<T>) => {
let attempt = 0;
while (true) {
const now = Date.now();
const wait = Math.max(0, lastRequest + minInterval - now);
if (wait > 0) {
note = `Throttling ${Math.ceil(wait)}ms`;
pushProgress();
await sleep(wait);
}
try {
const result = await fn();
lastRequest = Date.now();
note = undefined;
pushProgress();
return result;
} catch (error) {
if (!isRetryable(error) || attempt >= 2) throw error;
attempt += 1;
const backoff = Math.min(5000, 1000 * attempt);
note = `Backoff ${backoff}ms (retry ${attempt})`;
pushProgress();
await sleep(backoff);
}
}
};
const pickApplication = (entries: any[], applicationId?: number) => {
if (!entries.length) return null;
if (applicationId) {
const match = entries.find(
(entry: any) => entry?.applicationId === applicationId,
);
if (match) return match;
}
return (
entries
.filter((entry: any) => entry?.dataCerere)
.sort(
(a: any, b: any) => (b.dataCerere ?? 0) - (a.dataCerere ?? 0),
)[0] ?? entries[0]
);
};
const normalizeIntravilan = (values: string[]) => {
const normalized = values
.map((v) => String(v ?? "").trim().toLowerCase())
.filter(Boolean);
const unique = new Set(normalized);
if (!unique.size) return "-";
if (unique.size === 1)
return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt";
return "Mixt";
};
const formatCategories = (entries: any[]) => {
const map = new Map<string, number>();
for (const entry of entries) {
const key = String(entry?.categorieFolosinta ?? "").trim();
if (!key) continue;
const area = Number(entry?.suprafata ?? 0);
map.set(
key,
(map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0),
);
}
return Array.from(map.entries())
.map(([k, a]) => `${k}:${formatNumber(a)}`)
.join("; ");
};
const formatAddress = (item?: any) => {
const address = item?.immovableAddresses?.[0]?.address ?? null;
if (!address) return "-";
const parts: string[] = [];
if (address.addressDescription)
parts.push(address.addressDescription);
if (address.street) parts.push(`Str. ${address.street}`);
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
if (address.locality?.name) parts.push(address.locality.name);
return parts.length ? parts.join(", ") : "-";
};
/* Building cross-ref map */
const buildingMap = new Map<
string,
{ has: boolean; legal: boolean }
>();
for (const feature of cladiriFeatures) {
const attrs = feature.attributes ?? {};
const immovableId =
attrs.IMMOVABLE_ID ?? attrs.IMOVABLE_ID ?? null;
const workspaceId = attrs.WORKSPACE_ID ?? null;
const baseRef = baseCadRef(
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
);
const isLegal =
Number(attrs.IS_LEGAL ?? 0) === 1 ||
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true";
const add = (key: string) => {
if (!key) return;
const existing = buildingMap.get(key) ?? {
has: false,
legal: false,
};
existing.has = true;
if (isLegal) existing.legal = true;
buildingMap.set(key, existing);
};
const immKey = normalizeId(immovableId);
const wKey = makeWorkspaceKey(workspaceId, immovableId);
if (immKey) add(immKey);
if (wKey) add(wKey);
if (baseRef) add(baseRef);
}
/* Fetch immovable list */
const immovableListById = new Map<string, any>();
const immovableListByCad = new Map<string, any>();
const docByImmovable = new Map<string, any>();
const ownersByLandbook = new Map<string, Set<string>>();
const addOwner = (landbook: string, name: string) => {
if (!landbook || !name) return;
const existing =
ownersByLandbook.get(landbook) ?? new Set<string>();
existing.add(name);
ownersByLandbook.set(landbook, existing);
};
let listPage = 0;
let listTotalPages = 1;
let includeInscrisCF = true;
while (listPage < listTotalPages) {
const listResponse = await throttled(() =>
client.fetchImmovableListByAdminUnit(
65,
validated.siruta,
listPage,
200,
includeInscrisCF,
),
);
if (
listPage === 0 &&
!(listResponse?.content ?? []).length &&
includeInscrisCF
) {
includeInscrisCF = false;
listPage = 0;
listTotalPages = 1;
continue;
}
listTotalPages =
typeof listResponse?.totalPages === "number"
? listResponse.totalPages
: listTotalPages;
(listResponse?.content ?? []).forEach((item: any) => {
const idKey = normalizeId(item?.immovablePk);
if (idKey) immovableListById.set(idKey, item);
const cadKey = normalizeCadRef(
item?.identifierDetails ?? "",
);
if (cadKey) immovableListByCad.set(cadKey, item);
});
listPage += 1;
}
/* Fetch documentation data */
const immovableIds = Array.from(immovableListById.keys());
const docBatchSize = 50;
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
const batch = immovableIds.slice(i, i + docBatchSize);
const docResponse = await throttled(() =>
client.fetchDocumentationData(65, batch),
);
(docResponse?.immovables ?? []).forEach((item: any) => {
const idKey = normalizeId(item?.immovablePk);
if (idKey) docByImmovable.set(idKey, item);
});
(docResponse?.partTwoRegs ?? []).forEach((item: any) => {
if (
String(item?.nodeType ?? "").toUpperCase() === "P" &&
item?.landbookIE
) {
const name = String(item?.nodeName ?? "").trim();
if (name) addOwner(String(item.landbookIE), name);
}
});
}
/* Build CSV + detail map */
const csvRows: string[] = [];
const headers = [
"OBJECTID",
"IMMOVABLE_ID",
"APPLICATION_ID",
"NATIONAL_CADASTRAL_REFERENCE",
"NR_CAD",
"AREA_VALUE",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
];
csvRows.push(headers.join(","));
const detailsByObjectId = new Map<
string,
Record<string, unknown>
>();
for (let index = 0; index < terenuriFeatures.length; index += 1) {
const feature = terenuriFeatures[index]!;
const attrs = feature.attributes ?? {};
const objectId = attrs.OBJECTID ?? "";
const immovableId = attrs.IMMOVABLE_ID ?? "";
const workspaceId = attrs.WORKSPACE_ID ?? "";
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
let solicitant = "-";
let intravilan = "-";
let categorie = "-";
let proprietari = "-";
let nrCF = "-";
let nrCFVechi = "-";
let nrTopo = "-";
let addressText = "-";
if (immovableId && workspaceId) {
const appKey = `${workspaceId}:${immovableId}`;
let apps = immAppsCache.get(appKey);
if (!apps) {
apps = await throttled(() =>
client.fetchImmAppsByImmovable(
immovableId as string | number,
workspaceId as string | number,
),
);
immAppsCache.set(appKey, apps);
}
const chosen = pickApplication(
apps,
Number(applicationId ?? 0),
);
const appId =
chosen?.applicationId ??
(applicationId ? Number(applicationId) : null);
solicitant =
chosen?.solicitant ?? chosen?.deponent ?? solicitant;
if (appId) {
const folKey = `${workspaceId}:${immovableId}:${appId}`;
let fol = folCache.get(folKey);
if (!fol) {
fol = await throttled(() =>
client.fetchParcelFolosinte(
workspaceId as string | number,
immovableId as string | number,
appId,
),
);
folCache.set(folKey, fol);
}
intravilan = normalizeIntravilan(
fol.map((item: any) => item?.intravilan ?? ""),
);
categorie = formatCategories(fol);
}
}
const cadRefRaw = (attrs.NATIONAL_CADASTRAL_REFERENCE ??
"") as string;
const cadRef = normalizeCadRef(cadRefRaw);
const immKey = normalizeId(immovableId);
const listItem =
(immKey ? immovableListById.get(immKey) : undefined) ??
(cadRef ? immovableListByCad.get(cadRef) : undefined);
const docKey = listItem?.immovablePk
? normalizeId(listItem.immovablePk)
: "";
const docItem = docKey
? docByImmovable.get(docKey)
: undefined;
const landbookIE = docItem?.landbookIE ?? "";
const owners =
landbookIE && ownersByLandbook.get(String(landbookIE))
? Array.from(
ownersByLandbook.get(String(landbookIE)) ?? [],
)
: [];
const ownersByCad =
cadRefRaw && ownersByLandbook.get(String(cadRefRaw))
? Array.from(
ownersByLandbook.get(String(cadRefRaw)) ?? [],
)
: [];
proprietari =
Array.from(new Set([...owners, ...ownersByCad])).join(
"; ",
) || proprietari;
nrCF =
docItem?.landbookIE ||
listItem?.paperLbNo ||
listItem?.paperCadNo ||
nrCF;
const nrCFVechiRaw =
listItem?.paperLbNo || listItem?.paperCadNo || "";
nrCFVechi =
docItem?.landbookIE && nrCFVechiRaw !== nrCF
? nrCFVechiRaw
: nrCFVechi;
nrTopo =
listItem?.topNo ||
docItem?.topNo ||
listItem?.paperCadNo ||
nrTopo;
addressText = listItem
? formatAddress(listItem)
: addressText;
const parcelRef = baseCadRef(cadRefRaw);
const wKey = makeWorkspaceKey(workspaceId, immovableId);
const build =
(immKey ? buildingMap.get(immKey) : undefined) ??
(wKey ? buildingMap.get(wKey) : undefined) ??
(parcelRef ? buildingMap.get(parcelRef) : undefined) ?? {
has: false,
legal: false,
};
const hasBuilding = build.has ? 1 : 0;
const buildLegal = build.has ? (build.legal ? 1 : 0) : 0;
if (hasBuilding) hasBuildingCount += 1;
if (buildLegal === 1) legalBuildingCount += 1;
const areaValue =
typeof attrs.AREA_VALUE === "number" ? attrs.AREA_VALUE : null;
const detailRecord = {
NR_CAD: cadRefRaw,
NR_CF: nrCF,
NR_CF_VECHI: nrCFVechi,
NR_TOPO: nrTopo,
ADRESA: addressText,
PROPRIETARI: proprietari,
SUPRAFATA_2D:
areaValue !== null ? Number(areaValue.toFixed(2)) : "",
SUPRAFATA_R:
areaValue !== null ? Math.round(areaValue) : "",
SOLICITANT: solicitant,
INTRAVILAN: intravilan,
CATEGORIE_FOLOSINTA: categorie,
HAS_BUILDING: hasBuilding,
BUILD_LEGAL: buildLegal,
};
detailsByObjectId.set(String(objectId), detailRecord);
const row = [
objectId,
immovableId,
applicationId ?? "",
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
cadRefRaw,
attrs.AREA_VALUE ?? "",
nrCF,
nrCFVechi,
nrTopo,
`"${String(addressText).replace(/"/g, '""')}"`,
`"${String(proprietari).replace(/"/g, '""')}"`,
areaValue !== null ? areaValue.toFixed(2) : "",
areaValue !== null ? Math.round(areaValue) : "",
`"${String(solicitant).replace(/"/g, '""')}"`,
intravilan,
`"${String(categorie).replace(/"/g, '""')}"`,
hasBuilding,
buildLegal,
];
csvRows.push(row.join(","));
if (index % 10 === 0)
updatePhaseProgress(index + 1, terenuriCount);
}
updatePhaseProgress(terenuriFeatures.length, terenuriCount);
finishPhase();
csvContent = csvRows.join("\n");
/* GPKG terenuri */
setPhaseState("GPKG terenuri", weights.gpkgT, 1);
terenuriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_ACTIVE",
fields: terenuriFields,
features: terenuriGeo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
/* GPKG cladiri */
setPhaseState("GPKG cladiri", weights.gpkgC, 1);
cladiriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "CLADIRI_ACTIVE",
fields: cladiriFields,
features: cladiriGeo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
/* GPKG magic */
setPhaseState("GPKG magic", weights.gpkgM, 1);
const magicFields = Array.from(
new Set([
...terenuriFields,
"NR_CAD",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"ADRESA",
"PROPRIETARI",
"SUPRAFATA_2D",
"SUPRAFATA_R",
"SOLICITANT",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
"BUILD_LEGAL",
]),
);
const magicFeatures = terenuriGeo.features.map((feature) => {
const objectId = String(
feature.properties?.OBJECTID ?? "",
);
const extra = detailsByObjectId.get(objectId) ?? {};
return {
...feature,
properties: { ...feature.properties, ...extra },
};
});
magicGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_MAGIC",
fields: magicFields,
features: magicFeatures,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
} else {
/* ── Base mode ──────────────────────────────────────────── */
setPhaseState("GPKG terenuri", weights.gpkgT, 1);
terenuriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "TERENURI_ACTIVE",
fields: terenuriFields,
features: terenuriGeo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
setPhaseState("GPKG cladiri", weights.gpkgC, 1);
cladiriGpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt,
layers: [
{
name: "CLADIRI_ACTIVE",
fields: cladiriFields,
features: cladiriGeo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
}
/* ZIP */
setPhaseState("Comprimare ZIP", weights.zip, 1);
const zip = new JSZip();
if (!terenuriGpkg || !cladiriGpkg)
throw new Error("Failed to build GeoPackage files");
zip.file("terenuri.gpkg", terenuriGpkg);
zip.file("cladiri.gpkg", cladiriGpkg);
const report: Record<string, unknown> = {
siruta: validated.siruta,
generatedAt: new Date().toISOString(),
terenuri: {
count: terenuriFeatures.length,
expected: terenuriCount ?? null,
},
cladiri: {
count: cladiriFeatures.length,
expected: cladiriCount ?? null,
},
};
if (validated.mode === "magic" && magicGpkg && csvContent) {
zip.file("terenuri_magic.gpkg", magicGpkg);
zip.file("terenuri_complet.csv", csvContent);
report.magic = {
csvRows: csvContent.split("\n").length - 1,
hasBuildingCount,
legalBuildingCount,
};
}
zip.file("export_report.json", JSON.stringify(report, null, 2));
const zipBuffer = await withHeartbeat(() =>
zip.generateAsync({ type: "nodebuffer", compression: "STORE" }),
);
updatePhaseProgress(1, 1);
finishPhase();
/* Done */
const terenuriLabel =
typeof terenuriCount === "number"
? `${terenuriFeatures.length}/${terenuriCount}`
: `${terenuriFeatures.length}/?`;
const cladiriLabel =
typeof cladiriCount === "number"
? `${cladiriFeatures.length}/${cladiriCount}`
: `${cladiriFeatures.length}/?`;
message = `Finalizat 100% · Terenuri ${terenuriLabel} · Cladiri ${cladiriLabel}`;
status = "done";
phase = "Finalizat";
note = undefined;
pushProgress();
scheduleClear(jobId);
const filename =
validated.mode === "magic"
? `eterra_uat_${validated.siruta}_magic.zip`
: `eterra_uat_${validated.siruta}_terenuri_cladiri.zip`;
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
const errMessage =
error instanceof Error ? error.message : "Unexpected server error";
status = "error";
message = errMessage;
note = undefined;
pushProgress();
scheduleClear(jobId);
const lower = errMessage.toLowerCase();
const statusCode =
lower.includes("login failed") || lower.includes("session")
? 401
: 400;
return new Response(JSON.stringify({ error: errMessage }), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
}
}
@@ -0,0 +1,285 @@
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { esriToGeojson } from "@/modules/parcel-sync/services/esri-geojson";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
import {
clearProgress,
setProgress,
} from "@/modules/parcel-sync/services/progress-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type ExportLayerRequest = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
jobId?: string;
};
const validate = (body: ExportLayerRequest) => {
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "").trim();
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "").trim();
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
if (!layerId) throw new Error("Layer ID missing");
return { username, password, siruta, layerId, jobId };
};
const scheduleClear = (jobId?: string) => {
if (!jobId) return;
setTimeout(() => clearProgress(jobId), 60_000);
};
export async function POST(req: Request) {
let jobId: string | undefined;
let message: string | undefined;
let phase = "Initializare";
let note: string | undefined;
let status: "running" | "done" | "error" = "running";
let downloaded = 0;
let total: number | undefined;
let completedWeight = 0;
let currentWeight = 0;
let phaseTotal: number | undefined;
let phaseCurrent: number | undefined;
const pushProgress = () => {
if (!jobId) return;
setProgress({
jobId,
downloaded,
total,
status,
phase,
note,
message,
phaseCurrent,
phaseTotal,
});
};
const updateOverall = (fraction = 0) => {
const overall = completedWeight + currentWeight * fraction;
downloaded = Number(Math.min(100, Math.max(0, overall)).toFixed(1));
total = 100;
pushProgress();
};
const setPhaseState = (
next: string,
weight: number,
nextTotal?: number,
) => {
phase = next;
currentWeight = weight;
phaseTotal = nextTotal;
phaseCurrent = nextTotal ? 0 : undefined;
note = undefined;
updateOverall(0);
};
const updatePhaseProgress = (value: number, nextTotal?: number) => {
if (typeof nextTotal === "number") phaseTotal = nextTotal;
if (phaseTotal && phaseTotal > 0) {
phaseCurrent = value;
updateOverall(Math.min(1, value / phaseTotal));
} else {
phaseCurrent = undefined;
updateOverall(0);
}
};
const finishPhase = () => {
completedWeight += currentWeight;
currentWeight = 0;
phaseTotal = undefined;
phaseCurrent = undefined;
note = undefined;
updateOverall(0);
};
const withHeartbeat = async <T,>(task: () => Promise<T>) => {
let tick = 0.1;
updatePhaseProgress(tick, 1);
const interval = setInterval(() => {
tick = Math.min(0.9, tick + 0.05);
updatePhaseProgress(tick, 1);
}, 1200);
try {
return await task();
} finally {
clearInterval(interval);
}
};
try {
const body = (await req.json()) as ExportLayerRequest;
const validated = validate(body);
jobId = validated.jobId;
pushProgress();
const layer = findLayerById(validated.layerId);
if (!layer) throw new Error("Layer not configured");
const weights = {
auth: 5,
count: 5,
download: 70,
gpkg: 15,
finalize: 5,
};
/* Auth */
setPhaseState("Autentificare", weights.auth, 1);
const client = await EterraClient.create(
validated.username,
validated.password,
{ timeoutMs: 120_000 },
);
updatePhaseProgress(1, 1);
finishPhase();
/* Count */
let geometry;
setPhaseState("Numarare", weights.count, 2);
let count: number | undefined;
try {
if (layer.spatialFilter) {
geometry = await fetchUatGeometry(client, validated.siruta);
count = await client.countLayerByGeometry(layer, geometry);
} else {
count = await client.countLayer(layer, validated.siruta);
}
} catch (error) {
const msg =
error instanceof Error ? error.message : "Count error";
if (!msg.toLowerCase().includes("count unavailable")) throw error;
}
updatePhaseProgress(2, 2);
finishPhase();
if (layer.spatialFilter && !geometry) {
geometry = await fetchUatGeometry(client, validated.siruta);
}
const pageSize =
typeof count === "number"
? Math.min(1000, Math.max(200, Math.ceil(count / 8)))
: 500;
/* Download */
setPhaseState("Descarcare", weights.download, count);
const features = layer.spatialFilter
? await client.fetchAllLayerByGeometry(layer, geometry!, {
total: count,
pageSize,
delayMs: 250,
onProgress: (value, totalCount) => {
updatePhaseProgress(
value,
typeof totalCount === "number"
? totalCount
: value + pageSize,
);
},
})
: await client.fetchAllLayer(layer, validated.siruta, {
total: count,
pageSize,
delayMs: 250,
onProgress: (value, totalCount) => {
updatePhaseProgress(
value,
typeof totalCount === "number"
? totalCount
: value + pageSize,
);
},
});
updatePhaseProgress(
features.length,
count ?? features.length,
);
finishPhase();
/* Fields */
let fields: string[] = [];
try {
fields = await client.getLayerFieldNames(layer);
} catch (error) {
const msg = error instanceof Error ? error.message : "";
if (!msg.toLowerCase().includes("returned no fields")) throw error;
}
if (!fields.length) {
fields = Object.keys(features[0]?.attributes ?? {});
}
/* GPKG */
setPhaseState("GPKG", weights.gpkg, 1);
const geo = esriToGeojson(features);
const gpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt: getEpsg3844Wkt(),
layers: [
{
name: layer.name,
fields,
features: geo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
/* Finalize */
setPhaseState("Finalizare", weights.finalize, 1);
updatePhaseProgress(1, 1);
finishPhase();
status = "done";
phase = "Finalizat";
message =
typeof count === "number"
? `Finalizat 100% · ${features.length}/${count} elemente`
: `Finalizat 100% · ${features.length} elemente`;
pushProgress();
scheduleClear(jobId);
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
return new Response(new Uint8Array(gpkg), {
headers: {
"Content-Type": "application/geopackage+sqlite3",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
const errMessage =
error instanceof Error ? error.message : "Unexpected server error";
status = "error";
message = errMessage;
note = undefined;
pushProgress();
scheduleClear(jobId);
const lower = errMessage.toLowerCase();
const statusCode =
lower.includes("login failed") || lower.includes("session")
? 401
: 400;
return new Response(JSON.stringify({ error: errMessage }), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ export async function POST(req: Request) {
);
await EterraClient.create(username, password);
return NextResponse.json({ ok: true });
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
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 };
};