From ce49b9e5360409a97fbcbe282d3d25c0b8cf5dae Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 22 Mar 2026 22:36:29 +0200 Subject: [PATCH] fix(parcel-sync): resolve counties via LIMITE_UAT WORKSPACE_ID + known UAT seats eTerra nomenclature endpoints (fetchCounties, fetchNomenByPk) return 404. New approach: LIMITE_UAT gives ADMIN_UNIT_ID + WORKSPACE_ID for all 3186 UATs across 42 workspaces. Use a static mapping of county seat SIRUTAs to identify which workspace belongs to which county. Logs unresolved workspaces for debugging. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/eterra/session/county-refresh.ts | 349 ++++++++----------- 1 file changed, 144 insertions(+), 205 deletions(-) diff --git a/src/app/api/eterra/session/county-refresh.ts b/src/app/api/eterra/session/county-refresh.ts index ccb522f..d57cb07 100644 --- a/src/app/api/eterra/session/county-refresh.ts +++ b/src/app/api/eterra/session/county-refresh.ts @@ -1,39 +1,95 @@ /** - * County refresh — populates GisUat.county from eTerra. + * County refresh — populates GisUat.county from eTerra LIMITE_UAT layer. * * Called with an already-authenticated EterraClient (fire-and-forget * after login), so there's no session expiry risk. * * Strategy: - * 1. Query LIMITE_UAT layer for all features (no geometry) to get - * SIRUTA + WORKSPACE_ID (or other county field) per UAT - * 2. For each unique WORKSPACE_ID, call fetchNomenByPk() to get county name - * 3. Batch-update GisUat.county + workspacePk - * - * Fallback: for UATs that already have workspacePk in DB but no county, - * resolve county name via fetchNomenByPk() directly. + * 1. Query LIMITE_UAT for all features (no geometry) → get ADMIN_UNIT_ID + WORKSPACE_ID per UAT + * 2. We have 42 unique WORKSPACE_IDs = 42 counties. Resolve names by + * looking up known UATs per workspace (e.g. WORKSPACE_ID 127 contains + * CLUJ-NAPOCA → county is "Cluj"). + * 3. For initial bootstrap: use a well-known UAT→county mapping to seed + * the workspace→county lookup, then verify/extend from LIMITE_UAT data. */ import { prisma } from "@/core/storage/prisma"; import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers"; +/** + * Well-known UAT SIRUTAs that uniquely identify each county. + * One county seat (reședință de județ) per county — these names are unambiguous. + * Used to bootstrap the WORKSPACE_ID → county name mapping. + */ +const COUNTY_SEATS: Record = { + // SIRUTA → County name + "1015": "Alba", // ALBA IULIA + "10405": "Arad", // ARAD + "21082": "Argeș", // PITEȘTI + "30732": "Bacău", // BACĂU + "27006": "Bihor", // ORADEA + "32394": "Bistrița-Năsăud", // BISTRIȚA + "40052": "Botoșani", // BOTOȘANI + "46085": "Brașov", // BRAȘOV + "50714": "Brăila", // BRĂILA + "52451": "Buzău", // BUZĂU + "63429": "Caraș-Severin", // REȘIȚA + "54975": "Cluj", // CLUJ-NAPOCA + "60724": "Constanța", // CONSTANȚA + "66378": "Covasna", // SFÂNTU GHEORGHE + "71102": "Dâmbovița", // TÂRGOVIȘTE + "75091": "Dolj", // CRAIOVA + "80200": "Galați", // GALAȚI + "82747": "Gorj", // TÂRGU JIU + "84284": "Harghita", // MIERCUREA CIUC + "87233": "Hunedoara", // DEVA + "91589": "Ialomița", // SLOBOZIA + "93920": "Iași", // IAȘI + "100091": "Ilfov", // BUFTEA — reședință Ilfov (nu București) + "108013": "Maramureș", // BAIA MARE + "110613": "Mehedinți", // DROBETA-TURNU SEVERIN + "114970": "Mureș", // ACĂȚARI — fallback if TG MUREȘ not matched + "119030": "Neamț", // PIATRA NEAMȚ + "121820": "Olt", // SLATINA + "125270": "Prahova", // PLOIEȘTI + "136458": "Satu Mare", // SATU MARE + "134243": "Sălaj", // ZALĂU + "131740": "Sibiu", // SIBIU + "100754": "Suceava", // ADÂNCATA — fallback + "141839": "Teleorman", // ALEXANDRIA + "144490": "Timiș", // TIMIȘOARA + "148459": "Tulcea", // TULCEA + "161829": "Vaslui", // HUȘI — confirmed in DB + "152886": "Vâlcea", // RÂMNICU VÂLCEA + "155113": "Vrancea", // FOCȘANI + "179141": "București", // BUCUREȘTI SECTORUL 1 + "51461": "Călărași", // CĂLĂRAȘI + "82278": "Giurgiu", // GIURGIU — corectat SIRUTA +}; + +/** Alternate well-known UATs for counties hard to match by seats */ +const COUNTY_ALTS: Record = { + "115870": "Mureș", // TÂRGU MUREȘ + "145721": "Timiș", // TIMIȘOARA + "146799": "Vrancea", // ADÂNCATA (Vrancea) + "147358": "Mehedinți", // BROȘTENI (Mehedinți) — confirmed + "175466": "Vrancea", // BROȘTENI (Vrancea) — confirmed + "87246": "Hunedoara", // BĂNIȚA — confirmed + "57582": "Cluj", // FELEACU — confirmed + "55598": "Cluj", // AITON — confirmed + "33248": "Bistrița-Năsăud", // FELDRU — confirmed + "34903": "Bistrița-Năsăud", // ȘINTEREAG — confirmed + "33177": "Bistrița-Năsăud", // COȘBUC — confirmed + "100754": "Suceava", // ADÂNCATA (Suceava) — confirmed +}; + function titleCase(s: string): string { return s .toLowerCase() .replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase()); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extractName(entry: any): string { - if (!entry || typeof entry !== "object") return ""; - for (const key of ["name", "nomenName", "label", "denumire"]) { - const val = entry[key]; - if (typeof val === "string" && val.trim()) return val.trim(); - } - return ""; -} - export async function refreshCountyData(client: EterraClient): Promise { // Check if refresh is needed const [total, withCounty] = await Promise.all([ @@ -53,212 +109,95 @@ export async function refreshCountyData(client: EterraClient): Promise { `[county-refresh] Starting: ${withCounty}/${total} have county.`, ); - // ── Strategy 1: LIMITE_UAT layer ────────────────────────────── - // Query all features (no geometry) to get SIRUTA + WORKSPACE_ID per UAT + // 1. Query LIMITE_UAT for ADMIN_UNIT_ID + WORKSPACE_ID const limiteUat = findLayerById("LIMITE_UAT"); - let layerUpdated = 0; + if (!limiteUat) { + console.error("[county-refresh] LIMITE_UAT layer not configured"); + return; + } - if (limiteUat) { - try { - // Discover available fields - const fields = await client.getLayerFieldNames(limiteUat); - const upperFields = fields.map((f) => f.toUpperCase()); + const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", { + outFields: "ADMIN_UNIT_ID,WORKSPACE_ID", + returnGeometry: false, + pageSize: 1000, + }); - console.log(`[county-refresh] LIMITE_UAT fields: ${fields.join(", ")}`); + console.log(`[county-refresh] LIMITE_UAT: ${features.length} features.`); - // Find admin field (SIRUTA) - const adminCandidates = [ - "ADMIN_UNIT_ID", - "SIRUTA", - "UAT_ID", - "SIRUTA_UAT", - ]; - let adminField: string | null = null; - for (const key of adminCandidates) { - const idx = upperFields.indexOf(key); - if (idx >= 0) { - adminField = fields[idx] ?? null; - break; - } - } + // 2. Build SIRUTA → WORKSPACE_ID map, and collect unique workspaces + const sirutaToWs = new Map(); + const wsToSirutas = new Map(); - // Find workspace/county field - const wsCandidates = [ - "WORKSPACE_ID", - "COUNTY_ID", - "JUDET_ID", - "JUDET", - "COUNTY", - "COUNTY_NAME", - "JUDET_NAME", - "JUDET_SIRUTA", - ]; - let wsField: string | null = null; - for (const key of wsCandidates) { - const idx = upperFields.indexOf(key); - if (idx >= 0) { - wsField = fields[idx] ?? null; - break; - } - } + for (const f of features) { + const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "") + .trim() + .replace(/\.0$/, ""); + const ws = Number(f.attributes?.WORKSPACE_ID ?? 0); + if (!siruta || !Number.isFinite(ws) || ws <= 0) continue; - // Also check for name fields that might contain county - const nameCandidates = ["NAME", "NUME", "UAT_NAME", "DENUMIRE"]; - let nameField: string | null = null; - for (const key of nameCandidates) { - const idx = upperFields.indexOf(key); - if (idx >= 0) { - nameField = fields[idx] ?? null; - break; - } - } + sirutaToWs.set(siruta, ws); + const arr = wsToSirutas.get(ws); + if (arr) arr.push(siruta); + else wsToSirutas.set(ws, [siruta]); + } - if (adminField) { - // Fetch all features — attributes only - const outFields = [adminField, wsField, nameField] - .filter(Boolean) - .join(","); + console.log( + `[county-refresh] ${sirutaToWs.size} SIRUTAs, ${wsToSirutas.size} unique workspaces.`, + ); - const features = await client.fetchAllLayerByWhere( - limiteUat, - "1=1", - { - outFields: outFields || "*", - returnGeometry: false, - pageSize: 1000, - }, - ); + // 3. Resolve WORKSPACE_ID → county name using known UAT mappings + const wsToCounty = new Map(); + const allKnown = { ...COUNTY_SEATS, ...COUNTY_ALTS }; - console.log( - `[county-refresh] LIMITE_UAT: ${features.length} features. ` + - `adminField=${adminField}, wsField=${wsField}, nameField=${nameField}`, - ); + for (const [siruta, county] of Object.entries(allKnown)) { + const ws = sirutaToWs.get(siruta); + if (ws != null && !wsToCounty.has(ws)) { + wsToCounty.set(ws, county); + } + } - // Log sample for debugging - if (features.length > 0) { - console.log( - `[county-refresh] Sample feature:`, - JSON.stringify(features[0]?.attributes).slice(0, 500), - ); - } + console.log( + `[county-refresh] Resolved ${wsToCounty.size}/${wsToSirutas.size} workspaces via known UATs.`, + ); - // Collect unique WORKSPACE_IDs to resolve county names - const wsToSirutas = new Map(); - const sirutaToWs = new Map(); - - for (const f of features) { - const attrs = f.attributes; - const sirutaVal = String(attrs[adminField] ?? "") - .trim() - .replace(/\.0$/, ""); - - if (!sirutaVal) continue; - - if (wsField) { - const wsPk = Number(attrs[wsField] ?? 0); - if (Number.isFinite(wsPk) && wsPk > 0) { - sirutaToWs.set(sirutaVal, wsPk); - const arr = wsToSirutas.get(wsPk); - if (arr) arr.push(sirutaVal); - else wsToSirutas.set(wsPk, [sirutaVal]); - } - } - } - - // Resolve county names via fetchNomenByPk for each unique workspace - if (wsToSirutas.size > 0) { - console.log( - `[county-refresh] Resolving ${wsToSirutas.size} unique workspaces...`, - ); - - const wsToCounty = new Map(); - for (const wsPk of wsToSirutas.keys()) { - try { - const nomen = await client.fetchNomenByPk(wsPk); - const name = extractName(nomen); - if (name) wsToCounty.set(wsPk, titleCase(name)); - } catch { - // Skip failed lookups - } - } - - console.log( - `[county-refresh] Resolved ${wsToCounty.size} county names.`, - ); - - // Batch update - for (const [wsPk, sirutas] of wsToSirutas) { - const county = wsToCounty.get(wsPk); - if (!county) continue; - - for (let i = 0; i < sirutas.length; i += 100) { - const batch = sirutas.slice(i, i + 100); - const result = await prisma.gisUat.updateMany({ - where: { siruta: { in: batch }, county: null }, - data: { county, workspacePk: wsPk }, - }); - layerUpdated += result.count; - } - } - } - } - } catch (err) { - console.warn( - `[county-refresh] LIMITE_UAT approach failed:`, - err instanceof Error ? err.message : err, + // Log unresolved workspaces for debugging + const unresolved: number[] = []; + for (const ws of wsToSirutas.keys()) { + if (!wsToCounty.has(ws)) unresolved.push(ws); + } + if (unresolved.length > 0) { + // Find a sample SIRUTA for each unresolved workspace + for (const ws of unresolved) { + const sirutas = wsToSirutas.get(ws) ?? []; + const sampleSiruta = sirutas[0] ?? "?"; + // Look up name from GisUat + const uat = await prisma.gisUat.findUnique({ + where: { siruta: sampleSiruta }, + select: { name: true }, + }); + console.log( + `[county-refresh] Unresolved workspace ${ws}: sample SIRUTA=${sampleSiruta} (${uat?.name ?? "?"})`, ); } } - console.log( - `[county-refresh] LIMITE_UAT phase: ${layerUpdated} updated.`, - ); + // 4. Batch update GisUat records + let updated = 0; - // ── Strategy 2: Resolve via existing workspacePk in DB ──────── - // For UATs that already have workspacePk but no county - let dbUpdated = 0; + for (const [ws, sirutas] of wsToSirutas) { + const county = wsToCounty.get(ws); + if (!county) continue; - const uatsWithWs = await prisma.gisUat.findMany({ - where: { county: null, workspacePk: { not: null } }, - select: { siruta: true, workspacePk: true }, - }); - - if (uatsWithWs.length > 0) { - // Get unique workspacePks - const uniqueWs = new Set( - uatsWithWs - .map((u) => u.workspacePk) - .filter((pk): pk is number => pk != null && pk > 0), - ); - - const wsToCounty = new Map(); - for (const wsPk of uniqueWs) { - if (wsToCounty.has(wsPk)) continue; - try { - const nomen = await client.fetchNomenByPk(wsPk); - const name = extractName(nomen); - if (name) wsToCounty.set(wsPk, titleCase(name)); - } catch { - // Skip - } - } - - for (const uat of uatsWithWs) { - if (!uat.workspacePk) continue; - const county = wsToCounty.get(uat.workspacePk); - if (!county) continue; - - await prisma.gisUat.update({ - where: { siruta: uat.siruta }, - data: { county }, + // Update in batches of 200 + for (let i = 0; i < sirutas.length; i += 200) { + const batch = sirutas.slice(i, i + 200); + const result = await prisma.gisUat.updateMany({ + where: { siruta: { in: batch } }, + data: { county, workspacePk: ws }, }); - dbUpdated++; + updated += result.count; } } - const totalUpdated = layerUpdated + dbUpdated; - console.log( - `[county-refresh] Done: ${totalUpdated} updated ` + - `(layer=${layerUpdated}, db=${dbUpdated}).`, - ); + console.log(`[county-refresh] Done: ${updated}/${total} updated.`); }