fix(parcel-sync): use eTerra nomenclature API for county population

LIMITE_UAT layer lacks WORKSPACE_ID field, so the previous approach
failed silently. Now uses fetchCounties() + fetchAdminUnitsByCounty()
nomenclature API: Phase 1 fills county for UATs with existing
workspacePk, Phase 2 enumerates counties and matches by name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-22 20:56:43 +02:00
parent 86c39473a5
commit 79750b2a4a
+108 -114
View File
@@ -3,7 +3,6 @@ import { prisma } from "@/core/storage/prisma";
import { readFile } from "fs/promises";
import { join } from "path";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
@@ -158,15 +157,33 @@ export async function POST() {
/* ------------------------------------------------------------------ */
/* PATCH /api/eterra/uats */
/* */
/* Populate county names from eTerra. */
/* 1. fetchCounties() → build workspacePk → county name map */
/* 2. Query LIMITE_UAT layer (all features, no geometry) to get */
/* SIRUTA → WORKSPACE_ID mapping for every UAT */
/* 3. Batch-update GisUat.county + GisUat.workspacePk */
/* Populate county names from eTerra nomenclature API. */
/* */
/* Strategy (two phases): */
/* Phase 1: For UATs that already have workspacePk resolved, */
/* use fetchCounties() → countyMap[workspacePk] → instant update. */
/* Phase 2: For remaining UATs, enumerate counties → */
/* fetchAdminUnitsByCounty() per county → match by name. */
/* */
/* Requires active eTerra session. */
/* ------------------------------------------------------------------ */
/** Remove diacritics and lowercase for fuzzy name matching */
function normalizeName(s: string): string {
return s
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.trim();
}
/** Title-case: "SATU MARE" → "Satu Mare" */
function titleCase(s: string): string {
return s
.toLowerCase()
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
}
export async function PATCH() {
try {
// 1. Get eTerra credentials from session
@@ -187,19 +204,15 @@ export async function PATCH() {
const client = await EterraClient.create(username, password);
// 2. Fetch all counties from eTerra nomenclature → workspacePk → county name
// 2. Fetch all counties from eTerra nomenclature
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const counties: any[] = await client.fetchCounties();
const countyMap = new Map<number, string>();
const countyMap = new Map<number, string>(); // nomenPk → county name
for (const c of counties) {
const pk = Number(c?.nomenPk ?? 0);
const name = String(c?.name ?? "").trim();
if (pk > 0 && name) {
// Title-case: "CLUJ" → "Cluj", "SATU MARE" → "Satu Mare"
const titleCase = name
.toLowerCase()
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
countyMap.set(pk, titleCase);
countyMap.set(pk, titleCase(name));
}
}
@@ -212,121 +225,102 @@ export async function PATCH() {
console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`);
// 3. Query LIMITE_UAT layer for all features (no geometry)
// to get SIRUTA + WORKSPACE_ID per UAT
const limiteUat = findLayerById("LIMITE_UAT");
if (!limiteUat) {
return NextResponse.json(
{ error: "Layer LIMITE_UAT nu este configurat." },
{ status: 500 },
);
}
// Discover field names
const fields = await client.getLayerFieldNames(limiteUat);
const upperFields = fields.map((f) => f.toUpperCase());
// Find the SIRUTA/admin field
const adminFieldCandidates = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
let adminField: string | null = null;
for (const key of adminFieldCandidates) {
const idx = upperFields.indexOf(key);
if (idx >= 0) {
adminField = fields[idx] ?? null;
break;
}
}
// Find the WORKSPACE_ID field
const wsFieldCandidates = ["WORKSPACE_ID", "COUNTY_ID", "JUDET_ID"];
let wsField: string | null = null;
for (const key of wsFieldCandidates) {
const idx = upperFields.indexOf(key);
if (idx >= 0) {
wsField = fields[idx] ?? null;
break;
}
}
if (!adminField) {
return NextResponse.json(
{
error: `LIMITE_UAT nu are câmp SIRUTA. Câmpuri disponibile: ${fields.join(", ")}`,
},
{ status: 500 },
);
}
// Fetch all features — attributes only, no geometry
const outFields = [adminField, wsField, "NAME", "NUME", "UAT_NAME"]
.filter(Boolean)
.join(",");
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
outFields,
returnGeometry: false,
pageSize: 1000,
// 3. Load all UATs from DB
const allUats = await prisma.gisUat.findMany({
select: { siruta: true, name: true, county: true, workspacePk: true },
});
console.log(
`[uats-patch] Fetched ${features.length} UAT features from LIMITE_UAT`,
);
// Phase 1: instant fill for UATs that already have workspacePk
let phase1Updated = 0;
const needsCounty: Array<{ siruta: string; name: string }> = [];
// 4. Build SIRUTA → { county, workspacePk } map
const sirutaCountyMap = new Map<
string,
{ county: string; workspacePk: number }
>();
for (const uat of allUats) {
if (uat.county) continue; // already has county
for (const f of features) {
const attrs = f.attributes;
const sirutaVal = String(attrs[adminField] ?? "")
.trim()
.replace(/\.0$/, "");
const wsPk = Number(attrs[wsField ?? ""] ?? 0);
if (!sirutaVal || !Number.isFinite(wsPk) || wsPk <= 0) continue;
const countyName = countyMap.get(wsPk);
if (!countyName) continue;
sirutaCountyMap.set(sirutaVal, { county: countyName, workspacePk: wsPk });
if (uat.workspacePk && uat.workspacePk > 0) {
const county = countyMap.get(uat.workspacePk);
if (county) {
await prisma.gisUat.update({
where: { siruta: uat.siruta },
data: { county },
});
phase1Updated++;
continue;
}
}
needsCounty.push({ siruta: uat.siruta, name: uat.name });
}
console.log(
`[uats-patch] Mapped ${sirutaCountyMap.size} SIRUTAs to counties`,
`[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` +
`${needsCounty.length} remaining.`,
);
// 5. Batch-update GisUat records
let updated = 0;
const CHUNK_SIZE = 100;
const entries = Array.from(sirutaCountyMap.entries());
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
const chunk = entries.slice(i, i + CHUNK_SIZE);
const ops = chunk.map(([s, data]) =>
prisma.gisUat.updateMany({
where: { siruta: s },
data: { county: data.county, workspacePk: data.workspacePk },
}),
);
const results = await prisma.$transaction(ops);
for (const r of results) updated += r.count;
// Phase 2: enumerate UATs per county from nomenclature, match by name
// Build lookup: normalized name → list of SIRUTAs (for same-name UATs)
const nameToSirutas = new Map<string, string[]>();
for (const u of needsCounty) {
const key = normalizeName(u.name);
const arr = nameToSirutas.get(key);
if (arr) arr.push(u.siruta);
else nameToSirutas.set(key, [u.siruta]);
}
console.log(`[uats-patch] Updated ${updated} UAT records with county`);
let phase2Updated = 0;
const matchedSirutas = new Set<string>();
for (const [countyPk, countyName] of countyMap) {
if (nameToSirutas.size === 0) break; // all matched
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const uats: any[] = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) {
const eterraName = String(uat?.name ?? "").trim();
if (!eterraName) continue;
const key = normalizeName(eterraName);
const sirutas = nameToSirutas.get(key);
if (!sirutas || sirutas.length === 0) continue;
// Pick the first unmatched SIRUTA with this name
const siruta = sirutas.find((s) => !matchedSirutas.has(s));
if (!siruta) continue;
matchedSirutas.add(siruta);
await prisma.gisUat.update({
where: { siruta },
data: { county: countyName, workspacePk: countyPk },
});
phase2Updated++;
// If all SIRUTAs for this name matched, remove the key
if (sirutas.every((s) => matchedSirutas.has(s))) {
nameToSirutas.delete(key);
}
}
} catch (err) {
console.warn(
`[uats-patch] Failed to fetch UATs for county ${countyName}:`,
err instanceof Error ? err.message : err,
);
}
}
const totalUpdated = phase1Updated + phase2Updated;
console.log(
`[uats-patch] Phase 2: ${phase2Updated} updated via name match. ` +
`Total: ${totalUpdated}. Unmatched: ${needsCounty.length - phase2Updated}.`,
);
return NextResponse.json({
updated,
updated: totalUpdated,
phase1: phase1Updated,
phase2: phase2Updated,
totalCounties: countyMap.size,
totalMapped: sirutaCountyMap.size,
totalFeatures: features.length,
unmatched: needsCounty.length - phase2Updated,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";