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
+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 });
}
}