3da45a4cab
Map tab: when UAT has no local data, shows a "Sincronizează terenuri, clădiri și intravilan" button that triggers background base sync. Sync background (base mode): now also syncs LIMITE_INTRAV_DYNAMIC layer (intravilan boundaries) alongside TERENURI_ACTIVE + CLADIRI_ACTIVE. Non-critical — if intravilan fails, the rest continues. Also fixed remaining \u2192 unicode escapes in export/layers/epay tabs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
327 lines
9.7 KiB
TypeScript
327 lines
9.7 KiB
TypeScript
/* 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");
|
|
}
|
|
|
|
// Sync intravilan limits (always, lightweight layer)
|
|
phase = "Sincronizare limite intravilan";
|
|
push({});
|
|
try {
|
|
await syncLayer(username, password, siruta, "LIMITE_INTRAV_DYNAMIC", {
|
|
forceFullSync: forceSync,
|
|
jobId,
|
|
isSubStep: true,
|
|
});
|
|
} catch {
|
|
// Non-critical — don't fail the whole job
|
|
note = "Avertisment: limite intravilan nu s-au sincronizat";
|
|
push({});
|
|
}
|
|
|
|
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 6 hours (background jobs can be very long)
|
|
setTimeout(() => clearProgress(jobId), 6 * 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), 6 * 3_600_000);
|
|
}
|
|
}
|