diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 5c2e17b..e13e57f 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -5,7 +5,7 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M import { useTheme } from "next-themes"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store"; -import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection } from "@/features/settings/actions"; +import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, saveQlikSettings, testQlikConnection, saveFfApiSettings, testFfApiConnection } from "@/features/settings/actions"; import { useEffect } from "react"; import { UserManagement } from "@/features/admin/components/user-management"; import { ApiKeyManagement } from "@/features/admin/components/api-key-management"; @@ -38,40 +38,92 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr ); } -interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; } +interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; } interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; } function FfApiStatusSection() { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [url, setUrl] = useState(""); + const [savedUrl, setSavedUrl] = useState(null); + const [saving, setSaving] = useState(false); + const [testedUrl, setTestedUrl] = useState(null); - const fetchStatus = useCallback(async () => { - setLoading(true); - setError(null); - try { - const res = await fetch("/api/ff-status"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - setStatus(await res.json()); - } catch (e) { - setError(e instanceof Error ? e.message : "Erreur inconnue"); - } finally { - setLoading(false); - } + // Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut). + useEffect(() => { + getSavedDatabaseConfig() + .then((cfg) => { + setSavedUrl(cfg?.ffApiBaseUrl ?? null); + if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); + }) + .catch(() => { /* réglage optionnel : on laisse le champ vide */ }); }, []); - useEffect(() => { fetchStatus(); }, [fetchStatus]); + const runTest = useCallback((candidate?: string) => { + setLoading(true); + setError(null); + setStatus(null); + testFfApiConnection(candidate) + .then((res) => { + setTestedUrl(res.url); + if (res.success) setStatus(res.status as FfSyncStatus); + else setError(res.error ?? "Échec inconnu"); + }) + .catch((e) => setError(e instanceof Error ? e.message : "Erreur inconnue")) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { runTest(); }, [runTest]); + + const save = async () => { + setSaving(true); + setError(null); + try { + const res = await saveFfApiSettings(url); + if (!res.success) { setError(res.error ?? "Enregistrement impossible"); return; } + setSavedUrl(url.trim() || null); + runTest(url); + } finally { + setSaving(false); + } + }; return (
-
- - {error && {error}} + + {status && Connexion OK} + {error && {error}}
+ {/* L'URL réellement appelée : évite de croire qu'on teste celle du champ + alors que le réglage enregistré ou la variable d'environnement prime. */} + {testedUrl && ( +

+ Adresse testée : {testedUrl} + {!savedUrl && <> — valeur par défaut, aucun réglage enregistré} +

+ )} + {status && (

@@ -85,6 +137,7 @@ function FfApiStatusSection() { Table Sync Lignes + État @@ -97,6 +150,12 @@ function FfApiStatusSection() { {t.nbLignes?.toLocaleString("fr-FR") ?? "—"} + {/* Une table en erreur est précisément ce qu'on vient chercher ici. */} + + {t.statut === "ok" + ? ok + : {t.statut ?? "—"}} + ))} diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts index 838e1b4..333627a 100644 --- a/src/app/api/ff-status/route.ts +++ b/src/app/api/ff-status/route.ts @@ -1,10 +1,24 @@ import { NextResponse } from "next/server"; -import { getSyncStatus } from "@/lib/api-ff-client"; +import { testFfApiConnection } from "@/features/settings/actions"; +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * GET /api/ff-status — état de l'API REST FF Nancy. + * + * S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin + * qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP) + * et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans + * détail, impossible à distinguer d'une simple erreur d'URL. + */ export async function GET() { - const status = await getSyncStatus(); - if (!status) { - return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 }); + const res = await testFfApiConnection(); + if (!res.success) { + return NextResponse.json( + { error: "API FF Nancy non disponible", url: res.url, detail: res.error }, + { status: 503 }, + ); } - return NextResponse.json(status); + return NextResponse.json(res.status); } diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx index 5cae73c..158e204 100644 --- a/src/features/admin/components/api-connection-info.tsx +++ b/src/features/admin/components/api-connection-info.tsx @@ -56,6 +56,7 @@ const PARAMS: Array<{ name: string; desc: string }> = [ { name: "gamme, code1..code3", desc: "Filtres sur la gamme et la nomenclature" }, { name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" }, { name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" }, + { name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" }, ]; export function ApiConnectionInfo() { diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index 3fa513b..cb51049 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -18,6 +18,13 @@ export interface DbConfig { qlikHost?: string; qlikUser?: string; qlikPassword?: string; + /** + * API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et + * au rattrapage des ventes par magasin dans la Grille. Configurable ici pour + * ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer + * sans redéployer. + */ + ffApiBaseUrl?: string; } /** Lit la config existante (ou {} si absente). */ @@ -111,6 +118,82 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli } } +/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */ +export async function saveFfApiSettings(ffApiBaseUrl: string) { + try { + const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, ""); + if (cleaned && !/^https?:\/\//i.test(cleaned)) { + return { success: false, error: "L'URL doit commencer par http:// ou https://" }; + } + const existing = await readConfig(); + const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig; + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. + const { resetFfApiBaseCache } = await import("@/lib/api-ff-client"); + resetFfApiBaseCache(); + console.log(`[Settings] FF API base URL saved: ${cleaned || "(défaut)"}`); + return { success: true }; + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : String(error); + console.error("[Settings] Failed to save FF API config:", msg); + return { success: false, error: msg }; + } +} + +/** + * Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. + * + * L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir + * si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue + * donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. + */ +export async function testFfApiConnection(ffApiBaseUrl?: string) { + const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, ""); + const url = `${base}/api/sync/status`; + const started = Date.now(); + try { + const res = await fetch(url, { + cache: "no-store", + signal: AbortSignal.timeout(8000), + }); + const ms = Date.now() - started; + if (!res.ok) { + return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` }; + } + const body = await res.json().catch(() => null); + if (!body) { + return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` }; + } + // La réponse brute de l'API n'a pas la forme attendue par l'interface : + // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. + const { normalizeSyncStatus } = await import("@/lib/api-ff-client"); + const status = normalizeSyncStatus(body); + if (!status) { + return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` }; + } + return { success: true, url, ms, status }; + } catch (error: unknown) { + const ms = Date.now() - started; + const raw = error instanceof Error ? error.message : String(error); + const name = error instanceof Error ? error.name : ""; + // fetch masque la cause réelle derrière « fetch failed » : on la déplie. + const cause = (error as { cause?: { code?: string; message?: string } })?.cause; + let hint = raw; + if (name === "TimeoutError" || name === "AbortError") { + hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`; + } else if (cause?.code === "ENOTFOUND") { + hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`; + } else if (cause?.code === "ECONNREFUSED") { + hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`; + } else if (cause?.code) { + hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`; + } + console.error(`[Settings] FF API test KO (${url}):`, raw); + return { success: false, url, error: hint }; + } +} + export async function getSavedDatabaseConfig(): Promise { try { if (!(await fs.stat(CONFIG_FILE).catch(() => null))) { diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts index fb6a984..e4eb42e 100644 --- a/src/lib/api-ff-client.ts +++ b/src/lib/api-ff-client.ts @@ -9,7 +9,46 @@ * - Tous types → lire qtestock pour reconstruire stock12m */ -const FF_API_BASE = process.env.FF_API_BASE_URL ?? "https://api.ffnancy.fr"; +import fs from "fs/promises"; +import path from "path"; + +const CONFIG_FILE = path.join(process.cwd(), "data", ".db-config.json"); +const FF_API_BASE_DEFAULT = "https://api.ffnancy.fr"; +const FF_API_BASE_TTL_MS = 30_000; + +let ffApiBaseCache: { value: string; at: number } | null = null; + +/** + * URL de l'API FF Nancy, résolue **au moment de l'appel**. + * + * Ordre de priorité : réglage enregistré dans Paramètres → variable + * d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était + * figée au chargement du module depuis l'environnement seul : impossible de la + * corriger depuis l'application quand le serveur changeait d'adresse. + * + * Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête. + */ +async function getFfApiBase(): Promise { + if (ffApiBaseCache && Date.now() - ffApiBaseCache.at < FF_API_BASE_TTL_MS) { + return ffApiBaseCache.value; + } + let configured: string | undefined; + try { + const raw = await fs.readFile(CONFIG_FILE, "utf-8"); + const parsed = JSON.parse(raw) as { ffApiBaseUrl?: string }; + configured = parsed?.ffApiBaseUrl?.trim() || undefined; + } catch { + // Fichier absent ou illisible : on retombe sur l'environnement. + } + const value = (configured || process.env.FF_API_BASE_URL || FF_API_BASE_DEFAULT).replace(/\/+$/, ""); + ffApiBaseCache = { value, at: Date.now() }; + return value; +} + +/** Vide le cache — appelé après un enregistrement dans Paramètres. */ +export function resetFfApiBaseCache(): void { + ffApiBaseCache = null; +} // --------------------------------------------------------------------------- // Types API (shapes des réponses brutes) @@ -65,9 +104,62 @@ export interface FfCommande { qtecde: number; } +export interface FfSyncTable { + nom: string; + derniereSync: string; + nbLignes?: number; + /** "ok" ou état d'erreur renvoyé par la synchronisation. */ + statut?: string; + erreur?: string | null; +} + export interface FfSyncStatus { lastSync: string; - tables: { nom: string; derniereSync: string; nbLignes?: number }[]; + tables: FfSyncTable[]; +} + +/** + * Normalise la réponse de `/api/sync/status`. + * + * L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`, + * alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`. + * Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une + * coquille vide sans la moindre erreur, puisque la requête HTTP réussissait. + * + * Les deux formes sont acceptées ici, pour rester compatible si l'API évolue. + */ +export function normalizeSyncStatus(raw: unknown): FfSyncStatus | null { + if (!raw || typeof raw !== "object") return null; + const d = raw as Record; + + // Forme déjà attendue par l'application. + if (Array.isArray(d.tables)) { + return { + lastSync: String(d.lastSync ?? ""), + tables: d.tables as FfSyncTable[], + }; + } + + // Forme réelle de l'API FF Nancy. + const sync = Array.isArray(d.sync) ? (d.sync as Array>) : null; + if (!sync) return null; + + const tables: FfSyncTable[] = sync.map((t) => ({ + nom: String(t.table_name ?? t.nom ?? "—"), + derniereSync: String(t.last_sync ?? t.derniereSync ?? ""), + nbLignes: Number(t.rows_synced ?? t.nbLignes ?? 0) || 0, + statut: t.status != null ? String(t.status) : undefined, + erreur: t.error_msg != null ? String(t.error_msg) : null, + })); + + // Pas de date globale dans la réponse : on prend la plus récente des tables. + const lastSync = tables + .map((t) => t.derniereSync) + .filter(Boolean) + .sort() + .at(-1) ?? ""; + + return { lastSync, tables }; } /** Stock agrégé sur tous les sites pour un codein */ @@ -212,6 +304,7 @@ function extractFouNom(f: any): string { export async function getFournisseursFromApi( search?: string ): Promise<{ code: string; nom: string }[]> { + const FF_API_BASE = await getFfApiBase(); try { const url = search ? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500` @@ -242,6 +335,7 @@ export async function getFournisseursFromApi( export async function getArticlesByFournisseur( codefou: string ): Promise { + const FF_API_BASE = await getFfApiBase(); const raw = await fetchAllPages( (page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`, extractList, @@ -272,6 +366,7 @@ export async function getMouvementsByFournisseur( dateDebut: string, dateFin: string ): Promise { + const FF_API_BASE = await getFfApiBase(); const raw = await fetchAllPages( (page) => `${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`, @@ -301,6 +396,7 @@ export async function getMouvementsForDate( dateDebut: string, dateFin: string ): Promise { + const FF_API_BASE = await getFfApiBase(); const raw = await fetchAllPages( (page) => `${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`, @@ -362,6 +458,7 @@ export async function getMensuelByArticles( batchSize = 50 ): Promise> { const result = new Map(); + const FF_API_BASE = await getFfApiBase(); for (let i = 0; i < articles.length; i += batchSize) { const batch = articles.slice(i, i + batchSize); @@ -448,6 +545,7 @@ export async function getReferentielByArticles( batchSize = 50 ): Promise> { const result = new Map(); + const FF_API_BASE = await getFfApiBase(); for (let i = 0; i < articles.length; i += batchSize) { const batch = articles.slice(i, i + batchSize); @@ -479,6 +577,7 @@ export async function getCommandesByFournisseur( codefou: string ): Promise> { const result = new Map(); + const FF_API_BASE = await getFfApiBase(); try { const res = await fetch( `${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`, @@ -502,9 +601,10 @@ export async function getCommandesByFournisseur( export async function getSyncStatus(): Promise { try { + const FF_API_BASE = await getFfApiBase(); const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" }); if (!res.ok) return null; - return await res.json(); + return normalizeSyncStatus(await res.json()); } catch (err) { console.error("[api-ff] getSyncStatus error:", err); return null;