diff --git a/src/app/api/eterra/search/route.ts b/src/app/api/eterra/search/route.ts index f5c77f9..d1eb9d4 100644 --- a/src/app/api/eterra/search/route.ts +++ b/src/app/api/eterra/search/route.ts @@ -26,21 +26,15 @@ const workspaceCache = globalRef.__eterraWorkspaceCache = workspaceCache; /** - * Resolve eTerra workspace nomenPk 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. + * Resolve eTerra workspace ID for a given SIRUTA. * - * Strategy (fast path first): - * 1. Check in-memory cache - * 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) + * Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this + * SIRUTA, read the WORKSPACE_ID attribute. * - * 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( client: EterraClient, @@ -49,71 +43,48 @@ async function resolveWorkspace( const cached = workspaceCache.get(siruta); if (cached !== undefined) return cached; - // Fast path: direct nomen lookup try { - const nomen = await client.fetchNomenByPk(siruta); - console.log("[resolveWorkspace] direct nomen lookup for", siruta, "→", JSON.stringify(nomen).slice(0, 500)); - if (nomen) { - // Walk parent chain to find COUNTY - const parentPk = nomen?.parentNomenPk ?? nomen?.parentPk ?? nomen?.parent?.nomenPk; - if (parentPk) { - const parent = await client.fetchNomenByPk(parentPk); - console.log("[resolveWorkspace] parent nomen:", JSON.stringify(parent).slice(0, 500)); - const parentType = String(parent?.nomenType ?? parent?.type ?? "").toUpperCase(); - if (parentType === "COUNTY" || parentType === "JUDET") { - const countyPk = Number(parent?.nomenPk ?? parentPk); - workspaceCache.set(siruta, countyPk); - return countyPk; - } - // Maybe grandparent is the county (UAT → commune → county?) - const grandparentPk = parent?.parentNomenPk ?? parent?.parentPk; - if (grandparentPk) { - const grandparent = await client.fetchNomenByPk(grandparentPk); - const gpType = String(grandparent?.nomenType ?? grandparent?.type ?? "").toUpperCase(); - if (gpType === "COUNTY" || gpType === "JUDET") { - const countyPk = Number(grandparent?.nomenPk ?? grandparentPk); - workspaceCache.set(siruta, countyPk); - return countyPk; - } - } + // listLayer auto-discovers the correct admin field via buildWhere + const features = await client.listLayer( + { + id: "TERENURI_ACTIVE", + name: "TERENURI_ACTIVE", + endpoint: "aut", + whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", + }, + siruta, + { limit: 1, outFields: "WORKSPACE_ID" }, + ); + + const wsId = features?.[0]?.attributes?.WORKSPACE_ID; + console.log("[resolveWorkspace] ArcGIS WORKSPACE_ID for", siruta, "→", wsId); + if (wsId != null) { + const numWs = Number(wsId); + if (Number.isFinite(numWs)) { + workspaceCache.set(siruta, numWs); + // Persist to DB for future fast lookups + persistWorkspace(siruta, numWs); + return numWs; } } - } catch { - // Direct lookup failed — continue to full scan + } catch (e) { + console.log("[resolveWorkspace] ArcGIS query failed:", e instanceof Error ? e.message : e); } - // 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; } +/** 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) */ /* ------------------------------------------------------------------ */ @@ -238,33 +209,21 @@ export async function POST(req: Request) { 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; - console.log( - "[search] siruta:", - siruta, - "body.workspacePk:", - body.workspacePk, - "workspaceId:", - workspaceId, - ); if (!workspaceId || !Number.isFinite(workspaceId)) { - // Try DB lookup first (cheap) try { const dbUat = await prisma.gisUat.findUnique({ where: { siruta }, select: { workspacePk: true }, }); - console.log("[search] DB lookup result:", dbUat); if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk; - } catch (e) { - console.log("[search] DB lookup error:", e); + } catch { + // DB lookup failed } } if (!workspaceId || !Number.isFinite(workspaceId)) { - console.log("[search] falling back to resolveWorkspace..."); workspaceId = await resolveWorkspace(client, siruta); - console.log("[search] resolveWorkspace result:", workspaceId); } if (!workspaceId) { return NextResponse.json( @@ -272,6 +231,7 @@ export async function POST(req: Request) { { status: 400 }, ); } + console.log("[search] siruta:", siruta, "workspaceId:", workspaceId); const results: ParcelDetail[] = []; diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts index 4657121..c5f6c7e 100644 --- a/src/app/api/eterra/uats/route.ts +++ b/src/app/api/eterra/uats/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; -import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; -import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; +import { readFile } from "fs/promises"; +import { join } from "path"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -74,119 +74,74 @@ export async function GET() { /* ------------------------------------------------------------------ */ /* POST /api/eterra/uats */ /* */ -/* Sync check: compare eTerra county count vs DB. */ -/* If DB is empty or county count differs → full fetch + upsert. */ -/* Otherwise returns { synced: false, reason: "up-to-date" }. */ +/* Seed DB from static uat.json. */ +/* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */ +/* 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() { try { - // Need eTerra credentials for sync - const session = getSessionCredentials(); - const username = String( - session?.username || process.env.ETERRA_USERNAME || "", - ).trim(); - const password = String( - session?.password || process.env.ETERRA_PASSWORD || "", - ).trim(); + // Check if DB already has data + const dbCount = await prisma.gisUat.count(); + if (dbCount > 0) { + return NextResponse.json({ + synced: false, + reason: "already-seeded", + total: dbCount, + }); + } - 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( - { error: "Conectează-te la eTerra mai întâi.", synced: false }, - { status: 401 }, + { error: "Nu s-a putut citi uat.json", synced: false }, + { status: 500 }, ); } - const client = await EterraClient.create(username, password); - - // 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 - ) { + if (!Array.isArray(rawUats) || rawUats.length === 0) { return NextResponse.json({ synced: false, - reason: "up-to-date", - total: dbTotalUats, - counties: dbCountyCount, + reason: "empty-uat-json", + total: 0, }); } - // Full sync: fetch all UATs for all counties - const enriched: EnrichedUat[] = []; - - for (const county of counties) { - const countyPk = county?.nomenPk ?? county?.pk ?? county?.id; - 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; - } + // Batch insert in chunks of 500 (Prisma transaction limit) + const CHUNK_SIZE = 500; + let inserted = 0; + for (let i = 0; i < rawUats.length; i += CHUNK_SIZE) { + const chunk = rawUats.slice(i, i + CHUNK_SIZE); + await prisma.$transaction( + chunk + .filter((u) => u.siruta && u.name) + .map((u) => + prisma.gisUat.upsert({ + where: { siruta: String(u.siruta) }, + update: { name: String(u.name).trim() }, + create: { + siruta: String(u.siruta), + name: String(u.name).trim(), + }, + }), + ), + ); + inserted += chunk.length; } - if (enriched.length === 0) { - return NextResponse.json({ - synced: false, - reason: "no-data-from-eterra", - total: dbTotalUats, - }); - } - - // Batch upsert into DB - await prisma.$transaction( - enriched.map((u) => - prisma.gisUat.upsert({ - where: { siruta: u.siruta }, - update: { - name: u.name, - county: u.county, - workspacePk: u.workspacePk, - }, - create: { - siruta: u.siruta, - name: u.name, - county: u.county, - workspacePk: u.workspacePk, - }, - }), - ), - ); - - // Populate in-memory cache - populateWorkspaceCache(enriched); + console.log(`[uats] Seeded ${inserted} UATs from uat.json`); return NextResponse.json({ synced: true, - total: enriched.length, - counties: eterraCountyCount, + total: inserted, + source: "uat-json", }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 00585a2..0b4acf0 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -284,7 +284,6 @@ export function ParcelSyncModule() { const [siruta, setSiruta] = useState(""); const [workspacePk, setWorkspacePk] = useState(null); const uatRef = useRef(null); - const enrichedUatsFetched = useRef(false); /* ── Export state ────────────────────────────────────────────── */ const [exportJobId, setExportJobId] = useState(null); @@ -328,11 +327,12 @@ export function ParcelSyncModule() { // Load UATs from local DB (fast — no eTerra needed) fetch("/api/eterra/uats") .then((res) => res.json()) - .then((data: { uats?: UatEntry[] }) => { + .then((data: { uats?: UatEntry[]; total?: number }) => { if (data.uats && data.uats.length > 0) { setUatData(data.uats); } 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") .then((res) => res.json()) .then((fallback: UatEntry[]) => setUatData(fallback)) @@ -358,32 +358,10 @@ export function ParcelSyncModule() { }, [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 */ /* ════════════════════════════════════════════════════════════ */ diff --git a/src/modules/parcel-sync/services/session-store.ts b/src/modules/parcel-sync/services/session-store.ts index b146fdc..0ad4010 100644 --- a/src/modules/parcel-sync/services/session-store.ts +++ b/src/modules/parcel-sync/services/session-store.ts @@ -133,14 +133,18 @@ export function getSessionStatus(): SessionStatus { /* ------------------------------------------------------------------ */ 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[] = []; for (const jid of session.activeJobs) { const p = getProgress(jid); - // If progress exists and is still running, count it if (p && p.status === "running") { running.push(jid); } else { - // Clean up finished/unknown jobs session.activeJobs.delete(jid); } }