mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-21 22:18:08 +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 { GridWarmup } from "@/features/admin/components/grid-warmup";
|
||||||
import { ServerLogs } from "@/features/settings/components/server-logs";
|
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 }) {
|
function Section({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="apple-card">
|
<section className="apple-card">
|
||||||
@@ -251,19 +249,9 @@ export default function SettingsPage() {
|
|||||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||||
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
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 [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
||||||
const [dbError, setDbError] = useState<string | null>(null);
|
const [dbError, setDbError] = useState<string | null>(null);
|
||||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
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);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -277,20 +265,6 @@ export default function SettingsPage() {
|
|||||||
getDatabaseUrl
|
getDatabaseUrl
|
||||||
} = useDbSettingsStore();
|
} = 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 reloadFromServer = useCallback(async () => {
|
||||||
const config = await getSavedDatabaseConfig();
|
const config = await getSavedDatabaseConfig();
|
||||||
if (config) {
|
if (config) {
|
||||||
@@ -308,20 +282,13 @@ export default function SettingsPage() {
|
|||||||
console.error("Failed to parse saved URL", e);
|
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(() => {
|
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);
|
setIsMounted(true);
|
||||||
reloadFromServer();
|
reloadFromServer();
|
||||||
}, [reloadFromServer]);
|
}, [reloadFromServer]);
|
||||||
@@ -342,7 +309,7 @@ export default function SettingsPage() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaveStatus("saving");
|
setSaveStatus("saving");
|
||||||
const url = getDatabaseUrl();
|
const url = getDatabaseUrl();
|
||||||
const res = await saveDatabaseSettings(url, apiKey, selectedModel, aiProvider, googleAiKey, googleAiModel);
|
const res = await saveDatabaseSettings(url);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setSaveStatus("saved");
|
setSaveStatus("saved");
|
||||||
setTimeout(() => setSaveStatus("idle"), 2500);
|
setTimeout(() => setSaveStatus("idle"), 2500);
|
||||||
@@ -457,64 +424,6 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
<QlikSettingsSection />
|
<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 */}
|
{/* Apparence */}
|
||||||
<Section title="Apparence">
|
<Section title="Apparence">
|
||||||
<div className="flex items-center justify-between">
|
<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