feat(parcel-sync): background sync + download from DB
- 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
This commit is contained in:
@@ -1,16 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/eterra/export-local
|
* 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.
|
* 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 { prisma } from "@/core/storage/prisma";
|
||||||
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
|
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
|
||||||
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
|
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
|
||||||
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
||||||
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
||||||
|
import type { FeatureEnrichment } from "@/modules/parcel-sync/services/enrich-service";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -18,28 +25,35 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
type Body = {
|
type Body = {
|
||||||
siruta?: string;
|
siruta?: string;
|
||||||
|
mode?: "base" | "magic";
|
||||||
layerIds?: string[];
|
layerIds?: string[];
|
||||||
allLayers?: boolean;
|
allLayers?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const csvEscape = (val: unknown) => {
|
||||||
|
const s = String(val ?? "").replace(/"/g, '""');
|
||||||
|
return `"${s}"`;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Body;
|
const body = (await req.json()) as Body;
|
||||||
const siruta = String(body.siruta ?? "").trim();
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
|
||||||
if (!siruta || !/^\d+$/.test(siruta)) {
|
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||||
return new Response(JSON.stringify({ error: "SIRUTA invalid" }), {
|
return Response.json({ error: "SIRUTA invalid" }, { status: 400 });
|
||||||
status: 400,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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[];
|
let layerIds: string[];
|
||||||
if (body.layerIds?.length) {
|
if (body.layerIds?.length) {
|
||||||
layerIds = body.layerIds;
|
layerIds = body.layerIds;
|
||||||
} else if (body.allLayers) {
|
} else if (body.allLayers) {
|
||||||
// Find all layers that have data for this siruta
|
|
||||||
const layerGroups = await prisma.gisFeature.groupBy({
|
const layerGroups = await prisma.gisFeature.groupBy({
|
||||||
by: ["layerId"],
|
by: ["layerId"],
|
||||||
where: { siruta },
|
where: { siruta },
|
||||||
@@ -49,22 +63,19 @@ export async function POST(req: Request) {
|
|||||||
.filter((g) => g._count.id > 0)
|
.filter((g) => g._count.id > 0)
|
||||||
.map((g) => g.layerId);
|
.map((g) => g.layerId);
|
||||||
} else {
|
} else {
|
||||||
return new Response(
|
return Response.json(
|
||||||
JSON.stringify({ error: "Specifică layerIds sau allLayers=true" }),
|
{ error: "Specifică mode, layerIds, sau allLayers=true" },
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerIds.length === 0) {
|
if (layerIds.length === 0) {
|
||||||
return new Response(
|
return Response.json(
|
||||||
JSON.stringify({
|
{ error: "Niciun layer sincronizat în baza de date pentru acest UAT" },
|
||||||
error: "Niciun layer sincronizat în baza de date pentru acest UAT",
|
{ status: 404 },
|
||||||
}),
|
|
||||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If single layer, return GPKG directly. If multiple, ZIP them.
|
|
||||||
if (layerIds.length === 1) {
|
if (layerIds.length === 1) {
|
||||||
const layerId = layerIds[0]!;
|
const layerId = layerIds[0]!;
|
||||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||||
@@ -78,33 +89,388 @@ export async function POST(req: Request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple layers — ZIP
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
for (const layerId of layerIds) {
|
for (const layerId of layerIds) {
|
||||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||||
const layer = findLayerById(layerId);
|
const layer = findLayerById(layerId);
|
||||||
const name = layer?.name ?? layerId;
|
zip.file(`${layer?.name ?? layerId}.gpkg`, gpkg);
|
||||||
zip.file(`${name}.gpkg`, gpkg);
|
|
||||||
}
|
}
|
||||||
|
const zipBuf = await zip.generateAsync({ type: "nodebuffer" });
|
||||||
const zipBuffer = await zip.generateAsync({ type: "uint8array" });
|
return new Response(new Uint8Array(zipBuf), {
|
||||||
const filename = `eterra_local_${siruta}_${layerIds.length}layers.zip`;
|
|
||||||
return new Response(Buffer.from(zipBuffer), {
|
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/zip",
|
"Content-Type": "application/zip",
|
||||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
"Content-Disposition": `attachment; filename="eterra_local_${siruta}.zip"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
return new Response(JSON.stringify({ error: message }), {
|
return Response.json({ error: message }, { status: 500 });
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<string, unknown>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
const enrichment =
|
||||||
|
(record.enrichment as FeatureEnrichment | null) ??
|
||||||
|
({} as Partial<FeatureEnrichment>);
|
||||||
|
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<FeatureEnrichment>;
|
||||||
|
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<string, unknown> | 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) {
|
async function buildLayerGpkg(siruta: string, layerId: string) {
|
||||||
const features = await prisma.gisFeature.findMany({
|
const features = await prisma.gisFeature.findMany({
|
||||||
where: { layerId, siruta },
|
where: { layerId, siruta },
|
||||||
@@ -115,7 +481,6 @@ async function buildLayerGpkg(siruta: string, layerId: string) {
|
|||||||
throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`);
|
throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct GeoJSON features from DB records
|
|
||||||
const geoFeatures: GeoJsonFeature[] = features
|
const geoFeatures: GeoJsonFeature[] = features
|
||||||
.filter((f) => f.geometry != null)
|
.filter((f) => f.geometry != null)
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -124,9 +489,7 @@ async function buildLayerGpkg(siruta: string, layerId: string) {
|
|||||||
properties: f.attributes as Record<string, unknown>,
|
properties: f.attributes as Record<string, unknown>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Collect field names from first feature
|
|
||||||
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
|
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
|
||||||
|
|
||||||
const layer = findLayerById(layerId);
|
const layer = findLayerById(layerId);
|
||||||
const name = layer?.name ?? layerId;
|
const name = layer?.name ?? layerId;
|
||||||
|
|
||||||
|
|||||||
@@ -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<SyncProgress>) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -414,6 +414,13 @@ export function ParcelSyncModule() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
|
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
|
||||||
|
|
||||||
|
/* ── Background sync state ──────────────────────────────────── */
|
||||||
|
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
||||||
|
const [bgProgress, setBgProgress] = useState<ExportProgress | null>(null);
|
||||||
|
const [bgPhaseTrail, setBgPhaseTrail] = useState<string[]>([]);
|
||||||
|
const bgPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const [downloadingFromDb, setDownloadingFromDb] = useState(false);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* Load UAT data + check server session on mount */
|
/* Load UAT data + check server session on mount */
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
@@ -988,6 +995,192 @@ export function ParcelSyncModule() {
|
|||||||
[siruta, exportingLocal],
|
[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")
|
// Sync multiple layers sequentially (for "sync all" / "sync category")
|
||||||
const [syncQueue, setSyncQueue] = useState<string[]>([]);
|
const [syncQueue, setSyncQueue] = useState<string[]>([]);
|
||||||
const syncQueueRef = useRef<string[]>([]);
|
const syncQueueRef = useRef<string[]>([]);
|
||||||
@@ -2815,6 +3008,273 @@ export function ParcelSyncModule() {
|
|||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* ── Background sync + Download from DB ──────────────── */}
|
||||||
|
{sirutaValid && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="py-3 px-4 space-y-3">
|
||||||
|
{/* Row 1: Section label */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Procesare fundal & descărcare din DB
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
— pornește sincronizarea, închide pagina, descarcă mai târziu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Background sync buttons */}
|
||||||
|
{session.connected && (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-2.5 justify-start"
|
||||||
|
disabled={
|
||||||
|
exporting ||
|
||||||
|
(!!bgJobId && bgProgress?.status === "running")
|
||||||
|
}
|
||||||
|
onClick={() => void handleSyncBackground("base")}
|
||||||
|
>
|
||||||
|
{bgJobId &&
|
||||||
|
bgProgress?.status === "running" &&
|
||||||
|
!bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownToLine className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs font-semibold">
|
||||||
|
Sync fundal — Bază
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] opacity-60 font-normal">
|
||||||
|
Terenuri + clădiri → salvează în DB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||||||
|
disabled={
|
||||||
|
exporting ||
|
||||||
|
(!!bgJobId && bgProgress?.status === "running")
|
||||||
|
}
|
||||||
|
onClick={() => void handleSyncBackground("magic")}
|
||||||
|
>
|
||||||
|
{bgJobId &&
|
||||||
|
bgProgress?.status === "running" &&
|
||||||
|
bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs font-semibold">
|
||||||
|
Sync fundal — Magic
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] opacity-60 font-normal">
|
||||||
|
Sync + îmbogățire → salvează în DB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: Download from DB buttons */}
|
||||||
|
{dbTotalFeatures > 0 && (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-2.5 justify-start"
|
||||||
|
disabled={downloadingFromDb}
|
||||||
|
onClick={() => void handleDownloadFromDb("base")}
|
||||||
|
>
|
||||||
|
{downloadingFromDb ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Database className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs font-semibold">
|
||||||
|
Descarcă din DB — Bază
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] opacity-60 font-normal">
|
||||||
|
GPKG terenuri + clădiri (instant, fără eTerra)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||||||
|
disabled={downloadingFromDb}
|
||||||
|
onClick={() => void handleDownloadFromDb("magic")}
|
||||||
|
>
|
||||||
|
{downloadingFromDb ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs font-semibold">
|
||||||
|
Descarcă din DB — Magic
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] opacity-60 font-normal">
|
||||||
|
GPKG + CSV + raport calitate (instant)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!session.connected && dbTotalFeatures === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
|
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||||||
|
sau sincronizează mai întâi date în baza locală.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background sync progress */}
|
||||||
|
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"border-2 transition-colors",
|
||||||
|
bgProgress.status === "running" &&
|
||||||
|
"border-indigo-300 dark:border-indigo-700",
|
||||||
|
bgProgress.status === "error" &&
|
||||||
|
"border-rose-300 dark:border-rose-700",
|
||||||
|
bgProgress.status === "done" &&
|
||||||
|
"border-emerald-400 dark:border-emerald-600",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4 space-y-3">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<HardDrive className="h-3.5 w-3.5 text-indigo-500" />
|
||||||
|
<span className="font-semibold text-indigo-700 dark:text-indigo-400">
|
||||||
|
Sincronizare fundal
|
||||||
|
</span>
|
||||||
|
{bgProgress.status === "running" && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
(poți închide pagina)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase trail */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||||
|
{bgPhaseTrail.map((p, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="opacity-40">→</span>}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
i === bgPhaseTrail.length - 1
|
||||||
|
? "font-semibold text-foreground"
|
||||||
|
: "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress info */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{bgProgress.status === "running" && (
|
||||||
|
<Loader2 className="h-5 w-5 text-indigo-600 animate-spin shrink-0" />
|
||||||
|
)}
|
||||||
|
{bgProgress.status === "done" && (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||||
|
)}
|
||||||
|
{bgProgress.status === "error" && (
|
||||||
|
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{bgProgress.phase}</p>
|
||||||
|
{bgProgress.note && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{bgProgress.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bgProgress.message && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs mt-0.5",
|
||||||
|
bgProgress.status === "error"
|
||||||
|
? "text-rose-500"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bgProgress.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
|
||||||
|
{bgProgress.total && bgProgress.total > 0
|
||||||
|
? Math.round(
|
||||||
|
(bgProgress.downloaded / bgProgress.total) * 100,
|
||||||
|
)
|
||||||
|
: 0}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar */}
|
||||||
|
<div className="h-2.5 w-full rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2.5 rounded-full transition-all duration-300",
|
||||||
|
bgProgress.status === "running" && "bg-indigo-500",
|
||||||
|
bgProgress.status === "done" && "bg-emerald-500",
|
||||||
|
bgProgress.status === "error" && "bg-rose-500",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(2, bgProgress.total && bgProgress.total > 0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Done — show download from DB button */}
|
||||||
|
{bgProgress.status === "done" && (
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs border-teal-200 dark:border-teal-800"
|
||||||
|
disabled={downloadingFromDb}
|
||||||
|
onClick={() => void handleDownloadFromDb("magic")}
|
||||||
|
>
|
||||||
|
{downloadingFromDb ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Database className="mr-1.5 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Descarcă din DB (Magic)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setBgJobId(null);
|
||||||
|
setBgProgress(null);
|
||||||
|
setBgPhaseTrail([]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Închide
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
{exportProgress &&
|
{exportProgress &&
|
||||||
exportProgress.status !== "unknown" &&
|
exportProgress.status !== "unknown" &&
|
||||||
|
|||||||
Reference in New Issue
Block a user