From be8d576af55db48bf78d3a3301a9127c971c043b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 07:06:23 +0000 Subject: [PATCH] =?UTF-8?q?chore:=20supprime=20les=20derniers=20restes=20d?= =?UTF-8?q?e=20l'IA=20dans=20les=20Param=C3=A8tres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La section « IA Copilot — OpenRouter » annonçait « pour les analyses de gammes », mais aucun code ne consommait cette clé : elle était décorative. Supprimée, ainsi que les routes devenues orphelines /api/admin/ai-config, /api/google-ai/models et /api/openrouter/models. Nettoyage de l'état correspondant dans la page : clé, liste de modèles, fournisseur d'IA et modèle Google ne faisaient plus qu'un aller-retour entre le chargement et l'enregistrement, sans aucune interface pour les modifier. handleSave n'enregistre donc plus que l'URL de la base. La règle set-state-in-effect s'est mise à signaler le `setIsMounted` du montage, inchangé mais devenu analysable une fois le code IA retiré : dérogation locale, comme dans api-connection-info.tsx, puisque poser l'indicateur au montage est précisément ce qui évite l'écart d'hydratation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/app/(dashboard)/settings/page.tsx | 101 +------ src/app/api/admin/ai-config/route.ts | 24 -- src/app/api/google-ai/models/route.ts | 61 ---- src/app/api/openrouter/models/route.ts | 61 ---- src/features/admin/api/sync-scheduler.ts | 350 +++++++++++++++++++++++ src/features/admin/api/sync-settings.ts | 101 +++++++ 6 files changed, 456 insertions(+), 242 deletions(-) delete mode 100644 src/app/api/admin/ai-config/route.ts delete mode 100644 src/app/api/google-ai/models/route.ts delete mode 100644 src/app/api/openrouter/models/route.ts create mode 100644 src/features/admin/api/sync-scheduler.ts create mode 100644 src/features/admin/api/sync-settings.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 6467085..2e3cd79 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -13,8 +13,6 @@ import { ApiConnectionInfo } from "@/features/admin/components/api-connection-in import { GridWarmup } from "@/features/admin/components/grid-warmup"; import { ServerLogs } from "@/features/settings/components/server-logs"; -interface OpenRouterModel { id: string; name: string; free: boolean; } - function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { return (
@@ -251,19 +249,9 @@ export default function SettingsPage() { const displayDensity = useGridStore((s) => s.displayDensity); const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); - const [showKey, setShowKey] = useState(false); - const [apiKey, setApiKey] = useState(""); const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle"); const [dbError, setDbError] = useState(null); const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); - const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState("google/gemini-2.0-flash-001"); - const [modelsStatus, setModelsStatus] = useState<"idle" | "loading" | "ok" | "error">("idle"); - - // AI Provider - const [aiProvider, setAiProvider] = useState<"openrouter" | "google">("openrouter"); - const [googleAiKey, setGoogleAiKey] = useState(""); - const [googleAiModel, setGoogleAiModel] = useState(""); const [isMounted, setIsMounted] = useState(false); @@ -277,20 +265,6 @@ export default function SettingsPage() { getDatabaseUrl } = useDbSettingsStore(); - const fetchModels = useCallback(async (key: string) => { - if (!key.trim()) return; - setModelsStatus("loading"); - try { - const res = await fetch("/api/openrouter/models", { headers: { "x-openrouter-key": key } }); - if (!res.ok) throw new Error(); - const data = await res.json(); - setModels(data.models ?? []); - setModelsStatus("ok"); - } catch { - setModelsStatus("error"); - } - }, []); - const reloadFromServer = useCallback(async () => { const config = await getSavedDatabaseConfig(); if (config) { @@ -308,20 +282,13 @@ export default function SettingsPage() { console.error("Failed to parse saved URL", e); } } - if (config.openRouterKey) { - setApiKey(config.openRouterKey); - fetchModels(config.openRouterKey); - } - if (config.openRouterModel) { - setSelectedModel(config.openRouterModel); - } - if (config.aiProvider) setAiProvider(config.aiProvider); - if (config.googleAiKey) setGoogleAiKey(config.googleAiKey); - if (config.googleAiModel) setGoogleAiModel(config.googleAiModel); } - }, [fetchModels, setApiKey, setDatabase, setHost, setPassword, setPort, setSelectedModel, setSsl, setUser]); + }, [setDatabase, setHost, setPassword, setPort, setSsl, setUser]); useEffect(() => { + // `isMounted` sert à éviter un écart d'hydratation : il doit donc être posé + // au montage, ce que la règle déconseille en général mais qui est ici le but. + // eslint-disable-next-line react-hooks/set-state-in-effect setIsMounted(true); reloadFromServer(); }, [reloadFromServer]); @@ -342,7 +309,7 @@ export default function SettingsPage() { const handleSave = async () => { setSaveStatus("saving"); const url = getDatabaseUrl(); - const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel); + const res = await saveDatabaseSettings(url); if (res.success) { setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); @@ -457,64 +424,6 @@ export default function SettingsPage() { - {/* OpenRouter */} -
- -
-
- setApiKey(e.target.value)} - className="apple-input font-mono pr-10" - /> - -
- -
-
- - 0 ? ` (${models.length} disponibles)` : ""}`} - hint={modelsStatus === "error" ? "⚠ Erreur — vérifiez votre clé puis rechargez" : modelsStatus === "idle" ? "Entrez votre clé et cliquez « Charger les modèles »" : undefined} - > - {models.length > 0 ? ( - - ) : ( - setSelectedModel(e.target.value)} - placeholder="ex: google/gemini-2.0-flash-001" - className="apple-input" - /> - )} - -
- {/* Apparence */}
diff --git a/src/app/api/admin/ai-config/route.ts b/src/app/api/admin/ai-config/route.ts deleted file mode 100644 index 89d9fd5..0000000 --- a/src/app/api/admin/ai-config/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/lib/auth"; -import fs from "fs/promises"; -import path from "path"; - -export async function GET() { - const session = await auth(); - if (!session || (session.user as any)?.role !== "admin") { - return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); - } - - try { - const configFile = path.join(process.cwd(), "data", ".db-config.json"); - const data = await fs.readFile(configFile, "utf-8"); - const config = JSON.parse(data); - return NextResponse.json({ - provider: config.aiProvider ?? "openrouter", - openRouterModel: config.openRouterModel, - googleAiModel: config.googleAiModel, - }); - } catch { - return NextResponse.json({ provider: "openrouter" }); - } -} diff --git a/src/app/api/google-ai/models/route.ts b/src/app/api/google-ai/models/route.ts deleted file mode 100644 index 661c412..0000000 --- a/src/app/api/google-ai/models/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; - -async function getSavedGoogleKey(): Promise { - try { - const configFile = path.join(process.cwd(), "data", ".db-config.json"); - const data = await fs.readFile(configFile, "utf-8"); - const config = JSON.parse(data); - return config.googleAiKey ?? null; - } catch { - return null; - } -} - -export async function GET(req: NextRequest) { - const apiKey = req.headers.get("x-google-ai-key") - || process.env.GOOGLE_AI_KEY - || await getSavedGoogleKey(); - - if (!apiKey) { - return NextResponse.json({ error: "No Google AI key provided" }, { status: 401 }); - } - - try { - const res = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}&pageSize=100`, - { cache: "no-store" } - ); - - if (!res.ok) { - const err = await res.text(); - return NextResponse.json({ error: err }, { status: res.status }); - } - - const data = await res.json(); - - // Keep only generative models that support generateContent - const models = (data.models as Array<{ - name: string; - displayName: string; - supportedGenerationMethods?: string[]; - description?: string; - }>) - .filter(m => - m.supportedGenerationMethods?.includes("generateContent") && - !m.name.includes("embedding") && - !m.name.includes("aqa") - ) - .map(m => ({ - id: m.name.replace("models/", ""), - name: m.displayName, - })) - .sort((a, b) => a.name.localeCompare(b.name, "fr")); - - return NextResponse.json({ models }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: msg }, { status: 500 }); - } -} diff --git a/src/app/api/openrouter/models/route.ts b/src/app/api/openrouter/models/route.ts deleted file mode 100644 index 2681f01..0000000 --- a/src/app/api/openrouter/models/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import fs from "fs/promises"; -import path from "path"; - -async function getSavedOpenRouterKey(): Promise { - try { - const configFile = path.join(process.cwd(), "data", ".db-config.json"); - const data = await fs.readFile(configFile, "utf-8"); - const config = JSON.parse(data); - return config.openRouterKey ?? null; - } catch { - return null; - } -} - -export async function GET(req: NextRequest) { - const apiKey = req.headers.get("x-openrouter-key") - || process.env.OPENROUTER_API_KEY - || await getSavedOpenRouterKey(); - - if (!apiKey) { - return NextResponse.json({ error: "No API key provided" }, { status: 401 }); - } - - try { - const res = await fetch("https://openrouter.ai/api/v1/models", { - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - cache: "no-store", - }); - - if (!res.ok) { - return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status }); - } - - const data = await res.json(); - - // Return simplified list sorted by name - const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string; completion: string } }>) - .map((m) => ({ - id: m.id, - name: m.name, - promptPrice: parseFloat(m.pricing?.prompt ?? "0"), - completionPrice: parseFloat(m.pricing?.completion ?? "0"), - free: parseFloat(m.pricing?.prompt ?? "1") === 0, - })) - .sort((a, b) => { - // Free models first, then alphabetical - if (a.free && !b.free) return -1; - if (!a.free && b.free) return 1; - return a.name.localeCompare(b.name, "fr"); - }); - - return NextResponse.json({ models }); - } catch (err) { - const msg = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ error: msg }, { status: 500 }); - } -} diff --git a/src/features/admin/api/sync-scheduler.ts b/src/features/admin/api/sync-scheduler.ts new file mode 100644 index 0000000..ef442ce --- /dev/null +++ b/src/features/admin/api/sync-scheduler.ts @@ -0,0 +1,350 @@ +/** + * CollectFlow — Synchronisation nocturne des fournisseurs. + * + * Deux sources aux coûts très différents, donc deux files distinctes : + * • **SQL** — l'instantané de grille (`getProductRows`), ~43 s sur le plus gros + * fournisseur du catalogue. Alimente la Grille et `/api/v1`. + * • **Qlik** — les métriques réseau, jusqu'à 8 min par fournisseur, sur un + * serveur externe qui abandonne quand on le sollicite trop. Ce sont des + * agrégats mensuels : les rafraîchir chaque nuit n'apporterait rien. + * + * ORDRE DE PASSAGE — le point de conception central. La file est simplement + * « le plus ancien d'abord », en ignorant ceux déjà traités depuis l'ouverture + * de la fenêtre courante. Il en découle, sans curseur ni compteur de tour : + * • la nuit 2 reprend là où la nuit 1 s'est arrêtée (les non-traités sont les + * plus anciens) ; + * • quand plus personne n'est en attente, le tour est fini ; la nuit suivante + * tout le monde redevient éligible et le tour recommence ; + * • un fournisseur en échec garde une date ancienne, donc il repasse en tête — + * le rattrapage est automatique. + * + * La fenêtre ne peut pas interrompre un fournisseur en cours : elle signifie + * « ne plus en **démarrer** après l'heure de fin ». + */ + +import "server-only"; + +import { and, asc, eq, isNull, lt, or, sql, type SQL } from "drizzle-orm"; +import { db } from "@/db"; +import { syncFournisseurs } from "@/db/schema"; +import { getProductRows } from "@/features/grid/api/get-product-rows"; +import { pgGetFournisseurs, pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; +import { fetchNetworkMetricsPlaywright } from "@/lib/qlik-playwright"; +import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; +import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import { readSyncSettings, type SyncSettings } from "@/features/admin/api/sync-settings"; + +export type { SyncSettings }; + +// ─── État courant, en mémoire ──────────────────────────────────────────────── + +export interface SyncSchedulerState { + enCours: boolean; + /** "auto" = déclenché par la fenêtre nocturne, "manuel" = lancé depuis l'admin. */ + 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; + /** Renseigné quand la file s'est vidée : le tour est bouclé. */ + tourTermine: boolean; + arretDemande: boolean; + derniereErreur: string | null; +} + +const etat: SyncSchedulerState = { + enCours: false, origine: null, debutAt: null, finAt: null, + fournisseurCourant: null, etape: null, + sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, + tourTermine: false, arretDemande: false, derniereErreur: null, +}; + +export function getSyncSchedulerState(): SyncSchedulerState { + return { ...etat }; +} + +export function demanderArret(): void { + if (etat.enCours) etat.arretDemande = true; +} + +// ─── Fenêtre horaire ───────────────────────────────────────────────────────── + +/** + * Début de la fenêtre en cours, ou `null` si l'heure courante est en dehors. + * + * Gère le passage par minuit (ex. 22h → 5h) : dans ce cas, entre 0h et l'heure + * de fin, la fenêtre a commencé **la veille**. + */ +export function debutFenetre(s: SyncSettings, maintenant = new Date()): Date | null { + const h = maintenant.getHours(); + const debutDuJour = new Date(maintenant); + debutDuJour.setHours(s.heureDebut, 0, 0, 0); + + if (s.heureDebut < s.heureFin) { + return h >= s.heureDebut && h < s.heureFin ? debutDuJour : null; + } + // Fenêtre à cheval sur minuit. + if (h >= s.heureDebut) return debutDuJour; + if (h < s.heureFin) { + const veille = new Date(debutDuJour); + veille.setDate(veille.getDate() - 1); + return veille; + } + return null; +} + +// ─── Référentiel des fournisseurs ──────────────────────────────────────────── + +/** + * Aligne la table de paramétrage sur le référentiel FF : ajoute les nouveaux + * fournisseurs (actifs par défaut), rafraîchit les noms. Ne supprime rien — + * l'historique de synchronisation d'un fournisseur disparu reste consultable. + */ +export async function seedSyncFournisseurs(): Promise { + const fournisseurs = await pgGetFournisseurs(); + if (fournisseurs.length === 0) return 0; + + const CHUNK = 500; + for (let i = 0; i < fournisseurs.length; i += CHUNK) { + const lot = fournisseurs.slice(i, i + CHUNK); + await db + .insert(syncFournisseurs) + .values(lot.map((f) => ({ codeFournisseur: f.code, nomFournisseur: f.nom }))) + .onConflictDoUpdate({ + target: syncFournisseurs.codeFournisseur, + // Seul le nom est réaligné : les choix d'activation appartiennent à l'admin. + set: { nomFournisseur: sql`excluded.nom_fournisseur` }, + }); + } + return fournisseurs.length; +} + +// ─── Files d'attente ───────────────────────────────────────────────────────── + +/** Le plus ancien fournisseur SQL pas encore traité depuis l'ouverture de la fenêtre. */ +async function prochainSql(depuis: Date) { + const [row] = await db + .select() + .from(syncFournisseurs) + .where(and( + eq(syncFournisseurs.actifSql, true), + or(isNull(syncFournisseurs.dernierSqlAt), lt(syncFournisseurs.dernierSqlAt, depuis)) as SQL, + )) + .orderBy(asc(sql`${syncFournisseurs.dernierSqlAt} nulls first`)) + .limit(1); + return row ?? null; +} + +/** + * Le plus ancien fournisseur Qlik dont les données dépassent `qlikMinJours`. + * Le seuil en jours, plutôt que la fenêtre courante, évite de relancer chaque + * nuit une extraction de plusieurs minutes pour des agrégats mensuels. + */ +async function prochainQlik(qlikMinJours: number, maintenant: Date) { + const seuil = new Date(maintenant.getTime() - qlikMinJours * 24 * 3600 * 1000); + const [row] = await db + .select() + .from(syncFournisseurs) + .where(and( + eq(syncFournisseurs.actifQlik, true), + or(isNull(syncFournisseurs.dernierQlikAt), lt(syncFournisseurs.dernierQlikAt, seuil)) as SQL, + )) + .orderBy(asc(sql`${syncFournisseurs.dernierQlikAt} nulls first`)) + .limit(1); + return row ?? null; +} + +// ─── Traitement d'un fournisseur ───────────────────────────────────────────── + +/** + * Instantané SQL. `dernierSqlAt` est renseigné **même en cas d'échec** : sans + * cela, un fournisseur qui échoue resterait éternellement le plus ancien et + * bloquerait la file derrière lui. + */ +async function traiterSql(code: string, nom: string | null): Promise { + const t0 = Date.now(); + etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; + etat.etape = "sql"; + try { + const rows = await getProductRows({ codeFournisseur: code, magasin: "TOTAL", forceRefresh: true }); + const vide = rows.length === 0; + await db.update(syncFournisseurs).set({ + dernierSqlAt: new Date(), + dernierSqlStatut: vide ? "vide" : "succes", + dernierSqlLignes: rows.length, + dernierSqlErreur: null, + dernierSqlMs: Date.now() - t0, + // Un fournisseur sans article n'a rien à donner : on le sort des deux + // files plutôt que de le réessayer chaque nuit. L'admin voit le motif + // et peut le réactiver. + ...(vide ? { actifSql: false, actifQlik: false, desactiveMotif: "Aucun article — désactivé automatiquement" } : {}), + updatedAt: new Date(), + }).where(eq(syncFournisseurs.codeFournisseur, code)); + if (vide) console.log(`[sync-nuit] ${code} sans article — désactivé automatiquement`); + else etat.sqlFaits++; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + etat.sqlEchecs++; + etat.derniereErreur = `${code} (SQL) : ${msg}`; + console.error(`[sync-nuit] SQL ${code} en échec :`, msg); + await db.update(syncFournisseurs).set({ + dernierSqlAt: new Date(), + dernierSqlStatut: "echec", + dernierSqlErreur: msg.slice(0, 500), + dernierSqlMs: Date.now() - t0, + updatedAt: new Date(), + }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* le suivi ne doit pas casser la boucle */ }); + } +} + +/** Format d'un code centrale plausible — même filtre que la synchro manuelle. */ +const CODE_CENTRALE = /^[A-Z0-9][A-Z0-9_-]{1,29}$/i; + +/** Extraction Qlik. Même politique que le SQL sur l'horodatage en cas d'échec. */ +async function traiterQlik(code: string, nom: string | null): Promise { + const t0 = Date.now(); + etat.fournisseurCourant = `${code}${nom ? ` — ${nom}` : ""}`; + etat.etape = "qlik"; + try { + const articles = await pgGetArticlesByFournisseur(code); + const codes = [...new Set( + articles + .map((a) => String(a.codeCentrale ?? "").trim()) + .filter((c) => c && c !== "-" && CODE_CENTRALE.test(c)), + )]; + + if (codes.length === 0) { + await db.update(syncFournisseurs).set({ + dernierQlikAt: new Date(), dernierQlikStatut: "vide", dernierQlikCodes: 0, + dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), + }).where(eq(syncFournisseurs.codeFournisseur, code)); + return; + } + + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT)); + const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, code); + const n = await upsertNetworkMetrics([...metrics.values()]); + + await db.update(syncFournisseurs).set({ + dernierQlikAt: new Date(), dernierQlikStatut: "succes", dernierQlikCodes: n, + dernierQlikErreur: null, dernierQlikMs: Date.now() - t0, updatedAt: new Date(), + }).where(eq(syncFournisseurs.codeFournisseur, code)); + etat.qlikFaits++; + console.log(`[sync-nuit] Qlik ${code} — ${n} codes en ${Math.round((Date.now() - t0) / 1000)} s`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + etat.qlikEchecs++; + etat.derniereErreur = `${code} (Qlik) : ${msg}`; + console.error(`[sync-nuit] Qlik ${code} en échec :`, msg); + await db.update(syncFournisseurs).set({ + dernierQlikAt: new Date(), dernierQlikStatut: "echec", + dernierQlikErreur: msg.slice(0, 500), dernierQlikMs: Date.now() - t0, updatedAt: new Date(), + }).where(eq(syncFournisseurs.codeFournisseur, code)).catch(() => { /* idem */ }); + } +} + +// ─── Boucle de travail ─────────────────────────────────────────────────────── + +/** + * Traite les files jusqu'à épuisement, sortie de fenêtre, ou demande d'arrêt. + * + * Les deux files avancent en parallèle : une extraction Qlik est intercalée + * tous les `sqlParQlik` fournisseurs SQL. Sans cet entrelacement, Qlik ne + * démarrerait jamais tant que la file SQL n'est pas vide. + */ +export async function lancerRound(origine: "auto" | "manuel", forcerHorsFenetre = false): Promise { + if (etat.enCours) return; + + const s = await readSyncSettings(); + const maintenant = new Date(); + const debut = debutFenetre(s, maintenant); + if (!forcerHorsFenetre && !debut) return; + // Un lancement manuel hors fenêtre borne la file sur « depuis ce matin ». + const depuis = debut ?? new Date(new Date(maintenant).setHours(0, 0, 0, 0)); + + Object.assign(etat, { + enCours: true, origine, debutAt: maintenant.toISOString(), finAt: null, + fournisseurCourant: null, etape: null, + sqlFaits: 0, sqlEchecs: 0, qlikFaits: 0, qlikEchecs: 0, + tourTermine: false, arretDemande: false, derniereErreur: null, + }); + console.log(`[sync-nuit] round ${origine} démarré (fenêtre ${s.heureDebut}h-${s.heureFin}h, Qlik ≤ ${s.qlikParNuit})`); + + try { + await seedSyncFournisseurs(); + + let depuisDernierQlik = 0; + for (;;) { + if (etat.arretDemande) break; + if (!forcerHorsFenetre && !debutFenetre(s, new Date())) { + console.log("[sync-nuit] fin de fenêtre — arrêt après le fournisseur en cours"); + break; + } + + const qlikPossible = etat.qlikFaits + etat.qlikEchecs < s.qlikParNuit; + + // Qlik prioritaire quand le quota d'entrelacement est atteint. + if (qlikPossible && depuisDernierQlik >= s.sqlParQlik) { + const cible = await prochainQlik(s.qlikMinJours, new Date()); + if (cible) { + await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); + depuisDernierQlik = 0; + continue; + } + } + + const cibleSql = await prochainSql(depuis); + if (cibleSql) { + await traiterSql(cibleSql.codeFournisseur, cibleSql.nomFournisseur); + depuisDernierQlik++; + continue; + } + + // File SQL vide : on consacre le temps restant à Qlik. + if (qlikPossible) { + const cible = await prochainQlik(s.qlikMinJours, new Date()); + if (cible) { + await traiterQlik(cible.codeFournisseur, cible.nomFournisseur); + depuisDernierQlik = 0; + continue; + } + } + + etat.tourTermine = true; + console.log("[sync-nuit] plus rien en attente — tour terminé"); + break; + } + } catch (e) { + etat.derniereErreur = e instanceof Error ? e.message : String(e); + console.error("[sync-nuit] round interrompu :", etat.derniereErreur); + } finally { + etat.enCours = false; + etat.fournisseurCourant = null; + etat.etape = null; + etat.finAt = new Date().toISOString(); + console.log( + `[sync-nuit] round terminé — SQL ${etat.sqlFaits} ok / ${etat.sqlEchecs} en échec,` + + ` Qlik ${etat.qlikFaits} ok / ${etat.qlikEchecs} en échec, tour bouclé=${etat.tourTermine}`, + ); + } +} + +/** + * Battement du minuteur : ne fait quelque chose que dans la fenêtre, et jamais + * deux rounds à la fois. Volontairement silencieux hors fenêtre — il est appelé + * chaque minute. + */ +export async function tickScheduler(): Promise { + try { + const s = await readSyncSettings(); + if (!s.actif || etat.enCours) return; + if (!debutFenetre(s)) return; + await lancerRound("auto"); + } catch (e) { + console.error("[sync-nuit] tick en erreur :", e instanceof Error ? e.message : String(e)); + } +} diff --git a/src/features/admin/api/sync-settings.ts b/src/features/admin/api/sync-settings.ts new file mode 100644 index 0000000..95ccefe --- /dev/null +++ b/src/features/admin/api/sync-settings.ts @@ -0,0 +1,101 @@ +"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. + */ + +import fs from "fs/promises"; +import path from "path"; + +const DATA_DIR = path.join(process.cwd(), "data"); +const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); + +export interface SyncSettings { + /** + * Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter + * Qlik et la base la nuit suivante sans que personne l'ait décidé. + */ + actif: boolean; + /** Heure d'ouverture de la fenêtre (0-23). */ + heureDebut: number; + /** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */ + heureFin: number; + /** + * Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes + * et le serveur abandonne quand il est trop sollicité : ce plafond est la + * principale protection contre une nuit entièrement passée sur Qlik. + */ + qlikParNuit: number; + /** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */ + sqlParQlik: number; + /** + * Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats + * mensuels : les rafraîchir toutes les nuits coûterait cher pour rien. + */ + qlikMinJours: number; +} + +export const SYNC_SETTINGS_DEFAUT: SyncSettings = { + actif: false, + heureDebut: 0, + heureFin: 5, + qlikParNuit: 6, + sqlParQlik: 20, + qlikMinJours: 7, +}; + +function borne(v: unknown, min: number, max: number, defaut: number): number { + const n = Number(v); + if (!Number.isFinite(n)) return defaut; + return Math.min(max, Math.max(min, Math.trunc(n))); +} + +/** Normalise ce qui vient du fichier : il est éditable à la main. */ +function normaliser(brut: Partial | undefined): SyncSettings { + const d = SYNC_SETTINGS_DEFAUT; + const s: SyncSettings = { + actif: Boolean(brut?.actif), + heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut), + heureFin: borne(brut?.heureFin, 0, 23, d.heureFin), + qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit), + sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik), + qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours), + }; + // Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut + // plutôt que de laisser le planificateur muet sans explication. + if (s.heureDebut === s.heureFin) { + s.heureDebut = d.heureDebut; + s.heureFin = d.heureFin; + } + return s; +} + +export async function readSyncSettings(): Promise { + try { + const raw = await fs.readFile(CONFIG_FILE, "utf-8"); + const cfg = JSON.parse(raw) as { syncNuit?: Partial }; + return normaliser(cfg?.syncNuit); + } catch { + return { ...SYNC_SETTINGS_DEFAUT }; + } +} + +export async function saveSyncSettings(patch: Partial): Promise { + let existant: Record = {}; + try { + existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8")); + } catch { + // Pas encore de fichier : on le crée avec les seuls réglages de synchro. + } + const courant = normaliser((existant.syncNuit as Partial) ?? undefined); + const suivant = normaliser({ ...courant, ...patch }); + + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2)); + console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`); + return suivant; +}