From 79750b2a4a1789436f4cc35e67e96f64519d832a Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 22 Mar 2026 20:56:43 +0200 Subject: [PATCH] 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) --- src/app/api/eterra/uats/route.ts | 222 +++++++++++++++---------------- 1 file changed, 108 insertions(+), 114 deletions(-) diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts index d78744f..381e9bc 100644 --- a/src/app/api/eterra/uats/route.ts +++ b/src/app/api/eterra/uats/route.ts @@ -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(); + const countyMap = new Map(); // 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(); + 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(); + + 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";