chore: supprime les derniers restes de l'IA dans les Paramètres

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
Claude committed 2026-08-07 07:06:23 +00:00
1 parent fff4cd9641
commit be8d576af5
6 files changed
+456 -242

No files matched your search

+5 -96
View File
@@ -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 (
<section className="apple-card">
@@ -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<string | null>(null);
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
const [models, setModels] = useState<OpenRouterModel[]>([]);
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() {
<QlikSettingsSection />
{/* OpenRouter */}
<Section title="IA Copilot — OpenRouter" subtitle="Clé API pour les analyses de gammes par intelligence artificielle">
<Field label="Clé API" hint="Disponible sur openrouter.ai/keys — commence par sk-or-...">
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
placeholder="sk-or-v1-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="apple-input font-mono pr-10"
/>
<button
onClick={() => setShowKey((v) => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 transition-opacity hover:opacity-100 opacity-50"
>
{showKey ? <EyeOff className="w-4 h-4" style={{ color: "var(--text-primary)" }} /> : <Eye className="w-4 h-4" style={{ color: "var(--text-primary)" }} />}
</button>
</div>
<button
onClick={() => fetchModels(apiKey)}
disabled={!apiKey || modelsStatus === "loading"}
className="apple-btn-secondary h-9 px-4 whitespace-nowrap"
>
{modelsStatus === "loading" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
Charger les modèles
</button>
</div>
</Field>
<Field
label={`Modèle IA${models.length > 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 ? (
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="apple-input"
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.free ? "🆓 " : ""}{m.name}
</option>
))}
</select>
) : (
<input
type="text"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
placeholder="ex: google/gemini-2.0-flash-001"
className="apple-input"
/>
)}
</Field>
</Section>
{/* Apparence */}
<Section title="Apparence">
<div className="flex items-center justify-between">
-24
View File
@@ -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" });
}
}
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedGoogleKey(): Promise<string | null> {
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 });
}
}
-61
View File
@@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
async function getSavedOpenRouterKey(): Promise<string | null> {
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 });
}
}
+350
View File
@@ -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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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));
}
}
+101
View File
@@ -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<SyncSettings> | 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<SyncSettings> {
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
return normaliser(cfg?.syncNuit);
} catch {
return { ...SYNC_SETTINGS_DEFAUT };
}
}
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
let existant: Record<string, unknown> = {};
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<SyncSettings>) ?? 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;
}