diff --git a/src/app/(dashboard)/admin/synchronisation/client.tsx b/src/app/(dashboard)/admin/synchronisation/client.tsx new file mode 100644 index 0000000..0158b44 --- /dev/null +++ b/src/app/(dashboard)/admin/synchronisation/client.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + CalendarClock, Play, Square, RefreshCw, Loader2, AlertTriangle, + CheckCircle, Database, Network, Search, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SyncSettings { + actif: boolean; + heureDebut: number; + heureFin: number; + qlikParNuit: number; + sqlParQlik: number; + qlikMinJours: number; +} + +interface SyncState { + enCours: boolean; + origine: "auto" | "manuel" | null; + debutAt: string | null; + finAt: string | null; + fournisseurCourant: string | null; + etape: "sql" | "qlik" | null; + sqlFaits: number; + sqlEchecs: number; + qlikFaits: number; + qlikEchecs: number; + tourTermine: boolean; + derniereErreur: string | null; +} + +interface FournisseurRow { + codeFournisseur: string; + nomFournisseur: string | null; + actifSql: boolean; + actifQlik: boolean; + desactiveMotif: string | null; + dernierSqlAt: string | null; + dernierSqlStatut: string | null; + dernierSqlLignes: number | null; + dernierSqlMs: number | null; + dernierQlikAt: string | null; + dernierQlikStatut: string | null; + dernierQlikCodes: number | null; +} + +interface Resume { + total: number; actifsSql: number; actifsQlik: number; + desactivesAuto: number; enEchecSql: number; enEchecQlik: number; jamaisSql: number; +} + +const fmtDate = (iso: string | null) => + iso ? new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) : "jamais"; +const fmtDuree = (ms: number | null) => + ms == null ? "" : ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`; + +function Pastille({ statut }: { statut: string | null }) { + if (!statut) return —; + const couleurs: Record = { + succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500", + }; + const libelles: Record = { succes: "ok", echec: "échec", vide: "vide" }; + return {libelles[statut] ?? statut}; +} + +export function SynchronisationClient() { + const [settings, setSettings] = useState(null); + const [state, setState] = useState(null); + const [dansLaFenetre, setDansLaFenetre] = useState(false); + const [rows, setRows] = useState([]); + const [resume, setResume] = useState(null); + const [erreur, setErreur] = useState(null); + const [chargement, setChargement] = useState(true); + const [recherche, setRecherche] = useState(""); + const [filtre, setFiltre] = useState<"tous" | "actifs" | "inactifs" | "echecs">("tous"); + + // ── Lectures pures : aucun setState, pour rester appelables depuis un effet ── + const lireEtat = useCallback(async () => { + const res = await fetch("/api/admin/sync"); + if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); + return res.json(); + }, []); + + const lireFournisseurs = useCallback(async () => { + const res = await fetch("/api/admin/sync/fournisseurs"); + if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); + return res.json(); + }, []); + + const rafraichirEtat = useCallback(() => { + lireEtat() + .then((d) => { setSettings(d.settings); setState(d.state); setDansLaFenetre(d.dansLaFenetre); setErreur(null); }) + .catch((e) => setErreur(e instanceof Error ? e.message : String(e))); + }, [lireEtat]); + + const rafraichirTout = useCallback(() => { + Promise.all([lireEtat(), lireFournisseurs()]) + .then(([e, f]) => { + setSettings(e.settings); setState(e.state); setDansLaFenetre(e.dansLaFenetre); + setRows(f.fournisseurs ?? []); setResume(f.resume ?? null); setErreur(null); + }) + .catch((e) => setErreur(e instanceof Error ? e.message : String(e))) + .finally(() => setChargement(false)); + }, [lireEtat, lireFournisseurs]); + + useEffect(() => { rafraichirTout(); }, [rafraichirTout]); + + // Suivi rapproché pendant un round : l'état change à chaque fournisseur. + useEffect(() => { + if (!state?.enCours) return; + const id = setInterval(rafraichirEtat, 3000); + return () => clearInterval(id); + }, [state?.enCours, rafraichirEtat]); + + const majSettings = async (patch: Partial) => { + setErreur(null); + try { + const res = await fetch("/api/admin/sync", { + method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); + setSettings(d.settings); + } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } + }; + + const action = async (act: "start" | "stop" | "seed", forcer = false) => { + setErreur(null); + try { + const res = await fetch("/api/admin/sync", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: act, forcer }), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.error ?? `Erreur ${res.status}`); + rafraichirTout(); + } catch (e) { setErreur(e instanceof Error ? e.message : String(e)); } + }; + + const basculer = async (codes: string[], champ: "actifSql" | "actifQlik", valeur: boolean) => { + setErreur(null); + // Mise à jour optimiste : la liste peut compter des centaines de lignes, + // un rechargement complet à chaque case cochée serait pénible. + setRows((prev) => prev.map((r) => (codes.includes(r.codeFournisseur) + ? { ...r, [champ]: valeur, ...(valeur ? { desactiveMotif: null } : {}) } + : r))); + try { + const res = await fetch("/api/admin/sync/fournisseurs", { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ codes, [champ]: valeur }), + }); + if (!res.ok) throw new Error((await res.json()).error ?? `Erreur ${res.status}`); + } catch (e) { + setErreur(e instanceof Error ? e.message : String(e)); + rafraichirTout(); // l'optimisme était infondé : on resynchronise + } + }; + + const visibles = useMemo(() => { + const q = recherche.trim().toLowerCase(); + return rows.filter((r) => { + if (q && !`${r.codeFournisseur} ${r.nomFournisseur ?? ""}`.toLowerCase().includes(q)) return false; + if (filtre === "actifs") return r.actifSql || r.actifQlik; + if (filtre === "inactifs") return !r.actifSql && !r.actifQlik; + if (filtre === "echecs") return r.dernierSqlStatut === "echec" || r.dernierQlikStatut === "echec"; + return true; + }); + }, [rows, recherche, filtre]); + + const codesVisibles = useMemo(() => visibles.map((r) => r.codeFournisseur), [visibles]); + + return ( +
+
+

+ + Synchronisation nocturne +

+

+ Les fournisseurs sont traités du plus anciennement synchronisé au plus récent. Si la fenêtre + se referme avant la fin, la nuit suivante reprend là où on s'est arrêté ; quand tout le + monde est passé, un nouveau tour recommence. +

+ + {erreur && ( +
+ + {erreur} +
+ )} + + {/* ── Réglages ── */} + {settings && ( +
+
+ + +
+
+ + majSettings({ heureDebut: Number(e.target.value) })} + className="w-16 rounded-lg px-2 py-1.5 text-[13px]" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} /> +
+ h à +
+ + majSettings({ heureFin: Number(e.target.value) })} + className="w-16 rounded-lg px-2 py-1.5 text-[13px]" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} /> +
+ h +
+ +
+ + majSettings({ qlikParNuit: Number(e.target.value) })} + className="w-20 rounded-lg px-2 py-1.5 text-[13px]" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} /> +
+
+ + majSettings({ qlikMinJours: Number(e.target.value) })} + className="w-20 rounded-lg px-2 py-1.5 text-[13px]" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} /> +
+ +
+ + {state?.enCours ? ( + + ) : ( + + )} +
+ +

+ {dansLaFenetre + ? "Nous sommes actuellement dans la fenêtre." + : "Hors fenêtre — « Lancer maintenant » forcera un round immédiat."} + {" "}Une extraction Qlik peut durer 8 minutes : le plafond par nuit évite qu'elle + monopolise la fenêtre. Un fournisseur déjà démarré n'est jamais interrompu à l'heure de fin. +

+
+ )} + + {/* ── Round en cours ── */} + {state && (state.enCours || state.finAt) && ( +
+
+ {state.enCours + ? + : } + + {state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"} + + {state.enCours && state.fournisseurCourant && ( + + — {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant} + + )} +
+
+ SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`} + Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`} + {state.debutAt && Début {fmtDate(state.debutAt)}} + {state.tourTermine && Tour bouclé — tout le monde est à jour} +
+ {state.derniereErreur && ( +

Dernière erreur : {state.derniereErreur}

+ )} +
+ )} + + {/* ── Résumé ── */} + {resume && ( +
+ {[ + { l: "Fournisseurs", v: resume.total }, + { l: "Actifs SQL", v: resume.actifsSql }, + { l: "Actifs Qlik", v: resume.actifsQlik }, + { l: "Jamais synchronisés", v: resume.jamaisSql }, + { l: "Désactivés auto", v: resume.desactivesAuto }, + { l: "En échec", v: resume.enEchecSql + resume.enEchecQlik }, + ].map((s) => ( + + {s.l} {s.v} + + ))} +
+ )} + + {/* ── Filtres et actions de masse ── */} +
+
+ + setRecherche(e.target.value)} placeholder="Code ou nom" + className="rounded-lg pl-8 pr-3 py-1.5 text-[12px] w-56" + style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }} /> +
+ {(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => ( + + ))} +
+ {visibles.length} affichés : + + + + +
+ + {/* ── Tableau ── */} +
+ {chargement ? ( +
+ Chargement… +
+ ) : visibles.length === 0 ? ( +
+ Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel. +
+ ) : ( +
+ + + + {["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => ( + + ))} + + + + {visibles.map((r) => ( + + + + + + + + + ))} + +
{h}
+
+ {r.nomFournisseur ?? r.codeFournisseur} +
+
{r.codeFournisseur}
+
+ basculer([r.codeFournisseur], "actifSql", e.target.checked)} /> + + {" "} + {fmtDate(r.dernierSqlAt)} + {r.dernierSqlLignes != null && · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}} + + basculer([r.codeFournisseur], "actifQlik", e.target.checked)} /> + + {" "} + {fmtDate(r.dernierQlikAt)} + {r.dernierQlikCodes != null && · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes} + + {r.desactiveMotif} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx new file mode 100644 index 0000000..7956321 --- /dev/null +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -0,0 +1,15 @@ +import { SynchronisationClient } from "./client"; + +export const metadata = { + title: "Synchronisation nocturne — CollectFlow", +}; + +/** + * Paramétrage de la synchronisation nocturne des fournisseurs. + * + * L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes + * /api/admin/sync revérifient le rôle côté serveur. + */ +export default function SynchronisationPage() { + return ; +} diff --git a/src/app/api/admin/sync/fournisseurs/route.ts b/src/app/api/admin/sync/fournisseurs/route.ts new file mode 100644 index 0000000..aa20fbd --- /dev/null +++ b/src/app/api/admin/sync/fournisseurs/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { asc, eq, inArray, sql } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/db"; +import { syncFournisseurs } from "@/db/schema"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +async function requireAdmin(): Promise { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if ((session.user as { role?: string } | undefined)?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return null; +} + +/** + * GET — la liste complète des fournisseurs paramétrés, avec l'état de leur + * dernière synchronisation. + * + * Pas de pagination : quelques centaines de lignes tout au plus, et l'admin a + * besoin de la vue d'ensemble pour décider qui cadencer. + */ +export async function GET() { + const denied = await requireAdmin(); + if (denied) return denied; + + const rows = await db + .select() + .from(syncFournisseurs) + .orderBy(asc(sql`coalesce(${syncFournisseurs.nomFournisseur}, ${syncFournisseurs.codeFournisseur})`)); + + return NextResponse.json({ + success: true, + fournisseurs: rows, + resume: { + total: rows.length, + actifsSql: rows.filter((r) => r.actifSql).length, + actifsQlik: rows.filter((r) => r.actifQlik).length, + desactivesAuto: rows.filter((r) => r.desactiveMotif).length, + enEchecSql: rows.filter((r) => r.dernierSqlStatut === "echec").length, + enEchecQlik: rows.filter((r) => r.dernierQlikStatut === "echec").length, + jamaisSql: rows.filter((r) => !r.dernierSqlAt).length, + }, + }); +} + +const patchSchema = z.object({ + /** Codes visés. Permet d'activer ou désactiver en masse depuis la page. */ + codes: z.array(z.string().min(1)).min(1).max(2000), + actifSql: z.boolean().optional(), + actifQlik: z.boolean().optional(), +}).refine((v) => v.actifSql !== undefined || v.actifQlik !== undefined, { + message: "Préciser au moins actifSql ou actifQlik", +}); + +/** + * PATCH — active ou désactive des fournisseurs, pour l'une ou l'autre source. + * + * Réactiver lève le motif de désactivation automatique : sans cela, un + * fournisseur remis en service par l'admin garderait un « aucun article » + * trompeur, alors même qu'il vient d'être rouvert volontairement. + */ +export async function PATCH(req: NextRequest) { + const denied = await requireAdmin(); + if (denied) return denied; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); + } + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Paramètres invalides", details: parsed.error.issues.map((i) => i.message) }, + { status: 400 }, + ); + } + const { codes, actifSql, actifQlik } = parsed.data; + + const reactivation = actifSql === true || actifQlik === true; + const rows = await db + .update(syncFournisseurs) + .set({ + ...(actifSql !== undefined ? { actifSql } : {}), + ...(actifQlik !== undefined ? { actifQlik } : {}), + ...(reactivation ? { desactiveMotif: null } : {}), + updatedAt: new Date(), + }) + .where(codes.length === 1 ? eq(syncFournisseurs.codeFournisseur, codes[0]) : inArray(syncFournisseurs.codeFournisseur, codes)) + .returning({ code: syncFournisseurs.codeFournisseur }); + + console.log(`[api/admin/sync] ${rows.length} fournisseur(s) mis à jour (sql=${actifSql} qlik=${actifQlik})`); + return NextResponse.json({ success: true, misAJour: rows.length }); +} diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts new file mode 100644 index 0000000..ab79f45 --- /dev/null +++ b/src/app/api/admin/sync/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { readSyncSettings, saveSyncSettings } from "@/features/admin/api/sync-settings"; +import { + getSyncSchedulerState, + lancerRound, + demanderArret, + debutFenetre, + seedSyncFournisseurs, +} from "@/features/admin/api/sync-scheduler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +// Un lancement manuel peut enchaîner plusieurs fournisseurs. +export const maxDuration = 300; + +async function requireAdmin(): Promise { + const session = await auth(); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if ((session.user as { role?: string } | undefined)?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + return null; +} + +/** GET — réglages, état courant, et prochaine ouverture de fenêtre. */ +export async function GET() { + const denied = await requireAdmin(); + if (denied) return denied; + + const settings = await readSyncSettings(); + const maintenant = new Date(); + const debut = debutFenetre(settings, maintenant); + + return NextResponse.json({ + success: true, + settings, + state: getSyncSchedulerState(), + /** true = on est actuellement dans la fenêtre nocturne. */ + dansLaFenetre: debut !== null, + fenetreDebutAt: debut?.toISOString() ?? null, + maintenant: maintenant.toISOString(), + }); +} + +const settingsSchema = z.object({ + actif: z.boolean().optional(), + heureDebut: z.coerce.number().int().min(0).max(23).optional(), + heureFin: z.coerce.number().int().min(0).max(23).optional(), + qlikParNuit: z.coerce.number().int().min(0).max(200).optional(), + sqlParQlik: z.coerce.number().int().min(1).max(500).optional(), + qlikMinJours: z.coerce.number().int().min(0).max(365).optional(), +}); + +/** PATCH — met à jour les réglages. */ +export async function PATCH(req: NextRequest) { + const denied = await requireAdmin(); + if (denied) return denied; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); + } + const parsed = settingsSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Paramètres invalides", details: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, + { status: 400 }, + ); + } + + const settings = await saveSyncSettings(parsed.data); + return NextResponse.json({ success: true, settings }); +} + +const actionSchema = z.object({ + action: z.enum(["start", "stop", "seed"]), + /** + * Lancement manuel hors de la fenêtre nocturne. Réservé à l'admin qui sait + * ce qu'il fait : en pleine journée, la charge est visible par les magasins. + */ + forcer: z.boolean().optional(), +}); + +/** POST — démarre un round manuellement, l'arrête, ou resynchronise la liste. */ +export async function POST(req: NextRequest) { + const denied = await requireAdmin(); + if (denied) return denied; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 }); + } + const parsed = actionSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ error: "Action inconnue (start, stop ou seed)" }, { status: 400 }); + } + + if (parsed.data.action === "seed") { + const n = await seedSyncFournisseurs(); + return NextResponse.json({ success: true, fournisseurs: n }); + } + + if (parsed.data.action === "stop") { + demanderArret(); + return NextResponse.json({ success: true, state: getSyncSchedulerState() }); + } + + const etat = getSyncSchedulerState(); + if (etat.enCours) { + return NextResponse.json({ error: "Un round est déjà en cours.", state: etat }, { status: 409 }); + } + + // Fire-and-forget : le round dure des minutes, le client suit via GET. + void lancerRound("manuel", parsed.data.forcer === true).catch((e) => { + console.error("[api/admin/sync] round manuel en échec :", e instanceof Error ? e.message : String(e)); + }); + + return NextResponse.json({ success: true, state: getSyncSchedulerState() }); +} diff --git a/src/components/shared/sidebar.tsx b/src/components/shared/sidebar.tsx index 99b1eb3..613da03 100644 --- a/src/components/shared/sidebar.tsx +++ b/src/components/shared/sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft } from "lucide-react"; +import { LayoutGrid, Camera, FileDown, Settings, Package, PackageSearch, BarChart2, TrendingUp, Trophy, Megaphone, PackageMinus, ShoppingCart, LogOut, User as UserIcon, Loader2, ChevronRight, ChevronLeft, CalendarClock } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; @@ -19,6 +19,7 @@ const NAV_ITEMS = [ { icon: ShoppingCart, label: "Commandes auto", href: "/commandes-auto" }, { icon: Camera, label: "Snapshots", href: "/snapshots" }, { icon: FileDown, label: "Exports", href: "/exports", adminOnly: true }, + { icon: CalendarClock, label: "Synchronisation", href: "/admin/synchronisation", adminOnly: true }, { icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true }, ]; diff --git a/src/features/admin/api/sync-scheduler.ts b/src/features/admin/api/sync-scheduler.ts index ef442ce..89e83ef 100644 --- a/src/features/admin/api/sync-scheduler.ts +++ b/src/features/admin/api/sync-scheduler.ts @@ -277,6 +277,12 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre try { await seedSyncFournisseurs(); + // Garde anti-boucle. L'avancement de la file repose sur l'écriture de + // `dernier*At`, et cette écriture est volontairement non bloquante en cas + // d'erreur. Si elle échoue, le fournisseur resterait le plus ancien et + // serait repris sans fin : on retient donc ce qui a déjà été tenté. + const tentes = new Set(); + let depuisDernierQlik = 0; for (;;) { if (etat.arretDemande) break; @@ -290,7 +296,8 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre // Qlik prioritaire quand le quota d'entrelacement est atteint. if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) { const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible) { + if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { + tentes.add(`qlik:${cible.codeFournisseur}`); await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); depuisDernierQlik = 0; continue; @@ -299,6 +306,14 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre const cibleSql = await prochainSql(depuis); if (cibleSql) { + if (tentes.has(`sql:${cibleSql.codeFournisseur}`)) { + console.error( + `[sync-nuit] ${cibleSql.codeFournisseur} ressort de la file après avoir été traité :` + + " le suivi n'a pas pu être écrit. Arrêt du round pour ne pas boucler.", + ); + break; + } + tentes.add(`sql:${cibleSql.codeFournisseur}`); await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur); depuisDernierQlik++; continue; @@ -307,7 +322,8 @@ export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre // File SQL vide : on consacre le temps restant à Qlik. if (qlikPossible) { const cible = await prochainQlik(s.qlikMinJours, new Date()); - if (cible) { + if (cible && !tentes.has(`qlik:${cible.codeFournisseur}`)) { + tentes.add(`qlik:${cible.codeFournisseur}`); await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); depuisDernierQlik = 0; continue; diff --git a/src/features/admin/api/sync-settings.ts b/src/features/admin/api/sync-settings.ts index 95ccefe..541dbac 100644 --- a/src/features/admin/api/sync-settings.ts +++ b/src/features/admin/api/sync-settings.ts @@ -1,13 +1,16 @@ -"use server"; - /** * CollectFlow — Réglages de la synchronisation nocturne. * * Rangés dans `data/.db-config.json`, comme les autres réglages de * l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à * sauvegarder et à monter dans le conteneur. + * + * Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs + * (les défauts, le type), ce que `"use server"` interdit. */ +import "server-only"; + import fs from "fs/promises"; import path from "path"; diff --git a/src/features/admin/api/sync-timer.ts b/src/features/admin/api/sync-timer.ts new file mode 100644 index 0000000..ae1acd6 --- /dev/null +++ b/src/features/admin/api/sync-timer.ts @@ -0,0 +1,41 @@ +/** + * CollectFlow — Minuteur de la synchronisation nocturne. + * + * Démarré une fois au boot par `src/instrumentation.ts`. Il bat chaque minute + * et laisse `tickScheduler()` décider s'il y a quelque chose à faire : hors + * fenêtre, ou si un round tourne déjà, le battement ne coûte rien. + * + * Conséquence assumée du choix « minuteur interne » plutôt que cron système : + * rien ne tourne si l'application est arrêtée. En contrepartie, aucune + * configuration côté hôte, et le minuteur se réarme tout seul au redémarrage. + */ + +import "server-only"; + +import { tickScheduler } from "@/features/admin/api/sync-scheduler"; + +const INTERVALLE_MS = 60_000; + +/** + * Garde au niveau du module : en développement, le rechargement à chaud peut + * réévaluer `register()` plusieurs fois, et on se retrouverait avec autant de + * minuteurs empilés. + */ +let minuteur: ReturnType | null = null; + +export function startSyncTimer(): void { + if (minuteur) return; + + console.log(`[sync-nuit] minuteur démarré (battement ${INTERVALLE_MS / 1000} s)`); + minuteur = setInterval(() => { + // `tickScheduler` capture déjà ses propres erreurs ; ce `catch` ne couvre + // qu'un rejet inattendu, pour qu'il ne devienne jamais une exception non + // gérée capable d'abattre le processus. + void tickScheduler().catch((e) => { + console.error("[sync-nuit] battement en erreur :", e instanceof Error ? e.message : String(e)); + }); + }, INTERVALLE_MS); + + // Ne pas maintenir le processus en vie uniquement pour ce minuteur. + minuteur.unref?.(); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..4776c24 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,20 @@ +/** + * CollectFlow — Point d'entrée exécuté une fois au démarrage du serveur. + * + * Sert à armer le minuteur de la synchronisation nocturne. L'import est + * dynamique et conditionné au runtime Node : `register()` est aussi évalué + * dans le runtime Edge, où ni `setInterval` persistant ni l'accès à la base + * n'ont de sens. + */ +export async function register() { + if (process.env.NEXT_RUNTIME !== "nodejs") return; + + try { + const { startSyncTimer } = await import("@/features/admin/api/sync-timer"); + startSyncTimer(); + } catch (e) { + // Un démarrage réussi prime sur le planificateur : s'il ne s'arme pas, + // l'application doit rester utilisable et la synchro manuelle disponible. + console.error("[instrumentation] minuteur de synchro non démarré :", e instanceof Error ? e.message : String(e)); + } +}