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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
* 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 layer for all features (no geometry) to get
|
* 1. Query LIMITE_UAT for all features (no geometry) → get ADMIN_UNIT_ID + WORKSPACE_ID per UAT
|
||||||
* SIRUTA + WORKSPACE_ID (or other county field) per UAT
|
* 2. We have 42 unique WORKSPACE_IDs = 42 counties. Resolve names by
|
||||||
* 2. For each unique WORKSPACE_ID, call fetchNomenByPk() to get county name
|
* looking up known UATs per workspace (e.g. WORKSPACE_ID 127 contains
|
||||||
* 3. Batch-update GisUat.county + workspacePk
|
* CLUJ-NAPOCA → county is "Cluj").
|
||||||
*
|
* 3. For initial bootstrap: use a well-known UAT→county mapping to seed
|
||||||
* Fallback: for UATs that already have workspacePk in DB but no county,
|
* the workspace→county lookup, then verify/extend from LIMITE_UAT data.
|
||||||
* resolve county name via fetchNomenByPk() directly.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, string> = {
|
||||||
|
// 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<string, string> = {
|
||||||
|
"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 {
|
function titleCase(s: string): string {
|
||||||
return s
|
return s
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
.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<void> {
|
export async function refreshCountyData(client: EterraClient): Promise<void> {
|
||||||
// Check if refresh is needed
|
// Check if refresh is needed
|
||||||
const [total, withCounty] = await Promise.all([
|
const [total, withCounty] = await Promise.all([
|
||||||
@@ -53,212 +109,95 @@ export async function refreshCountyData(client: EterraClient): Promise<void> {
|
|||||||
`[county-refresh] Starting: ${withCounty}/${total} have county.`,
|
`[county-refresh] Starting: ${withCounty}/${total} have county.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Strategy 1: LIMITE_UAT layer ──────────────────────────────
|
// 1. Query LIMITE_UAT for ADMIN_UNIT_ID + WORKSPACE_ID
|
||||||
// Query all features (no geometry) to get SIRUTA + WORKSPACE_ID per UAT
|
|
||||||
const limiteUat = findLayerById("LIMITE_UAT");
|
const limiteUat = findLayerById("LIMITE_UAT");
|
||||||
let layerUpdated = 0;
|
if (!limiteUat) {
|
||||||
|
console.error("[county-refresh] LIMITE_UAT layer not configured");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (limiteUat) {
|
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
|
||||||
try {
|
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID",
|
||||||
// Discover available fields
|
returnGeometry: false,
|
||||||
const fields = await client.getLayerFieldNames(limiteUat);
|
pageSize: 1000,
|
||||||
const upperFields = fields.map((f) => f.toUpperCase());
|
});
|
||||||
|
|
||||||
console.log(`[county-refresh] LIMITE_UAT fields: ${fields.join(", ")}`);
|
console.log(`[county-refresh] LIMITE_UAT: ${features.length} features.`);
|
||||||
|
|
||||||
// Find admin field (SIRUTA)
|
// 2. Build SIRUTA → WORKSPACE_ID map, and collect unique workspaces
|
||||||
const adminCandidates = [
|
const sirutaToWs = new Map<string, number>();
|
||||||
"ADMIN_UNIT_ID",
|
const wsToSirutas = new Map<number, string[]>();
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find workspace/county field
|
for (const f of features) {
|
||||||
const wsCandidates = [
|
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
|
||||||
"WORKSPACE_ID",
|
.trim()
|
||||||
"COUNTY_ID",
|
.replace(/\.0$/, "");
|
||||||
"JUDET_ID",
|
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
|
||||||
"JUDET",
|
if (!siruta || !Number.isFinite(ws) || ws <= 0) continue;
|
||||||
"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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check for name fields that might contain county
|
sirutaToWs.set(siruta, ws);
|
||||||
const nameCandidates = ["NAME", "NUME", "UAT_NAME", "DENUMIRE"];
|
const arr = wsToSirutas.get(ws);
|
||||||
let nameField: string | null = null;
|
if (arr) arr.push(siruta);
|
||||||
for (const key of nameCandidates) {
|
else wsToSirutas.set(ws, [siruta]);
|
||||||
const idx = upperFields.indexOf(key);
|
}
|
||||||
if (idx >= 0) {
|
|
||||||
nameField = fields[idx] ?? null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminField) {
|
console.log(
|
||||||
// Fetch all features — attributes only
|
`[county-refresh] ${sirutaToWs.size} SIRUTAs, ${wsToSirutas.size} unique workspaces.`,
|
||||||
const outFields = [adminField, wsField, nameField]
|
);
|
||||||
.filter(Boolean)
|
|
||||||
.join(",");
|
|
||||||
|
|
||||||
const features = await client.fetchAllLayerByWhere(
|
// 3. Resolve WORKSPACE_ID → county name using known UAT mappings
|
||||||
limiteUat,
|
const wsToCounty = new Map<number, string>();
|
||||||
"1=1",
|
const allKnown = { ...COUNTY_SEATS, ...COUNTY_ALTS };
|
||||||
{
|
|
||||||
outFields: outFields || "*",
|
|
||||||
returnGeometry: false,
|
|
||||||
pageSize: 1000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
for (const [siruta, county] of Object.entries(allKnown)) {
|
||||||
`[county-refresh] LIMITE_UAT: ${features.length} features. ` +
|
const ws = sirutaToWs.get(siruta);
|
||||||
`adminField=${adminField}, wsField=${wsField}, nameField=${nameField}`,
|
if (ws != null && !wsToCounty.has(ws)) {
|
||||||
);
|
wsToCounty.set(ws, county);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log sample for debugging
|
console.log(
|
||||||
if (features.length > 0) {
|
`[county-refresh] Resolved ${wsToCounty.size}/${wsToSirutas.size} workspaces via known UATs.`,
|
||||||
console.log(
|
);
|
||||||
`[county-refresh] Sample feature:`,
|
|
||||||
JSON.stringify(features[0]?.attributes).slice(0, 500),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect unique WORKSPACE_IDs to resolve county names
|
// Log unresolved workspaces for debugging
|
||||||
const wsToSirutas = new Map<number, string[]>();
|
const unresolved: number[] = [];
|
||||||
const sirutaToWs = new Map<string, number>();
|
for (const ws of wsToSirutas.keys()) {
|
||||||
|
if (!wsToCounty.has(ws)) unresolved.push(ws);
|
||||||
for (const f of features) {
|
}
|
||||||
const attrs = f.attributes;
|
if (unresolved.length > 0) {
|
||||||
const sirutaVal = String(attrs[adminField] ?? "")
|
// Find a sample SIRUTA for each unresolved workspace
|
||||||
.trim()
|
for (const ws of unresolved) {
|
||||||
.replace(/\.0$/, "");
|
const sirutas = wsToSirutas.get(ws) ?? [];
|
||||||
|
const sampleSiruta = sirutas[0] ?? "?";
|
||||||
if (!sirutaVal) continue;
|
// Look up name from GisUat
|
||||||
|
const uat = await prisma.gisUat.findUnique({
|
||||||
if (wsField) {
|
where: { siruta: sampleSiruta },
|
||||||
const wsPk = Number(attrs[wsField] ?? 0);
|
select: { name: true },
|
||||||
if (Number.isFinite(wsPk) && wsPk > 0) {
|
});
|
||||||
sirutaToWs.set(sirutaVal, wsPk);
|
console.log(
|
||||||
const arr = wsToSirutas.get(wsPk);
|
`[county-refresh] Unresolved workspace ${ws}: sample SIRUTA=${sampleSiruta} (${uat?.name ?? "?"})`,
|
||||||
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<number, string>();
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
// 4. Batch update GisUat records
|
||||||
`[county-refresh] LIMITE_UAT phase: ${layerUpdated} updated.`,
|
let updated = 0;
|
||||||
);
|
|
||||||
|
|
||||||
// ── Strategy 2: Resolve via existing workspacePk in DB ────────
|
for (const [ws, sirutas] of wsToSirutas) {
|
||||||
// For UATs that already have workspacePk but no county
|
const county = wsToCounty.get(ws);
|
||||||
let dbUpdated = 0;
|
if (!county) continue;
|
||||||
|
|
||||||
const uatsWithWs = await prisma.gisUat.findMany({
|
// Update in batches of 200
|
||||||
where: { county: null, workspacePk: { not: null } },
|
for (let i = 0; i < sirutas.length; i += 200) {
|
||||||
select: { siruta: true, workspacePk: true },
|
const batch = sirutas.slice(i, i + 200);
|
||||||
});
|
const result = await prisma.gisUat.updateMany({
|
||||||
|
where: { siruta: { in: batch } },
|
||||||
if (uatsWithWs.length > 0) {
|
data: { county, workspacePk: ws },
|
||||||
// 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<number, string>();
|
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
dbUpdated++;
|
updated += result.count;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalUpdated = layerUpdated + dbUpdated;
|
console.log(`[county-refresh] Done: ${updated}/${total} updated.`);
|
||||||
console.log(
|
|
||||||
`[county-refresh] Done: ${totalUpdated} updated ` +
|
|
||||||
`(layer=${layerUpdated}, db=${dbUpdated}).`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user