mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 09:46:56 +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,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() });
|
||||
}
|
||||
Reference in new issue
Block a user