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