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:
Claude committed 2026-08-07 07:28:11 +00:00
1 parent be8d576af5
commit 0bd7ec9798
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&apos;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&apos;elle
monopolise la fenêtre. Un fournisseur déjà démarré n&apos;est jamais interrompu à l&apos;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 });
}
+125
View File
@@ -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() });
}