fix(parcel-sync): robust county population + local feature count in dropdown
- PATCH /api/eterra/uats: handle nested responses (unwrapArray), try multiple field names (extractName/extractCode), log sample UAT for debugging, match by code first then by name - GET /api/eterra/uats: include localFeatures count per SIRUTA via GisFeature groupBy query - Dropdown: show green badge with local feature count, county with dash - Add SKILLS.md for ParcelSync/eTerra/GIS module context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,18 +12,22 @@ export const dynamic = "force-dynamic";
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type EnrichedUat = {
|
||||
type UatResponse = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
workspacePk: number;
|
||||
/** Number of GIS features synced locally for this UAT */
|
||||
localFeatures: number;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function populateWorkspaceCache(uats: EnrichedUat[]) {
|
||||
function populateWorkspaceCache(
|
||||
uats: Array<{ siruta: string; workspacePk: number }>,
|
||||
) {
|
||||
const wsGlobal = globalThis as {
|
||||
__eterraWorkspaceCache?: Map<string, number>;
|
||||
};
|
||||
@@ -37,24 +41,109 @@ function populateWorkspaceCache(uats: EnrichedUat[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove diacritics and uppercase for fuzzy name matching */
|
||||
function normalizeName(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Title-case: "SATU MARE" → "Satu Mare" */
|
||||
function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a name from an eTerra nomenclature entry.
|
||||
* Tries multiple possible field names.
|
||||
*/
|
||||
// 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", "NAME"]) {
|
||||
const val = entry[key];
|
||||
if (typeof val === "string" && val.trim()) return val.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a SIRUTA code from an eTerra nomenclature entry.
|
||||
* Tries multiple possible field names (nomenPk ≠ SIRUTA, but code might be).
|
||||
*/
|
||||
// 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",
|
||||
"CODE",
|
||||
]) {
|
||||
const val = entry[key];
|
||||
if (val != null) {
|
||||
const s = String(val).trim();
|
||||
if (s && /^\d+$/.test(s)) return s;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a potentially nested response (Spring Boot Page format).
|
||||
* eTerra sometimes returns {content: [...]} instead of flat arrays.
|
||||
*/
|
||||
// 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;
|
||||
if (Array.isArray(data.results)) return data.results;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* GET /api/eterra/uats */
|
||||
/* */
|
||||
/* Always serves from local PostgreSQL (GisUat table). */
|
||||
/* Includes local GIS feature counts per UAT for the UI indicator. */
|
||||
/* No eTerra credentials needed — instant response. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rows = await prisma.gisUat.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
// Fetch UATs and local feature counts in parallel
|
||||
const [rows, featureCounts] = await Promise.all([
|
||||
prisma.gisUat.findMany({ orderBy: { name: "asc" } }),
|
||||
prisma.gisFeature
|
||||
.groupBy({
|
||||
by: ["siruta"],
|
||||
_count: { id: true },
|
||||
})
|
||||
.then((groups) => {
|
||||
const map = new Map<string, number>();
|
||||
for (const g of groups) {
|
||||
map.set(g.siruta, g._count.id);
|
||||
}
|
||||
return map;
|
||||
}),
|
||||
]);
|
||||
|
||||
const uats: EnrichedUat[] = rows.map((r) => ({
|
||||
const uats: UatResponse[] = rows.map((r) => ({
|
||||
siruta: r.siruta,
|
||||
name: r.name,
|
||||
county: r.county ?? "",
|
||||
workspacePk: r.workspacePk ?? 0,
|
||||
localFeatures: featureCounts.get(r.siruta) ?? 0,
|
||||
}));
|
||||
|
||||
// Populate in-memory workspace cache for search route
|
||||
@@ -163,27 +252,11 @@ export async function POST() {
|
||||
/* Phase 1: For UATs that already have workspacePk resolved, */
|
||||
/* use fetchCounties() → countyMap[workspacePk] → instant update. */
|
||||
/* Phase 2: For remaining UATs, enumerate counties → */
|
||||
/* fetchAdminUnitsByCounty() per county → match by name. */
|
||||
/* fetchAdminUnitsByCounty() per county → match by code or name. */
|
||||
/* */
|
||||
/* Requires active eTerra session. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Remove diacritics and lowercase for fuzzy name matching */
|
||||
function normalizeName(s: string): string {
|
||||
return s
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toUpperCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Title-case: "SATU MARE" → "Satu Mare" */
|
||||
function titleCase(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
try {
|
||||
// 1. Get eTerra credentials from session
|
||||
@@ -205,20 +278,33 @@ export async function PATCH() {
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
// 2. Fetch all counties from eTerra nomenclature
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const counties: any[] = await client.fetchCounties();
|
||||
const rawCounties = await client.fetchCounties();
|
||||
const counties = unwrapArray(rawCounties);
|
||||
const countyMap = new Map<number, string>(); // nomenPk → county name
|
||||
for (const c of counties) {
|
||||
const pk = Number(c?.nomenPk ?? 0);
|
||||
const name = String(c?.name ?? "").trim();
|
||||
const name = extractName(c);
|
||||
if (pk > 0 && name) {
|
||||
countyMap.set(pk, titleCase(name));
|
||||
}
|
||||
}
|
||||
|
||||
if (countyMap.size === 0) {
|
||||
// Log raw response for debugging
|
||||
console.error(
|
||||
"[uats-patch] fetchCounties returned 0 counties. Raw sample:",
|
||||
JSON.stringify(rawCounties).slice(0, 500),
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Nu s-au putut obține județele din eTerra." },
|
||||
{
|
||||
error: "Nu s-au putut obține județele din eTerra.",
|
||||
debug: {
|
||||
rawType: typeof rawCounties,
|
||||
isArray: Array.isArray(rawCounties),
|
||||
length: Array.isArray(rawCounties) ? rawCounties.length : null,
|
||||
sample: JSON.stringify(rawCounties).slice(0, 300),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
@@ -231,7 +317,7 @@ export async function PATCH() {
|
||||
});
|
||||
|
||||
// Phase 1: instant fill for UATs that already have workspacePk
|
||||
let phase1Updated = 0;
|
||||
const phase1Ops: Array<ReturnType<typeof prisma.gisUat.update>> = [];
|
||||
const needsCounty: Array<{ siruta: string; name: string }> = [];
|
||||
|
||||
for (const uat of allUats) {
|
||||
@@ -240,26 +326,37 @@ export async function PATCH() {
|
||||
if (uat.workspacePk && uat.workspacePk > 0) {
|
||||
const county = countyMap.get(uat.workspacePk);
|
||||
if (county) {
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta: uat.siruta },
|
||||
data: { county },
|
||||
});
|
||||
phase1Updated++;
|
||||
phase1Ops.push(
|
||||
prisma.gisUat.update({
|
||||
where: { siruta: uat.siruta },
|
||||
data: { county },
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
needsCounty.push({ siruta: uat.siruta, name: uat.name });
|
||||
}
|
||||
|
||||
// Execute phase 1 in batches
|
||||
let phase1Updated = 0;
|
||||
for (let i = 0; i < phase1Ops.length; i += 100) {
|
||||
const batch = phase1Ops.slice(i, i + 100);
|
||||
await prisma.$transaction(batch);
|
||||
phase1Updated += batch.length;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` +
|
||||
`${needsCounty.length} remaining.`,
|
||||
);
|
||||
|
||||
// Phase 2: enumerate UATs per county from nomenclature, match by name
|
||||
// Build lookup: normalized name → list of SIRUTAs (for same-name UATs)
|
||||
// Phase 2: enumerate UATs per county from nomenclature, match by code or name
|
||||
// Build lookups
|
||||
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);
|
||||
@@ -267,60 +364,87 @@ export async function PATCH() {
|
||||
}
|
||||
|
||||
let phase2Updated = 0;
|
||||
let codeMatches = 0;
|
||||
let nameMatches = 0;
|
||||
const matchedSirutas = new Set<string>();
|
||||
let loggedSample = false;
|
||||
|
||||
for (const [countyPk, countyName] of countyMap) {
|
||||
if (nameToSirutas.size === 0) break; // all matched
|
||||
if (matchedSirutas.size >= needsCounty.length) break;
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const uats: any[] = await client.fetchAdminUnitsByCounty(countyPk);
|
||||
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
|
||||
const uats = unwrapArray(rawUats);
|
||||
|
||||
// Log first county's first UAT for debugging
|
||||
if (!loggedSample && uats.length > 0) {
|
||||
console.log(
|
||||
`[uats-patch] Sample UAT from ${countyName}:`,
|
||||
JSON.stringify(uats[0]).slice(0, 500),
|
||||
);
|
||||
loggedSample = true;
|
||||
}
|
||||
|
||||
for (const uat of uats) {
|
||||
const eterraName = String(uat?.name ?? "").trim();
|
||||
// Strategy A: match by code (might be SIRUTA)
|
||||
const code = extractCode(uat);
|
||||
if (code && sirutaSet.has(code) && !matchedSirutas.has(code)) {
|
||||
matchedSirutas.add(code);
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta: code },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2Updated++;
|
||||
codeMatches++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strategy B: match by normalized name
|
||||
const eterraName = extractName(uat);
|
||||
if (!eterraName) continue;
|
||||
|
||||
const key = normalizeName(eterraName);
|
||||
const sirutas = nameToSirutas.get(key);
|
||||
if (!sirutas || sirutas.length === 0) continue;
|
||||
|
||||
// Pick the first unmatched SIRUTA with this name
|
||||
const siruta = sirutas.find((s) => !matchedSirutas.has(s));
|
||||
if (!siruta) continue;
|
||||
|
||||
matchedSirutas.add(siruta);
|
||||
|
||||
await prisma.gisUat.update({
|
||||
where: { siruta },
|
||||
data: { county: countyName, workspacePk: countyPk },
|
||||
});
|
||||
phase2Updated++;
|
||||
nameMatches++;
|
||||
|
||||
// If all SIRUTAs for this name matched, remove the key
|
||||
if (sirutas.every((s) => matchedSirutas.has(s))) {
|
||||
nameToSirutas.delete(key);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[uats-patch] Failed to fetch UATs for county ${countyName}:`,
|
||||
`[uats-patch] Failed to fetch UATs for county ${countyName} (pk=${countyPk}):`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const totalUpdated = phase1Updated + phase2Updated;
|
||||
const unmatched = needsCounty.length - phase2Updated;
|
||||
console.log(
|
||||
`[uats-patch] Phase 2: ${phase2Updated} updated via name match. ` +
|
||||
`Total: ${totalUpdated}. Unmatched: ${needsCounty.length - phase2Updated}.`,
|
||||
`[uats-patch] Phase 2: ${phase2Updated} (${codeMatches} by code, ${nameMatches} by name). ` +
|
||||
`Total: ${totalUpdated}. Unmatched: ${unmatched}.`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
updated: totalUpdated,
|
||||
phase1: phase1Updated,
|
||||
phase2: phase2Updated,
|
||||
codeMatches,
|
||||
nameMatches,
|
||||
totalCounties: countyMap.size,
|
||||
unmatched: needsCounty.length - phase2Updated,
|
||||
unmatched,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
|
||||
Reference in New Issue
Block a user