mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
fix(ff): le panneau API FF Nancy affichait une coquille vide + URL configurable
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
5 files changed
+285
-28
No files matched your search
@@ -5,7 +5,7 @@ import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, M
|
|||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-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 { useEffect } from "react";
|
||||||
import { UserManagement } from "@/features/admin/components/user-management";
|
import { UserManagement } from "@/features/admin/components/user-management";
|
||||||
import { ApiKeyManagement } from "@/features/admin/components/api-key-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[]; }
|
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
||||||
|
|
||||||
function FfApiStatusSection() {
|
function FfApiStatusSection() {
|
||||||
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
||||||
setLoading(true);
|
useEffect(() => {
|
||||||
setError(null);
|
getSavedDatabaseConfig()
|
||||||
try {
|
.then((cfg) => {
|
||||||
const res = await fetch("/api/ff-status");
|
setSavedUrl(cfg?.ffApiBaseUrl ?? null);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
||||||
setStatus(await res.json());
|
})
|
||||||
} catch (e) {
|
.catch(() => { /* réglage optionnel : on laisse le champ vide */ });
|
||||||
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
<Section title="API FF Nancy" subtitle="Source des données produits — synchronisation nuit depuis SQL Server (données J-1)">
|
||||||
<div className="flex items-center gap-3">
|
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
|
||||||
<button onClick={fetchStatus} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
<input
|
||||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
type="text"
|
||||||
Vérifier le statut
|
placeholder="https://api.ffnancy.fr"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
||||||
|
className="apple-input w-full"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button onClick={save} disabled={saving} className="apple-btn h-9 px-4">
|
||||||
|
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
{error && <span className="text-[12px] text-red-400">{error}</span>}
|
<button onClick={() => runTest(url)} disabled={loading} className="apple-btn-secondary h-9 px-4">
|
||||||
|
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
Tester
|
||||||
|
</button>
|
||||||
|
{status && <span className="text-[12px] text-emerald-400 flex items-center gap-1"><CheckCircle className="w-3.5 h-3.5" /> Connexion OK</span>}
|
||||||
|
{error && <span className="text-[12px] text-red-400 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> {error}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<p className="text-[11px] text-[var(--text-muted)]">
|
||||||
|
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
||||||
|
{!savedUrl && <> — valeur par défaut, aucun réglage enregistré</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||||
@@ -85,6 +137,7 @@ function FfApiStatusSection() {
|
|||||||
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
<th className="text-left px-3 py-2 font-medium text-[var(--text-secondary)]">Table</th>
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Sync</th>
|
||||||
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">Lignes</th>
|
||||||
|
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -97,6 +150,12 @@ function FfApiStatusSection() {
|
|||||||
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
|
||||||
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
|
||||||
</td>
|
</td>
|
||||||
|
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{t.statut === "ok"
|
||||||
|
? <span className="text-emerald-400">ok</span>
|
||||||
|
: <span className="text-red-400" title={t.erreur ?? undefined}>{t.statut ?? "—"}</span>}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { NextResponse } from "next/server";
|
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() {
|
export async function GET() {
|
||||||
const status = await getSyncStatus();
|
const res = await testFfApiConnection();
|
||||||
if (!status) {
|
if (!res.success) {
|
||||||
return NextResponse.json({ error: "API FF Nancy non disponible" }, { status: 503 });
|
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);
|
||||||
}
|
}
|
||||||
@@ -56,6 +56,7 @@ const PARAMS: Array<{ name: string; desc: string }> = [
|
|||||||
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et la nomenclature" },
|
{ 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: "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: "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() {
|
export function ApiConnectionInfo() {
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export interface DbConfig {
|
|||||||
qlikHost?: string;
|
qlikHost?: string;
|
||||||
qlikUser?: string;
|
qlikUser?: string;
|
||||||
qlikPassword?: 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). */
|
/** 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<DbConfig | null> {
|
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||||
try {
|
try {
|
||||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||||
|
|||||||
+103
-3
@@ -9,7 +9,46 @@
|
|||||||
* - Tous types → lire qtestock pour reconstruire stock12m
|
* - 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<string> {
|
||||||
|
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)
|
// Types API (shapes des réponses brutes)
|
||||||
@@ -65,9 +104,62 @@ export interface FfCommande {
|
|||||||
qtecde: number;
|
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 {
|
export interface FfSyncStatus {
|
||||||
lastSync: string;
|
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<string, unknown>;
|
||||||
|
|
||||||
|
// 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<Record<string, unknown>>) : 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 */
|
/** Stock agrégé sur tous les sites pour un codein */
|
||||||
@@ -212,6 +304,7 @@ function extractFouNom(f: any): string {
|
|||||||
export async function getFournisseursFromApi(
|
export async function getFournisseursFromApi(
|
||||||
search?: string
|
search?: string
|
||||||
): Promise<{ code: string; nom: string }[]> {
|
): Promise<{ code: string; nom: string }[]> {
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
try {
|
try {
|
||||||
const url = search
|
const url = search
|
||||||
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
|
? `${FF_API_BASE}/api/fournisseurs?search=${encodeURIComponent(search)}&limit=500`
|
||||||
@@ -242,6 +335,7 @@ export async function getFournisseursFromApi(
|
|||||||
export async function getArticlesByFournisseur(
|
export async function getArticlesByFournisseur(
|
||||||
codefou: string
|
codefou: string
|
||||||
): Promise<FfArticle[]> {
|
): Promise<FfArticle[]> {
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
const raw = await fetchAllPages<unknown>(
|
const raw = await fetchAllPages<unknown>(
|
||||||
(page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`,
|
(page) => `${FF_API_BASE}/api/articles?codefou=${encodeURIComponent(codefou)}&page=${page}&limit=500`,
|
||||||
extractList,
|
extractList,
|
||||||
@@ -272,6 +366,7 @@ export async function getMouvementsByFournisseur(
|
|||||||
dateDebut: string,
|
dateDebut: string,
|
||||||
dateFin: string
|
dateFin: string
|
||||||
): Promise<FfMouvement[]> {
|
): Promise<FfMouvement[]> {
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
const raw = await fetchAllPages<unknown>(
|
const raw = await fetchAllPages<unknown>(
|
||||||
(page) =>
|
(page) =>
|
||||||
`${FF_API_BASE}/api/mouvements/articles?codefou=${encodeURIComponent(codefou)}&dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
`${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,
|
dateDebut: string,
|
||||||
dateFin: string
|
dateFin: string
|
||||||
): Promise<FfMouvement[]> {
|
): Promise<FfMouvement[]> {
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
const raw = await fetchAllPages<unknown>(
|
const raw = await fetchAllPages<unknown>(
|
||||||
(page) =>
|
(page) =>
|
||||||
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
`${FF_API_BASE}/api/mouvements/articles?dateDebut=${dateDebut}&dateFin=${dateFin}&page=${page}&limit=1000`,
|
||||||
@@ -362,6 +458,7 @@ export async function getMensuelByArticles(
|
|||||||
batchSize = 50
|
batchSize = 50
|
||||||
): Promise<Map<string, FfMensuelEntry[]>> {
|
): Promise<Map<string, FfMensuelEntry[]>> {
|
||||||
const result = new Map<string, FfMensuelEntry[]>();
|
const result = new Map<string, FfMensuelEntry[]>();
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
|
|
||||||
for (let i = 0; i < articles.length; i += batchSize) {
|
for (let i = 0; i < articles.length; i += batchSize) {
|
||||||
const batch = articles.slice(i, i + batchSize);
|
const batch = articles.slice(i, i + batchSize);
|
||||||
@@ -448,6 +545,7 @@ export async function getReferentielByArticles(
|
|||||||
batchSize = 50
|
batchSize = 50
|
||||||
): Promise<Map<string, FfReferentiel>> {
|
): Promise<Map<string, FfReferentiel>> {
|
||||||
const result = new Map<string, FfReferentiel>();
|
const result = new Map<string, FfReferentiel>();
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
|
|
||||||
for (let i = 0; i < articles.length; i += batchSize) {
|
for (let i = 0; i < articles.length; i += batchSize) {
|
||||||
const batch = articles.slice(i, i + batchSize);
|
const batch = articles.slice(i, i + batchSize);
|
||||||
@@ -479,6 +577,7 @@ export async function getCommandesByFournisseur(
|
|||||||
codefou: string
|
codefou: string
|
||||||
): Promise<Map<string, number>> {
|
): Promise<Map<string, number>> {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
`${FF_API_BASE}/api/commandes/articles?codefou=${encodeURIComponent(codefou)}`,
|
||||||
@@ -502,9 +601,10 @@ export async function getCommandesByFournisseur(
|
|||||||
|
|
||||||
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||||
try {
|
try {
|
||||||
|
const FF_API_BASE = await getFfApiBase();
|
||||||
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
const res = await fetch(`${FF_API_BASE}/api/sync/status`, { cache: "no-store" });
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return await res.json();
|
return normalizeSyncStatus(await res.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[api-ff] getSyncStatus error:", err);
|
console.error("[api-ff] getSyncStatus error:", err);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in new issue
Block a user