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 { readFile } from "fs/promises";
import { join } from "path"; import { join } from "path";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; 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"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -158,15 +157,33 @@ export async function POST() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* PATCH /api/eterra/uats */ /* PATCH /api/eterra/uats */
/* */ /* */
/* Populate county names from eTerra. */ /* Populate county names from eTerra nomenclature API. */
/* 1. fetchCounties() → build workspacePk → county name map */ /* */
/* 2. Query LIMITE_UAT layer (all features, no geometry) to get */ /* Strategy (two phases): */
/* SIRUTA → WORKSPACE_ID mapping for every UAT */ /* Phase 1: For UATs that already have workspacePk resolved, */
/* 3. Batch-update GisUat.county + GisUat.workspacePk */ /* use fetchCounties() → countyMap[workspacePk] → instant update. */
/* Phase 2: For remaining UATs, enumerate counties → */
/* fetchAdminUnitsByCounty() per county → match by name. */
/* */ /* */
/* Requires active eTerra session. */ /* 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() { export async function PATCH() {
try { try {
// 1. Get eTerra credentials from session // 1. Get eTerra credentials from session
@@ -187,19 +204,15 @@ export async function PATCH() {
const client = await EterraClient.create(username, password); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const counties: any[] = await client.fetchCounties(); 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) { for (const c of counties) {
const pk = Number(c?.nomenPk ?? 0); const pk = Number(c?.nomenPk ?? 0);
const name = String(c?.name ?? "").trim(); const name = String(c?.name ?? "").trim();
if (pk > 0 && name) { if (pk > 0 && name) {
// Title-case: "CLUJ" → "Cluj", "SATU MARE" → "Satu Mare" countyMap.set(pk, titleCase(name));
const titleCase = name
.toLowerCase()
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
countyMap.set(pk, titleCase);
} }
} }
@@ -212,121 +225,102 @@ export async function PATCH() {
console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`); console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`);
// 3. Query LIMITE_UAT layer for all features (no geometry) // 3. Load all UATs from DB
// to get SIRUTA + WORKSPACE_ID per UAT const allUats = await prisma.gisUat.findMany({
const limiteUat = findLayerById("LIMITE_UAT"); select: { siruta: true, name: true, county: true, workspacePk: true },
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,
}); });
console.log( // Phase 1: instant fill for UATs that already have workspacePk
`[uats-patch] Fetched ${features.length} UAT features from LIMITE_UAT`, let phase1Updated = 0;
); const needsCounty: Array<{ siruta: string; name: string }> = [];
// 4. Build SIRUTA → { county, workspacePk } map for (const uat of allUats) {
const sirutaCountyMap = new Map< if (uat.county) continue; // already has county
string,
{ county: string; workspacePk: number }
>();
for (const f of features) { if (uat.workspacePk && uat.workspacePk > 0) {
const attrs = f.attributes; const county = countyMap.get(uat.workspacePk);
const sirutaVal = String(attrs[adminField] ?? "") if (county) {
.trim() await prisma.gisUat.update({
.replace(/\.0$/, ""); where: { siruta: uat.siruta },
const wsPk = Number(attrs[wsField ?? ""] ?? 0); data: { county },
});
if (!sirutaVal || !Number.isFinite(wsPk) || wsPk <= 0) continue; phase1Updated++;
continue;
const countyName = countyMap.get(wsPk); }
if (!countyName) continue; }
needsCounty.push({ siruta: uat.siruta, name: uat.name });
sirutaCountyMap.set(sirutaVal, { county: countyName, workspacePk: wsPk });
} }
console.log( 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 // Phase 2: enumerate UATs per county from nomenclature, match by name
let updated = 0; // Build lookup: normalized name → list of SIRUTAs (for same-name UATs)
const CHUNK_SIZE = 100; const nameToSirutas = new Map<string, string[]>();
const entries = Array.from(sirutaCountyMap.entries()); for (const u of needsCounty) {
const key = normalizeName(u.name);
for (let i = 0; i < entries.length; i += CHUNK_SIZE) { const arr = nameToSirutas.get(key);
const chunk = entries.slice(i, i + CHUNK_SIZE); if (arr) arr.push(u.siruta);
const ops = chunk.map(([s, data]) => else nameToSirutas.set(key, [u.siruta]);
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;
} }
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({ return NextResponse.json({
updated, updated: totalUpdated,
phase1: phase1Updated,
phase2: phase2Updated,
totalCounties: countyMap.size, totalCounties: countyMap.size,
totalMapped: sirutaCountyMap.size, unmatched: needsCounty.length - phase2Updated,
totalFeatures: features.length,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Eroare server"; const message = error instanceof Error ? error.message : "Eroare server";