From 94e82aa40402cf3accb505f5c0ee3f25f0b32235 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 10:56:29 +0000 Subject: [PATCH] fix(ff): le panneau API FF Nancy affichait une coquille vide + URL configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux problèmes distincts, dont un mal diagnostiqué au départ. 1. FORMAT DE RÉPONSE — cause réelle du « test ne donne rien ». Le serveur fonctionne (vérifié : HTTP 200, synchro du jour, 33 tables), mais il renvoie { sync: [{ table_name, last_sync, rows_synced, status, error_msg }] } alors que l'application lisait { lastSync, tables: [{ nom, derniereSync, nbLignes }] }. Les champs n'existaient pas : le panneau s'affichait vide sans erreur, la requête HTTP ayant réussi. normalizeSyncStatus() traduit désormais les deux formes, et le tableau expose l'état par table (une table en erreur est justement l'information qu'on vient chercher). 2. URL NON CONFIGURABLE. FF_API_BASE était figée au chargement du module depuis process.env, sans champ dans l'interface : impossible de corriger l'adresse sans redéployer. Elle est maintenant résolue à l'appel — réglage enregistré, puis variable d'environnement, puis défaut — avec un champ, un bouton Enregistrer et un test dans Paramètres. Le test distingue désormais DNS, connexion refusée, délai dépassé et code HTTP, et affiche l'adresse réellement appelée : un « HTTP 503 » nu ne permettait pas de séparer une mauvaise URL d'une panne. /api/ff-status renvoie le même détail. Ajout au passage du paramètre `compute` dans la doc API des Paramètres. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/app/(dashboard)/settings/page.tsx | 99 ++++++++++++---- src/app/api/ff-status/route.ts | 24 +++- .../admin/components/api-connection-info.tsx | 1 + src/features/settings/actions.ts | 83 ++++++++++++++ src/lib/api-ff-client.ts | 106 +++++++++++++++++- 5 files changed, 285 insertions(+), 28 deletions(-) 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;