From c43082baee72afedc60a79947cd105228240b8cf Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 8 Mar 2026 01:53:24 +0200 Subject: [PATCH] feat(parcel-sync): background sync + download from DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/eterra/sync-background: fire-and-forget server-side processing Starts sync + optional enrichment in background, returns 202 immediately. Progress tracked via existing /api/eterra/progress polling. Work continues in Node.js event loop even if browser is closed. Progress persists 1 hour for background jobs (vs 60s for normal). - Enhanced POST /api/eterra/export-local: base/magic mode support mode=base: ZIP with terenuri.gpkg + cladiri.gpkg from local DB mode=magic: adds terenuri_magic.gpkg (enrichment merged, includes no-geom), terenuri_complet.csv, raport_calitate.txt, export_report.json All from PostgreSQL — zero eTerra API calls, instant download. - UI: background sync section in Export tab 'Sync fundal Baza/Magic' buttons: start background processing 'Descarc─â din DB Baza/Magic' buttons: instant download from local DB Background job progress card with indigo theme (distinct from export) localStorage job recovery: resume polling after page refresh 'Descarc─â din DB' button shown on completion --- src/app/api/eterra/export-local/route.ts | 429 ++++++++++++++-- src/app/api/eterra/sync-background/route.ts | 311 ++++++++++++ .../components/parcel-sync-module.tsx | 460 ++++++++++++++++++ 3 files changed, 1167 insertions(+), 33 deletions(-) create mode 100644 src/app/api/eterra/sync-background/route.ts diff --git a/src/app/api/eterra/export-local/route.ts b/src/app/api/eterra/export-local/route.ts index 80599dc..187bd50 100644 --- a/src/app/api/eterra/export-local/route.ts +++ b/src/app/api/eterra/export-local/route.ts @@ -1,16 +1,23 @@ /** * POST /api/eterra/export-local * - * Export features from local PostgreSQL database as GPKG. + * Export features from local PostgreSQL database as GPKG / ZIP. * No eTerra connection needed — serves from previously synced data. * - * Body: { siruta, layerIds?: string[], allLayers?: boolean } + * Modes: + * - base: ZIP with terenuri.gpkg + cladiri.gpkg + * - magic: ZIP with terenuri.gpkg + cladiri.gpkg + terenuri_magic.gpkg + * + terenuri_complet.csv + raport_calitate.txt + export_report.json + * - layer: single layer GPKG (legacy, layerIds/allLayers) + * + * Body: { siruta, mode?: "base"|"magic", layerIds?: string[], allLayers?: boolean } */ import { prisma } from "@/core/storage/prisma"; import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export"; import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject"; import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers"; import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson"; +import type { FeatureEnrichment } from "@/modules/parcel-sync/services/enrich-service"; import JSZip from "jszip"; export const runtime = "nodejs"; @@ -18,28 +25,35 @@ export const dynamic = "force-dynamic"; type Body = { siruta?: string; + mode?: "base" | "magic"; layerIds?: string[]; allLayers?: boolean; }; +const csvEscape = (val: unknown) => { + const s = String(val ?? "").replace(/"/g, '""'); + return `"${s}"`; +}; + export async function POST(req: Request) { try { const body = (await req.json()) as Body; const siruta = String(body.siruta ?? "").trim(); if (!siruta || !/^\d+$/.test(siruta)) { - return new Response(JSON.stringify({ error: "SIRUTA invalid" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return Response.json({ error: "SIRUTA invalid" }, { status: 400 }); } - // Determine which layers to export + // ── New: "base" or "magic" mode → full ZIP from DB ── + if (body.mode === "base" || body.mode === "magic") { + return buildFullZip(siruta, body.mode); + } + + // ── Legacy: single/multi layer GPKG ── let layerIds: string[]; if (body.layerIds?.length) { layerIds = body.layerIds; } else if (body.allLayers) { - // Find all layers that have data for this siruta const layerGroups = await prisma.gisFeature.groupBy({ by: ["layerId"], where: { siruta }, @@ -49,22 +63,19 @@ export async function POST(req: Request) { .filter((g) => g._count.id > 0) .map((g) => g.layerId); } else { - return new Response( - JSON.stringify({ error: "Specifică layerIds sau allLayers=true" }), - { status: 400, headers: { "Content-Type": "application/json" } }, + return Response.json( + { error: "Specifică mode, layerIds, sau allLayers=true" }, + { status: 400 }, ); } if (layerIds.length === 0) { - return new Response( - JSON.stringify({ - error: "Niciun layer sincronizat în baza de date pentru acest UAT", - }), - { status: 404, headers: { "Content-Type": "application/json" } }, + return Response.json( + { error: "Niciun layer sincronizat în baza de date pentru acest UAT" }, + { status: 404 }, ); } - // If single layer, return GPKG directly. If multiple, ZIP them. if (layerIds.length === 1) { const layerId = layerIds[0]!; const gpkg = await buildLayerGpkg(siruta, layerId); @@ -78,33 +89,388 @@ export async function POST(req: Request) { }); } - // Multiple layers — ZIP const zip = new JSZip(); for (const layerId of layerIds) { const gpkg = await buildLayerGpkg(siruta, layerId); const layer = findLayerById(layerId); - const name = layer?.name ?? layerId; - zip.file(`${name}.gpkg`, gpkg); + zip.file(`${layer?.name ?? layerId}.gpkg`, gpkg); } - - const zipBuffer = await zip.generateAsync({ type: "uint8array" }); - const filename = `eterra_local_${siruta}_${layerIds.length}layers.zip`; - return new Response(Buffer.from(zipBuffer), { + const zipBuf = await zip.generateAsync({ type: "nodebuffer" }); + return new Response(new Uint8Array(zipBuf), { headers: { "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="${filename}"`, + "Content-Disposition": `attachment; filename="eterra_local_${siruta}.zip"`, }, }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; - return new Response(JSON.stringify({ error: message }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + return Response.json({ error: message }, { status: 500 }); } } -/** Build a GPKG from local DB features for one layer+siruta */ +/* ────────────────────────────────────────────────────────── */ +/* Full ZIP export from DB (base / magic) */ +/* ────────────────────────────────────────────────────────── */ + +async function buildFullZip(siruta: string, mode: "base" | "magic") { + const srsWkt = getEpsg3844Wkt(); + + // Load from DB + const dbTerenuri = await prisma.gisFeature.findMany({ + where: { layerId: "TERENURI_ACTIVE", siruta }, + select: { + attributes: true, + geometry: true, + enrichment: true, + geometrySource: true, + }, + }); + const dbCladiri = await prisma.gisFeature.findMany({ + where: { layerId: "CLADIRI_ACTIVE", siruta }, + select: { attributes: true, geometry: true }, + }); + + if (dbTerenuri.length === 0 && dbCladiri.length === 0) { + return Response.json( + { + error: + "Baza de date este goală pentru acest UAT. Rulează sincronizarea mai întâi.", + }, + { status: 404 }, + ); + } + + const toGeoFeatures = ( + records: { attributes: unknown; geometry: unknown }[], + ): GeoJsonFeature[] => + records + .filter((r) => r.geometry != null) + .map((r) => ({ + type: "Feature" as const, + geometry: r.geometry as GeoJsonFeature["geometry"], + properties: r.attributes as Record, + })); + + const terenuriGeo = toGeoFeatures(dbTerenuri); + const cladiriGeo = toGeoFeatures(dbCladiri); + + const terenuriFields = + terenuriGeo.length > 0 ? Object.keys(terenuriGeo[0]!.properties) : []; + const cladiriFields = + cladiriGeo.length > 0 ? Object.keys(cladiriGeo[0]!.properties) : []; + + const terenuriGpkg = await buildGpkg({ + srsId: 3844, + srsWkt, + layers: [ + { + name: "TERENURI_ACTIVE", + fields: terenuriFields, + features: terenuriGeo, + }, + ], + }); + const cladiriGpkg = await buildGpkg({ + srsId: 3844, + srsWkt, + layers: [ + { name: "CLADIRI_ACTIVE", fields: cladiriFields, features: cladiriGeo }, + ], + }); + + const zip = new JSZip(); + zip.file("terenuri.gpkg", terenuriGpkg); + zip.file("cladiri.gpkg", cladiriGpkg); + + if (mode === "magic") { + // ── Magic: enrichment-merged GPKG + CSV + quality report ── + const headers = [ + "OBJECTID", + "IMMOVABLE_ID", + "APPLICATION_ID", + "NATIONAL_CADASTRAL_REFERENCE", + "NR_CAD", + "AREA_VALUE", + "NR_CF", + "NR_CF_VECHI", + "NR_TOPO", + "ADRESA", + "PROPRIETARI", + "PROPRIETARI_VECHI", + "SUPRAFATA_2D", + "SUPRAFATA_R", + "SOLICITANT", + "INTRAVILAN", + "CATEGORIE_FOLOSINTA", + "HAS_BUILDING", + "BUILD_LEGAL", + "HAS_GEOMETRY", + ]; + const csvRows: string[] = [headers.map(csvEscape).join(",")]; + + const magicFeatures: GeoJsonFeature[] = []; + const magicFields = Array.from( + new Set([ + ...terenuriFields, + "NR_CAD", + "NR_CF", + "NR_CF_VECHI", + "NR_TOPO", + "ADRESA", + "PROPRIETARI", + "PROPRIETARI_VECHI", + "SUPRAFATA_2D", + "SUPRAFATA_R", + "SOLICITANT", + "INTRAVILAN", + "CATEGORIE_FOLOSINTA", + "HAS_BUILDING", + "BUILD_LEGAL", + ]), + ); + + let hasBuildingCount = 0; + let legalBuildingCount = 0; + + for (const record of dbTerenuri) { + const attrs = record.attributes as Record; + const enrichment = + (record.enrichment as FeatureEnrichment | null) ?? + ({} as Partial); + const geom = record.geometry as GeoJsonFeature["geometry"]; + const geomSource = ( + record as unknown as { geometrySource: string | null } + ).geometrySource; + const hasGeometry = geom != null && geomSource !== "NO_GEOMETRY" ? 1 : 0; + + const e = enrichment as Partial; + if (Number(e.HAS_BUILDING ?? 0)) hasBuildingCount += 1; + if (Number(e.BUILD_LEGAL ?? 0)) legalBuildingCount += 1; + + csvRows.push( + [ + attrs.OBJECTID ?? "", + attrs.IMMOVABLE_ID ?? "", + attrs.APPLICATION_ID ?? "", + attrs.NATIONAL_CADASTRAL_REFERENCE ?? "", + e.NR_CAD ?? "", + attrs.AREA_VALUE ?? "", + e.NR_CF ?? "", + e.NR_CF_VECHI ?? "", + e.NR_TOPO ?? "", + e.ADRESA ?? "", + e.PROPRIETARI ?? "", + e.PROPRIETARI_VECHI ?? "", + e.SUPRAFATA_2D ?? "", + e.SUPRAFATA_R ?? "", + e.SOLICITANT ?? "", + e.INTRAVILAN ?? "", + e.CATEGORIE_FOLOSINTA ?? "", + e.HAS_BUILDING ?? 0, + e.BUILD_LEGAL ?? 0, + hasGeometry, + ] + .map(csvEscape) + .join(","), + ); + + magicFeatures.push({ + type: "Feature", + geometry: geom, + properties: { ...attrs, ...e, HAS_GEOMETRY: hasGeometry }, + }); + } + + const magicGpkg = await buildGpkg({ + srsId: 3844, + srsWkt, + layers: [ + { + name: "TERENURI_MAGIC", + fields: [...magicFields, "HAS_GEOMETRY"], + features: magicFeatures, + includeNullGeometry: true, + }, + ], + }); + + zip.file("terenuri_magic.gpkg", magicGpkg); + zip.file("terenuri_complet.csv", csvRows.join("\n")); + + // ── Quality analysis ── + const withGeomRecords = dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }).geometrySource !== + "NO_GEOMETRY", + ); + const noGeomRecords = dbTerenuri.filter( + (r) => + (r as unknown as { geometrySource: string | null }).geometrySource === + "NO_GEOMETRY", + ); + + const analyzeRecords = (records: typeof dbTerenuri) => { + let enriched = 0, + withOwners = 0, + withOldOwners = 0, + withCF = 0; + let withAddress = 0, + withArea = 0, + withCategory = 0, + withBuilding = 0; + let complete = 0, + partial = 0, + empty = 0; + for (const r of records) { + const en = r.enrichment as Record | null; + if (!en) { + empty++; + continue; + } + enriched++; + const ho = !!en.PROPRIETARI && en.PROPRIETARI !== "-"; + const hoo = + !!en.PROPRIETARI_VECHI && String(en.PROPRIETARI_VECHI).trim() !== ""; + const hc = !!en.NR_CF && en.NR_CF !== "-"; + const ha = !!en.ADRESA && en.ADRESA !== "-"; + const harea = + en.SUPRAFATA_2D != null && + en.SUPRAFATA_2D !== "" && + Number(en.SUPRAFATA_2D) > 0; + const hcat = !!en.CATEGORIE_FOLOSINTA && en.CATEGORIE_FOLOSINTA !== "-"; + const hb = Number(en.HAS_BUILDING ?? 0) === 1; + if (ho) withOwners++; + if (hoo) withOldOwners++; + if (hc) withCF++; + if (ha) withAddress++; + if (harea) withArea++; + if (hcat) withCategory++; + if (hb) withBuilding++; + if (ho && hc && harea) complete++; + else if (ho || hc || ha || harea || hcat) partial++; + else empty++; + } + return { + total: records.length, + enriched, + withOwners, + withOldOwners, + withCF, + withAddress, + withArea, + withCategory, + withBuilding, + complete, + partial, + empty, + }; + }; + + const qAll = analyzeRecords(dbTerenuri); + const qGeo = analyzeRecords(withGeomRecords); + const qNoGeo = analyzeRecords(noGeomRecords); + + // Quality report + const pct = (n: number, t: number) => + t > 0 ? `${((n / t) * 100).toFixed(1)}%` : "0%"; + const fmt = (n: number) => n.toLocaleString("ro-RO"); + const lines: string[] = [ + `══════════════════════════════════════════════════════════`, + ` RAPORT CALITATE DATE — UAT SIRUTA ${siruta}`, + ` Generat: ${new Date().toISOString().replace("T", " ").slice(0, 19)}`, + ` Sursă: bază de date locală (fără conexiune eTerra)`, + `══════════════════════════════════════════════════════════`, + ``, + `STARE BAZĂ DE DATE`, + `─────────────────────────────────────────────────────────`, + ` Total parcele: ${fmt(dbTerenuri.length)}`, + ` • Cu geometrie (contur GIS): ${fmt(withGeomRecords.length)}`, + ` • Fără geometrie (doar date): ${fmt(noGeomRecords.length)}`, + ` Clădiri: ${fmt(cladiriGeo.length)}`, + ``, + `CALITATE ÎMBOGĂȚIRE — TOATE PARCELELE (${fmt(qAll.total)})`, + `─────────────────────────────────────────────────────────`, + ` Îmbogățite: ${fmt(qAll.enriched)} (${pct(qAll.enriched, qAll.total)})`, + ` Cu proprietari: ${fmt(qAll.withOwners)} (${pct(qAll.withOwners, qAll.total)})`, + ` Cu prop. vechi: ${fmt(qAll.withOldOwners)} (${pct(qAll.withOldOwners, qAll.total)})`, + ` Cu nr. CF: ${fmt(qAll.withCF)} (${pct(qAll.withCF, qAll.total)})`, + ` Cu adresă: ${fmt(qAll.withAddress)} (${pct(qAll.withAddress, qAll.total)})`, + ` Cu suprafață: ${fmt(qAll.withArea)} (${pct(qAll.withArea, qAll.total)})`, + ` Cu categorie fol.: ${fmt(qAll.withCategory)} (${pct(qAll.withCategory, qAll.total)})`, + ` Cu clădire: ${fmt(qAll.withBuilding)} (${pct(qAll.withBuilding, qAll.total)})`, + ` ────────────────`, + ` Complete (prop+CF+sup): ${fmt(qAll.complete)} (${pct(qAll.complete, qAll.total)})`, + ` Parțiale: ${fmt(qAll.partial)} (${pct(qAll.partial, qAll.total)})`, + ` Goale (fără date): ${fmt(qAll.empty)} (${pct(qAll.empty, qAll.total)})`, + ``, + ]; + + if (withGeomRecords.length > 0) { + lines.push( + `PARCELE CU GEOMETRIE (${fmt(qGeo.total)})`, + `─────────────────────────────────────────────────────────`, + ` Complete: ${fmt(qGeo.complete)} Parțiale: ${fmt(qGeo.partial)} Goale: ${fmt(qGeo.empty)}`, + ``, + ); + } + if (noGeomRecords.length > 0) { + lines.push( + `PARCELE FĂRĂ GEOMETRIE (${fmt(qNoGeo.total)})`, + `─────────────────────────────────────────────────────────`, + ` Complete: ${fmt(qNoGeo.complete)} Parțiale: ${fmt(qNoGeo.partial)} Goale: ${fmt(qNoGeo.empty)}`, + ``, + ); + } + lines.push( + `NOTE`, + `─────────────────────────────────────────────────────────`, + ` • Export din baza de date locală — fără conexiune eTerra`, + ` • "Complete" = are proprietari + nr. CF + suprafață`, + `══════════════════════════════════════════════════════════`, + ); + zip.file("raport_calitate.txt", lines.join("\n")); + + const report = { + siruta, + generatedAt: new Date().toISOString(), + source: "local-db (descărcare din DB)", + terenuri: { + total: dbTerenuri.length, + withGeom: withGeomRecords.length, + noGeom: noGeomRecords.length, + }, + cladiri: { count: cladiriGeo.length }, + magic: { + csvRows: csvRows.length - 1, + hasBuildingCount, + legalBuildingCount, + }, + quality: { all: qAll, withGeom: qGeo, noGeom: qNoGeo }, + }; + zip.file("export_report.json", JSON.stringify(report, null, 2)); + } + + const zipBuf = await zip.generateAsync({ + type: "nodebuffer", + compression: "STORE", + }); + const filename = + mode === "magic" + ? `eterra_uat_${siruta}_magic_local.zip` + : `eterra_uat_${siruta}_local.zip`; + + return new Response(new Uint8Array(zipBuf), { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} + +/* ────────────────────────────────────────────────────────── */ +/* Layer GPKG builder */ +/* ────────────────────────────────────────────────────────── */ + async function buildLayerGpkg(siruta: string, layerId: string) { const features = await prisma.gisFeature.findMany({ where: { layerId, siruta }, @@ -115,7 +481,6 @@ async function buildLayerGpkg(siruta: string, layerId: string) { throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`); } - // Reconstruct GeoJSON features from DB records const geoFeatures: GeoJsonFeature[] = features .filter((f) => f.geometry != null) .map((f) => ({ @@ -124,9 +489,7 @@ async function buildLayerGpkg(siruta: string, layerId: string) { properties: f.attributes as Record, })); - // Collect field names from first feature const fields = Object.keys(geoFeatures[0]?.properties ?? {}); - const layer = findLayerById(layerId); const name = layer?.name ?? layerId; diff --git a/src/app/api/eterra/sync-background/route.ts b/src/app/api/eterra/sync-background/route.ts new file mode 100644 index 0000000..ee7e8cd --- /dev/null +++ b/src/app/api/eterra/sync-background/route.ts @@ -0,0 +1,311 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * POST /api/eterra/sync-background + * + * Starts a background sync + enrichment job on the server. + * Returns immediately with the jobId — work continues in-process. + * Progress is tracked via /api/eterra/progress?jobId=... + * + * The user can close the browser and come back later; + * data is written to PostgreSQL and persists across sessions. + * + * Body: { siruta, mode?: "base"|"magic", forceSync?: boolean, includeNoGeometry?: boolean } + */ + +import { + getSessionCredentials, + registerJob, + unregisterJob, +} from "@/modules/parcel-sync/services/session-store"; +import { + setProgress, + clearProgress, + type SyncProgress, +} from "@/modules/parcel-sync/services/progress-store"; +import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; +import { + enrichFeatures, + getLayerFreshness, + isFresh, +} from "@/modules/parcel-sync/services/enrich-service"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Body = { + username?: string; + password?: string; + siruta?: string | number; + mode?: "base" | "magic"; + forceSync?: boolean; + includeNoGeometry?: boolean; +}; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const session = getSessionCredentials(); + const username = String( + body.username || session?.username || process.env.ETERRA_USERNAME || "", + ).trim(); + const password = String( + body.password || session?.password || process.env.ETERRA_PASSWORD || "", + ).trim(); + const siruta = String(body.siruta ?? "").trim(); + const mode = body.mode === "magic" ? "magic" : "base"; + const forceSync = body.forceSync === true; + const includeNoGeometry = body.includeNoGeometry === true; + + if (!username || !password) { + return Response.json( + { error: "Credențiale lipsă — conectează-te la eTerra." }, + { status: 401 }, + ); + } + if (!/^\d+$/.test(siruta)) { + return Response.json({ error: "SIRUTA invalid" }, { status: 400 }); + } + + const jobId = crypto.randomUUID(); + registerJob(jobId); + + // Set initial progress so the UI picks it up immediately + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "running", + phase: "Pornire sincronizare fundal", + }); + + // Fire and forget — runs in the Node.js event loop after the response is sent. + // In Docker standalone mode the Node.js process is long-lived. + void runBackground({ + jobId, + username, + password, + siruta, + mode, + forceSync, + includeNoGeometry, + }); + + return Response.json( + { + jobId, + message: `Sincronizare ${mode === "magic" ? "Magic" : "bază"} pornită în fundal.`, + }, + { status: 202 }, + ); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return Response.json({ error: msg }, { status: 500 }); + } +} + +/* ────────────────────────────────────────────────── */ +/* Background worker */ +/* ────────────────────────────────────────────────── */ + +async function runBackground(params: { + jobId: string; + username: string; + password: string; + siruta: string; + mode: "base" | "magic"; + forceSync: boolean; + includeNoGeometry: boolean; +}) { + const { + jobId, + username, + password, + siruta, + mode, + forceSync, + includeNoGeometry, + } = params; + + // Weighted progress (same logic as export-bundle) + let completedWeight = 0; + let currentWeight = 0; + let phase = "Inițializare"; + let note: string | undefined; + + const push = (partial: Partial) => { + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "running", + phase, + note, + ...partial, + } as SyncProgress); + }; + + const updateOverall = (fraction = 0) => { + const overall = completedWeight + currentWeight * fraction; + push({ + downloaded: Number(Math.min(100, Math.max(0, overall)).toFixed(1)), + total: 100, + }); + }; + + const setPhase = (next: string, weight: number) => { + phase = next; + currentWeight = weight; + note = undefined; + updateOverall(0); + }; + + const finishPhase = () => { + completedWeight += currentWeight; + currentWeight = 0; + note = undefined; + updateOverall(0); + }; + + try { + const isMagic = mode === "magic"; + const hasNoGeom = includeNoGeometry; + const weights = isMagic + ? hasNoGeom + ? { sync: 35, noGeom: 10, enrich: 55 } + : { sync: 40, noGeom: 0, enrich: 60 } + : hasNoGeom + ? { sync: 70, noGeom: 30, enrich: 0 } + : { sync: 100, noGeom: 0, enrich: 0 }; + + /* ── Phase 1: Sync GIS layers ──────────────────────── */ + setPhase("Verificare date locale", weights.sync); + + const [terenuriStatus, cladiriStatus] = await Promise.all([ + getLayerFreshness(siruta, "TERENURI_ACTIVE"), + getLayerFreshness(siruta, "CLADIRI_ACTIVE"), + ]); + + const terenuriNeedsSync = + forceSync || + !isFresh(terenuriStatus.lastSynced) || + terenuriStatus.featureCount === 0; + const cladiriNeedsSync = + forceSync || + !isFresh(cladiriStatus.lastSynced) || + cladiriStatus.featureCount === 0; + + if (terenuriNeedsSync) { + phase = "Sincronizare terenuri"; + push({}); + const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", { + forceFullSync: forceSync, + jobId, + isSubStep: true, + }); + if (r.status === "error") + throw new Error(r.error ?? "Sync terenuri failed"); + } + updateOverall(0.5); + + if (cladiriNeedsSync) { + phase = "Sincronizare clădiri"; + push({}); + const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", { + forceFullSync: forceSync, + jobId, + isSubStep: true, + }); + if (r.status === "error") + throw new Error(r.error ?? "Sync clădiri failed"); + } + + if (!terenuriNeedsSync && !cladiriNeedsSync) { + note = "Date proaspete — sync skip"; + } + finishPhase(); + + /* ── Phase 2: No-geometry import (optional) ──────── */ + if (hasNoGeom && weights.noGeom > 0) { + setPhase("Import parcele fără geometrie", weights.noGeom); + const noGeomClient = await EterraClient.create(username, password, { + timeoutMs: 120_000, + }); + const res = await syncNoGeometryParcels(noGeomClient, siruta, { + onProgress: (done, tot, ph) => { + phase = ph; + push({}); + }, + }); + if (res.status === "error") { + note = `Avertisment no-geom: ${res.error}`; + push({}); + } else { + const cleanNote = + res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : ""; + note = `${res.imported} parcele noi importate${cleanNote}`; + push({}); + } + finishPhase(); + } + + /* ── Phase 3: Enrich (magic mode only) ────────────── */ + if (isMagic) { + setPhase("Verificare îmbogățire", weights.enrich); + const enrichStatus = await getLayerFreshness(siruta, "TERENURI_ACTIVE"); + const needsEnrich = + forceSync || + enrichStatus.enrichedCount === 0 || + enrichStatus.enrichedCount < enrichStatus.featureCount; + + if (needsEnrich) { + phase = "Îmbogățire parcele (CF, proprietari, adrese)"; + push({}); + const client = await EterraClient.create(username, password, { + timeoutMs: 120_000, + }); + const enrichResult = await enrichFeatures(client, siruta, { + onProgress: (done, tot, ph) => { + phase = ph; + const frac = tot > 0 ? done / tot : 0; + updateOverall(frac); + }, + }); + note = + enrichResult.status === "done" + ? `Îmbogățite ${enrichResult.enrichedCount}/${enrichResult.totalFeatures ?? "?"}` + : `Eroare: ${enrichResult.error}`; + } else { + note = "Îmbogățire existentă — skip"; + } + finishPhase(); + } + + /* ── Done ──────────────────────────────────────────── */ + setProgress({ + jobId, + downloaded: 100, + total: 100, + status: "done", + phase: "Sincronizare completă", + message: `Datele sunt în baza de date. Descarcă ZIP-ul de acolo oricând.`, + note, + }); + unregisterJob(jobId); + // Keep progress visible for 1 hour (background jobs stay longer) + setTimeout(() => clearProgress(jobId), 3_600_000); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare necunoscută"; + setProgress({ + jobId, + downloaded: 0, + total: 100, + status: "error", + phase: "Eroare sincronizare fundal", + message: msg, + }); + unregisterJob(jobId); + setTimeout(() => clearProgress(jobId), 3_600_000); + } +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index e069780..589777b 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -414,6 +414,13 @@ export function ParcelSyncModule() { } | null>(null); const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done + /* ── Background sync state ──────────────────────────────────── */ + const [bgJobId, setBgJobId] = useState(null); + const [bgProgress, setBgProgress] = useState(null); + const [bgPhaseTrail, setBgPhaseTrail] = useState([]); + const bgPollingRef = useRef | null>(null); + const [downloadingFromDb, setDownloadingFromDb] = useState(false); + /* ════════════════════════════════════════════════════════════ */ /* Load UAT data + check server session on mount */ /* ════════════════════════════════════════════════════════════ */ @@ -988,6 +995,192 @@ export function ParcelSyncModule() { [siruta, exportingLocal], ); + /* ════════════════════════════════════════════════════════════ */ + /* Background sync — fire-and-forget server-side processing */ + /* ════════════════════════════════════════════════════════════ */ + + const startBgPolling = useCallback( + (jid: string) => { + if (bgPollingRef.current) clearInterval(bgPollingRef.current); + bgPollingRef.current = setInterval(async () => { + try { + const res = await fetch( + `/api/eterra/progress?jobId=${encodeURIComponent(jid)}`, + ); + const data = (await res.json()) as ExportProgress; + setBgProgress(data); + if (data.phase) { + setBgPhaseTrail((prev) => { + if (prev[prev.length - 1] === data.phase) return prev; + return [...prev, data.phase!]; + }); + } + if (data.status === "done" || data.status === "error") { + if (bgPollingRef.current) { + clearInterval(bgPollingRef.current); + bgPollingRef.current = null; + } + // Clean localStorage marker + try { + localStorage.removeItem("parcel-sync:bg-job"); + } catch { + /* */ + } + // Refresh sync status and DB summary + refreshSyncRef.current?.(); + void fetchDbSummary(); + } + } catch { + /* ignore polling errors */ + } + }, 1500); + }, + [fetchDbSummary], + ); + + // Cleanup bg polling on unmount + useEffect(() => { + return () => { + if (bgPollingRef.current) clearInterval(bgPollingRef.current); + }; + }, []); + + // Recover background job from localStorage on mount + useEffect(() => { + try { + const raw = localStorage.getItem("parcel-sync:bg-job"); + if (!raw) return; + const saved = JSON.parse(raw) as { + jobId: string; + siruta: string; + startedAt: string; + }; + // Ignore jobs older than 2 hours + const age = Date.now() - new Date(saved.startedAt).getTime(); + if (age > 2 * 60 * 60 * 1000) { + localStorage.removeItem("parcel-sync:bg-job"); + return; + } + // Check if job is still running + void (async () => { + try { + const res = await fetch( + `/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`, + ); + const data = (await res.json()) as ExportProgress; + if (data.status === "running") { + setBgJobId(saved.jobId); + setBgProgress(data); + if (data.phase) setBgPhaseTrail([data.phase]); + startBgPolling(saved.jobId); + } else if (data.status === "done") { + setBgJobId(saved.jobId); + setBgProgress(data); + if (data.phase) setBgPhaseTrail(["Sincronizare completă"]); + localStorage.removeItem("parcel-sync:bg-job"); + } else { + localStorage.removeItem("parcel-sync:bg-job"); + } + } catch { + localStorage.removeItem("parcel-sync:bg-job"); + } + })(); + } catch { + /* */ + } + }, [startBgPolling]); + + const handleSyncBackground = useCallback( + async (mode: "base" | "magic") => { + if (!siruta || exporting) return; + try { + const res = await fetch("/api/eterra/sync-background", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + mode, + includeNoGeometry: includeNoGeom, + }), + }); + const data = (await res.json()) as { jobId?: string; error?: string }; + if (!res.ok || data.error) { + setSyncProgress(data.error ?? `Eroare ${res.status}`); + setTimeout(() => setSyncProgress(""), 5_000); + return; + } + const jid = data.jobId!; + setBgJobId(jid); + setBgProgress({ + jobId: jid, + downloaded: 0, + total: 100, + status: "running", + phase: "Pornire sincronizare fundal", + }); + setBgPhaseTrail(["Pornire sincronizare fundal"]); + // Persist in localStorage so we can recover on page refresh + try { + localStorage.setItem( + "parcel-sync:bg-job", + JSON.stringify({ + jobId: jid, + siruta, + startedAt: new Date().toISOString(), + }), + ); + } catch { + /* */ + } + startBgPolling(jid); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare rețea"; + setSyncProgress(msg); + setTimeout(() => setSyncProgress(""), 5_000); + } + }, + [siruta, exporting, includeNoGeom, startBgPolling], + ); + + const handleDownloadFromDb = useCallback( + async (mode: "base" | "magic") => { + if (!siruta || downloadingFromDb) return; + setDownloadingFromDb(true); + try { + const res = await fetch("/api/eterra/export-local", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siruta, mode }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^"]+)"?/.exec(cd); + const filename = match?.[1] ?? `eterra_uat_${siruta}_${mode}_local.zip`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (error) { + const msg = + error instanceof Error ? error.message : "Eroare descărcare"; + setSyncProgress(msg); + setTimeout(() => setSyncProgress(""), 5_000); + } + setDownloadingFromDb(false); + }, + [siruta, downloadingFromDb], + ); + // Sync multiple layers sequentially (for "sync all" / "sync category") const [syncQueue, setSyncQueue] = useState([]); const syncQueueRef = useRef([]); @@ -2815,6 +3008,273 @@ export function ParcelSyncModule() { return null; })()} + {/* ── Background sync + Download from DB ──────────────── */} + {sirutaValid && ( + + + {/* Row 1: Section label */} +
+ + + Procesare fundal & descărcare din DB + + + — pornește sincronizarea, închide pagina, descarcă mai târziu + +
+ + {/* Row 2: Background sync buttons */} + {session.connected && ( +
+ + +
+ )} + + {/* Row 3: Download from DB buttons */} + {dbTotalFeatures > 0 && ( +
+ + +
+ )} + + {!session.connected && dbTotalFeatures === 0 && ( +

+ Conectează-te la eTerra pentru a porni sincronizarea fundal, + sau sincronizează mai întâi date în baza locală. +

+ )} +
+
+ )} + + {/* Background sync progress */} + {bgJobId && bgProgress && bgProgress.status !== "unknown" && ( + + + {/* Label */} +
+ + + Sincronizare fundal + + {bgProgress.status === "running" && ( + + (poți închide pagina) + + )} +
+ + {/* Phase trail */} +
+ {bgPhaseTrail.map((p, i) => ( + + {i > 0 && } + + {p} + + + ))} +
+ + {/* Progress info */} +
+ {bgProgress.status === "running" && ( + + )} + {bgProgress.status === "done" && ( + + )} + {bgProgress.status === "error" && ( + + )} +
+

{bgProgress.phase}

+ {bgProgress.note && ( +

+ {bgProgress.note} +

+ )} + {bgProgress.message && ( +

+ {bgProgress.message} +

+ )} +
+ + {bgProgress.total && bgProgress.total > 0 + ? Math.round( + (bgProgress.downloaded / bgProgress.total) * 100, + ) + : 0} + % + +
+ + {/* Bar */} +
+
0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`, + }} + /> +
+ + {/* Done — show download from DB button */} + {bgProgress.status === "done" && ( +
+ + +
+ )} + + + )} + {/* Progress bar */} {exportProgress && exportProgress.status !== "unknown" &&