mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat(admin): synchronisation nocturne des fournisseurs, paramétrable
Page /admin/synchronisation : choisir quels fournisseurs sont cadencés, pour quelle source, et suivre l'avancement. ORDRE DE PASSAGE. La file est « le plus anciennement synchronisé d'abord », en ignorant ceux déjà traités depuis l'ouverture de la fenêtre. Il en découle, sans curseur ni compteur de tour : la nuit 2 reprend là où la nuit 1 s'est arrêtée, le tour recommence quand tout le monde est passé, et un fournisseur en échec garde une date ancienne donc repasse en tête. DEUX FILES. SQL (~43 s sur le plus gros) et Qlik (jusqu'à 8 min, sur un serveur qui abandonne quand on le sollicite trop) ont leur propre activation et leur propre rythme. Une extraction Qlik est intercalée tous les N fournisseurs SQL, sinon elle ne démarrerait jamais avant que la file SQL soit vide. Un plafond par nuit et un âge minimal évitent de refaire chaque nuit des agrégats mensuels. Détails qui comptent : - La fenêtre signifie « ne plus DÉMARRER après l'heure de fin » : une extraction en cours n'est jamais coupée. Le passage par minuit est géré. - L'horodatage est écrit même en cas d'échec, sans quoi le fournisseur fautif bloquerait la file derrière lui. - Garde anti-boucle : l'avancement repose sur cette écriture, volontairement non bloquante ; si elle échoue, le round s'arrête au lieu de reprendre le même fournisseur indéfiniment. - Un fournisseur sans article est désactivé automatiquement, motif affiché, réactivable — plutôt que réessayé chaque nuit. - Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter Qlik la nuit suivante sans décision explicite. Déclenchement par minuteur interne (instrumentation.ts), battement d'une minute. Aucune configuration côté hôte, se réarme au démarrage ; en contrepartie rien ne tourne si l'application est arrêtée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
9 files changed
+740
-5
No files matched your search
@@ -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 <span className="text-[11px]" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||
const couleurs: Record<string, string> = {
|
||||
succes: "text-emerald-500", echec: "text-red-500", vide: "text-amber-500",
|
||||
};
|
||||
const libelles: Record<string, string> = { succes: "ok", echec: "échec", vide: "vide" };
|
||||
return <span className={cn("text-[11px] font-semibold", couleurs[statut] ?? "")}>{libelles[statut] ?? statut}</span>;
|
||||
}
|
||||
|
||||
export function SynchronisationClient() {
|
||||
const [settings, setSettings] = useState<SyncSettings | null>(null);
|
||||
const [state, setState] = useState<SyncState | null>(null);
|
||||
const [dansLaFenetre, setDansLaFenetre] = useState(false);
|
||||
const [rows, setRows] = useState<FournisseurRow[]>([]);
|
||||
const [resume, setResume] = useState<Resume | null>(null);
|
||||
const [erreur, setErreur] = useState<string | null>(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<SyncSettings>) => {
|
||||
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 (
|
||||
<div className="min-h-screen p-6" style={{ background: "var(--bg-base)" }}>
|
||||
<div className="mx-auto max-w-screen-xl">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2" style={{ color: "var(--text-primary)" }}>
|
||||
<CalendarClock className="w-6 h-6" style={{ color: "var(--accent)" }} />
|
||||
Synchronisation nocturne
|
||||
</h1>
|
||||
<p className="text-[13px] mt-1 mb-5" style={{ color: "var(--text-muted)" }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{erreur && (
|
||||
<div className="rounded-xl p-3.5 mb-4 flex items-start gap-2.5 text-[13px]"
|
||||
style={{ background: "var(--accent-error-bg)", border: "1px solid var(--accent-error)", color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{erreur}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Réglages ── */}
|
||||
{settings && (
|
||||
<div className="rounded-2xl p-4 mb-4" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.actif}
|
||||
onChange={(e) => majSettings({ actif: e.target.checked })} className="w-4 h-4" />
|
||||
<span className="text-[13px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
Synchronisation nocturne active
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>De</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureDebut}
|
||||
onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h à</span>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>À</label>
|
||||
<input type="number" min={0} max={23} value={settings.heureFin}
|
||||
onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
<span className="pb-2 text-[13px]" style={{ color: "var(--text-muted)" }}>h</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik / nuit</label>
|
||||
<input type="number" min={0} max={200} value={settings.qlikParNuit}
|
||||
onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] mb-1" style={{ color: "var(--text-muted)" }}>Qlik si + vieux que</label>
|
||||
<input type="number" min={0} max={365} value={settings.qlikMinJours}
|
||||
onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{state?.enCours ? (
|
||||
<button onClick={() => action("stop")} className="rounded-xl px-4 py-2 text-[13px] font-semibold flex items-center gap-2"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)", border: "1px solid var(--accent-error)" }}>
|
||||
<Square className="w-3.5 h-3.5" /> Arrêter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => action("start", !dansLaFenetre)} className="rounded-xl px-4 py-2 text-[13px] font-semibold text-white flex items-center gap-2"
|
||||
style={{ background: "var(--accent)" }}>
|
||||
<Play className="w-3.5 h-3.5" /> Lancer maintenant
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] mt-3" style={{ color: "var(--text-muted)" }}>
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Round en cours ── */}
|
||||
{state && (state.enCours || state.finAt) && (
|
||||
<div className="rounded-2xl p-4 mb-4 text-[13px]"
|
||||
style={{ background: state.enCours ? "var(--accent-bg)" : "var(--bg-surface)", border: `1px solid ${state.enCours ? "var(--accent-border)" : "var(--border)"}` }}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{state.enCours
|
||||
? <Loader2 className="w-4 h-4 animate-spin" style={{ color: "var(--accent)" }} />
|
||||
: <CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
<span className="font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
{state.enCours ? `Round ${state.origine} en cours` : "Dernier round terminé"}
|
||||
</span>
|
||||
{state.enCours && state.fournisseurCourant && (
|
||||
<span style={{ color: "var(--text-secondary)" }}>
|
||||
— {state.etape === "qlik" ? "Qlik" : "SQL"} : {state.fournisseurCourant}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span><Database className="w-3 h-3 inline mr-1" />SQL : {state.sqlFaits} ok{state.sqlEchecs > 0 && `, ${state.sqlEchecs} en échec`}</span>
|
||||
<span><Network className="w-3 h-3 inline mr-1" />Qlik : {state.qlikFaits} ok{state.qlikEchecs > 0 && `, ${state.qlikEchecs} en échec`}</span>
|
||||
{state.debutAt && <span>Début {fmtDate(state.debutAt)}</span>}
|
||||
{state.tourTermine && <span className="text-emerald-500 font-semibold">Tour bouclé — tout le monde est à jour</span>}
|
||||
</div>
|
||||
{state.derniereErreur && (
|
||||
<p className="mt-2 text-[11px] text-red-400">Dernière erreur : {state.derniereErreur}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Résumé ── */}
|
||||
{resume && (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-[12px]">
|
||||
{[
|
||||
{ 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) => (
|
||||
<span key={s.l} className="rounded-lg px-2.5 py-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
{s.l} <strong style={{ color: "var(--text-primary)" }}>{s.v}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Filtres et actions de masse ── */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
<input value={recherche} onChange={(e) => 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)" }} />
|
||||
</div>
|
||||
{(["tous", "actifs", "inactifs", "echecs"] as const).map((f) => (
|
||||
<button key={f} onClick={() => setFiltre(f)}
|
||||
className="rounded-full px-3 py-1 text-[12px] font-semibold"
|
||||
style={{
|
||||
background: filtre === f ? "var(--accent)" : "var(--bg-elevated)",
|
||||
color: filtre === f ? "#fff" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border)",
|
||||
}}>
|
||||
{f === "echecs" ? "en échec" : f}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<span className="text-[11px]" style={{ color: "var(--text-muted)" }}>{visibles.length} affichés :</span>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", true)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Activer SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifSql", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver SQL
|
||||
</button>
|
||||
<button onClick={() => basculer(codesVisibles, "actifQlik", false)} className="rounded-lg px-2.5 py-1 text-[11px]"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
Désactiver Qlik
|
||||
</button>
|
||||
<button onClick={() => action("seed")} title="Récupérer les nouveaux fournisseurs du référentiel"
|
||||
className="rounded-lg px-2.5 py-1 text-[11px] flex items-center gap-1"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<RefreshCw className="w-3 h-3" /> Actualiser la liste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tableau ── */}
|
||||
<div className="rounded-2xl overflow-hidden" style={{ background: "var(--bg-surface)", border: "1px solid var(--border)" }}>
|
||||
{chargement ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin inline mr-2" /> Chargement…
|
||||
</div>
|
||||
) : visibles.length === 0 ? (
|
||||
<div className="p-6 text-center text-[13px]" style={{ color: "var(--text-muted)" }}>
|
||||
Aucun fournisseur. Utilisez « Actualiser la liste » pour importer le référentiel.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
{["Fournisseur", "SQL", "Dernier SQL", "Qlik", "Dernier Qlik", ""].map((h) => (
|
||||
<th key={h} className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-secondary)", borderBottom: "1px solid var(--border)" }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibles.map((r) => (
|
||||
<tr key={r.codeFournisseur} style={{ borderBottom: "1px solid var(--border)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
{r.nomFournisseur ?? r.codeFournisseur}
|
||||
</div>
|
||||
<div className="text-[10px]" style={{ color: "var(--text-muted)" }}>{r.codeFournisseur}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifSql} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifSql", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierSqlStatut} />{" "}
|
||||
{fmtDate(r.dernierSqlAt)}
|
||||
{r.dernierSqlLignes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierSqlLignes.toLocaleString("fr-FR")} lignes {fmtDuree(r.dernierSqlMs)}</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input type="checkbox" checked={r.actifQlik} className="w-4 h-4"
|
||||
onChange={(e) => basculer([r.codeFournisseur], "actifQlik", e.target.checked)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
||||
<Pastille statut={r.dernierQlikStatut} />{" "}
|
||||
{fmtDate(r.dernierQlikAt)}
|
||||
{r.dernierQlikCodes != null && <span style={{ color: "var(--text-muted)" }}> · {r.dernierQlikCodes.toLocaleString("fr-FR")} codes</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
{r.desactiveMotif}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <SynchronisationClient />;
|
||||
}
|
||||
@@ -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<NextResponse | null> {
|
||||
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 });
|
||||
}
|
||||
@@ -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<NextResponse | null> {
|
||||
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() });
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<typeof setInterval> | 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?.();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user