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

+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;
}