fix(parcel-sync): use LIMITE_UAT + fetchNomenByPk for county data
fetchCounties() returns 404 — endpoint doesn't exist on eTerra. New approach: query LIMITE_UAT layer for all features (no geometry) to discover SIRUTA + WORKSPACE_ID per UAT, then resolve each unique WORKSPACE_ID to county name via fetchNomenByPk(). Fallback: resolve county for UATs that already have workspacePk in DB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* County refresh — populates GisUat.county from eTerra nomenclature.
|
* County refresh — populates GisUat.county from eTerra.
|
||||||
*
|
*
|
||||||
* 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. fetchCounties() → build countyMap (nomenPk → county name)
|
* 1. Query LIMITE_UAT layer for all features (no geometry) to get
|
||||||
* 2. Phase 1: UATs with workspacePk → instant lookup
|
* SIRUTA + WORKSPACE_ID (or other county field) per UAT
|
||||||
* 3. Phase 2: enumerate counties → fetchAdminUnitsByCounty() → match by name
|
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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";
|
||||||
function normalizeName(s: string): string {
|
|
||||||
return s
|
|
||||||
.normalize("NFD")
|
|
||||||
.replace(/[\u0300-\u036f]/g, "")
|
|
||||||
.toUpperCase()
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function titleCase(s: string): string {
|
function titleCase(s: string): string {
|
||||||
return s
|
return s
|
||||||
@@ -30,43 +27,13 @@ function titleCase(s: string): string {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
function extractName(entry: any): string {
|
function extractName(entry: any): string {
|
||||||
if (!entry || typeof entry !== "object") return "";
|
if (!entry || typeof entry !== "object") return "";
|
||||||
for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) {
|
for (const key of ["name", "nomenName", "label", "denumire"]) {
|
||||||
const val = entry[key];
|
const val = entry[key];
|
||||||
if (typeof val === "string" && val.trim()) return val.trim();
|
if (typeof val === "string" && val.trim()) return val.trim();
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function extractCode(entry: any): string {
|
|
||||||
if (!entry || typeof entry !== "object") return "";
|
|
||||||
for (const key of [
|
|
||||||
"code",
|
|
||||||
"sirutaCode",
|
|
||||||
"siruta",
|
|
||||||
"externalCode",
|
|
||||||
"cod",
|
|
||||||
]) {
|
|
||||||
const val = entry[key];
|
|
||||||
if (val != null) {
|
|
||||||
const s = String(val).trim();
|
|
||||||
if (s && /^\d+$/.test(s)) return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function unwrapArray(data: any): any[] {
|
|
||||||
if (Array.isArray(data)) return data;
|
|
||||||
if (data && typeof data === "object") {
|
|
||||||
if (Array.isArray(data.content)) return data.content;
|
|
||||||
if (Array.isArray(data.data)) return data.data;
|
|
||||||
if (Array.isArray(data.items)) return data.items;
|
|
||||||
}
|
|
||||||
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([
|
||||||
@@ -86,133 +53,212 @@ export async function refreshCountyData(client: EterraClient): Promise<void> {
|
|||||||
`[county-refresh] Starting: ${withCounty}/${total} have county.`,
|
`[county-refresh] Starting: ${withCounty}/${total} have county.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Fetch counties
|
// ── Strategy 1: LIMITE_UAT layer ──────────────────────────────
|
||||||
const rawCounties = await client.fetchCounties();
|
// Query all features (no geometry) to get SIRUTA + WORKSPACE_ID per UAT
|
||||||
const counties = unwrapArray(rawCounties);
|
const limiteUat = findLayerById("LIMITE_UAT");
|
||||||
const countyMap = new Map<number, string>();
|
let layerUpdated = 0;
|
||||||
for (const c of counties) {
|
|
||||||
const pk = Number(c?.nomenPk ?? 0);
|
|
||||||
const name = extractName(c);
|
|
||||||
if (pk > 0 && name) countyMap.set(pk, titleCase(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (countyMap.size === 0) {
|
|
||||||
console.error(
|
|
||||||
"[county-refresh] fetchCounties returned 0 counties. Raw sample:",
|
|
||||||
JSON.stringify(rawCounties).slice(0, 500),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[county-refresh] Got ${countyMap.size} counties.`);
|
|
||||||
|
|
||||||
// 2. Load all UATs needing county
|
|
||||||
const allUats = await prisma.gisUat.findMany({
|
|
||||||
where: { county: null },
|
|
||||||
select: { siruta: true, name: true, workspacePk: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Phase 1: UATs with workspacePk
|
|
||||||
let phase1 = 0;
|
|
||||||
const needsCounty: Array<{ siruta: string; name: string }> = [];
|
|
||||||
|
|
||||||
for (const uat of allUats) {
|
|
||||||
if (uat.workspacePk && uat.workspacePk > 0) {
|
|
||||||
const county = countyMap.get(uat.workspacePk);
|
|
||||||
if (county) {
|
|
||||||
await prisma.gisUat.update({
|
|
||||||
where: { siruta: uat.siruta },
|
|
||||||
data: { county },
|
|
||||||
});
|
|
||||||
phase1++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
needsCounty.push({ siruta: uat.siruta, name: uat.name });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[county-refresh] Phase 1: ${phase1} via workspacePk. ${needsCounty.length} remaining.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Phase 2: enumerate counties → match by code or name
|
|
||||||
const nameToSirutas = new Map<string, string[]>();
|
|
||||||
const sirutaSet = new Set<string>();
|
|
||||||
for (const u of needsCounty) {
|
|
||||||
sirutaSet.add(u.siruta);
|
|
||||||
const key = normalizeName(u.name);
|
|
||||||
const arr = nameToSirutas.get(key);
|
|
||||||
if (arr) arr.push(u.siruta);
|
|
||||||
else nameToSirutas.set(key, [u.siruta]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let phase2 = 0;
|
|
||||||
let codeMatches = 0;
|
|
||||||
let nameMatches = 0;
|
|
||||||
const matched = new Set<string>();
|
|
||||||
|
|
||||||
for (const [countyPk, countyName] of countyMap) {
|
|
||||||
if (matched.size >= needsCounty.length) break;
|
|
||||||
|
|
||||||
|
if (limiteUat) {
|
||||||
try {
|
try {
|
||||||
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
|
// Discover available fields
|
||||||
const uats = unwrapArray(rawUats);
|
const fields = await client.getLayerFieldNames(limiteUat);
|
||||||
|
const upperFields = fields.map((f) => f.toUpperCase());
|
||||||
|
|
||||||
|
console.log(`[county-refresh] LIMITE_UAT fields: ${fields.join(", ")}`);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminField) {
|
||||||
|
// Fetch all features — attributes only
|
||||||
|
const outFields = [adminField, wsField, nameField]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(",");
|
||||||
|
|
||||||
|
const features = await client.fetchAllLayerByWhere(
|
||||||
|
limiteUat,
|
||||||
|
"1=1",
|
||||||
|
{
|
||||||
|
outFields: outFields || "*",
|
||||||
|
returnGeometry: false,
|
||||||
|
pageSize: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Log first response for debugging
|
|
||||||
if (phase2 === 0 && codeMatches === 0 && nameMatches === 0 && uats.length > 0) {
|
|
||||||
console.log(
|
console.log(
|
||||||
`[county-refresh] Sample from ${countyName} (${uats.length} UATs):`,
|
`[county-refresh] LIMITE_UAT: ${features.length} features. ` +
|
||||||
JSON.stringify(uats[0]).slice(0, 500),
|
`adminField=${adminField}, wsField=${wsField}, nameField=${nameField}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Log sample for debugging
|
||||||
|
if (features.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`[county-refresh] Sample feature:`,
|
||||||
|
JSON.stringify(features[0]?.attributes).slice(0, 500),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const uat of uats) {
|
// Collect unique WORKSPACE_IDs to resolve county names
|
||||||
// Strategy A: match by code
|
const wsToSirutas = new Map<number, string[]>();
|
||||||
const code = extractCode(uat);
|
const sirutaToWs = new Map<string, number>();
|
||||||
if (code && sirutaSet.has(code) && !matched.has(code)) {
|
|
||||||
matched.add(code);
|
for (const f of features) {
|
||||||
await prisma.gisUat.update({
|
const attrs = f.attributes;
|
||||||
where: { siruta: code },
|
const sirutaVal = String(attrs[adminField] ?? "")
|
||||||
data: { county: countyName, workspacePk: countyPk },
|
.trim()
|
||||||
});
|
.replace(/\.0$/, "");
|
||||||
phase2++;
|
|
||||||
codeMatches++;
|
if (!sirutaVal) continue;
|
||||||
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy B: match by name
|
// Resolve county names via fetchNomenByPk for each unique workspace
|
||||||
const eterraName = extractName(uat);
|
if (wsToSirutas.size > 0) {
|
||||||
if (!eterraName) continue;
|
console.log(
|
||||||
const key = normalizeName(eterraName);
|
`[county-refresh] Resolving ${wsToSirutas.size} unique workspaces...`,
|
||||||
const sirutas = nameToSirutas.get(key);
|
);
|
||||||
if (!sirutas || sirutas.length === 0) continue;
|
|
||||||
|
|
||||||
const siruta = sirutas.find((s) => !matched.has(s));
|
const wsToCounty = new Map<number, string>();
|
||||||
if (!siruta) continue;
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
matched.add(siruta);
|
console.log(
|
||||||
await prisma.gisUat.update({
|
`[county-refresh] Resolved ${wsToCounty.size} county names.`,
|
||||||
where: { siruta },
|
);
|
||||||
data: { county: countyName, workspacePk: countyPk },
|
|
||||||
|
// 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 },
|
||||||
});
|
});
|
||||||
phase2++;
|
layerUpdated += result.count;
|
||||||
nameMatches++;
|
}
|
||||||
|
}
|
||||||
if (sirutas.every((s) => matched.has(s))) {
|
|
||||||
nameToSirutas.delete(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[county-refresh] County ${countyName} failed:`,
|
`[county-refresh] LIMITE_UAT approach failed:`,
|
||||||
err instanceof Error ? err.message : err,
|
err instanceof Error ? err.message : err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[county-refresh] Done: ${phase1 + phase2} updated ` +
|
`[county-refresh] LIMITE_UAT phase: ${layerUpdated} updated.`,
|
||||||
`(phase1=${phase1}, phase2=${phase2}: ${codeMatches} code, ${nameMatches} name). ` +
|
);
|
||||||
`Unmatched: ${needsCounty.length - phase2}.`,
|
|
||||||
|
// ── Strategy 2: Resolve via existing workspacePk in DB ────────
|
||||||
|
// For UATs that already have workspacePk but no county
|
||||||
|
let dbUpdated = 0;
|
||||||
|
|
||||||
|
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<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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUpdated = layerUpdated + dbUpdated;
|
||||||
|
console.log(
|
||||||
|
`[county-refresh] Done: ${totalUpdated} updated ` +
|
||||||
|
`(layer=${layerUpdated}, db=${dbUpdated}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user