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:
@@ -77,6 +77,9 @@ model GisUat {
|
|||||||
name String
|
name String
|
||||||
county String?
|
county String?
|
||||||
workspacePk Int?
|
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
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
|||||||
@@ -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
|
* Called with an already-authenticated EterraClient (fire-and-forget
|
||||||
* after login), so there's no session expiry risk.
|
* after login), so there's no session expiry risk.
|
||||||
*
|
*
|
||||||
* Strategy:
|
* Strategy:
|
||||||
* 1. Query LIMITE_UAT for all features (no geometry) →
|
* 1. Query LIMITE_UAT for all features WITH geometry →
|
||||||
* get ADMIN_UNIT_ID (SIRUTA) + WORKSPACE_ID per UAT
|
* get ADMIN_UNIT_ID, WORKSPACE_ID, AREA_VALUE, LAST_UPDATED_DTM + rings
|
||||||
* 2. Map WORKSPACE_ID → county name. eTerra uses fixed workspace IDs
|
* 2. Map WORKSPACE_ID → county name via verified mapping
|
||||||
* for Romania's 42 counties — these are stable infrastructure identifiers
|
* 3. Batch-update GisUat: county, workspacePk, geometry, areaValue, lastUpdatedDtm
|
||||||
* (Romania has had 41 counties + București since 1997).
|
* 4. On subsequent runs: skip UATs where lastUpdatedDtm hasn't changed
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
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.
|
* eTerra WORKSPACE_ID → Romanian county name.
|
||||||
*
|
*
|
||||||
* Verified by cross-referencing LIMITE_UAT sample UATs:
|
* Verified by cross-referencing LIMITE_UAT sample UATs + DB confirmations.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
const WORKSPACE_TO_COUNTY: Record<number, string> = {
|
const WORKSPACE_TO_COUNTY: Record<number, string> = {
|
||||||
10: "Alba",
|
10: "Alba",
|
||||||
@@ -74,23 +69,32 @@ const WORKSPACE_TO_COUNTY: Record<number, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function refreshCountyData(client: EterraClient): Promise<void> {
|
export async function refreshCountyData(client: EterraClient): Promise<void> {
|
||||||
// Check if refresh is needed
|
const total = await prisma.gisUat.count();
|
||||||
const [total, withCounty] = await Promise.all([
|
if (total === 0) return;
|
||||||
prisma.gisUat.count(),
|
|
||||||
|
// 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: { county: { not: null } } }),
|
||||||
|
prisma.gisUat.count({
|
||||||
|
where: { geometry: { not: Prisma.AnyNull } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (total === 0) return;
|
const needsCounty = withCounty < total * 0.5;
|
||||||
if (withCounty > total * 0.5) {
|
const needsGeometry = withGeometry < total * 0.5;
|
||||||
|
|
||||||
|
if (!needsCounty && !needsGeometry) {
|
||||||
console.log(
|
console.log(
|
||||||
`[county-refresh] ${withCounty}/${total} already have county, skipping.`,
|
`[county-refresh] ${withCounty}/${total} counties, ${withGeometry}/${total} geometries — skipping.`,
|
||||||
);
|
);
|
||||||
return;
|
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");
|
const limiteUat = findLayerById("LIMITE_UAT");
|
||||||
if (!limiteUat) {
|
if (!limiteUat) {
|
||||||
console.error("[county-refresh] LIMITE_UAT layer not configured.");
|
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", {
|
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
|
||||||
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID",
|
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID,AREA_VALUE,LAST_UPDATED_DTM",
|
||||||
returnGeometry: false,
|
returnGeometry: needsGeometry,
|
||||||
pageSize: 1000,
|
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;
|
if (features.length === 0) return;
|
||||||
|
|
||||||
// 2. Group SIRUTAs by WORKSPACE_ID and resolve county name
|
// 2. Log unknown workspaces
|
||||||
const wsToSirutas = new Map<number, string[]>();
|
const seenWs = new Set<number>();
|
||||||
|
|
||||||
for (const f of features) {
|
for (const f of features) {
|
||||||
|
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
||||||
|
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} (SIRUTA ${siruta}). Add to mapping.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upsert each UAT with county, workspacePk, geometry, area, lastUpdatedDtm
|
||||||
|
let updated = 0;
|
||||||
|
const BATCH = 50;
|
||||||
|
|
||||||
|
for (let i = 0; i < features.length; i += BATCH) {
|
||||||
|
const batch = features.slice(i, i + BATCH);
|
||||||
|
const ops = [];
|
||||||
|
|
||||||
|
for (const f of batch) {
|
||||||
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
|
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\.0$/, "");
|
.replace(/\.0$/, "");
|
||||||
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
||||||
if (!siruta || !Number.isFinite(ws) || ws <= 0) continue;
|
if (!siruta || 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 },
|
|
||||||
});
|
|
||||||
console.warn(
|
|
||||||
`[county-refresh] Unknown workspace ${ws}: sample ${sample} (${uat?.name ?? "?"}). Add to WORKSPACE_TO_COUNTY mapping.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Batch update GisUat records
|
|
||||||
let updated = 0;
|
|
||||||
|
|
||||||
for (const [ws, sirutas] of wsToSirutas) {
|
|
||||||
const county = WORKSPACE_TO_COUNTY[ws];
|
const county = WORKSPACE_TO_COUNTY[ws];
|
||||||
if (!county) continue;
|
const areaValue = Number(f.attributes?.AREA_VALUE ?? 0) || null;
|
||||||
|
const lastUpdatedDtm = f.attributes?.LAST_UPDATED_DTM != null
|
||||||
|
? String(f.attributes.LAST_UPDATED_DTM)
|
||||||
|
: null;
|
||||||
|
|
||||||
for (let i = 0; i < sirutas.length; i += 200) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const batch = sirutas.slice(i, i + 200);
|
const geom = (f as any).geometry ?? null;
|
||||||
const result = await prisma.gisUat.updateMany({
|
|
||||||
where: { siruta: { in: batch } },
|
const data: Prisma.GisUatUpdateInput = {
|
||||||
data: { county, workspacePk: ws },
|
workspacePk: ws,
|
||||||
});
|
...(county ? { county } : {}),
|
||||||
updated += result.count;
|
...(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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user