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:
AI Assistant
2026-03-08 01:53:24 +02:00
parent bcc7a54325
commit c43082baee
3 changed files with 1167 additions and 33 deletions
+396 -33
View File
@@ -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<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) {
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<string, unknown>,
}));
// Collect field names from first feature
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
const layer = findLayerById(layerId);
const name = layer?.name ?? layerId;
+311
View File
@@ -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);
}
}