Files
ArchiTools/src/app/api/eterra/sync-background/route.ts
T
AI Assistant bde25d8d84 feat(parcel-sync): add LIMITE_UAT to sync package everywhere
All sync paths now include both admin layers (LIMITE_INTRAV_DYNAMIC +
LIMITE_UAT) as best-effort alongside terenuri + cladiri:
- export-bundle (hero buttons)
- sync-background (fire-and-forget)
- auto-refresh scheduler (weekday nights)
- weekend deep sync (weekend nights)
- freshness check (export tab badge)

LIMITE_UAT rarely changes so incremental sync will skip it almost
every time, but it stays fresh in the DB freshness check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:13:29 +02:00

329 lines
9.8 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 admin layers (always, lightweight)
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
push({});
try {
await syncLayer(username, password, siruta, adminLayer, {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
} catch {
// Non-critical — don't fail the whole job
note = `Avertisment: ${adminLayer} nu s-a 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);
}
}