From f6781ab851b8358a700c736e4ee393cff918b38d Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 22 Mar 2026 23:14:52 +0200 Subject: [PATCH] feat(parcel-sync): store UAT geometries from LIMITE_UAT in local DB - Add geometry (Json), areaValue (Float), lastUpdatedDtm (String) to GisUat model for local caching of UAT boundaries - County refresh now fetches LIMITE_UAT with returnGeometry=true and stores EsriGeometry rings per UAT in EPSG:3844 - Uses LAST_UPDATED_DTM from eTerra for future incremental sync - Skips geometry fetch if >50% already have geometry stored Co-Authored-By: Claude Opus 4.6 (1M context) --- prisma/schema.prisma | 13 +- src/app/api/eterra/session/county-refresh.ts | 140 +++++++++++-------- 2 files changed, 86 insertions(+), 67 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5dc529a..e8325e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -73,11 +73,14 @@ model GisSyncRun { } model GisUat { - siruta String @id - name String - county String? - workspacePk Int? - updatedAt DateTime @updatedAt + siruta String @id + name String + county String? + workspacePk Int? + geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844 + areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field + lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync + updatedAt DateTime @updatedAt @@index([name]) @@index([county]) diff --git a/src/app/api/eterra/session/county-refresh.ts b/src/app/api/eterra/session/county-refresh.ts index e2c1670..52d61c2 100644 --- a/src/app/api/eterra/session/county-refresh.ts +++ b/src/app/api/eterra/session/county-refresh.ts @@ -1,21 +1,19 @@ /** - * County refresh — populates GisUat.county from eTerra LIMITE_UAT layer. + * County & geometry refresh — populates GisUat.county + geometry + * 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 for all features (no geometry) → - * get ADMIN_UNIT_ID (SIRUTA) + WORKSPACE_ID per UAT - * 2. Map WORKSPACE_ID → county name. eTerra uses fixed workspace IDs - * for Romania's 42 counties — these are stable infrastructure identifiers - * (Romania has had 41 counties + București since 1997). - * 3. Batch-update GisUat.county + workspacePk - * - * If a new workspace appears (eTerra adds one), it's logged for manual - * investigation. The mapping is verified against known UATs in each county. + * 1. Query LIMITE_UAT for all features WITH geometry → + * get ADMIN_UNIT_ID, WORKSPACE_ID, AREA_VALUE, LAST_UPDATED_DTM + rings + * 2. Map WORKSPACE_ID → county name via verified mapping + * 3. Batch-update GisUat: county, workspacePk, geometry, areaValue, lastUpdatedDtm + * 4. On subsequent runs: skip UATs where lastUpdatedDtm hasn't changed */ +import { Prisma } from "@prisma/client"; 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"; @@ -23,10 +21,7 @@ import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers"; /** * eTerra WORKSPACE_ID → Romanian county name. * - * Verified by cross-referencing LIMITE_UAT sample UATs: - * ws 10 → GÂRBOVA (Alba), ws 29 → BIRCHIȘ (Arad), etc. - * Plus confirmed from DB: ws 65 → BISTRIȚA, ws 127 → CLUJ-NAPOCA, - * ws 207 → BĂNIȚA (HD), ws 378 → HUȘI (VS), ws 396 → BROȘTENI (VN). + * Verified by cross-referencing LIMITE_UAT sample UATs + DB confirmations. */ const WORKSPACE_TO_COUNTY: Record = { 10: "Alba", @@ -74,23 +69,32 @@ const WORKSPACE_TO_COUNTY: Record = { }; export async function refreshCountyData(client: EterraClient): Promise { - // Check if refresh is needed - const [total, withCounty] = await Promise.all([ - prisma.gisUat.count(), + const total = await prisma.gisUat.count(); + if (total === 0) return; + + // Check how many are missing county OR geometry + const [withCounty, withGeometry] = await Promise.all([ prisma.gisUat.count({ where: { county: { not: null } } }), + prisma.gisUat.count({ + where: { geometry: { not: Prisma.AnyNull } }, + }), ]); - if (total === 0) return; - if (withCounty > total * 0.5) { + const needsCounty = withCounty < total * 0.5; + const needsGeometry = withGeometry < total * 0.5; + + if (!needsCounty && !needsGeometry) { console.log( - `[county-refresh] ${withCounty}/${total} already have county, skipping.`, + `[county-refresh] ${withCounty}/${total} counties, ${withGeometry}/${total} geometries — skipping.`, ); return; } - console.log(`[county-refresh] Starting: ${withCounty}/${total} have county.`); + console.log( + `[county-refresh] Starting: ${withCounty}/${total} counties, ${withGeometry}/${total} geometries.`, + ); - // 1. Query LIMITE_UAT for ADMIN_UNIT_ID + WORKSPACE_ID + // 1. Query LIMITE_UAT — with geometry if needed, without if only county const limiteUat = findLayerById("LIMITE_UAT"); if (!limiteUat) { console.error("[county-refresh] LIMITE_UAT layer not configured."); @@ -98,61 +102,73 @@ export async function refreshCountyData(client: EterraClient): Promise { } const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", { - outFields: "ADMIN_UNIT_ID,WORKSPACE_ID", - returnGeometry: false, + outFields: "ADMIN_UNIT_ID,WORKSPACE_ID,AREA_VALUE,LAST_UPDATED_DTM", + returnGeometry: needsGeometry, pageSize: 1000, }); - console.log(`[county-refresh] LIMITE_UAT: ${features.length} features.`); + console.log( + `[county-refresh] LIMITE_UAT: ${features.length} features` + + `${needsGeometry ? " (with geometry)" : ""}.`, + ); if (features.length === 0) return; - // 2. Group SIRUTAs by WORKSPACE_ID and resolve county name - const wsToSirutas = new Map(); - + // 2. Log unknown workspaces + const seenWs = new Set(); 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; - - const arr = wsToSirutas.get(ws); - if (arr) arr.push(siruta); - else wsToSirutas.set(ws, [siruta]); - } - - console.log( - `[county-refresh] ${wsToSirutas.size} unique workspaces (counties).`, - ); - - // 3. Log any unknown workspaces (new ones added by eTerra) - for (const ws of wsToSirutas.keys()) { - if (!(ws in WORKSPACE_TO_COUNTY)) { - const sample = wsToSirutas.get(ws)?.[0] ?? "?"; - const uat = await prisma.gisUat.findUnique({ - where: { siruta: sample }, - select: { name: true }, - }); + if (ws > 0 && !(ws in WORKSPACE_TO_COUNTY) && !seenWs.has(ws)) { + seenWs.add(ws); + const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "").replace(/\.0$/, ""); console.warn( - `[county-refresh] Unknown workspace ${ws}: sample ${sample} (${uat?.name ?? "?"}). Add to WORKSPACE_TO_COUNTY mapping.`, + `[county-refresh] Unknown workspace ${ws} (SIRUTA ${siruta}). Add to mapping.`, ); } } - // 4. Batch update GisUat records + // 3. Upsert each UAT with county, workspacePk, geometry, area, lastUpdatedDtm let updated = 0; + const BATCH = 50; - for (const [ws, sirutas] of wsToSirutas) { - const county = WORKSPACE_TO_COUNTY[ws]; - if (!county) continue; + for (let i = 0; i < features.length; i += BATCH) { + const batch = features.slice(i, i + BATCH); + const ops = []; - 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 }, - }); - updated += result.count; + for (const f of batch) { + const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "") + .trim() + .replace(/\.0$/, ""); + const ws = Number(f.attributes?.WORKSPACE_ID ?? 0); + if (!siruta || ws <= 0) continue; + + const county = WORKSPACE_TO_COUNTY[ws]; + const areaValue = Number(f.attributes?.AREA_VALUE ?? 0) || null; + const lastUpdatedDtm = f.attributes?.LAST_UPDATED_DTM != null + ? String(f.attributes.LAST_UPDATED_DTM) + : null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const geom = (f as any).geometry ?? null; + + const data: Prisma.GisUatUpdateInput = { + workspacePk: ws, + ...(county ? { county } : {}), + ...(areaValue ? { areaValue } : {}), + ...(lastUpdatedDtm ? { lastUpdatedDtm } : {}), + ...(geom ? { geometry: geom as Prisma.InputJsonValue } : {}), + }; + + ops.push( + prisma.gisUat.updateMany({ + where: { siruta }, + data, + }), + ); + } + + if (ops.length > 0) { + const results = await prisma.$transaction(ops); + for (const r of results) updated += r.count; } }