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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-22 23:14:52 +02:00
parent 86e43cecae
commit f6781ab851
2 changed files with 86 additions and 67 deletions
+8 -5
View File
@@ -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])
+78 -62
View File
@@ -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<number, string> = {
10: "Alba",
@@ -74,23 +69,32 @@ const WORKSPACE_TO_COUNTY: Record<number, string> = {
};
export async function refreshCountyData(client: EterraClient): Promise<void> {
// 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<void> {
}
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<number, string[]>();
// 2. Log unknown workspaces
const seenWs = new Set<number>();
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;
}
}