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