feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)

- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ)
- Incremental sync engine (OBJECTID comparison, only downloads new features)
- PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models)
- 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status)
- Full UI with 3 tabs (Sincronizare, Parcele, Istoric)
- Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD)
- Real-time sync progress tracking with polling
This commit is contained in:
AI Assistant
2026-03-06 00:36:29 +02:00
parent 51dbfcb2bd
commit 7cdea66fa2
25 changed files with 3097 additions and 12 deletions
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { useI18n } from "@/core/i18n";
import { ParcelSyncModule } from "@/modules/parcel-sync";
export default function ParcelSyncPage() {
const { t } = useI18n();
return (
<FeatureGate flag="module.parcel-sync" fallback={<ModuleDisabled />}>
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("parcel-sync.title")}
</h1>
<p className="text-muted-foreground">
{t("parcel-sync.description")}
</p>
</div>
<ParcelSyncModule />
</div>
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="mx-auto max-w-6xl py-12 text-center text-muted-foreground">
<p>Modulul eTerra Parcele este dezactivat.</p>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
const siruta = String(body.siruta ?? "").trim();
if (!username || !password)
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
if (!/^\d+$/.test(siruta))
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
const layerId = body.layerId ? String(body.layerId) : undefined;
const layer = layerId ? findLayerById(layerId) : undefined;
if (layerId && !layer)
return NextResponse.json({ error: "Layer necunoscut" }, { status: 400 });
const client = await EterraClient.create(username, password);
let geometry;
if (layer?.spatialFilter) {
geometry = await fetchUatGeometry(client, siruta);
}
const count = layer
? geometry
? await client.countLayerByGeometry(layer, geometry)
: await client.countLayer(layer, siruta)
: await client.countParcels(siruta);
return NextResponse.json({ count });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from "next/server";
import { PrismaClient, type Prisma } from "@prisma/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const prisma = new PrismaClient();
type Body = {
siruta?: string;
layerId?: string;
search?: string;
page?: number;
pageSize?: number;
projectId?: string;
};
/**
* List features stored in local GIS database with pagination & search.
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "").trim();
const search = (body.search ?? "").trim();
const page = Math.max(1, body.page ?? 1);
const pageSize = Math.min(200, Math.max(1, body.pageSize ?? 50));
if (!siruta) {
return NextResponse.json(
{ error: "SIRUTA obligatoriu" },
{ status: 400 },
);
}
const where: Prisma.GisFeatureWhereInput = { siruta };
if (layerId) where.layerId = layerId;
if (body.projectId) where.projectId = body.projectId;
if (search) {
where.OR = [
{ cadastralRef: { contains: search, mode: "insensitive" } },
{ inspireId: { contains: search, mode: "insensitive" } },
];
}
const [features, total] = await Promise.all([
prisma.gisFeature.findMany({
where,
select: {
id: true,
layerId: true,
siruta: true,
objectId: true,
inspireId: true,
cadastralRef: true,
areaValue: true,
isActive: true,
attributes: true,
projectId: true,
createdAt: true,
updatedAt: true,
// geometry omitted for list — too large; fetch single feature by ID for geometry
},
orderBy: { objectId: "asc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.gisFeature.count({ where }),
]);
return NextResponse.json({
features,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* GET /api/eterra/features?id=... — Single feature with full geometry.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "ID obligatoriu" }, { status: 400 });
}
const feature = await prisma.gisFeature.findUnique({ where: { id } });
if (!feature) {
return NextResponse.json({ error: "Negăsit" }, { status: 404 });
}
return NextResponse.json(feature);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import {
EterraClient,
type EsriGeometry,
} from "@/modules/parcel-sync/services/eterra-client";
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
username?: string;
password?: string;
siruta?: string | number;
layerIds?: string[]; // subset — omit to count all
};
/**
* POST — Count features per layer on the remote eTerra server.
*/
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
const siruta = String(body.siruta ?? "").trim();
if (!username || !password)
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
if (!/^\d+$/.test(siruta))
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
const client = await EterraClient.create(username, password);
let uatGeometry: EsriGeometry | undefined;
// Pre-fetch UAT geometry for spatial layers
try {
uatGeometry = await fetchUatGeometry(client, siruta);
} catch {
// Some layers don't need it
}
const layers = body.layerIds
? LAYER_CATALOG.filter((l) => body.layerIds!.includes(l.id))
: LAYER_CATALOG;
const results: Record<string, { count: number; error?: string }> = {};
// Count layers in parallel, max 4 concurrent
const chunks: (typeof layers)[] = [];
for (let i = 0; i < layers.length; i += 4) {
chunks.push(layers.slice(i, i + 4));
}
for (const chunk of chunks) {
const promises = chunk.map(async (layer) => {
try {
const count =
layer.spatialFilter && uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
results[layer.id] = { count };
} catch (err) {
results[layer.id] = {
count: 0,
error: err instanceof Error ? err.message : "eroare",
};
}
});
await Promise.all(promises);
}
return NextResponse.json({ siruta, counts: results });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = (await req.json()) as { username?: string; password?: string };
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
if (!username || !password)
return NextResponse.json(
{ error: "Credențiale eTerra lipsă" },
{ status: 400 },
);
await EterraClient.create(username, password);
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getProgress } from "@/modules/parcel-sync/services/progress-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/progress?jobId=...
* Poll sync progress for a running job.
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const jobId = url.searchParams.get("jobId");
if (!jobId) {
return NextResponse.json({ error: "jobId obligatoriu" }, { status: 400 });
}
const progress = getProgress(jobId);
if (!progress) {
return NextResponse.json({ jobId, status: "unknown" });
}
return NextResponse.json(progress);
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getSyncStatus } from "@/modules/parcel-sync/services/sync-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/sync-status?siruta=...
* Returns sync run history & local feature counts per layer.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const siruta = url.searchParams.get("siruta");
if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
}
const status = await getSyncStatus(siruta);
return NextResponse.json(status);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300; // 5 minute timeout for long syncs
type Body = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
uatName?: string;
jobId?: string;
forceFullSync?: boolean;
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as Body;
const username = (
body.username ??
process.env.ETERRA_USERNAME ??
""
).trim();
const password = (
body.password ??
process.env.ETERRA_PASSWORD ??
""
).trim();
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim();
if (!username || !password)
return NextResponse.json({ error: "Credențiale lipsă" }, { status: 400 });
if (!/^\d+$/.test(siruta))
return NextResponse.json({ error: "SIRUTA invalid" }, { status: 400 });
const result = await syncLayer(username, password, siruta, layerId, {
uatName: body.uatName,
jobId: body.jobId,
forceFullSync: body.forceFullSync,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}