fix: workspace resolution via ArcGIS listLayer + seed UATs from uat.json

- resolveWorkspace: use listLayer() instead of listLayerByWhere() with
  hardcoded field names. Auto-discovers admin field (ADMIN_UNIT_ID/SIRUTA)
  from ArcGIS layer metadata via buildWhere().
- resolveWorkspace: persist WORKSPACE_ID to DB on first resolution for
  fast subsequent lookups.
- UATs POST: seed from uat.json (correct SIRUTA codes) instead of eTerra
  nomenclature API (nomenPk != SIRUTA, county nomenPk != WORKSPACE_ID).
- Remove eTerra nomenclature dependency from UATs endpoint.
- Fix activeJobs Set iteration error on container restart.
- Remove unused enrichedUatsFetched ref.
This commit is contained in:
AI Assistant
2026-03-06 21:24:51 +02:00
parent 1b72d641cd
commit 6b8feb9075
4 changed files with 107 additions and 210 deletions
+45 -85
View File
@@ -26,21 +26,15 @@ const workspaceCache =
globalRef.__eterraWorkspaceCache = workspaceCache; globalRef.__eterraWorkspaceCache = workspaceCache;
/** /**
* Resolve eTerra workspace nomenPk for a given SIRUTA. * Resolve eTerra workspace ID for a given SIRUTA.
* Strategy: fetch all counties, then for each county fetch its UATs
* until we find one whose nomenPk matches the SIRUTA.
* Results are cached globally (survives hot-reload).
*/
/**
* Resolve eTerra workspace nomenPk for a given SIRUTA.
* *
* Strategy (fast path first): * Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this
* 1. Check in-memory cache * SIRUTA, read the WORKSPACE_ID attribute.
* 2. Direct nomen lookup: GET /api/adm/nomen/{siruta} → read parentNomenPk
* then GET parent to check if it's a COUNTY → that's the workspace
* 3. Full scan: fetch all counties → their UATs (slow fallback, ~42 calls)
* *
* Results are cached globally. * Uses `listLayer()` (not `listLayerByWhere`) so the admin field name
* (ADMIN_UNIT_ID, SIRUTA, UAT_ID…) is auto-discovered from layer metadata.
*
* SIRUTA ≠ eTerra nomenPk, so nomenclature API lookups don't help.
*/ */
async function resolveWorkspace( async function resolveWorkspace(
client: EterraClient, client: EterraClient,
@@ -49,71 +43,48 @@ async function resolveWorkspace(
const cached = workspaceCache.get(siruta); const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
// Fast path: direct nomen lookup
try { try {
const nomen = await client.fetchNomenByPk(siruta); // listLayer auto-discovers the correct admin field via buildWhere
console.log("[resolveWorkspace] direct nomen lookup for", siruta, "→", JSON.stringify(nomen).slice(0, 500)); const features = await client.listLayer(
if (nomen) { {
// Walk parent chain to find COUNTY id: "TERENURI_ACTIVE",
const parentPk = nomen?.parentNomenPk ?? nomen?.parentPk ?? nomen?.parent?.nomenPk; name: "TERENURI_ACTIVE",
if (parentPk) { endpoint: "aut",
const parent = await client.fetchNomenByPk(parentPk); whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
console.log("[resolveWorkspace] parent nomen:", JSON.stringify(parent).slice(0, 500)); },
const parentType = String(parent?.nomenType ?? parent?.type ?? "").toUpperCase(); siruta,
if (parentType === "COUNTY" || parentType === "JUDET") { { limit: 1, outFields: "WORKSPACE_ID" },
const countyPk = Number(parent?.nomenPk ?? parentPk); );
workspaceCache.set(siruta, countyPk);
return countyPk; const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
} console.log("[resolveWorkspace] ArcGIS WORKSPACE_ID for", siruta, "→", wsId);
// Maybe grandparent is the county (UAT → commune → county?) if (wsId != null) {
const grandparentPk = parent?.parentNomenPk ?? parent?.parentPk; const numWs = Number(wsId);
if (grandparentPk) { if (Number.isFinite(numWs)) {
const grandparent = await client.fetchNomenByPk(grandparentPk); workspaceCache.set(siruta, numWs);
const gpType = String(grandparent?.nomenType ?? grandparent?.type ?? "").toUpperCase(); // Persist to DB for future fast lookups
if (gpType === "COUNTY" || gpType === "JUDET") { persistWorkspace(siruta, numWs);
const countyPk = Number(grandparent?.nomenPk ?? grandparentPk); return numWs;
workspaceCache.set(siruta, countyPk);
return countyPk;
} }
} }
} } catch (e) {
} console.log("[resolveWorkspace] ArcGIS query failed:", e instanceof Error ? e.message : e);
} catch {
// Direct lookup failed — continue to full scan
} }
// Slow fallback: iterate all counties and their UATs
try {
const counties = await client.fetchCounties();
for (const county of counties) {
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id;
if (!countyPk) continue;
try {
const uats = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) {
// Try all possible identifier fields
const candidates = [
uat?.nomenPk,
uat?.siruta,
uat?.code,
uat?.pk,
].filter(Boolean).map(String);
for (const c of candidates) {
workspaceCache.set(c, Number(countyPk));
}
}
const resolved = workspaceCache.get(siruta);
if (resolved !== undefined) return resolved;
} catch {
continue;
}
}
} catch {
// Can't fetch counties
}
return null; return null;
} }
/** Fire-and-forget: save WORKSPACE_ID to GisUat row */
function persistWorkspace(siruta: string, workspacePk: number) {
prisma.gisUat
.upsert({
where: { siruta },
update: { workspacePk },
create: { siruta, name: siruta, workspacePk },
})
.catch(() => {});
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helper formatters (same logic as export-bundle magic mode) */ /* Helper formatters (same logic as export-bundle magic mode) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -238,33 +209,21 @@ export async function POST(req: Request) {
const client = await EterraClient.create(username, password); const client = await EterraClient.create(username, password);
// Use provided workspacePk — or look up from DB — or resolve from eTerra // Workspace resolution chain: body → DB → ArcGIS layer query
let workspaceId = body.workspacePk ?? null; let workspaceId = body.workspacePk ?? null;
console.log(
"[search] siruta:",
siruta,
"body.workspacePk:",
body.workspacePk,
"workspaceId:",
workspaceId,
);
if (!workspaceId || !Number.isFinite(workspaceId)) { if (!workspaceId || !Number.isFinite(workspaceId)) {
// Try DB lookup first (cheap)
try { try {
const dbUat = await prisma.gisUat.findUnique({ const dbUat = await prisma.gisUat.findUnique({
where: { siruta }, where: { siruta },
select: { workspacePk: true }, select: { workspacePk: true },
}); });
console.log("[search] DB lookup result:", dbUat);
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk; if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
} catch (e) { } catch {
console.log("[search] DB lookup error:", e); // DB lookup failed
} }
} }
if (!workspaceId || !Number.isFinite(workspaceId)) { if (!workspaceId || !Number.isFinite(workspaceId)) {
console.log("[search] falling back to resolveWorkspace...");
workspaceId = await resolveWorkspace(client, siruta); workspaceId = await resolveWorkspace(client, siruta);
console.log("[search] resolveWorkspace result:", workspaceId);
} }
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json( return NextResponse.json(
@@ -272,6 +231,7 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
); );
} }
console.log("[search] siruta:", siruta, "workspaceId:", workspaceId);
const results: ParcelDetail[] = []; const results: ParcelDetail[] = [];
+45 -90
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma"; import { prisma } from "@/core/storage/prisma";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { readFile } from "fs/promises";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { join } from "path";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -74,119 +74,74 @@ export async function GET() {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* POST /api/eterra/uats */ /* POST /api/eterra/uats */
/* */ /* */
/* Sync check: compare eTerra county count vs DB. */ /* Seed DB from static uat.json. */
/* If DB is empty or county count differs → full fetch + upsert. */ /* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */
/* Otherwise returns { synced: false, reason: "up-to-date" }. */ /* for populating UAT data. uat.json has correct SIRUTA codes. */
/* Workspace (county) PKs are resolved lazily via ArcGIS layer query */
/* in the search route and persisted to DB on first resolution. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export async function POST() { export async function POST() {
try { try {
// Need eTerra credentials for sync // Check if DB already has data
const session = getSessionCredentials(); const dbCount = await prisma.gisUat.count();
const username = String( if (dbCount > 0) {
session?.username || process.env.ETERRA_USERNAME || "", return NextResponse.json({
).trim(); synced: false,
const password = String( reason: "already-seeded",
session?.password || process.env.ETERRA_PASSWORD || "", total: dbCount,
).trim(); });
}
if (!username || !password) { // Read uat.json from public/ directory
let rawUats: Array<{ siruta: string; name: string }>;
try {
const filePath = join(process.cwd(), "public", "uat.json");
const content = await readFile(filePath, "utf-8");
rawUats = JSON.parse(content);
} catch {
return NextResponse.json( return NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi.", synced: false }, { error: "Nu s-a putut citi uat.json", synced: false },
{ status: 401 }, { status: 500 },
); );
} }
const client = await EterraClient.create(username, password); if (!Array.isArray(rawUats) || rawUats.length === 0) {
// Quick check: fetch county list from eTerra (lightweight)
const counties = await client.fetchCounties();
const eterraCountyCount = counties.length;
// Compare with DB
const dbCounties = await prisma.gisUat.groupBy({
by: ["county"],
_count: true,
});
const dbCountyCount = dbCounties.length;
const dbTotalUats = await prisma.gisUat.count();
// If county counts match and we have data → already synced
if (
dbCountyCount === eterraCountyCount &&
dbCountyCount > 0 &&
dbTotalUats > 0
) {
return NextResponse.json({ return NextResponse.json({
synced: false, synced: false,
reason: "up-to-date", reason: "empty-uat-json",
total: dbTotalUats, total: 0,
counties: dbCountyCount,
}); });
} }
// Full sync: fetch all UATs for all counties // Batch insert in chunks of 500 (Prisma transaction limit)
const enriched: EnrichedUat[] = []; const CHUNK_SIZE = 500;
let inserted = 0;
for (const county of counties) { for (let i = 0; i < rawUats.length; i += CHUNK_SIZE) {
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id; const chunk = rawUats.slice(i, i + CHUNK_SIZE);
const countyName = String(county?.name ?? "").trim();
if (!countyPk || !countyName) continue;
try {
const uats = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) {
const uatPk = String(uat?.nomenPk ?? uat?.pk ?? "");
const uatName = String(uat?.name ?? "").trim();
if (!uatPk || !uatName) continue;
enriched.push({
siruta: uatPk,
name: uatName,
county: countyName,
workspacePk: Number(countyPk),
});
}
} catch {
continue;
}
}
if (enriched.length === 0) {
return NextResponse.json({
synced: false,
reason: "no-data-from-eterra",
total: dbTotalUats,
});
}
// Batch upsert into DB
await prisma.$transaction( await prisma.$transaction(
enriched.map((u) => chunk
.filter((u) => u.siruta && u.name)
.map((u) =>
prisma.gisUat.upsert({ prisma.gisUat.upsert({
where: { siruta: u.siruta }, where: { siruta: String(u.siruta) },
update: { update: { name: String(u.name).trim() },
name: u.name,
county: u.county,
workspacePk: u.workspacePk,
},
create: { create: {
siruta: u.siruta, siruta: String(u.siruta),
name: u.name, name: String(u.name).trim(),
county: u.county,
workspacePk: u.workspacePk,
}, },
}), }),
), ),
); );
inserted += chunk.length;
}
// Populate in-memory cache console.log(`[uats] Seeded ${inserted} UATs from uat.json`);
populateWorkspaceCache(enriched);
return NextResponse.json({ return NextResponse.json({
synced: true, synced: true,
total: enriched.length, total: inserted,
counties: eterraCountyCount, source: "uat-json",
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Eroare server"; const message = error instanceof Error ? error.message : "Eroare server";
@@ -284,7 +284,6 @@ export function ParcelSyncModule() {
const [siruta, setSiruta] = useState(""); const [siruta, setSiruta] = useState("");
const [workspacePk, setWorkspacePk] = useState<number | null>(null); const [workspacePk, setWorkspacePk] = useState<number | null>(null);
const uatRef = useRef<HTMLDivElement>(null); const uatRef = useRef<HTMLDivElement>(null);
const enrichedUatsFetched = useRef(false);
/* ── Export state ────────────────────────────────────────────── */ /* ── Export state ────────────────────────────────────────────── */
const [exportJobId, setExportJobId] = useState<string | null>(null); const [exportJobId, setExportJobId] = useState<string | null>(null);
@@ -328,11 +327,12 @@ export function ParcelSyncModule() {
// Load UATs from local DB (fast — no eTerra needed) // Load UATs from local DB (fast — no eTerra needed)
fetch("/api/eterra/uats") fetch("/api/eterra/uats")
.then((res) => res.json()) .then((res) => res.json())
.then((data: { uats?: UatEntry[] }) => { .then((data: { uats?: UatEntry[]; total?: number }) => {
if (data.uats && data.uats.length > 0) { if (data.uats && data.uats.length > 0) {
setUatData(data.uats); setUatData(data.uats);
} else { } else {
// DB empty — fall back to static uat.json (no county/workspace) // DB empty — seed from uat.json via POST, then load from uat.json
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
fetch("/uat.json") fetch("/uat.json")
.then((res) => res.json()) .then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback)) .then((fallback: UatEntry[]) => setUatData(fallback))
@@ -358,32 +358,10 @@ export function ParcelSyncModule() {
}, [fetchSession]); }, [fetchSession]);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Sync UATs from eTerra → DB on connect (lightweight check) */ /* (Sync effect removed — POST seeds from uat.json, no */
/* eTerra nomenclature needed. Workspace resolved lazily.) */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
useEffect(() => {
if (!session.connected || enrichedUatsFetched.current) return;
enrichedUatsFetched.current = true;
// POST triggers sync check — only does full fetch if data changed
fetch("/api/eterra/uats", { method: "POST" })
.then((res) => res.json())
.then((data: { synced?: boolean }) => {
if (data.synced) {
// Data changed — reload from DB
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((fresh: { uats?: UatEntry[] }) => {
if (fresh.uats && fresh.uats.length > 0) {
setUatData(fresh.uats);
}
})
.catch(() => {});
}
})
.catch(() => {});
}, [session.connected]);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* UAT autocomplete filter */ /* UAT autocomplete filter */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
@@ -133,14 +133,18 @@ export function getSessionStatus(): SessionStatus {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function getRunningJobs(session: EterraSession): string[] { function getRunningJobs(session: EterraSession): string[] {
// Guard: ensure activeJobs is iterable (Set). Containers may restart
// with stale globalThis where the Set was serialized as plain object.
if (!(session.activeJobs instanceof Set)) {
session.activeJobs = new Set();
return [];
}
const running: string[] = []; const running: string[] = [];
for (const jid of session.activeJobs) { for (const jid of session.activeJobs) {
const p = getProgress(jid); const p = getProgress(jid);
// If progress exists and is still running, count it
if (p && p.status === "running") { if (p && p.status === "running") {
running.push(jid); running.push(jid);
} else { } else {
// Clean up finished/unknown jobs
session.activeJobs.delete(jid); session.activeJobs.delete(jid);
} }
} }