mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
6 files changed
+456
-242
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user