feat(parcel-sync): incremental sync, smart export, auto-refresh + weekend deep sync
Sync Incremental: - Add fetchObjectIds (returnIdsOnly) to eterra-client — fetches only OBJECTIDs in 1 request - Add fetchFeaturesByObjectIds — downloads only delta features by OBJECTID IN (...) - Rewrite syncLayer: compare remote IDs vs local, download only new features - Fallback to full sync for first sync, forceFullSync, or delta > 50% - Reduces sync time from ~10 min to ~5-10s for typical updates Smart Export Tab: - Hero buttons detect DB freshness — use export-local (instant) when data is fresh - Dynamic subtitles: "Din DB (sync acum Xh)" / "Sync incremental" / "Sync complet" - Re-sync link when data is fresh but user wants forced refresh - Removed duplicate "Descarca din DB" buttons from background section Auto-Refresh Scheduler: - Self-contained timer via instrumentation.ts (Next.js startup hook) - Weekday 1-5 AM: incremental refresh for existing UATs in DB - Staggered processing with random delays between UATs - Health check before processing, respects eTerra maintenance Weekend Deep Sync: - Full Magic processing for 9 large municipalities (Cluj, Bistrita, TgMures, etc.) - Runs Fri/Sat/Sun 23:00-04:00, round-robin intercalated between cities - 4 steps per city: sync terenuri, sync cladiri, import no-geom, enrichment - State persisted in KeyValueStore — survives restarts, continues across nights - Email status report at end of each session via Brevo SMTP - Admin page at /wds: add/remove cities, view progress, reset - Hint link on export tab pointing to /wds API endpoints: - POST /api/eterra/auto-refresh — N8N-compatible cron endpoint (Bearer token auth) - GET/POST /api/eterra/weekend-sync — queue management for /wds page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -68,6 +68,8 @@ services:
|
|||||||
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
||||||
- NOTIFICATION_FROM_NAME=Alerte Termene
|
- NOTIFICATION_FROM_NAME=Alerte Termene
|
||||||
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
||||||
|
# Weekend Deep Sync email reports (comma-separated for multiple recipients)
|
||||||
|
- WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-}
|
||||||
# Portal-only users (comma-separated, redirected to /portal)
|
# Portal-only users (comma-separated, redirected to /portal)
|
||||||
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
|
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
|
||||||
# Address Book API (inter-service auth for external tools)
|
# Address Book API (inter-service auth for external tools)
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
|
Moon,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { Input } from "@/shared/components/ui/input";
|
||||||
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
dbStats?: {
|
||||||
|
terenuri: number;
|
||||||
|
cladiri: number;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueueState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS: StepName[] = [
|
||||||
|
"sync_terenuri",
|
||||||
|
"sync_cladiri",
|
||||||
|
"import_nogeom",
|
||||||
|
"enrich",
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_LABELS: Record<StepName, string> = {
|
||||||
|
sync_terenuri: "Terenuri",
|
||||||
|
sync_cladiri: "Cladiri",
|
||||||
|
import_nogeom: "No-geom",
|
||||||
|
enrich: "Enrichment",
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Page */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export default function WeekendDeepSyncPage() {
|
||||||
|
const [state, setState] = useState<QueueState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Add city form
|
||||||
|
const [newSiruta, setNewSiruta] = useState("");
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newCounty, setNewCounty] = useState("");
|
||||||
|
|
||||||
|
const fetchState = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/weekend-sync");
|
||||||
|
const data = (await res.json()) as { state: QueueState | null };
|
||||||
|
setState(data.state);
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchState();
|
||||||
|
}, [fetchState]);
|
||||||
|
|
||||||
|
const doAction = async (body: Record<string, unknown>) => {
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await fetch("/api/eterra/weekend-sync", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await fetchState();
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
}
|
||||||
|
setActionLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newSiruta.trim() || !newName.trim()) return;
|
||||||
|
await doAction({
|
||||||
|
action: "add",
|
||||||
|
siruta: newSiruta.trim(),
|
||||||
|
name: newName.trim(),
|
||||||
|
county: newCounty.trim(),
|
||||||
|
priority: 3,
|
||||||
|
});
|
||||||
|
setNewSiruta("");
|
||||||
|
setNewName("");
|
||||||
|
setNewCounty("");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl py-12 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||||
|
<p>Se incarca...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cities = state?.cities ?? [];
|
||||||
|
const totalSteps = cities.length * STEPS.length;
|
||||||
|
const doneSteps = cities.reduce(
|
||||||
|
(sum, c) => sum + STEPS.filter((s) => c.steps[s] === "done").length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const progressPct = totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||||
|
<Moon className="h-6 w-6 text-indigo-500" />
|
||||||
|
Weekend Deep Sync
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Sincronizare Magic completa pentru municipii mari — Vin/Sam/Dum
|
||||||
|
23:00-04:00
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void fetchState()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Reincarca
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">{cities.length}</span> orase in
|
||||||
|
coada
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Progres ciclu:{" "}
|
||||||
|
<span className="font-semibold">{doneSteps}/{totalSteps}</span>{" "}
|
||||||
|
pasi ({progressPct}%)
|
||||||
|
</span>
|
||||||
|
{state?.totalSessions != null && state.totalSessions > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{state.totalSessions} sesiuni | {state.completedCycles ?? 0}{" "}
|
||||||
|
cicluri complete
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{state?.lastSessionDate && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Ultima sesiune: {state.lastSessionDate}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<div className="h-2 w-full rounded-full bg-muted mt-2">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-indigo-500 transition-all duration-300"
|
||||||
|
style={{ width: `${Math.max(1, progressPct)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* City cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{cities
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((city) => {
|
||||||
|
const doneCount = STEPS.filter(
|
||||||
|
(s) => city.steps[s] === "done",
|
||||||
|
).length;
|
||||||
|
const hasError = STEPS.some((s) => city.steps[s] === "error");
|
||||||
|
const allDone = doneCount === STEPS.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={city.siruta}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
allDone && "border-emerald-200 dark:border-emerald-800",
|
||||||
|
hasError && "border-rose-200 dark:border-rose-800",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="py-3 px-4 space-y-2">
|
||||||
|
{/* City header */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-semibold">{city.name}</span>
|
||||||
|
{city.county && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
jud. {city.county}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] font-mono"
|
||||||
|
>
|
||||||
|
{city.siruta}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
P{city.priority}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Status icon */}
|
||||||
|
{allDone ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-emerald-500 ml-auto" />
|
||||||
|
) : hasError ? (
|
||||||
|
<XCircle className="h-4 w-4 text-rose-500 ml-auto" />
|
||||||
|
) : doneCount > 0 ? (
|
||||||
|
<Clock className="h-4 w-4 text-amber-500 ml-auto" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() =>
|
||||||
|
void doAction({
|
||||||
|
action: "reset",
|
||||||
|
siruta: city.siruta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Reseteaza progresul"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px] text-destructive"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Stergi ${city.name} din coada?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
void doAction({
|
||||||
|
action: "remove",
|
||||||
|
siruta: city.siruta,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Sterge din coada"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps progress */}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{STEPS.map((step) => {
|
||||||
|
const status = city.steps[step];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
|
||||||
|
status === "done" &&
|
||||||
|
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
|
||||||
|
status === "error" &&
|
||||||
|
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
|
||||||
|
status === "pending" &&
|
||||||
|
"bg-muted/30 border-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STEP_LABELS[step]}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB stats + error */}
|
||||||
|
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||||
|
{city.dbStats && city.dbStats.total > 0 && (
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
DB: {city.dbStats.terenuri.toLocaleString("ro")} ter.
|
||||||
|
+ {city.dbStats.cladiri.toLocaleString("ro")} clad.
|
||||||
|
</span>
|
||||||
|
{city.dbStats.enriched > 0 && (
|
||||||
|
<span className="text-teal-600 dark:text-teal-400">
|
||||||
|
{city.dbStats.enriched.toLocaleString("ro")}{" "}
|
||||||
|
enriched
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{city.lastActivity && (
|
||||||
|
<span>
|
||||||
|
Ultima activitate:{" "}
|
||||||
|
{new Date(city.lastActivity).toLocaleString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{city.errorMessage && (
|
||||||
|
<span className="text-rose-500 truncate max-w-[300px]">
|
||||||
|
{city.errorMessage}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add city form */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Adauga oras in coada
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 items-end flex-wrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">SIRUTA</label>
|
||||||
|
<Input
|
||||||
|
placeholder="ex: 54975"
|
||||||
|
value={newSiruta}
|
||||||
|
onChange={(e) => setNewSiruta(e.target.value)}
|
||||||
|
className="w-28 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 flex-1 min-w-[150px]">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Nume oras
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="ex: Cluj-Napoca"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Judet</label>
|
||||||
|
<Input
|
||||||
|
placeholder="ex: Cluj"
|
||||||
|
value={newCounty}
|
||||||
|
onChange={(e) => setNewCounty(e.target.value)}
|
||||||
|
className="w-32 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
actionLoading || !newSiruta.trim() || !newName.trim()
|
||||||
|
}
|
||||||
|
onClick={() => void handleAdd()}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Adauga
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Reset all button */}
|
||||||
|
{cities.length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
disabled={actionLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
"Resetezi progresul pentru TOATE orasele? Se va reporni ciclul de la zero.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
void doAction({ action: "reset_all" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
Reseteaza tot
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info footer */}
|
||||||
|
<div className="text-xs text-muted-foreground space-y-1 pb-4">
|
||||||
|
<p>
|
||||||
|
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
|
||||||
|
(23:00-04:00). Procesarea e intercalata intre orase si se reia de
|
||||||
|
unde a ramas.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
|
||||||
|
manual. In cadrul aceleiasi prioritati, ordinea e aleatorie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import {
|
||||||
|
getLayerFreshness,
|
||||||
|
isFresh,
|
||||||
|
} from "@/modules/parcel-sync/services/enrich-service";
|
||||||
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 300; // 5 min max — N8N handles overall timeout
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
type UatRefreshResult = {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
action: "synced" | "fresh" | "error";
|
||||||
|
reason?: string;
|
||||||
|
terenuri?: { new: number; removed: number };
|
||||||
|
cladiri?: { new: number; removed: number };
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/auto-refresh
|
||||||
|
*
|
||||||
|
* Server-to-server endpoint called by N8N cron to keep DB data fresh.
|
||||||
|
* Auth: Authorization: Bearer <NOTIFICATION_CRON_SECRET>
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* ?maxUats=5 — max UATs to process per run (default 5, max 10)
|
||||||
|
* ?maxAgeHours=168 — freshness threshold in hours (default 168 = 7 days)
|
||||||
|
* ?forceFullSync=true — force full re-download (for weekly deep sync)
|
||||||
|
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// ── Auth ──
|
||||||
|
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "NOTIFICATION_CRON_SECRET not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authHeader = request.headers.get("Authorization");
|
||||||
|
const token = authHeader?.replace("Bearer ", "");
|
||||||
|
if (token !== secret) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse params ──
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const maxUats = Math.min(
|
||||||
|
Number(url.searchParams.get("maxUats") ?? "5") || 5,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const maxAgeHours =
|
||||||
|
Number(url.searchParams.get("maxAgeHours") ?? "168") || 168;
|
||||||
|
const forceFullSync = url.searchParams.get("forceFullSync") === "true";
|
||||||
|
const includeEnrichment =
|
||||||
|
url.searchParams.get("includeEnrichment") === "true";
|
||||||
|
|
||||||
|
// ── Credentials ──
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health check ──
|
||||||
|
const health = await checkEterraHealthNow();
|
||||||
|
if (!health.available) {
|
||||||
|
return NextResponse.json({
|
||||||
|
processed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
duration: "0s",
|
||||||
|
message: `eTerra indisponibil: ${health.message ?? "maintenance"}`,
|
||||||
|
details: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find UATs with data in DB ──
|
||||||
|
const uatGroups = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta"],
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve UAT names
|
||||||
|
const sirutas = uatGroups.map((g) => g.siruta);
|
||||||
|
const uatRecords = await prisma.gisUat.findMany({
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
select: { siruta: true, name: true },
|
||||||
|
});
|
||||||
|
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
|
||||||
|
|
||||||
|
// ── Check freshness per UAT ──
|
||||||
|
type UatCandidate = {
|
||||||
|
siruta: string;
|
||||||
|
uatName: string;
|
||||||
|
featureCount: number;
|
||||||
|
terenuriStale: boolean;
|
||||||
|
cladiriStale: boolean;
|
||||||
|
enrichedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stale: UatCandidate[] = [];
|
||||||
|
const fresh: string[] = [];
|
||||||
|
|
||||||
|
for (const group of uatGroups) {
|
||||||
|
const sir = group.siruta;
|
||||||
|
const [tStatus, cStatus] = await Promise.all([
|
||||||
|
getLayerFreshness(sir, "TERENURI_ACTIVE"),
|
||||||
|
getLayerFreshness(sir, "CLADIRI_ACTIVE"),
|
||||||
|
]);
|
||||||
|
const tFresh = isFresh(tStatus.lastSynced, maxAgeHours);
|
||||||
|
const cFresh = isFresh(cStatus.lastSynced, maxAgeHours);
|
||||||
|
|
||||||
|
if (forceFullSync || !tFresh || !cFresh) {
|
||||||
|
stale.push({
|
||||||
|
siruta: sir,
|
||||||
|
uatName: nameMap.get(sir) ?? sir,
|
||||||
|
featureCount: group._count.id,
|
||||||
|
terenuriStale: !tFresh || forceFullSync,
|
||||||
|
cladiriStale: !cFresh || forceFullSync,
|
||||||
|
enrichedCount: tStatus.enrichedCount,
|
||||||
|
totalCount: tStatus.featureCount + cStatus.featureCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fresh.push(sir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle stale UATs so we don't always process the same ones first
|
||||||
|
for (let i = stale.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toProcess = stale.slice(0, maxUats);
|
||||||
|
const startTime = Date.now();
|
||||||
|
const details: UatRefreshResult[] = [];
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
// ── Process stale UATs ──
|
||||||
|
for (let idx = 0; idx < toProcess.length; idx++) {
|
||||||
|
const uat = toProcess[idx]!;
|
||||||
|
|
||||||
|
// Random delay between UATs (30-120s) to spread load
|
||||||
|
if (idx > 0) {
|
||||||
|
const delay = 30_000 + Math.random() * 90_000;
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uatStart = Date.now();
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Processing UAT ${uat.siruta} (${uat.uatName})...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let terenuriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||||
|
let cladiriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||||
|
|
||||||
|
if (uat.terenuriStale) {
|
||||||
|
const res = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||||
|
uatName: uat.uatName,
|
||||||
|
forceFullSync,
|
||||||
|
});
|
||||||
|
terenuriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uat.cladiriStale) {
|
||||||
|
const res = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||||
|
uatName: uat.uatName,
|
||||||
|
forceFullSync,
|
||||||
|
});
|
||||||
|
cladiriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: re-enrich if partial enrichment
|
||||||
|
if (includeEnrichment && uat.enrichedCount < uat.totalCount) {
|
||||||
|
try {
|
||||||
|
const { EterraClient } = await import(
|
||||||
|
"@/modules/parcel-sync/services/eterra-client"
|
||||||
|
);
|
||||||
|
const { enrichFeatures } = await import(
|
||||||
|
"@/modules/parcel-sync/services/enrich-service"
|
||||||
|
);
|
||||||
|
const enrichClient = await EterraClient.create(username, password);
|
||||||
|
await enrichFeatures(enrichClient, uat.siruta);
|
||||||
|
} catch (enrichErr) {
|
||||||
|
console.warn(
|
||||||
|
`[auto-refresh] Enrichment failed for ${uat.siruta}:`,
|
||||||
|
enrichErr instanceof Error ? enrichErr.message : enrichErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationMs = Date.now() - uatStart;
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] UAT ${uat.siruta}: terenuri +${terenuriResult.newFeatures}/-${terenuriResult.removedFeatures}, cladiri +${cladiriResult.newFeatures}/-${cladiriResult.removedFeatures} (${(durationMs / 1000).toFixed(1)}s)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
details.push({
|
||||||
|
siruta: uat.siruta,
|
||||||
|
uatName: uat.uatName,
|
||||||
|
action: "synced",
|
||||||
|
terenuri: { new: terenuriResult.newFeatures, removed: terenuriResult.removedFeatures },
|
||||||
|
cladiri: { new: cladiriResult.newFeatures, removed: cladiriResult.removedFeatures },
|
||||||
|
durationMs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
errorCount++;
|
||||||
|
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.error(`[auto-refresh] Error on UAT ${uat.siruta}: ${msg}`);
|
||||||
|
details.push({
|
||||||
|
siruta: uat.siruta,
|
||||||
|
uatName: uat.uatName,
|
||||||
|
action: "error",
|
||||||
|
reason: msg,
|
||||||
|
durationMs: Date.now() - uatStart,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDuration = Date.now() - startTime;
|
||||||
|
const durationStr =
|
||||||
|
totalDuration > 60_000
|
||||||
|
? `${Math.floor(totalDuration / 60_000)}m ${Math.round((totalDuration % 60_000) / 1000)}s`
|
||||||
|
: `${Math.round(totalDuration / 1000)}s`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Completed ${toProcess.length}/${stale.length} UATs, ${errorCount} errors (${durationStr})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
processed: toProcess.length,
|
||||||
|
skipped: fresh.length,
|
||||||
|
staleTotal: stale.length,
|
||||||
|
errors: errorCount,
|
||||||
|
duration: durationStr,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||||
|
const KV_KEY = "queue-state";
|
||||||
|
|
||||||
|
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeekendSyncState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/eterra/weekend-sync
|
||||||
|
* Returns the current queue state.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
// Auth handled by middleware (route is not excluded)
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!row?.value) {
|
||||||
|
return NextResponse.json({ state: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with DB feature counts per city
|
||||||
|
const state = row.value as unknown as WeekendSyncState;
|
||||||
|
const sirutas = state.cities.map((c) => c.siruta);
|
||||||
|
|
||||||
|
const counts = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta", "layerId"],
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedCounts = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta"],
|
||||||
|
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
|
||||||
|
|
||||||
|
type CityStats = {
|
||||||
|
terenuri: number;
|
||||||
|
cladiri: number;
|
||||||
|
total: number;
|
||||||
|
enriched: number;
|
||||||
|
};
|
||||||
|
const statsMap = new Map<string, CityStats>();
|
||||||
|
|
||||||
|
for (const c of counts) {
|
||||||
|
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
|
||||||
|
existing.total += c._count.id;
|
||||||
|
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
|
||||||
|
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
|
||||||
|
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
|
||||||
|
statsMap.set(c.siruta, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const citiesWithStats = state.cities.map((c) => ({
|
||||||
|
...c,
|
||||||
|
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
state: { ...state, cities: citiesWithStats },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/weekend-sync
|
||||||
|
* Modify the queue: add/remove cities, reset steps, change priority.
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Auth handled by middleware (route is not excluded)
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
action: "add" | "remove" | "reset" | "reset_all" | "set_priority";
|
||||||
|
siruta?: string;
|
||||||
|
name?: string;
|
||||||
|
county?: string;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load current state
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const state: WeekendSyncState = row?.value
|
||||||
|
? (row.value as unknown as WeekendSyncState)
|
||||||
|
: { cities: [], totalSessions: 0, completedCycles: 0 };
|
||||||
|
|
||||||
|
const freshSteps: Record<StepName, StepStatus> = {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (body.action) {
|
||||||
|
case "add": {
|
||||||
|
if (!body.siruta || !body.name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "siruta si name sunt obligatorii" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.cities.some((c) => c.siruta === body.siruta)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${body.name} (${body.siruta}) e deja in coada` },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.cities.push({
|
||||||
|
siruta: body.siruta,
|
||||||
|
name: body.name,
|
||||||
|
county: body.county ?? "",
|
||||||
|
priority: body.priority ?? 3,
|
||||||
|
steps: { ...freshSteps },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reset": {
|
||||||
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||||
|
if (city) {
|
||||||
|
city.steps = { ...freshSteps };
|
||||||
|
city.errorMessage = undefined;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "reset_all": {
|
||||||
|
for (const city of state.cities) {
|
||||||
|
city.steps = { ...freshSteps };
|
||||||
|
city.errorMessage = undefined;
|
||||||
|
}
|
||||||
|
state.completedCycles = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "set_priority": {
|
||||||
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||||
|
if (city && body.priority != null) {
|
||||||
|
city.priority = body.priority;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: KV_NAMESPACE,
|
||||||
|
key: KV_KEY,
|
||||||
|
value: state as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, cities: state.cities.length });
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Next.js instrumentation hook — runs once at server startup.
|
||||||
|
* Used to initialize background schedulers.
|
||||||
|
*/
|
||||||
|
export async function register() {
|
||||||
|
// Only run on the server (not during build or in edge runtime)
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
// Start the ParcelSync auto-refresh scheduler (side-effect import)
|
||||||
|
await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -58,6 +58,6 @@ export const config = {
|
|||||||
* - /favicon.ico, /robots.txt, /sitemap.xml
|
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||||
* - Files with extensions (images, fonts, etc.)
|
* - Files with extensions (images, fonts, etc.)
|
||||||
*/
|
*/
|
||||||
"/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
"/((?!api/auth|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Moon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||||
@@ -141,6 +143,20 @@ export function ExportTab({
|
|||||||
|
|
||||||
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
||||||
|
|
||||||
|
const allFresh =
|
||||||
|
dbLayersSummary.length > 0 && dbLayersSummary.every((l) => l.isFresh);
|
||||||
|
const hasData = dbTotalFeatures > 0;
|
||||||
|
const canExportLocal = allFresh && hasData;
|
||||||
|
|
||||||
|
const oldestSyncDate = dbLayersSummary.reduce(
|
||||||
|
(oldest, l) => {
|
||||||
|
if (!l.lastSynced) return oldest;
|
||||||
|
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
||||||
|
return oldest;
|
||||||
|
},
|
||||||
|
null as Date | null,
|
||||||
|
);
|
||||||
|
|
||||||
const progressPct =
|
const progressPct =
|
||||||
exportProgress?.total && exportProgress.total > 0
|
exportProgress?.total && exportProgress.total > 0
|
||||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||||
@@ -649,52 +665,85 @@ export function ExportTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero buttons */}
|
{/* Hero buttons */}
|
||||||
{sirutaValid && session.connected ? (
|
{sirutaValid && (session.connected || canExportLocal) ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
size="lg"
|
<Button
|
||||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
size="lg"
|
||||||
disabled={exporting}
|
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||||
onClick={() => void handleExportBundle("base")}
|
disabled={exporting || downloadingFromDb}
|
||||||
>
|
onClick={() =>
|
||||||
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
|
canExportLocal
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
? void handleDownloadFromDb("base")
|
||||||
) : (
|
: void handleExportBundle("base")
|
||||||
<FileDown className="mr-2 h-5 w-5" />
|
}
|
||||||
)}
|
>
|
||||||
<div className="text-left">
|
{(exporting || downloadingFromDb) &&
|
||||||
<div className="font-semibold">
|
exportProgress?.phase !== "Detalii parcele" ? (
|
||||||
Descarcă Terenuri și Clădiri
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
) : canExportLocal ? (
|
||||||
|
<Database className="mr-2 h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<FileDown className="mr-2 h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">
|
||||||
|
Descarcă Terenuri și Clădiri
|
||||||
|
</div>
|
||||||
|
<div className="text-xs opacity-70 font-normal">
|
||||||
|
{canExportLocal
|
||||||
|
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||||
|
: hasData
|
||||||
|
? "Sync incremental + GPKG"
|
||||||
|
: "Sync complet + GPKG"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs opacity-70 font-normal">
|
</Button>
|
||||||
Sync + GPKG (din cache dacă e proaspăt)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||||
disabled={exporting}
|
disabled={exporting || downloadingFromDb}
|
||||||
onClick={() => void handleExportBundle("magic")}
|
onClick={() =>
|
||||||
>
|
canExportLocal
|
||||||
{exporting && exportProgress?.phase === "Detalii parcele" ? (
|
? void handleDownloadFromDb("magic")
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
: void handleExportBundle("magic")
|
||||||
) : (
|
}
|
||||||
<Sparkles className="mr-2 h-5 w-5" />
|
>
|
||||||
)}
|
{(exporting || downloadingFromDb) &&
|
||||||
<div className="text-left">
|
exportProgress?.phase === "Detalii parcele" ? (
|
||||||
<div className="font-semibold">Magic</div>
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
<div className="text-xs opacity-70 font-normal">
|
) : (
|
||||||
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
|
<Sparkles className="mr-2 h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Magic</div>
|
||||||
|
<div className="text-xs opacity-70 font-normal">
|
||||||
|
{canExportLocal
|
||||||
|
? `GPKG + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||||
|
: "Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{canExportLocal && session.connected && (
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={() => void handleExportBundle("base")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||||||
|
Re-sincronizează de pe eTerra
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
{!session.connected ? (
|
{!session.connected && !canExportLocal ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||||||
@@ -1222,54 +1271,6 @@ export function ExportTab({
|
|||||||
</div>
|
</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 && (
|
{!session.connected && dbTotalFeatures === 0 && (
|
||||||
<p className="text-xs text-muted-foreground ml-6">
|
<p className="text-xs text-muted-foreground ml-6">
|
||||||
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||||||
@@ -1280,6 +1281,21 @@ export function ExportTab({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Weekend Deep Sync hint */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Moon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Municipii mari cu Magic complet?{" "}
|
||||||
|
<Link
|
||||||
|
href="/wds"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Weekend Deep Sync
|
||||||
|
</Link>
|
||||||
|
{" "}— sincronizare automata Vin/Sam/Dum noaptea.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Background sync progress */}
|
{/* Background sync progress */}
|
||||||
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Self-contained auto-refresh scheduler for ParcelSync.
|
||||||
|
*
|
||||||
|
* Runs inside the existing Node.js process — no external dependencies.
|
||||||
|
* Checks every 30 minutes; during the night window (1–5 AM) it picks
|
||||||
|
* stale UATs one at a time with random delays between them.
|
||||||
|
*
|
||||||
|
* Activated by importing this module (side-effect). The globalThis guard
|
||||||
|
* ensures only one scheduler runs per process, surviving HMR in dev.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { syncLayer } from "./sync-service";
|
||||||
|
import { getLayerFreshness, isFresh } from "./enrich-service";
|
||||||
|
import { isEterraAvailable } from "./eterra-health";
|
||||||
|
import { isWeekendWindow, runWeekendDeepSync } from "./weekend-deep-sync";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Configuration */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Night window: only run between these hours (server local time) */
|
||||||
|
const NIGHT_START_HOUR = 1;
|
||||||
|
const NIGHT_END_HOUR = 5;
|
||||||
|
|
||||||
|
/** How often to check if we should run (ms) */
|
||||||
|
const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
|
||||||
|
|
||||||
|
/** Max UATs per nightly run */
|
||||||
|
const MAX_UATS_PER_RUN = 5;
|
||||||
|
|
||||||
|
/** Freshness threshold — sync if older than this */
|
||||||
|
const MAX_AGE_HOURS = 168; // 7 days
|
||||||
|
|
||||||
|
/** Delay between UATs: 60–180s (random, spreads load on eTerra) */
|
||||||
|
const MIN_DELAY_MS = 60_000;
|
||||||
|
const MAX_DELAY_MS = 180_000;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Singleton guard */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const g = globalThis as {
|
||||||
|
__autoRefreshTimer?: ReturnType<typeof setInterval>;
|
||||||
|
__parcelSyncRunning?: boolean; // single flag for all sync modes
|
||||||
|
__autoRefreshLastRun?: string; // ISO date of last completed run
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Core logic */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function runAutoRefresh() {
|
||||||
|
// Prevent concurrent runs (shared with weekend sync)
|
||||||
|
if (g.__parcelSyncRunning) return;
|
||||||
|
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < NIGHT_START_HOUR || hour >= NIGHT_END_HOUR) return;
|
||||||
|
|
||||||
|
// Only run once per night (check date)
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
if (g.__autoRefreshLastRun === today) return;
|
||||||
|
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) return;
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[auto-refresh] eTerra indisponibil, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.__parcelSyncRunning = true;
|
||||||
|
console.log("[auto-refresh] Pornire refresh nocturn...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find UATs with data in DB
|
||||||
|
const uatGroups = await prisma.gisFeature.groupBy({
|
||||||
|
by: ["siruta"],
|
||||||
|
_count: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uatGroups.length === 0) {
|
||||||
|
console.log("[auto-refresh] Niciun UAT in DB, skip.");
|
||||||
|
g.__autoRefreshLastRun = today;
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve names
|
||||||
|
const sirutas = uatGroups.map((gr) => gr.siruta);
|
||||||
|
const uatRecords = await prisma.gisUat.findMany({
|
||||||
|
where: { siruta: { in: sirutas } },
|
||||||
|
select: { siruta: true, name: true },
|
||||||
|
});
|
||||||
|
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
|
||||||
|
|
||||||
|
// Check freshness
|
||||||
|
type StaleUat = { siruta: string; name: string };
|
||||||
|
const stale: StaleUat[] = [];
|
||||||
|
|
||||||
|
for (const gr of uatGroups) {
|
||||||
|
const tStatus = await getLayerFreshness(gr.siruta, "TERENURI_ACTIVE");
|
||||||
|
if (!isFresh(tStatus.lastSynced, MAX_AGE_HOURS)) {
|
||||||
|
stale.push({
|
||||||
|
siruta: gr.siruta,
|
||||||
|
name: nameMap.get(gr.siruta) ?? gr.siruta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stale.length === 0) {
|
||||||
|
console.log("[auto-refresh] Toate UAT-urile sunt proaspete, skip.");
|
||||||
|
g.__autoRefreshLastRun = today;
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle so different UATs get priority each night
|
||||||
|
for (let i = stale.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = stale.slice(0, MAX_UATS_PER_RUN);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] ${stale.length} UAT-uri stale, procesez ${batch.length}: ${batch.map((u) => u.name).join(", ")}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < batch.length; i++) {
|
||||||
|
const uat = batch[i]!;
|
||||||
|
|
||||||
|
// Random staggered delay between UATs
|
||||||
|
if (i > 0) {
|
||||||
|
const delay =
|
||||||
|
MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Pauza ${Math.round(delay / 1000)}s inainte de ${uat.name}...`,
|
||||||
|
);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check we're still in the night window
|
||||||
|
const currentHour = new Date().getHours();
|
||||||
|
if (currentHour >= NIGHT_END_HOUR) {
|
||||||
|
console.log("[auto-refresh] Fereastra nocturna s-a inchis, opresc.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check eTerra is still available
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[auto-refresh] eTerra a devenit indisponibil, opresc.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const tRes = await syncLayer(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
uat.siruta,
|
||||||
|
"TERENURI_ACTIVE",
|
||||||
|
{ uatName: uat.name },
|
||||||
|
);
|
||||||
|
const cRes = await syncLayer(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
uat.siruta,
|
||||||
|
"CLADIRI_ACTIVE",
|
||||||
|
{ uatName: uat.name },
|
||||||
|
);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] ${uat.name} (${uat.siruta}): terenuri +${tRes.newFeatures}/-${tRes.removedFeatures}, cladiri +${cRes.newFeatures}/-${cRes.removedFeatures} (${dur}s)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(
|
||||||
|
`[auto-refresh] Eroare ${uat.name} (${uat.siruta}): ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.__autoRefreshLastRun = today;
|
||||||
|
console.log("[auto-refresh] Run nocturn finalizat.");
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[auto-refresh] Eroare generala: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Weekend deep sync wrapper */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function runWeekendCheck() {
|
||||||
|
if (g.__parcelSyncRunning) return;
|
||||||
|
if (!isWeekendWindow()) return;
|
||||||
|
|
||||||
|
g.__parcelSyncRunning = true;
|
||||||
|
try {
|
||||||
|
await runWeekendDeepSync();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] Eroare: ${msg}`);
|
||||||
|
} finally {
|
||||||
|
g.__parcelSyncRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Start scheduler (once per process) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
if (!g.__autoRefreshTimer) {
|
||||||
|
g.__autoRefreshTimer = setInterval(() => {
|
||||||
|
// Weekend nights (Fri/Sat/Sun 23-04): deep sync for large cities
|
||||||
|
// Weekday nights (1-5 AM): incremental refresh for existing data
|
||||||
|
if (isWeekendWindow()) {
|
||||||
|
void runWeekendCheck();
|
||||||
|
} else {
|
||||||
|
void runAutoRefresh();
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Also check once shortly after startup (60s delay to let everything init)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isWeekendWindow()) {
|
||||||
|
void runWeekendCheck();
|
||||||
|
} else {
|
||||||
|
void runAutoRefresh();
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Scheduler pornit — verificare la fiecare ${CHECK_INTERVAL_MS / 60_000} min`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Weekday: ${NIGHT_START_HOUR}:00–${NIGHT_END_HOUR}:00 refresh incremental`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[auto-refresh] Weekend: Vin/Sam/Dum 23:00–04:00 deep sync municipii`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -297,6 +297,81 @@ export class EterraClient {
|
|||||||
return this.countLayerWithParams(layer, params, true);
|
return this.countLayerWithParams(layer, params, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
|
||||||
|
|
||||||
|
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
|
||||||
|
const where = await this.buildWhere(layer, siruta);
|
||||||
|
return this.fetchObjectIdsByWhere(layer, where);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchObjectIdsByWhere(
|
||||||
|
layer: LayerConfig,
|
||||||
|
where: string,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", where);
|
||||||
|
params.set("returnIdsOnly", "true");
|
||||||
|
const data = await this.queryLayer(layer, params, false);
|
||||||
|
return data.objectIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchObjectIdsByGeometry(
|
||||||
|
layer: LayerConfig,
|
||||||
|
geometry: EsriGeometry,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("f", "json");
|
||||||
|
params.set("where", "1=1");
|
||||||
|
params.set("returnIdsOnly", "true");
|
||||||
|
this.applyGeometryParams(params, geometry);
|
||||||
|
const data = await this.queryLayer(layer, params, true);
|
||||||
|
return data.objectIds ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Fetch specific features by OBJECTID list ------------------- */
|
||||||
|
|
||||||
|
async fetchFeaturesByObjectIds(
|
||||||
|
layer: LayerConfig,
|
||||||
|
objectIds: number[],
|
||||||
|
options?: {
|
||||||
|
baseWhere?: string;
|
||||||
|
outFields?: string;
|
||||||
|
returnGeometry?: boolean;
|
||||||
|
onProgress?: ProgressCallback;
|
||||||
|
delayMs?: number;
|
||||||
|
},
|
||||||
|
): Promise<EsriFeature[]> {
|
||||||
|
if (objectIds.length === 0) return [];
|
||||||
|
const chunkSize = 500;
|
||||||
|
const all: EsriFeature[] = [];
|
||||||
|
const total = objectIds.length;
|
||||||
|
for (let i = 0; i < objectIds.length; i += chunkSize) {
|
||||||
|
const chunk = objectIds.slice(i, i + chunkSize);
|
||||||
|
const idList = chunk.join(",");
|
||||||
|
const idWhere = `OBJECTID IN (${idList})`;
|
||||||
|
const where = options?.baseWhere
|
||||||
|
? `(${options.baseWhere}) AND ${idWhere}`
|
||||||
|
: idWhere;
|
||||||
|
try {
|
||||||
|
const features = await this.fetchAllLayerByWhere(layer, where, {
|
||||||
|
outFields: options?.outFields ?? "*",
|
||||||
|
returnGeometry: options?.returnGeometry ?? true,
|
||||||
|
delayMs: options?.delayMs ?? 200,
|
||||||
|
});
|
||||||
|
all.push(...features);
|
||||||
|
} catch (err) {
|
||||||
|
// Log but continue with remaining chunks — partial results better than none
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
options?.onProgress?.(all.length, total);
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
async listLayer(
|
async listLayer(
|
||||||
layer: LayerConfig,
|
layer: LayerConfig,
|
||||||
siruta: string,
|
siruta: string,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { Prisma, PrismaClient } from "@prisma/client";
|
import { Prisma, PrismaClient } from "@prisma/client";
|
||||||
import { EterraClient } from "./eterra-client";
|
import { EterraClient } from "./eterra-client";
|
||||||
import type { LayerConfig } from "./eterra-client";
|
import type { LayerConfig, EsriFeature } from "./eterra-client";
|
||||||
import { esriToGeojson } from "./esri-geojson";
|
import { esriToGeojson } from "./esri-geojson";
|
||||||
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
||||||
import { fetchUatGeometry } from "./uat-geometry";
|
import { fetchUatGeometry } from "./uat-geometry";
|
||||||
@@ -116,50 +116,107 @@ export async function syncLayer(
|
|||||||
uatGeometry = await fetchUatGeometry(client, siruta);
|
uatGeometry = await fetchUatGeometry(client, siruta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count remote features
|
|
||||||
push({ phase: "Numărare remote" });
|
|
||||||
let remoteCount: number;
|
|
||||||
try {
|
|
||||||
remoteCount = uatGeometry
|
|
||||||
? await client.countLayerByGeometry(layer, uatGeometry)
|
|
||||||
: await client.countLayer(layer, siruta);
|
|
||||||
} catch {
|
|
||||||
remoteCount = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
push({ phase: "Verificare locală", total: remoteCount });
|
|
||||||
|
|
||||||
// Get local OBJECTIDs for this layer+siruta
|
// Get local OBJECTIDs for this layer+siruta
|
||||||
|
push({ phase: "Verificare locală" });
|
||||||
const localFeatures = await prisma.gisFeature.findMany({
|
const localFeatures = await prisma.gisFeature.findMany({
|
||||||
where: { layerId, siruta },
|
where: { layerId, siruta },
|
||||||
select: { objectId: true },
|
select: { objectId: true },
|
||||||
});
|
});
|
||||||
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
||||||
|
|
||||||
// Fetch all remote features
|
// Fetch remote OBJECTIDs only (fast — returnIdsOnly)
|
||||||
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
|
push({ phase: "Comparare ID-uri remote" });
|
||||||
|
let remoteObjIdArray: number[];
|
||||||
|
try {
|
||||||
|
remoteObjIdArray = uatGeometry
|
||||||
|
? await client.fetchObjectIdsByGeometry(layer, uatGeometry)
|
||||||
|
: await client.fetchObjectIds(layer, siruta);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`[syncLayer] fetchObjectIds failed for ${layerId}/${siruta}: ${msg} — falling back to full sync`,
|
||||||
|
);
|
||||||
|
remoteObjIdArray = [];
|
||||||
|
}
|
||||||
|
const remoteObjIds = new Set(remoteObjIdArray);
|
||||||
|
const remoteCount = remoteObjIds.size;
|
||||||
|
|
||||||
const allRemote = uatGeometry
|
// Compute delta
|
||||||
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
const newObjIdArray = [...remoteObjIds].filter((id) => !localObjIds.has(id));
|
||||||
total: remoteCount > 0 ? remoteCount : undefined,
|
const removedObjIds = [...localObjIds].filter(
|
||||||
onProgress: (dl, tot) =>
|
(id) => !remoteObjIds.has(id),
|
||||||
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
|
);
|
||||||
delayMs: 200,
|
|
||||||
})
|
// Decide: incremental (download only delta) or full sync
|
||||||
: await client.fetchAllLayerByWhere(
|
const deltaRatio =
|
||||||
layer,
|
remoteCount > 0 ? newObjIdArray.length / remoteCount : 1;
|
||||||
await buildWhere(client, layer, siruta),
|
const useFullSync =
|
||||||
{
|
options?.forceFullSync ||
|
||||||
|
localObjIds.size === 0 ||
|
||||||
|
deltaRatio > 0.5;
|
||||||
|
|
||||||
|
let allRemote: EsriFeature[];
|
||||||
|
|
||||||
|
if (useFullSync) {
|
||||||
|
// Full sync: download all features (first sync or forced)
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features (complet)",
|
||||||
|
downloaded: 0,
|
||||||
|
total: remoteCount,
|
||||||
|
});
|
||||||
|
allRemote = uatGeometry
|
||||||
|
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
||||||
total: remoteCount > 0 ? remoteCount : undefined,
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
onProgress: (dl, tot) =>
|
onProgress: (dl, tot) =>
|
||||||
push({
|
push({
|
||||||
phase: "Descărcare features",
|
phase: "Descărcare features (complet)",
|
||||||
downloaded: dl,
|
downloaded: dl,
|
||||||
total: tot,
|
total: tot,
|
||||||
}),
|
}),
|
||||||
delayMs: 200,
|
delayMs: 200,
|
||||||
},
|
})
|
||||||
);
|
: await client.fetchAllLayerByWhere(
|
||||||
|
layer,
|
||||||
|
await buildWhere(client, layer, siruta),
|
||||||
|
{
|
||||||
|
total: remoteCount > 0 ? remoteCount : undefined,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features (complet)",
|
||||||
|
downloaded: dl,
|
||||||
|
total: tot,
|
||||||
|
}),
|
||||||
|
delayMs: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (newObjIdArray.length > 0) {
|
||||||
|
// Incremental sync: download only the new features
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features noi",
|
||||||
|
downloaded: 0,
|
||||||
|
total: newObjIdArray.length,
|
||||||
|
});
|
||||||
|
const baseWhere = uatGeometry
|
||||||
|
? undefined
|
||||||
|
: await buildWhere(client, layer, siruta);
|
||||||
|
allRemote = await client.fetchFeaturesByObjectIds(
|
||||||
|
layer,
|
||||||
|
newObjIdArray,
|
||||||
|
{
|
||||||
|
baseWhere,
|
||||||
|
onProgress: (dl, tot) =>
|
||||||
|
push({
|
||||||
|
phase: "Descărcare features noi",
|
||||||
|
downloaded: dl,
|
||||||
|
total: tot,
|
||||||
|
}),
|
||||||
|
delayMs: 200,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Nothing new to download
|
||||||
|
allRemote = [];
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to GeoJSON for geometry storage
|
// Convert to GeoJSON for geometry storage
|
||||||
const geojson = esriToGeojson(allRemote);
|
const geojson = esriToGeojson(allRemote);
|
||||||
@@ -169,19 +226,11 @@ export async function syncLayer(
|
|||||||
if (objId != null) geojsonByObjId.set(objId, f);
|
if (objId != null) geojsonByObjId.set(objId, f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which OBJECTIDs are new
|
// For incremental sync, newObjIds = the delta we downloaded
|
||||||
const remoteObjIds = new Set<number>();
|
// For full sync, newObjIds = all remote (if forced) or only truly new
|
||||||
for (const f of allRemote) {
|
|
||||||
const objId = f.attributes.OBJECTID as number | undefined;
|
|
||||||
if (objId != null) remoteObjIds.add(objId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newObjIds = options?.forceFullSync
|
const newObjIds = options?.forceFullSync
|
||||||
? remoteObjIds
|
? remoteObjIds
|
||||||
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
|
: new Set(newObjIdArray);
|
||||||
const removedObjIds = [...localObjIds].filter(
|
|
||||||
(id) => !remoteObjIds.has(id),
|
|
||||||
);
|
|
||||||
|
|
||||||
push({
|
push({
|
||||||
phase: "Salvare în baza de date",
|
phase: "Salvare în baza de date",
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
/**
|
||||||
|
* Weekend Deep Sync — full Magic processing for large cities.
|
||||||
|
*
|
||||||
|
* Runs Fri/Sat/Sun nights 23:00–04:00. Processes cities in round-robin
|
||||||
|
* (one step per city, then rotate) so progress is spread across cities.
|
||||||
|
* State is persisted in KeyValueStore — survives restarts and continues
|
||||||
|
* across multiple nights/weekends.
|
||||||
|
*
|
||||||
|
* Steps per city (each is resumable):
|
||||||
|
* 1. sync_terenuri — syncLayer TERENURI_ACTIVE
|
||||||
|
* 2. sync_cladiri — syncLayer CLADIRI_ACTIVE
|
||||||
|
* 3. import_nogeom — import parcels without geometry
|
||||||
|
* 4. enrich — enrichFeatures (slowest, naturally resumable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
import { syncLayer } from "./sync-service";
|
||||||
|
import { EterraClient } from "./eterra-client";
|
||||||
|
import { isEterraAvailable } from "./eterra-health";
|
||||||
|
import { sendEmail } from "@/core/notifications/email-service";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* City queue configuration */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type CityConfig = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number; // lower = higher priority
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Initial queue — priority 1 = first processed */
|
||||||
|
const DEFAULT_CITIES: CityConfig[] = [
|
||||||
|
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||||
|
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||||
|
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||||
|
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||||
|
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||||
|
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||||
|
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||||
|
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||||
|
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step definitions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
"sync_terenuri",
|
||||||
|
"sync_cladiri",
|
||||||
|
"import_nogeom",
|
||||||
|
"enrich",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type StepName = (typeof STEPS)[number];
|
||||||
|
type StepStatus = "pending" | "done" | "error";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Persisted state */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type CityState = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
priority: number;
|
||||||
|
steps: Record<StepName, StepStatus>;
|
||||||
|
lastActivity?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WeekendSyncState = {
|
||||||
|
cities: CityState[];
|
||||||
|
lastSessionDate?: string;
|
||||||
|
totalSessions: number;
|
||||||
|
completedCycles: number; // how many full cycles (all cities done)
|
||||||
|
};
|
||||||
|
|
||||||
|
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||||
|
const KV_KEY = "queue-state";
|
||||||
|
|
||||||
|
async function loadState(): Promise<WeekendSyncState> {
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
});
|
||||||
|
if (row?.value && typeof row.value === "object") {
|
||||||
|
return row.value as unknown as WeekendSyncState;
|
||||||
|
}
|
||||||
|
// Initialize with default cities
|
||||||
|
return {
|
||||||
|
cities: DEFAULT_CITIES.map((c) => ({
|
||||||
|
...c,
|
||||||
|
steps: {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
totalSessions: 0,
|
||||||
|
completedCycles: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveState(state: WeekendSyncState): Promise<void> {
|
||||||
|
// Retry once on failure — state persistence is critical for resume
|
||||||
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
try {
|
||||||
|
await prisma.keyValueStore.upsert({
|
||||||
|
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||||
|
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||||
|
create: {
|
||||||
|
namespace: KV_NAMESPACE,
|
||||||
|
key: KV_KEY,
|
||||||
|
value: state as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 0) {
|
||||||
|
console.warn("[weekend-sync] saveState retry...");
|
||||||
|
await sleep(2000);
|
||||||
|
} else {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] saveState failed: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Time window */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const WEEKEND_START_HOUR = 23;
|
||||||
|
const WEEKEND_END_HOUR = 4;
|
||||||
|
const PAUSE_BETWEEN_STEPS_MS = 60_000 + Math.random() * 60_000; // 60-120s
|
||||||
|
|
||||||
|
/** Check if current time is within the weekend sync window */
|
||||||
|
export function isWeekendWindow(): boolean {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat
|
||||||
|
const hour = now.getHours();
|
||||||
|
|
||||||
|
// Fri 23:00+ or Sat 23:00+ or Sun 23:00+
|
||||||
|
if ((day === 5 || day === 6 || day === 0) && hour >= WEEKEND_START_HOUR) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Sat 00-04 (continuation of Friday night) or Sun 00-04 or Mon 00-04
|
||||||
|
if ((day === 6 || day === 0 || day === 1) && hour < WEEKEND_END_HOUR) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if still within the window (called during processing) */
|
||||||
|
function stillInWindow(): boolean {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
// We can be in 23,0,1,2,3 — stop at 4
|
||||||
|
if (hour >= WEEKEND_END_HOUR && hour < WEEKEND_START_HOUR) return false;
|
||||||
|
return isWeekendWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Step executors */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function executeStep(
|
||||||
|
city: CityState,
|
||||||
|
step: StepName,
|
||||||
|
client: EterraClient,
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
switch (step) {
|
||||||
|
case "sync_terenuri": {
|
||||||
|
const res = await syncLayer(
|
||||||
|
process.env.ETERRA_USERNAME!,
|
||||||
|
process.env.ETERRA_PASSWORD!,
|
||||||
|
city.siruta,
|
||||||
|
"TERENURI_ACTIVE",
|
||||||
|
{ uatName: city.name, forceFullSync: true },
|
||||||
|
);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "sync_cladiri": {
|
||||||
|
const res = await syncLayer(
|
||||||
|
process.env.ETERRA_USERNAME!,
|
||||||
|
process.env.ETERRA_PASSWORD!,
|
||||||
|
city.siruta,
|
||||||
|
"CLADIRI_ACTIVE",
|
||||||
|
{ uatName: city.name, forceFullSync: true },
|
||||||
|
);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: `Cl\u0103diri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "import_nogeom": {
|
||||||
|
const { syncNoGeometryParcels } = await import("./no-geom-sync");
|
||||||
|
const res = await syncNoGeometryParcels(client, city.siruta);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status !== "error",
|
||||||
|
message: `No-geom: ${res.imported} importate, ${res.skipped} skip [${dur}s]`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "enrich": {
|
||||||
|
const { enrichFeatures } = await import("./enrich-service");
|
||||||
|
const res = await enrichFeatures(client, city.siruta);
|
||||||
|
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||||
|
return {
|
||||||
|
success: res.status === "done",
|
||||||
|
message: res.status === "done"
|
||||||
|
? `Enrichment: ${res.enrichedCount}/${res.totalFeatures ?? "?"} (${dur}s)`
|
||||||
|
: `Enrichment eroare: ${res.error ?? "necunoscuta"} (${dur}s)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Main runner */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type SessionLog = {
|
||||||
|
city: string;
|
||||||
|
step: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runWeekendDeepSync(): Promise<void> {
|
||||||
|
const username = process.env.ETERRA_USERNAME;
|
||||||
|
const password = process.env.ETERRA_PASSWORD;
|
||||||
|
if (!username || !password) return;
|
||||||
|
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[weekend-sync] eTerra indisponibil, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Prevent running twice in the same session
|
||||||
|
if (state.lastSessionDate === today) return;
|
||||||
|
|
||||||
|
state.totalSessions++;
|
||||||
|
state.lastSessionDate = today;
|
||||||
|
|
||||||
|
// Ensure new default cities are added if config expanded
|
||||||
|
for (const dc of DEFAULT_CITIES) {
|
||||||
|
if (!state.cities.some((c) => c.siruta === dc.siruta)) {
|
||||||
|
state.cities.push({
|
||||||
|
...dc,
|
||||||
|
steps: {
|
||||||
|
sync_terenuri: "pending",
|
||||||
|
sync_cladiri: "pending",
|
||||||
|
import_nogeom: "pending",
|
||||||
|
enrich: "pending",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionStart = Date.now();
|
||||||
|
const log: SessionLog[] = [];
|
||||||
|
let stepsCompleted = 0;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Sesiune #${state.totalSessions} pornita. ${state.cities.length} orase in coada.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create eTerra client (shared across steps)
|
||||||
|
let client: EterraClient;
|
||||||
|
try {
|
||||||
|
client = await EterraClient.create(username, password);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error(`[weekend-sync] Nu se poate conecta la eTerra: ${msg}`);
|
||||||
|
await saveState(state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort cities: priority first, then shuffle within same priority
|
||||||
|
const sorted = [...state.cities].sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||||
|
return Math.random() - 0.5; // random within same priority
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-robin: iterate through steps, for each step iterate through cities
|
||||||
|
for (const stepName of STEPS) {
|
||||||
|
// Find cities that still need this step
|
||||||
|
const needsStep = sorted.filter((c) => c.steps[stepName] === "pending");
|
||||||
|
if (needsStep.length === 0) continue;
|
||||||
|
|
||||||
|
for (const city of needsStep) {
|
||||||
|
// Check time window
|
||||||
|
if (!stillInWindow()) {
|
||||||
|
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check eTerra health
|
||||||
|
if (!isEterraAvailable()) {
|
||||||
|
console.log("[weekend-sync] eTerra indisponibil, opresc.");
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause between steps
|
||||||
|
if (stepsCompleted > 0) {
|
||||||
|
const pause = 60_000 + Math.random() * 60_000;
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Pauza ${Math.round(pause / 1000)}s inainte de ${city.name} / ${stepName}`,
|
||||||
|
);
|
||||||
|
await sleep(pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute step
|
||||||
|
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
|
||||||
|
try {
|
||||||
|
const result = await executeStep(city, stepName, client);
|
||||||
|
city.steps[stepName] = result.success ? "done" : "error";
|
||||||
|
if (!result.success) city.errorMessage = result.message;
|
||||||
|
city.lastActivity = new Date().toISOString();
|
||||||
|
log.push({
|
||||||
|
city: city.name,
|
||||||
|
step: stepName,
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] ${city.name}: ${stepName} → ${result.success ? "OK" : "EROARE"} — ${result.message}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
city.steps[stepName] = "error";
|
||||||
|
city.errorMessage = msg;
|
||||||
|
city.lastActivity = new Date().toISOString();
|
||||||
|
log.push({
|
||||||
|
city: city.name,
|
||||||
|
step: stepName,
|
||||||
|
success: false,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stepsCompleted++;
|
||||||
|
// Save state after each step (crash safety)
|
||||||
|
await saveState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all cities completed all steps → new cycle
|
||||||
|
const allDone = state.cities.every((c) =>
|
||||||
|
STEPS.every((s) => c.steps[s] === "done"),
|
||||||
|
);
|
||||||
|
if (allDone) {
|
||||||
|
state.completedCycles++;
|
||||||
|
// Reset for next cycle
|
||||||
|
for (const city of state.cities) {
|
||||||
|
for (const step of STEPS) {
|
||||||
|
city.steps[step] = "pending";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[weekend-sync] Ciclu complet #${state.completedCycles}! Reset pentru urmatorul ciclu.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveState(state);
|
||||||
|
await sendStatusEmail(state, log, sessionStart);
|
||||||
|
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Email status report */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function sendStatusEmail(
|
||||||
|
state: WeekendSyncState,
|
||||||
|
log: SessionLog[],
|
||||||
|
sessionStart: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
|
||||||
|
if (!emailTo) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const duration = Date.now() - sessionStart;
|
||||||
|
const durMin = Math.round(duration / 60_000);
|
||||||
|
const durStr =
|
||||||
|
durMin >= 60
|
||||||
|
? `${Math.floor(durMin / 60)}h ${durMin % 60}m`
|
||||||
|
: `${durMin}m`;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const dayNames = [
|
||||||
|
"Duminic\u0103",
|
||||||
|
"Luni",
|
||||||
|
"Mar\u021Bi",
|
||||||
|
"Miercuri",
|
||||||
|
"Joi",
|
||||||
|
"Vineri",
|
||||||
|
"S\u00E2mb\u0103t\u0103",
|
||||||
|
];
|
||||||
|
const dayName = dayNames[now.getDay()] ?? "";
|
||||||
|
const dateStr = now.toLocaleDateString("ro-RO", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build city progress table
|
||||||
|
const cityRows = state.cities
|
||||||
|
.sort((a, b) => a.priority - b.priority)
|
||||||
|
.map((c) => {
|
||||||
|
const doneCount = STEPS.filter((s) => c.steps[s] === "done").length;
|
||||||
|
const errorCount = STEPS.filter((s) => c.steps[s] === "error").length;
|
||||||
|
const icon =
|
||||||
|
doneCount === STEPS.length
|
||||||
|
? "\u2713"
|
||||||
|
: doneCount > 0
|
||||||
|
? "\u25D0"
|
||||||
|
: "\u25CB";
|
||||||
|
const color =
|
||||||
|
doneCount === STEPS.length
|
||||||
|
? "#22c55e"
|
||||||
|
: errorCount > 0
|
||||||
|
? "#ef4444"
|
||||||
|
: doneCount > 0
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#9ca3af";
|
||||||
|
const stepDetail = STEPS.map(
|
||||||
|
(s) =>
|
||||||
|
`<span style="color:${c.steps[s] === "done" ? "#22c55e" : c.steps[s] === "error" ? "#ef4444" : "#9ca3af"}">${s.replace("_", " ")}</span>`,
|
||||||
|
).join(" \u2192 ");
|
||||||
|
return `<tr>
|
||||||
|
<td style="padding:4px 8px;color:${color};font-size:16px">${icon}</td>
|
||||||
|
<td style="padding:4px 8px;font-weight:600">${c.name}</td>
|
||||||
|
<td style="padding:4px 8px;color:#6b7280;font-size:12px">${c.county}</td>
|
||||||
|
<td style="padding:4px 8px">${doneCount}/${STEPS.length}</td>
|
||||||
|
<td style="padding:4px 8px;font-size:11px">${stepDetail}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
// Build session log
|
||||||
|
const logRows =
|
||||||
|
log.length > 0
|
||||||
|
? log
|
||||||
|
.map(
|
||||||
|
(l) =>
|
||||||
|
`<tr>
|
||||||
|
<td style="padding:2px 6px;font-size:12px">${l.success ? "\u2713" : "\u2717"}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:12px">${l.city}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:12px;color:#6b7280">${l.step}</td>
|
||||||
|
<td style="padding:2px 6px;font-size:11px;color:#6b7280">${l.message}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("\n")
|
||||||
|
: '<tr><td colspan="4" style="padding:8px;color:#9ca3af;font-size:12px">Niciun pas executat in aceasta sesiune</td></tr>';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family:system-ui,sans-serif;max-width:700px;margin:0 auto">
|
||||||
|
<h2 style="color:#1f2937;margin-bottom:4px">Weekend Sync — ${dayName} ${dateStr}</h2>
|
||||||
|
<p style="color:#6b7280;margin-top:0">Durata sesiune: ${durStr} | Sesiunea #${state.totalSessions} | Cicluri complete: ${state.completedCycles}</p>
|
||||||
|
|
||||||
|
<h3 style="color:#374151;margin-bottom:8px">Progres per ora\u0219</h3>
|
||||||
|
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb;border-radius:6px">
|
||||||
|
<thead><tr style="background:#f9fafb">
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px"></th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Ora\u0219</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Jude\u021B</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Pa\u0219i</th>
|
||||||
|
<th style="padding:6px 8px;text-align:left;font-size:12px">Detaliu</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>${cityRows}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color:#374151;margin-top:16px;margin-bottom:8px">Activitate sesiune curent\u0103</h3>
|
||||||
|
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb">
|
||||||
|
<tbody>${logRows}</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
|
||||||
|
Generat automat de ArchiTools Weekend Sync
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendEmail({
|
||||||
|
to: emailTo,
|
||||||
|
subject: `[ArchiTools] Weekend Sync — ${dayName} ${dateStr}`,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
console.log(`[weekend-sync] Email status trimis la ${emailTo}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(`[weekend-sync] Nu s-a putut trimite email: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user