Compare commits

..
Author SHA1 Message Date
Claude 3fa92e891b merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait
l'API. La fusion a révélé que /produits, sur main, fait la même chose que la
page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités,
URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits).

Résolution, main prioritaire :
- sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son
  travail sur la tendance et Qlik est postérieur et plus complet.
- get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel
  upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir.
- settings : les deux côtés (ServerLogs de main + les sections API d'ici).

Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et
composants. searchNetworkProductsPlaywright disparaît avec la version main de
qlik-playwright ; rien d'autre ne l'utilisait.

api-enrich se branche désormais sur le module de tendance de main
(features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure
est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue
« vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi
exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et
nouveau.

Apport conservé de la branche : l'API /api/v1, absente de main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 11:13:44 +00:00
Claude 94e82aa404 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
2026-08-06 10:56:29 +00:00
Claude 18edda7167 feat(api): expose la tendance réseau calculée dans /api/v1
La colonne « Tendance » de la Grille était la seule information visible que
l'API ne fournissait pas : elle est calculée dans le navigateur par
computeNetworkTrend(), qui vivait dans un composant "use client" donc
inaccessible côté serveur. L'API livrait la série mensuelle brute et laissait
chaque appelant refaire la régression — au risque de diverger de l'affichage.

- Le calcul part dans src/lib/network-trend.ts (module pur, sans React) ;
  le composant le réexporte, aucun import existant ne change.
- enrichRows ajoute `trend` : { direction, pct, label, monthsUsed }, calculé
  sur la même série que la colonne de l'application.
- openapi.json : schéma Trend documenté, et il est dit explicitement que la
  liste des propriétés de Product n'est pas exhaustive — la réponse porte la
  ligne de grille entière.

Vérifié au passage que rien n'est perdu en chemin : upsertGridRows stocke le
ProductRow complet dans la colonne jsonb `payload` (les colonnes scalaires ne
servent qu'aux index), ne filtre que les lignes sans codein, et pickFields
renvoie tout tant que `fields` n'est pas précisé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:36:35 +00:00
Claude 3a825866d2 feat(api): /api/v1/grid calcule le fournisseur à la demande
L'API lisait uniquement la table grid_rows, écrite en effet de bord quand un
humain ouvre la page Grille. Un fournisseur jamais ouvert renvoyait donc
202 not_ready : une app ou un agent externe ne pouvait consulter que ce qui
avait déjà été parcouru dans l'interface, ce qui vide l'API de son intérêt.

/api/v1/grid déclenche désormais getProductRows() quand l'instantané manque.
Le calcul est déjà protégé en amont (cache 10 min + verrou anti-concurrence)
et persiste l'instantané, donc seul le premier appel paie le coût ; les
suivants repassent par le chemin SQL rapide.

- compute=1 (défaut) : calcul à la demande, meta.computedOnDemand signale
  quand il a eu lieu. compute=0 : comportement strict d'avant.
- Fournisseur inconnu ou sans article : 404 explicite, distinct d'une panne
  (500) et de « pas encore calculé » (202).
- /api/v1/products/{codein} fait de même lorsque `fournisseur` est fourni,
  le calcul se faisant par lot fournisseur.
- openapi.json mis à jour : c'est ce document que lit un agent externe, il
  annonçait « aucun endpoint ne déclenche de recalcul ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-06 10:29:26 +00:00
Claude 54d51e8eab fix(reseau): cast texte sur artcentrale pour la jointure code centrale
Le type réel de articles.artcentrale varie selon l'installation FF Nancy
(varchar ou numérique). La comparaison avec des codes centraux (chaînes)
passe désormais par TRIM(...::text), comme ailleurs dans ce fichier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:25:42 +00:00
Claude c565672259 feat(reseau): page Recherche réseau — chercher dans le catalogue Qlik
La Grille part de NOTRE catalogue : on extrait nos codes centraux puis on
demande à Qlik les métriques de ces codes. Un produit vendu par le réseau
mais absent de FF Nancy était donc invisible par construction.

Cette page inverse le sens : elle interroge Qlik d'abord, par libellé ou par
code centrale, et affiche aussi les produits que nous ne référençons pas.

- searchNetworkProductsPlaywright() : sélection par SelectValues sur
  « Article Code » (mode code) ou par SearchListObjectFor +
  AcceptListObjectSearch sur la dimension « Article » (mode plein texte,
  avec repli sur des noms de champs si la master dimension échoue).
  Cube [Article Code, Article, Mois] × 6 mesures, passe N-1 incluse pour
  obtenir 12 mois glissants contigus. Garde-fou à 300 produits/recherche.
- /api/qlik/search : job asynchrone (POST + polling GET) comme la sync,
  une seule recherche à la fois pour ménager le moteur Qlik. Met les
  résultats en cache dans qlik_network_metrics et enrichit avec le
  catalogue local (badge « Chez moi » / « Réseau uniquement », stock).
- Page /recherche-reseau : tableau triable, filtres, fiche produit avec la
  courbe 12 mois, export Excel.
- Composants de tendance extraits de heatmap-grid vers features/network
  pour être partagés (comportement inchangé).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 11:22:53 +00:00
Claude 4b229acc9b feat(api): API prête pour une IA externe type ChatGPT
Adapte l'API CollectFlow aux exigences des Actions ChatGPT et garantit que
les données soient réellement disponibles pour un consommateur externe.

Schéma OpenAPI :
- openapi.json devient PUBLIC (sans clé). Il ne décrit que la structure, sans
  aucune donnée, or ChatGPT importe le schéma par URL avant que la clé ne soit
  configurée : l'exiger rendait l'import impossible.
- servers[0].url est désormais ABSOLUE (une URL relative est rejetée à
  l'import), construite depuis l'hôte appelant ou COLLECTFLOW_PUBLIC_URL.
- operationId sur chaque opération (requis par les Actions), schémas de
  réponse typés, et descriptions rédigées pour le modèle : quelle gamme
  utiliser pour raisonner, pourquoi préférer caParMagasinReseau au CA brut,
  que faire d'un 202 not_ready.

Disponibilité des données :
- Nouveau préchauffage /api/admin/grid-warmup + bouton dans Paramètres.
  Sans lui, l'API ne sert que les fournisseurs déjà ouverts à la main dans la
  Grille — une IA externe n'aurait presque rien vu. Le job calcule tous les
  fournisseurs séquentiellement (paralléliser saturerait PostgreSQL), saute
  ceux à jour depuis moins de 24 h et suit son avancement.

Documentation :
- Paramètres → marche à suivre pas à pas pour brancher un GPT (import du
  schéma, auth par clé personnalisée X-API-Key), et mention de
  COLLECTFLOW_PUBLIC_URL quand le domaine public diffère.

L'assistant interne et son API api.ffnancy.fr ne sont pas touchés.

Vérifié sur PostgreSQL local : schéma servi sans clé en 200, URL absolue,
5 operationId, auth apiKey/X-API-Key, surcharge COLLECTFLOW_PUBLIC_URL
effective, 5 endpoints en 200 avec la clé, 401 JSON sans clé, et recherche
transversale renvoyant bien plusieurs fournisseurs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:21:31 +00:00
Claude a45e09777b feat(api): métriques Qlik + gamme serveur à jour, et doc de connexion dans Paramètres
L'instantané grid_rows fige la ligne au moment du calcul, mais deux données
évoluent indépendamment et doivent refléter l'état courant :

- Métriques réseau Qlik : qlik_network_metrics est alimentée par les syncs et
  les recherches réseau, hors du calcul de grille. Relues à chaque appel et
  exposées dans `network` (null quand le produit n'en a pas). Les colonnes
  caReseau/qteReseau/... de la ligne sont réalignées dessus.
- Gamme serveur : codeGamme peut être surchargée par un snapshot de session.
  L'API expose `codeGammeServeur`, la gamme NON modifiée telle qu'elle est en
  base PostgreSQL, relue à chaque appel. codeGammeInit est gardé aligné.

Ajout de pgGetGammesByCodeins() : variante sans jointure artfou1, nécessaire
car la recherche de l'API est transversale (pas de fournisseur connu).

Ce n'est pas un recalcul : deux lectures indexées bornées à la page courante
(500 lignes max). Mesuré à ~20 ms, soit le même coût que sans enrichissement.
Paramètre enrich=0 pour servir l'instantané brut.

Paramètres → nouvelle section « API CollectFlow — Connexion » : URL de base
déduite de l'origine, en-têtes d'authentification, liste des endpoints et des
paramètres, exemples curl copiables, lien vers openapi.json.

Vérifié sur PostgreSQL local avec un instantané volontairement périmé :
gamme snapshot A → serveur C, caReseau 111 → 990000, produit sans données
réseau → network null, produit sans gamme → codeGammeServeur null, enrich=0
redonnant bien les valeurs figées, et toujours zéro recalcul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
Claude e79d7e51da feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques
réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et
getProductRows() exige un fournisseur : chercher un produit sans le connaître
était impossible.

Contrainte de conception : l'API ne recalcule jamais rien. La grille était
reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui
appellerait getProductRows() serait lente et imprévisible. On persiste donc
le résultat d'un calcul qui a déjà lieu, et on le sert.

- Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) +
  payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant
  par getProductRows(), purge des articles disparus via computed_at. Survit
  aux redémarrages, contrairement au cache mémoire.
- Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous
  fournisseurs), products/:codein, network/:codeCentrale, openapi.json.
  Pagination, tri sur liste blanche, projection de champs, validation zod.
  202 not_ready si un fournisseur n'a pas encore d'instantané.
- Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base,
  révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi
  un script recevait une redirection 302 vers /login au lieu d'un 401 JSON.
- Gestion des clés dans /settings (server actions, clé affichée une seule fois).

Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé
révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et
24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route
/api/grid/rows en déclenche un à chaque appel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00
26 changed files with 2784 additions and 28 deletions

No files matched your search

+56
View File
@@ -122,6 +122,62 @@ async function main() {
`);
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
`);
console.log("[DB Init] Table grid_rows is verified/created.");
// Clés d'API : seul le hachage SHA-256 est stocké.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
`);
console.log("[DB Init] Table api_keys is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
+104 -20
View File
@@ -5,9 +5,12 @@ 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";
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
import { GridWarmup } from "@/features/admin/components/grid-warmup";
import { ServerLogs } from "@/features/settings/components/server-logs";
interface OpenRouterModel { id: string; name: string; free: boolean; }
@@ -36,40 +39,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<FfSyncStatus | null>(null);
const [loading, setLoading] = useState(false);
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 () => {
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 (
<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">
<button onClick={fetchStatus} 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" />}
Vérifier le statut
<Field label="URL de l'API" hint="Laisser vide pour utiliser la valeur par défaut (https://api.ffnancy.fr)">
<input
type="text"
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>
{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>
{/* 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 && (
<div className="space-y-2 mt-2">
<p className="text-[12px] text-[var(--text-secondary)]">
@@ -83,6 +138,7 @@ function FfApiStatusSection() {
<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)]">Lignes</th>
<th className="text-right px-3 py-2 font-medium text-[var(--text-secondary)]">État</th>
</tr>
</thead>
<tbody>
@@ -95,6 +151,12 @@ function FfApiStatusSection() {
<td className="px-3 py-2 text-right text-[var(--text-muted)]">
{t.nbLignes?.toLocaleString("fr-FR") ?? "—"}
</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>
))}
</tbody>
@@ -625,6 +687,28 @@ export default function SettingsPage() {
<UserManagement />
</Section>
{/* API publique /api/v1 — connexion puis gestion des clés */}
<Section
title="API CollectFlow — Connexion"
subtitle="Tout ce qu'il faut pour appeler l'API depuis un script ou un outil externe"
>
<ApiConnectionInfo />
</Section>
<Section
title="Clés d'API"
subtitle="Créer et révoquer les clés d'accès à /api/v1"
>
<ApiKeyManagement />
</Section>
<Section
title="Données exposées à l'API"
subtitle="Calculer l'instantané de tous les fournisseurs pour qu'une IA externe y ait accès"
>
<GridWarmup />
</Section>
{/* Save Button — Floating/Sticky style at bottom */}
<div className="sticky bottom-6 flex justify-end pt-4 pb-2">
<button
+144
View File
@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
import { getProductRows } from "@/features/grid/api/get-product-rows";
import { listGridSuppliers } from "@/lib/grid-store";
// Le préchauffage parcourt tous les fournisseurs : c'est long par nature. On rend la
// main immédiatement et le client interroge GET pour suivre l'avancement.
export const maxDuration = 300;
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Préchauffage de l'instantané de grille (`grid_rows`).
*
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
* ouverte dans l'application. Sans préchauffage, un consommateur externe (ChatGPT) ne
* verrait donc que les fournisseurs déjà consultés à la main.
*
* Ce job calcule chaque fournisseur une fois, séquentiellement — la persistance se fait
* en effet de bord de `getProductRows()`. Séquentiel volontairement : le calcul est
* lourd en SQL, le paralléliser saturerait PostgreSQL.
*/
type WarmupStatus = "idle" | "running" | "success" | "error";
interface WarmupJob {
status: WarmupStatus;
total: number;
done: number;
skipped: number;
failed: number;
currentFournisseur?: string;
startedAt: string;
finishedAt?: string;
error?: string;
lastErrors: string[];
}
let job: WarmupJob | null = null;
function publicJob(j: WarmupJob | null) {
if (!j) {
return { status: "idle" as const, total: 0, done: 0, skipped: 0, failed: 0, lastErrors: [] };
}
return j;
}
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
async function runWarmup(j: WarmupJob, staleHours: number): Promise<void> {
try {
const fournisseurs = await pgGetFournisseurs();
// Les fournisseurs déjà calculés récemment sont sautés : un second passage
// coûte alors quasiment rien, ce qui rend le bouton rejouable sans crainte.
const existing = await listGridSuppliers();
const freshLimit = Date.now() - staleHours * 3600_000;
const fresh = new Set(
existing
.filter((s) => s.computedAt && new Date(s.computedAt).getTime() > freshLimit)
.map((s) => s.codeFournisseur),
);
j.total = fournisseurs.length;
console.log(`[grid-warmup] ${fournisseurs.length} fournisseurs, ${fresh.size} déjà à jour (< ${staleHours}h)`);
for (const f of fournisseurs) {
if (fresh.has(f.code)) {
j.skipped++;
continue;
}
j.currentFournisseur = `${f.code} — ${f.nom}`;
try {
await getProductRows({ codeFournisseur: f.code, magasin: "TOTAL", forceRefresh: true });
j.done++;
} catch (e) {
j.failed++;
const msg = `${f.code}: ${e instanceof Error ? e.message.slice(0, 120) : String(e)}`;
// On garde une trace des derniers échecs sans faire exploser la mémoire.
if (j.lastErrors.length < 10) j.lastErrors.push(msg);
console.error("[grid-warmup]", msg);
}
}
j.currentFournisseur = undefined;
j.status = "success";
j.finishedAt = new Date().toISOString();
console.log(`[grid-warmup] terminé — ${j.done} calculés, ${j.skipped} à jour, ${j.failed} en échec`);
} catch (e) {
j.status = "error";
j.error = e instanceof Error ? e.message : String(e);
j.finishedAt = new Date().toISOString();
console.error("[grid-warmup] échec global:", j.error);
}
}
/** GET /api/admin/grid-warmup — avancement du préchauffage. */
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/admin/grid-warmup?staleHours=24
* Démarre le préchauffage en arrière-plan et répond immédiatement.
*/
export async function POST(req: NextRequest) {
const denied = await requireAdmin();
if (denied) return denied;
if (job?.status === "running") {
return NextResponse.json({ success: true, ...publicJob(job) });
}
const raw = Number(req.nextUrl.searchParams.get("staleHours"));
const staleHours = Number.isFinite(raw) ? Math.min(720, Math.max(0, raw)) : 24;
job = {
status: "running",
total: 0,
done: 0,
skipped: 0,
failed: 0,
startedAt: new Date().toISOString(),
lastErrors: [],
};
const current = job;
console.log(`[grid-warmup] démarré (staleHours=${staleHours})`);
void runWarmup(current, staleHours).catch((e) => {
current.status = "error";
current.error = e instanceof Error ? e.message : String(e);
current.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
+19 -5
View File
@@ -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);
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { fournisseursQuerySchema } from "@/lib/api-schemas";
import { listGridSuppliers } from "@/lib/grid-store";
import { pgGetFournisseurs } from "@/lib/pg-ff-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/fournisseurs?search=…&withData=1
*
* Liste des fournisseurs. Par défaut, tout le référentiel (`fouident`), enrichi de la
* fraîcheur de l'instantané de grille quand il existe — ce qui permet à l'appelant de
* savoir d'avance quels fournisseurs `/api/v1/grid` peut servir immédiatement.
*
* `withData=1` restreint aux seuls fournisseurs déjà présents dans l'instantané.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = fournisseursQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const { search, withData } = parsed.data;
const snapshot = await listGridSuppliers();
const byCode = new Map(snapshot.map((s) => [s.codeFournisseur, s]));
if (withData === "1") {
const term = search?.toLowerCase();
const data = snapshot
.filter((s) => !term
|| s.codeFournisseur.toLowerCase().includes(term)
|| (s.nomFournisseur ?? "").toLowerCase().includes(term))
.map((s) => ({
code: s.codeFournisseur,
nom: s.nomFournisseur,
hasSnapshot: true,
rowCount: s.rowCount,
computedAt: s.computedAt,
}));
return ok(data, { meta: { total: data.length, scope: "instantané uniquement" } });
}
const fournisseurs = await pgGetFournisseurs(search);
const data = fournisseurs.map((f) => {
const snap = byCode.get(f.code);
return {
code: f.code,
nom: f.nom,
hasSnapshot: Boolean(snap),
rowCount: snap?.rowCount ?? 0,
computedAt: snap?.computedAt ?? null,
};
});
return ok(data, { meta: { total: data.length, withSnapshot: byCode.size } });
}
+111
View File
@@ -0,0 +1,111 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { gridQuerySchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows, getGridFreshness } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Le calcul à la demande (premier appel sur un fournisseur inconnu) enchaîne
// plusieurs requêtes SQL sur la base miroir FF : on laisse de la marge.
export const maxDuration = 300;
/**
* GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=
*
* Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
* l'instantané persisté (`grid_rows`).
*
* Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
* (`compute=1`, défaut) : sans cela, un appelant externe ne pourrait consulter que les
* fournisseurs déjà ouverts dans la Grille par un humain. Le calcul est protégé par le
* cache et le verrou anti-concurrence de `getProductRows()`, et il persiste l'instantané
* au passage — les appels suivants repassent donc par le chemin rapide.
*
* `compute=0` restaure le comportement strict : échec immédiat en `202 not_ready`.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = gridQuerySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let freshness = await getGridFreshness(q.fournisseur);
let computedOnDemand = false;
if (!freshness) {
if (q.compute === "0") {
return fail(
"not_ready",
`Aucun instantané pour le fournisseur « ${q.fournisseur} ». Relancez sans « compute=0 » pour le calculer à la demande.`,
{ fournisseur: q.fournisseur },
);
}
console.log(`[api/v1/grid] instantané absent pour ${q.fournisseur} — calcul à la demande (via ${authCtx.via})`);
try {
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
await getProductRows({ codeFournisseur: q.fournisseur, magasin: "TOTAL" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/v1/grid] calcul à la demande KO pour ${q.fournisseur}:`, msg);
return fail(
"internal_error",
`Le calcul de la grille a échoué pour le fournisseur « ${q.fournisseur} ».`,
{ fournisseur: q.fournisseur },
);
}
freshness = await getGridFreshness(q.fournisseur);
if (!freshness) {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
"not_found",
`Aucun article pour le fournisseur « ${q.fournisseur} ». Vérifiez le code auprès de /api/v1/fournisseurs.`,
{ fournisseur: q.fournisseur },
);
}
computedOnDemand = true;
}
const result = await queryGridRows({
codeFournisseur: q.fournisseur,
gamme: q.gamme,
code1: q.code1,
code2: q.code2,
code3: q.code3,
search: q.search,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
meta: {
fournisseur: q.fournisseur,
computedAt: result.computedAt,
snapshotComputedAt: freshness.computedAt,
snapshotRowCount: freshness.rowCount,
enrichi: q.enrich === "1",
/** true = l'instantané n'existait pas et vient d'être calculé par cet appel. */
computedOnDemand,
},
},
);
}
@@ -0,0 +1,40 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/network/:codeCentrale
*
* Métriques réseau Qlik d'un produit (CA, quantité, nombre de magasins, CA/magasin,
* marge %) et sa courbe de quantités mensuelles sur 12 mois glissants.
*
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
* séparé de cette API.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codeCentrale: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codeCentrale } = await ctx.params;
if (!codeCentrale) return fail("bad_request", "Paramètre 'codeCentrale' requis.");
const metrics = await getNetworkMetricsByCodeCentrale([codeCentrale]);
const found = metrics.get(codeCentrale);
if (!found) {
return fail(
"not_found",
`Aucune donnée réseau en cache pour le code centrale « ${codeCentrale} ». Lancez une synchronisation Qlik ou une recherche réseau.`,
);
}
return ok({ codeCentrale, ...found }, { meta: { fetchedAt: found.fetchedAt, source: "cache" } });
}
+350
View File
@@ -0,0 +1,350 @@
import { NextRequest } from "next/server";
import { GRID_SORT_KEYS } from "@/lib/grid-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/openapi.json — spécification OpenAPI 3.1 de l'API CollectFlow.
*
* **Volontairement accessible sans authentification.** Le document ne décrit que la
* structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée
* métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL,
* avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import
* impossible.
*
* `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les
* Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée.
*/
export async function GET(req: NextRequest) {
const configured = process.env.COLLECTFLOW_PUBLIC_URL?.trim().replace(/\/+$/, "");
const origin = configured || req.nextUrl.origin;
const paginationParams = [
{ name: "page", in: "query", schema: { type: "integer", minimum: 1, default: 1 }, description: "Numéro de page." },
{ name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 500, default: 100 }, description: "Nombre de lignes par page (500 maximum)." },
];
const sortParams = [
{ name: "sort", in: "query", schema: { type: "string", enum: GRID_SORT_KEYS }, description: "Colonne de tri." },
{ name: "order", in: "query", schema: { type: "string", enum: ["asc", "desc"], default: "desc" }, description: "Sens du tri." },
{
name: "fields",
in: "query",
schema: { type: "string" },
description:
"Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). "
+ "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les "
+ "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus.",
},
{
name: "enrich",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
"1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur "
+ "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut.",
},
];
const spec = {
openapi: "3.1.0",
info: {
title: "CollectFlow API",
version: "1.0.0",
description:
"Lecture des données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, "
+ "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n"
+ "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies "
+ "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule "
+ "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. "
+ "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n"
+ "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache "
+ "alimenté par une synchronisation séparée.\n\n"
+ "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques "
+ "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée "
+ "(`codeGammeServeur`).",
},
servers: [{ url: `${origin}/api/v1`, description: "API CollectFlow" }],
security: [{ ApiKeyAuth: [] }],
components: {
securitySchemes: {
ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" },
},
schemas: {
Error: {
type: "object",
properties: {
error: {
type: "object",
properties: {
code: {
type: "string",
enum: ["unauthorized", "forbidden", "bad_request", "not_found", "not_ready", "internal_error"],
},
message: { type: "string" },
},
},
},
},
Pagination: {
type: "object",
properties: {
page: { type: "integer" },
limit: { type: "integer" },
total: { type: "integer" },
totalPages: { type: "integer" },
},
},
Fournisseur: {
type: "object",
properties: {
code: { type: "string", description: "Code fournisseur, à passer au paramètre `fournisseur`." },
nom: { type: ["string", "null"] },
hasSnapshot: { type: "boolean", description: "false = données pas encore disponibles pour ce fournisseur." },
rowCount: { type: "integer" },
computedAt: { type: ["string", "null"], format: "date-time" },
},
},
NetworkMetrics: {
type: ["object", "null"],
description: "Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.",
properties: {
caReseau: { type: "number" },
qteReseau: { type: "number" },
nbMagasinsReseau: { type: "integer", description: "Nombre de magasins du réseau vendant le produit (sur ~270)." },
caParMagasinReseau: { type: "number", description: "CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer." },
margePctReseau: { type: "number", description: "Taux de marge, ratio brut (0.32 = 32 %)." },
qteByMonth: { type: ["object", "null"], description: "Quantités vendues par mois, clés « YYYY-MM »." },
fetchedAt: { type: ["string", "null"], format: "date-time" },
},
},
Product: {
type: "object",
description:
"Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous "
+ "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine "
+ "de champs). Utilisez `fields` pour n'en garder qu'une partie.",
properties: {
codein: { type: "string", description: "Identifiant interne de l'article." },
codeFournisseur: { type: "string" },
nomFournisseur: { type: "string" },
libelle1: { type: "string", description: "Libellé de l'article." },
gtin: { type: "string" },
reference: { type: "string" },
codeCentrale: { type: "string", description: "Code centrale — clé de jointure avec le réseau Qlik." },
codeGammeServeur: {
type: ["string", "null"],
description:
"Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour "
+ "raisonner. `null` = aucune gamme. Valeurs : A (pilier), B (bonne rotation), "
+ "C (performance), D (saisonnier/niche), Z (sortie).",
},
codeGamme: { type: ["string", "null"], description: "Gamme courante, éventuellement surchargée par une modification locale non enregistrée." },
totalCa: { type: "number", description: "CA sur 12 mois, nos magasins." },
totalQuantite: { type: "number" },
totalMarge: { type: "number" },
tauxMarge: { type: "number" },
stockActuel: { type: "number" },
pcb: { type: "number", description: "Conditionnement (nombre d'unités par colis)." },
prixAchat: { type: "number" },
prixVente: { type: "number" },
derniereVente: { type: "string", format: "date" },
nbJoursDerniereVente: { type: "integer" },
sales12m: { type: "object", description: "Quantités vendues par mois, clés « YYYYMM »." },
stock12m: { type: "object", description: "Stock de fin de mois, clés « YYYYMM »." },
receptions12m: { type: "object", description: "Quantités reçues par mois, clés « YYYYMM »." },
sales12mByStore: { type: "object", description: "Ventes par magasin (292, 579) puis par mois." },
stock12mByStore: { type: "object", description: "Stock de fin de mois par magasin puis par mois." },
receptions12mByStore: { type: "object", description: "Réceptions par magasin puis par mois." },
qteReseauByMonth: { type: ["object", "null"], description: "Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance." },
network: { $ref: "#/components/schemas/NetworkMetrics" },
trend: { $ref: "#/components/schemas/Trend" },
},
},
Trend: {
type: ["object", "null"],
description:
"Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne "
+ "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible.",
properties: {
direction: { type: "string", enum: ["up", "down", "flat"], description: "Seuil à ±8 % de variation modélisée." },
pct: { type: ["number", "null"], description: "Variation modélisée sur la période (0.23 = +23 %)." },
label: { type: "string", description: "Libellé prêt à afficher : « Forte hausse », « Stable »…" },
monthsUsed: { type: "integer", description: "Nombre de mois réellement utilisés (12 au maximum)." },
},
},
},
},
paths: {
"/fournisseurs": {
get: {
operationId: "listerFournisseurs",
summary: "Lister les fournisseurs",
description:
"Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données "
+ "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu.",
parameters: [
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre sur le nom ou le code." },
{ name: "withData", in: "query", schema: { type: "string", enum: ["0", "1"] }, description: "1 = uniquement ceux dont les données sont disponibles." },
],
responses: {
"200": {
description: "Liste des fournisseurs",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Fournisseur" } },
meta: { type: "object" },
},
},
},
},
},
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/grid": {
get: {
operationId: "listerProduitsFournisseur",
summary: "Lister les produits d'un fournisseur",
description:
"Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques "
+ "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été "
+ "calculées, l'API les calcule à la demande (premier appel de quelques secondes, "
+ "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas "
+ "ou n'a aucun article — le signaler plutôt que de conclure à une panne.",
parameters: [
{ name: "fournisseur", in: "query", required: true, schema: { type: "string" }, description: "Code fournisseur (voir listerFournisseurs)." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme (A, B, C, D, Z)." },
{ name: "code1", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 1." },
{ name: "code2", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 2." },
{ name: "code3", in: "query", schema: { type: "string" }, description: "Filtre nomenclature niveau 3." },
{ name: "search", in: "query", schema: { type: "string" }, description: "Filtre texte : libellé, codein, GTIN, référence ou code centrale." },
{
name: "compute",
in: "query",
schema: { type: "string", enum: ["0", "1"], default: "1" },
description:
"1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. "
+ "0 : refuse le calcul et répond immédiatement 202 not_ready.",
},
...sortParams,
...paginationParams,
],
responses: {
"200": {
description: "Produits du fournisseur",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
pagination: { $ref: "#/components/schemas/Pagination" },
meta: { type: "object" },
},
},
},
},
},
"202": { description: "Données pas encore calculées (uniquement avec compute=0)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"400": { description: "Paramètres invalides", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"404": { description: "Fournisseur inconnu ou sans article", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/products/search": {
get: {
operationId: "rechercherProduits",
summary: "Rechercher des produits, tous fournisseurs confondus",
description:
"Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans "
+ "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom.",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string", maxLength: 120 }, description: "Termes recherchés." },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Restreint à un fournisseur." },
{ name: "gamme", in: "query", schema: { type: "string" }, description: "Filtre sur la gamme." },
...sortParams,
...paginationParams,
],
responses: {
"200": {
description: "Produits correspondants",
content: {
"application/json": {
schema: {
type: "object",
properties: {
data: { type: "array", items: { $ref: "#/components/schemas/Product" } },
pagination: { $ref: "#/components/schemas/Pagination" },
meta: { type: "object" },
},
},
},
},
},
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/products/{codein}": {
get: {
operationId: "obtenirProduit",
summary: "Fiche complète d'un produit",
description: "Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.",
parameters: [
{ name: "codein", in: "path", required: true, schema: { type: "string" }, description: "Identifiant interne de l'article." },
{ name: "fournisseur", in: "query", schema: { type: "string" }, description: "Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs." },
],
responses: {
"200": {
description: "Fiche produit",
content: {
"application/json": {
schema: {
type: "object",
properties: { data: { $ref: "#/components/schemas/Product" }, meta: { type: "object" } },
},
},
},
},
"404": { description: "Produit absent de l'instantané", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
"/network/{codeCentrale}": {
get: {
operationId: "obtenirMetriquesReseau",
summary: "Métriques du réseau Qlik pour un produit",
description: "Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.",
parameters: [
{ name: "codeCentrale", in: "path", required: true, schema: { type: "string" }, description: "Code centrale du produit (format 10000XXXXXX)." },
],
responses: {
"200": {
description: "Métriques réseau",
content: {
"application/json": {
schema: {
type: "object",
properties: { data: { $ref: "#/components/schemas/NetworkMetrics" }, meta: { type: "object" } },
},
},
},
},
"404": { description: "Aucune donnée réseau pour ce code centrale", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
"401": { description: "Clé d'API absente, invalide ou révoquée", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } },
},
},
},
},
};
// CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers.
return Response.json(spec, { headers: { "Access-Control-Allow-Origin": "*" } });
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail } from "@/lib/api-response";
import { productDetailSchema } from "@/lib/api-schemas";
import { getGridRowByCodein } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
import { getProductRows } from "@/features/grid/api/get-product-rows";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Idem /api/v1/grid : le calcul à la demande peut prendre plusieurs secondes.
export const maxDuration = 300;
/**
* GET /api/v1/products/:codein?fournisseur=…
*
* Fiche complète d'un produit : le `ProductRow` intégral (séries mensuelles,
* ventilations par magasin, stock, marges, gammes) enrichi des métriques réseau Qlik
* lues **en cache** (`qlik_network_metrics`) — aucun appel à Qlik.
*
* `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
* sans lui, l'instantané le plus récent est renvoyé.
*/
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ codein: string }> },
) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const { codein } = await ctx.params;
if (!codein) return fail("bad_request", "Paramètre 'codein' requis.");
const parsed = productDetailSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
let found = await getGridRowByCodein(codein, parsed.data.fournisseur);
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if (!found && parsed.data.compute === "1" && parsed.data.fournisseur) {
console.log(`[api/v1/products] ${codein} absent — calcul du lot ${parsed.data.fournisseur} à la demande`);
try {
await getProductRows({ codeFournisseur: parsed.data.fournisseur, magasin: "TOTAL" });
found = await getGridRowByCodein(codein, parsed.data.fournisseur);
} catch (e) {
console.error(`[api/v1/products] calcul KO pour ${parsed.data.fournisseur}:`, e instanceof Error ? e.message : String(e));
}
}
if (!found) {
return fail(
"not_found",
parsed.data.fournisseur
? `Produit « ${codein} » introuvable chez le fournisseur « ${parsed.data.fournisseur} ».`
: `Aucun instantané pour le produit « ${codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande.`,
);
}
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
// calcul de la grille, alors que ces deux données évoluent indépendamment.
const [enriched] = await enrichRows([found.row]);
return ok(enriched, {
meta: {
computedAt: found.computedAt,
networkFetchedAt: enriched.network?.fetchedAt ?? null,
hasNetwork: enriched.network !== null,
},
});
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
import { ok, fail, buildPagination } from "@/lib/api-response";
import { productSearchSchema, pickFields, toSortKey } from "@/lib/api-schemas";
import { queryGridRows } from "@/lib/grid-store";
import { enrichRows } from "@/lib/api-enrich";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=
*
* **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
* tous fournisseurs confondus. C'est ce qu'aucune route existante ne sait faire :
* `getProductRows()` exige un fournisseur, donc chercher un produit sans le connaître
* était impossible.
*
* Lit uniquement l'instantané `grid_rows` : un fournisseur jamais ouvert dans la
* Grille n'apparaît pas encore dans les résultats.
*/
export async function GET(req: NextRequest) {
const authCtx = await requireApiAuth(req);
if (authCtx instanceof Response) return authCtx;
const parsed = productSearchSchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
if (!parsed.success) {
return fail("bad_request", "Paramètres invalides.", parsed.error.issues);
}
const q = parsed.data;
const result = await queryGridRows({
codeFournisseur: q.fournisseur,
gamme: q.gamme,
search: q.q,
sort: toSortKey(q.sort),
order: q.order,
page: q.page,
limit: q.limit,
});
// Métriques Qlik et gamme serveur relues au moment de l'appel (voir api-enrich).
const rows = q.enrich === "1" ? await enrichRows(result.rows) : result.rows;
return ok(
rows.map((r) => pickFields(r, q.fields)),
{
pagination: buildPagination(q.page, q.limit, result.total),
meta: {
query: q.q,
computedAt: result.computedAt,
scope: q.fournisseur ? `fournisseur:${q.fournisseur}` : "tous fournisseurs",
enrichi: q.enrich === "1",
},
},
);
}
+76
View File
@@ -144,6 +144,82 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", {
fetchedAt: timestamp("fetched_at").defaultNow(),
});
/**
* Instantané persisté des lignes de grille (une ligne par fournisseur × article).
*
* Raison d'être : `getProductRows()` reconstruit la grille en direct et ne la garde
* qu'en mémoire 10 minutes — perdu à chaque redémarrage. L'API `/api/v1` ne doit
* jamais déclencher ce calcul, elle lit donc cette table, remplie en effet de bord
* quand quelqu'un ouvre la Grille. Aucun calcul supplémentaire n'est introduit : on
* arrête simplement de jeter le résultat.
*
* Seul `magasin = TOTAL` est persisté : `payload` embarque déjà les ventilations par
* magasin (sales12mByStore, caByStore…).
*/
export const gridRows = pgTable("grid_rows", {
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
codein: varchar("codein", { length: 20 }).notNull(),
// Colonnes scalaires : servent aux filtres, au tri et à la recherche en SQL.
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
libelle1: varchar("libelle1", { length: 500 }),
gtin: varchar("gtin", { length: 30 }),
reference: varchar("reference", { length: 100 }),
codeCentrale: varchar("code_centrale", { length: 20 }),
code1: varchar("code1", { length: 20 }),
code2: varchar("code2", { length: 20 }),
code3: varchar("code3", { length: 20 }),
codeGamme: varchar("code_gamme", { length: 20 }),
codeGammeInit: varchar("code_gamme_init", { length: 20 }),
totalCa: numeric("total_ca", { precision: 16, scale: 2 }),
totalQuantite: numeric("total_quantite", { precision: 14, scale: 2 }),
totalMarge: numeric("total_marge", { precision: 16, scale: 2 }),
tauxMarge: numeric("taux_marge", { precision: 8, scale: 4 }),
stockActuel: numeric("stock_actuel", { precision: 14, scale: 2 }),
caReseau: numeric("ca_reseau", { precision: 16, scale: 2 }),
qteReseau: numeric("qte_reseau", { precision: 14, scale: 2 }),
nbMagasinsReseau: integer("nb_magasins_reseau"),
caParMagasinReseau: numeric("ca_par_magasin_reseau", { precision: 16, scale: 2 }),
margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }),
/** ProductRow complet (séries mensuelles, ventilations par magasin, dates…). */
payload: jsonb("payload").notNull(),
/** Date du calcul ayant produit cette ligne — expose la fraîcheur au consommateur. */
computedAt: timestamp("computed_at").defaultNow().notNull(),
}, (table) => {
return [
uniqueIndex("uq_grid_rows_fou_codein").on(table.codeFournisseur, table.codein),
index("idx_grid_rows_fournisseur").on(table.codeFournisseur),
index("idx_grid_rows_codein").on(table.codein),
index("idx_grid_rows_gamme").on(table.codeGamme),
index("idx_grid_rows_code_centrale").on(table.codeCentrale),
index("idx_grid_rows_total_ca").on(table.totalCa),
index("idx_grid_rows_libelle").on(table.libelle1),
];
});
/**
* Clés d'API pour les appelants non-navigateur (scripts, outils externes).
*
* Seul le hachage SHA-256 est stocké : la clé en clair n'est affichée qu'une fois,
* à la création. Une clé révoquée conserve sa ligne (traçabilité) via `revokedAt`.
*/
export const apiKeys = pgTable("api_keys", {
id: serial("id").primaryKey(),
/** Nom lisible choisi à la création (ex "Export Excel comptabilité"). */
name: varchar("name", { length: 100 }).notNull(),
/** Préfixe en clair (ex "cf_a1b2c3") — sert à identifier la clé dans l'UI. */
keyPrefix: varchar("key_prefix", { length: 20 }).notNull(),
/** SHA-256 hexadécimal de la clé complète. */
keyHash: text("key_hash").notNull().unique(),
/** 'admin' ou 'user' — même sémantique que users.role. */
role: varchar("role", { length: 20 }).default("user").notNull(),
createdBy: varchar("created_by", { length: 50 }),
createdAt: timestamp("created_at").defaultNow(),
lastUsedAt: timestamp("last_used_at"),
revokedAt: timestamp("revoked_at"),
});
/** AI Context rules per supplier (Epic: AI Context) */
export const aiSupplierContext = pgTable("ai_supplier_context", {
/** Supplier code serving as the primary key */
+117
View File
@@ -0,0 +1,117 @@
"use server";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1` (administrateurs).
*
* La clé en clair n'existe qu'une fois : elle est renvoyée par `createApiKey()` et
* jamais restituée ensuite (seul son hachage SHA-256 est stocké). Une clé révoquée
* conserve sa ligne, pour garder la trace de son usage passé.
*/
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { generateApiKey } from "@/lib/api-auth";
import { revalidatePath } from "next/cache";
async function ensureAdmin() {
const session = await auth();
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
throw new Error("Accès refusé : Droits administrateur requis.");
}
return session;
}
export interface ApiKeyRow {
id: number;
name: string;
keyPrefix: string;
role: string;
createdBy: string | null;
createdAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
}
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
export async function getApiKeys(): Promise<ApiKeyRow[]> {
await ensureAdmin();
const rows = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
keyPrefix: apiKeys.keyPrefix,
role: apiKeys.role,
createdBy: apiKeys.createdBy,
createdAt: apiKeys.createdAt,
lastUsedAt: apiKeys.lastUsedAt,
revokedAt: apiKeys.revokedAt,
})
.from(apiKeys)
.orderBy(desc(apiKeys.createdAt));
return rows.map((r) => ({
...r,
createdAt: r.createdAt ? r.createdAt.toISOString() : null,
lastUsedAt: r.lastUsedAt ? r.lastUsedAt.toISOString() : null,
revokedAt: r.revokedAt ? r.revokedAt.toISOString() : null,
}));
}
/**
* Crée une clé et renvoie sa valeur **en clair, une seule et unique fois**.
* L'appelant doit la présenter immédiatement à l'utilisateur : elle est irrécupérable.
*/
export async function createApiKey(
name: string,
role: "admin" | "user" = "user",
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
const session = await ensureAdmin();
const trimmed = name.trim();
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
if (trimmed.length > 100) return { success: false, error: "Nom trop long (100 caractères max)." };
try {
const { key, keyHash, keyPrefix } = generateApiKey();
await db.insert(apiKeys).values({
name: trimmed,
keyPrefix,
keyHash,
role,
createdBy: (session?.user as { name?: string | null } | undefined)?.name ?? null,
});
revalidatePath("/settings");
return { success: true, key, keyPrefix };
} catch (err) {
console.error("[api-keys] création KO:", err);
return { success: false, error: "Erreur technique lors de la création de la clé." };
}
}
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] révocation KO:", err);
return { success: false, error: "Erreur lors de la révocation." };
}
}
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
await ensureAdmin();
try {
await db.delete(apiKeys).where(eq(apiKeys.id, id));
revalidatePath("/settings");
return { success: true };
} catch (err) {
console.error("[api-keys] suppression KO:", err);
return { success: false, error: "Erreur lors de la suppression." };
}
}
@@ -0,0 +1,184 @@
"use client";
/**
* CollectFlow — Informations de connexion à l'API `/api/v1`.
*
* Tout ce qu'il faut pour appeler l'API depuis un script : URL de base, en-tête
* d'authentification, liste des endpoints et exemples copiables. L'URL de base est
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
*/
import { useEffect, useState } from "react";
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
className="shrink-0 rounded-lg px-2 py-1.5"
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
</button>
);
}
function CodeLine({ children, copy }: { children: string; copy?: string }) {
return (
<div className="flex items-start gap-2">
<code
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
{children}
</code>
<CopyButton text={copy ?? children} />
</div>
);
}
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
];
const PARAMS: Array<{ name: string; desc: string }> = [
{ name: "page, limit", desc: "Pagination (limit ≤ 500, défaut 100)" },
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
{ 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() {
const [origin, setOrigin] = useState("");
// `window` n'existe pas au rendu serveur : lire l'origine après montage est le
// seul moyen d'afficher l'URL réelle sans provoquer d'écart d'hydratation.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { setOrigin(window.location.origin); }, []);
const base = `${origin || "https://votre-domaine"}/api/v1`;
return (
<div className="space-y-5">
{/* Connexion */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
<CodeLine>{base}</CodeLine>
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Une clé créée ci-dessous, dans l&apos;un ou l&apos;autre de ces en-têtes. Depuis un navigateur
connecté, la session suffit — aucune clé n&apos;est nécessaire.
</p>
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
</div>
{/* Endpoints */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{ENDPOINTS.map((e, i) => (
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Paramètres */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[11px]">
<tbody>
{PARAMS.map((p, i) => (
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Exemples */}
<div className="space-y-2">
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
</div>
{/* Branchement d'une IA externe (ChatGPT) */}
<div className="space-y-2">
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
Connecter une IA externe (ChatGPT)
</p>
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
<li>Créez une clé d&apos;API dans la section ci-dessous et copiez-la.</li>
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
<li>
Cliquez sur <strong>Importer depuis une URL</strong> et collez l&apos;adresse du schéma :
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
<span style={{ color: "var(--text-muted)" }}>
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l&apos;importer.
Votre application doit être joignable depuis Internet.
</span>
</li>
<li>
Dans <strong>Authentification</strong>, choisissez <strong>Clé d&apos;API</strong>, type{" "}
<strong>Personnalisé</strong>, nom d&apos;en-tête <code className="font-mono">X-API-Key</code>,
et collez votre clé.
</li>
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
</ol>
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
Si votre domaine public diffère de celui affiché ici, renseignez la variable
d&apos;environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l&apos;URL
déclarée dans le schéma.
</p>
</div>
{/* Comportement à connaître */}
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
<p><strong>Aucun recalcul.</strong> L&apos;API lit l&apos;instantané de la grille, écrit quand la Grille
est ouverte dans l&apos;application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
<code className="font-mono">null</code> quand le produit n&apos;a pas de données réseau.</p>
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
</div>
<a
href="/api/v1/openapi.json"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
style={{ color: "var(--accent)" }}
>
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
);
}
@@ -0,0 +1,217 @@
"use client";
/**
* CollectFlow — Gestion des clés de l'API `/api/v1`.
*
* La clé générée n'est affichable qu'une fois : elle est mise en évidence après
* création, puis irrécupérable (seul son hachage est stocké côté base).
*/
import { useState, useEffect, useCallback } from "react";
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
function fmtDate(iso: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
export function ApiKeyManagement() {
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [freshKey, setFreshKey] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
// statement est un await. Cela évite une cascade de rendus au montage.
const refresh = useCallback(async () => {
try {
setKeys(await getApiKeys());
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Erreur de chargement");
} finally {
setLoading(false);
}
}, []);
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || creating) return;
setCreating(true);
setError(null);
const res = await createApiKey(name.trim(), role);
if (res.success) {
setFreshKey(res.key);
setName("");
setCopied(false);
await refresh();
} else {
setError(res.error);
}
setCreating(false);
};
const handleRevoke = async (id: number) => {
await revokeApiKey(id);
await refresh();
};
const handleDelete = async (id: number) => {
await deleteApiKey(id);
await refresh();
};
return (
<div className="space-y-4">
<p className="text-[12px] text-[var(--text-secondary)]">
Les clés donnent accès à l&apos;API de lecture <code className="font-mono">/api/v1</code> (grille,
recherche, métriques réseau) depuis un script ou un outil externe, via l&apos;en-tête{" "}
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
</p>
{/* Clé fraîchement créée — visible une seule fois */}
{freshKey && (
<div className="rounded-xl p-3.5 space-y-2"
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
</div>
<div className="flex items-center gap-2">
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
{freshKey}
</code>
<button
type="button"
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
className="rounded-lg px-2.5 py-2 shrink-0"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
title="Copier"
>
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<button
type="button"
onClick={() => setFreshKey(null)}
className="text-[11px] underline"
style={{ color: "var(--text-muted)" }}
>
J&apos;ai copié la clé, masquer
</button>
</div>
)}
{/* Création */}
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom de la clé (ex. Export comptabilité)"
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as "admin" | "user")}
className="rounded-lg px-3 py-2 text-[13px] outline-none"
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
>
<option value="user">Rôle : utilisateur</option>
<option value="admin">Rôle : administrateur</option>
</select>
<button
type="submit"
disabled={creating || !name.trim()}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Créer
</button>
</form>
{error && (
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
)}
{/* Liste */}
{loading ? (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
</div>
) : keys.length === 0 ? (
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
) : (
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
<table className="w-full text-[12px]">
<thead>
<tr style={{ background: "var(--bg-elevated)" }}>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
<span className="inline-flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
{k.name}
</span>
{k.revokedAt && (
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
Révoquée
</span>
)}
</td>
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{!k.revokedAt ? (
<button
onClick={() => handleRevoke(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--accent-error)" }}
title="Révoquer"
>
<Ban className="w-3.5 h-3.5" /> Révoquer
</button>
) : (
<button
onClick={() => handleDelete(k.id)}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
style={{ color: "var(--text-muted)" }}
title="Supprimer définitivement"
>
<Trash2 className="w-3.5 h-3.5" /> Supprimer
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,148 @@
"use client";
/**
* CollectFlow — Préchauffage des données exposées par l'API.
*
* L'API `/api/v1` ne calcule jamais : elle lit un instantané écrit quand la Grille est
* ouverte dans l'application. Sans ce préchauffage, une IA externe ne verrait que les
* fournisseurs déjà consultés à la main — d'où ce bouton, qui calcule tout le catalogue
* une bonne fois.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
interface WarmupState {
status: "idle" | "running" | "success" | "error";
total: number;
done: number;
skipped: number;
failed: number;
currentFournisseur?: string;
finishedAt?: string;
error?: string;
lastErrors: string[];
}
export function GridWarmup() {
const [state, setState] = useState<WarmupState | null>(null);
const [error, setError] = useState<string | null>(null);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const running = state?.status === "running";
const fetchState = useCallback(async () => {
try {
const res = await fetch("/api/admin/grid-warmup");
if (res.ok) setState(await res.json());
} catch {
// Réseau instable : on retentera au tick suivant.
}
}, []);
// Récupère l'état au montage : un préchauffage peut déjà tourner.
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { fetchState(); }, [fetchState]);
// Suivi de l'avancement tant que le job tourne.
useEffect(() => {
if (!running) return;
let cancelled = false;
const tick = async () => {
await fetchState();
if (!cancelled) pollRef.current = setTimeout(tick, 2000);
};
pollRef.current = setTimeout(tick, 2000);
return () => { cancelled = true; if (pollRef.current) clearTimeout(pollRef.current); };
}, [running, fetchState]);
const start = async () => {
setError(null);
try {
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
const data = await res.json();
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
setState(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
return (
<div className="space-y-3">
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
L&apos;API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
l&apos;application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
sautés, il est donc sans risque de le relancer.
</p>
<div className="flex items-center gap-2.5">
<button
onClick={start}
disabled={running}
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
style={{ background: "var(--accent)" }}
>
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
</button>
{state && state.total > 0 && (
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
{traites} / {state.total} ({pct} %)
</span>
)}
</div>
{running && state && (
<div className="space-y-1.5">
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
</div>
{state.currentFournisseur && (
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
En cours : {state.currentFournisseur}
</p>
)}
</div>
)}
{state?.status === "success" && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
<span>
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
<strong>{state.skipped}</strong> déjà à jour
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
</span>
</div>
)}
{state && state.failed > 0 && state.lastErrors.length > 0 && (
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
<ul className="mt-1 pl-4 list-disc space-y-0.5">
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
</ul>
</details>
)}
{(error || state?.error) && (
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
<span>{error ?? state?.error}</span>
</div>
)}
{state?.status === "idle" && (
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
<Database className="w-3.5 h-3.5" />
Aucun préchauffage lancé depuis le démarrage de l&apos;application.
</p>
)}
</div>
);
}
+23
View File
@@ -16,6 +16,8 @@ import { sessionSnapshots } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
// Persiste l'instantané lu par /api/v1 : sans lui, l'API n'aurait aucune donnée.
import { upsertGridRows } from "@/lib/grid-store";
interface GetProductRowsInput {
codeFournisseur: string;
@@ -66,6 +68,23 @@ async function refreshGammeInit(rows: ProductRow[], codeFournisseur: string): Pr
}
}
/**
* Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
*
* Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
* par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
* Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
*/
async function persistGridSnapshot(input: GetProductRowsInput, rows: ProductRow[]): Promise<void> {
if ((input.magasin ?? "TOTAL") !== "TOTAL") return;
try {
const n = await upsertGridRows(input.codeFournisseur, rows);
console.log(`[getProductRows] instantané persisté: ${n} lignes pour ${input.codeFournisseur}`);
} catch (e) {
console.error("[getProductRows] persistance grid_rows KO:", (e as Error).message?.slice(0, 200));
}
}
export async function getProductRows(input: GetProductRowsInput): Promise<ProductRow[]> {
const cacheKey = gridRowsCacheKey(input);
const cached = gridRowsCache.get(cacheKey);
@@ -84,6 +103,10 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
const promise = buildProductRows(input).then((rows) => {
gridRowsCache.set(cacheKey, { rows, createdAt: Date.now() });
gridRowsPending.delete(cacheKey);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
// l'affichage de la Grille ne doit pas attendre l'écriture.
void persistGridSnapshot(input, rows);
return rows;
}).catch((error) => {
gridRowsPending.delete(cacheKey);
+83
View File
@@ -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<DbConfig | null> {
try {
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
+122
View File
@@ -0,0 +1,122 @@
/**
* CollectFlow — Authentification de l'API publique `/api/v1`.
*
* Deux voies d'entrée :
* 1. **Clé d'API** (`X-API-Key` ou `Authorization: Bearer …`) — pour les appelants
* non-navigateur : scripts, outils externes, assistant IA.
* 2. **Session NextAuth** — le front de CollectFlow utilise donc les mêmes routes.
*
* En cas d'échec on renvoie un **401 JSON**, jamais une redirection : `src/middleware.ts`
* exempte `/api/v1/*` précisément pour que ce module puisse répondre proprement.
*
* Seul le hachage SHA-256 des clés est stocké (table `api_keys`) ; la clé en clair
* n'existe qu'au moment de sa création.
*/
import "server-only";
import { createHash, randomBytes, timingSafeEqual } from "crypto";
import type { NextRequest } from "next/server";
import { db } from "@/db";
import { apiKeys } from "@/db/schema";
import { eq } from "drizzle-orm";
import { auth } from "@/lib/auth";
import { fail } from "@/lib/api-response";
export interface AuthContext {
/** Origine de l'authentification — utile pour les logs et l'audit. */
via: "api_key" | "session";
role: string;
/** Nom de la clé, ou identifiant de l'utilisateur connecté. */
subject: string;
}
/** Préfixe des clés CollectFlow, pour les reconnaître d'un coup d'œil. */
const KEY_PREFIX = "cf_";
export function hashApiKey(key: string): string {
return createHash("sha256").update(key, "utf8").digest("hex");
}
/**
* Génère une clé d'API. Renvoie la clé **en clair** (à afficher une seule fois),
* son hachage à stocker, et un préfixe lisible pour l'identifier dans l'UI.
*/
export function generateApiKey(): { key: string; keyHash: string; keyPrefix: string } {
const secret = randomBytes(24).toString("base64url");
const key = `${KEY_PREFIX}${secret}`;
return { key, keyHash: hashApiKey(key), keyPrefix: key.slice(0, 11) };
}
/** Comparaison à temps constant de deux hachages hexadécimaux. */
function hashesEqual(a: string, b: string): boolean {
const ba = Buffer.from(a, "hex");
const bb = Buffer.from(b, "hex");
if (ba.length !== bb.length || ba.length === 0) return false;
return timingSafeEqual(ba, bb);
}
/** Extrait la clé d'un en-tête `X-API-Key` ou `Authorization: Bearer …`. */
function readKeyFromRequest(req: NextRequest): string | null {
const direct = req.headers.get("x-api-key");
if (direct?.trim()) return direct.trim();
const authz = req.headers.get("authorization");
if (authz) {
const m = authz.match(/^Bearer\s+(.+)$/i);
if (m) return m[1].trim();
}
return null;
}
/**
* Authentifie une requête `/api/v1`.
*
* Renvoie un `AuthContext` en cas de succès, ou une réponse d'erreur JSON prête à
* être retournée par le handler. À appeler en première ligne de chaque endpoint :
*
* const authCtx = await requireApiAuth(req);
* if (authCtx instanceof NextResponse) return authCtx;
*/
export async function requireApiAuth(req: NextRequest): Promise<AuthContext | Response> {
const presented = readKeyFromRequest(req);
if (presented) {
const presentedHash = hashApiKey(presented);
// La colonne key_hash est unique : la recherche par égalité suffit, et la
// comparaison à temps constant ci-dessous couvre le reste.
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, presentedHash)).limit(1);
if (!row || !hashesEqual(row.keyHash, presentedHash)) {
return fail("unauthorized", "Clé d'API invalide.");
}
if (row.revokedAt) {
return fail("unauthorized", "Clé d'API révoquée.");
}
// Trace d'usage — jamais bloquante pour la requête en cours.
void db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, row.id))
.catch((e) => console.error("[api-auth] lastUsedAt KO:", (e as Error).message?.slice(0, 120)));
return { via: "api_key", role: row.role, subject: row.name };
}
// Repli sur la session du navigateur (le front utilise les mêmes routes).
const session = await auth();
if (session?.user) {
const user = session.user as { name?: string | null; role?: string };
return { via: "session", role: user.role ?? "user", subject: user.name ?? "session" };
}
return fail(
"unauthorized",
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
);
}
/** Restreint un endpoint aux appelants administrateurs. */
export function requireAdminRole(ctx: AuthContext): Response | null {
if (ctx.role !== "admin") {
return fail("forbidden", "Rôle administrateur requis pour cette opération.");
}
return null;
}
+146
View File
@@ -0,0 +1,146 @@
/**
* CollectFlow — Enrichissement des lignes servies par `/api/v1`.
*
* L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
* doivent pourtant refléter l'état **courant** au moment de l'appel :
*
* 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
* les synchronisations et les recherches réseau, indépendamment du calcul de la
* grille. On les relit donc à la lecture, et on les expose telles quelles quand
* elles existent (`network: null` sinon).
*
* 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session
* (modification locale non enregistrée côté serveur). L'API expose la gamme **non
* modifiée**, celle réellement présente en base PostgreSQL, relue à chaque appel.
*
* Ce n'est pas un recalcul : deux lectures indexées bornées au nombre de lignes de la
* page (500 au maximum), de l'ordre de quelques millisecondes.
*/
import "server-only";
import type { ProductRow } from "@/types/grid";
import { getNetworkMetricsByCodeCentrale, type NetworkMetricCached } from "@/lib/qlik-network-cache";
import { pgGetGammesByCodeins } from "@/lib/pg-ff-client";
// Module de la Grille : l'API doit renvoyer exactement la tendance affichée dans
// l'application, pas une seconde implémentation qui divergerait.
import { computeNetworkTrend, trendLabel } from "@/features/grid/lib/network-trend";
/** Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille. */
export interface ProductTrend {
direction: "up" | "down" | "flat";
/** Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base. */
pct: number | null;
/** Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »… */
label: string;
/**
* `true` = tendance calculée sur la quantité **par magasin vendeur**, ce qui
* neutralise l'effet d'un simple élargissement de la distribution. `false` =
* repli sur les quantités brutes, faute de nombre de magasins sur 12 mois.
*/
surQteParMagasin: boolean;
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */
nouveau: boolean;
/** Nombre de mois de la série (12 quand elle est complète). */
monthsUsed: number;
}
export interface EnrichedProductRow extends ProductRow {
/**
* Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
* `null` si l'article n'a aucune gamme en base.
*/
codeGammeServeur: string | null;
/** Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas. */
network: NetworkMetricCached | null;
/**
* Tendance réseau calculée par régression linéaire sur les 12 derniers mois.
* `null` quand il n'y a aucune série mensuelle. Fournie pour que l'appelant —
* en particulier un agent — n'ait pas à réimplémenter le calcul et à diverger
* de ce que montre l'application.
*/
trend: ProductTrend | null;
}
/**
* Enrichit un lot de lignes. Les deux lectures sont indépendantes et lancées en
* parallèle ; un échec de l'une n'empêche pas l'autre (l'API reste servie, avec
* la valeur de l'instantané en repli).
*/
export async function enrichRows(rows: ProductRow[]): Promise<EnrichedProductRow[]> {
if (rows.length === 0) return [];
const codesCentraux = [...new Set(rows.map((r) => r.codeCentrale).filter((c): c is string => Boolean(c)))];
const codeins = [...new Set(rows.map((r) => r.codein).filter(Boolean))];
const [network, gammes] = await Promise.all([
codesCentraux.length
? getNetworkMetricsByCodeCentrale(codesCentraux).catch((e) => {
console.error("[api-enrich] métriques réseau KO:", (e as Error).message?.slice(0, 160));
return new Map<string, NetworkMetricCached>();
})
: Promise.resolve(new Map<string, NetworkMetricCached>()),
codeins.length
? pgGetGammesByCodeins(codeins).catch((e) => {
console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160));
return new Map<string, string>();
})
: Promise.resolve(new Map<string, string>()),
]);
return rows.map((row) => {
const metrics = row.codeCentrale ? network.get(row.codeCentrale) ?? null : null;
// Gamme absente de la base = pas de gamme, et non « inconnue » : on distingue
// explicitement par `null` plutôt que de retomber sur la valeur du snapshot.
const gammeServeur = gammes.get(row.codein) ?? null;
const enriched: EnrichedProductRow = {
...row,
codeGammeServeur: gammeServeur,
network: metrics,
trend: null, // renseignée plus bas, une fois la série mensuelle connue
};
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
// on la garde alignée pour que les deux champs ne divergent jamais.
if (gammes.size > 0) {
enriched.codeGammeInit = gammeServeur as ProductRow["codeGammeInit"];
}
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
// cache quand il a quelque chose à dire.
if (metrics) {
enriched.caReseau = metrics.caReseau;
enriched.qteReseau = metrics.qteReseau;
enriched.nbMagasinsReseau = metrics.nbMagasinsReseau;
enriched.caParMagasinReseau = metrics.caParMagasinReseau;
enriched.margePctReseau = metrics.margePctReseau;
enriched.qteReseauByMonth = metrics.qteByMonth;
enriched.networkFetchedAt = metrics.fetchedAt ?? undefined;
}
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille, à
// savoir la série des quantités **et** celle du nombre de magasins vendeurs
// — sans la seconde, le calcul se rabat sur les quantités brutes et
// confond « vend mieux » avec « est distribué plus largement ».
const serie = enriched.qteReseauByMonth;
const nbMagByMonth = metrics?.metricsByMonth
? Object.fromEntries(
Object.entries(metrics.metricsByMonth).map(([mois, m]) => [mois, Number(m?.nbMag) || 0]),
)
: null;
const t = computeNetworkTrend(serie, nbMagByMonth);
if (t.hasData) {
enriched.trend = {
direction: t.direction,
pct: t.pct,
label: trendLabel(t.pct, t.nouveau),
surQteParMagasin: t.surQteParMagasin,
nouveau: t.nouveau,
monthsUsed: t.values.length,
};
}
return enriched;
});
}
+103 -3
View File
@@ -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<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)
@@ -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<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 */
@@ -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<FfArticle[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(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<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(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<FfMouvement[]> {
const FF_API_BASE = await getFfApiBase();
const raw = await fetchAllPages<unknown>(
(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<Map<string, FfMensuelEntry[]>> {
const result = new Map<string, FfMensuelEntry[]>();
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<Map<string, FfReferentiel>> {
const result = new Map<string, FfReferentiel>();
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<Map<string, number>> {
const result = new Map<string, number>();
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<FfSyncStatus | null> {
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;
+67
View File
@@ -0,0 +1,67 @@
/**
* CollectFlow — Enveloppe de réponse de l'API publique `/api/v1`.
*
* Une forme unique pour tout : `{ data, pagination?, meta }` en succès,
* `{ error: { code, message } }` en échec, avec de vrais statuts HTTP.
* Les consommateurs (scripts, IA, outils externes) peuvent ainsi traiter
* toutes les réponses de la même manière.
*/
import { NextResponse } from "next/server";
export type ApiErrorCode =
| "unauthorized"
| "forbidden"
| "bad_request"
| "not_found"
| "not_ready"
| "internal_error";
const STATUS_BY_CODE: Record<ApiErrorCode, number> = {
unauthorized: 401,
forbidden: 403,
bad_request: 400,
not_found: 404,
not_ready: 202,
internal_error: 500,
};
export interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export function buildPagination(page: number, limit: number, total: number): Pagination {
return { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)) };
}
/** Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). */
export function ok<T>(
data: T,
opts: { pagination?: Pagination; meta?: Record<string, unknown> } = {},
): NextResponse {
return NextResponse.json({
data,
...(opts.pagination ? { pagination: opts.pagination } : {}),
meta: { ...(opts.meta ?? {}) },
});
}
/**
* Réponse d'erreur. `details` sert notamment aux erreurs de validation zod.
*
* Note : `not_ready` renvoie un 202 — ce n'est pas un échec mais une donnée pas
* encore calculée (voir /api/v1/grid sur un fournisseur jamais ouvert).
*/
export function fail(
code: ApiErrorCode,
message: string,
details?: unknown,
): NextResponse {
return NextResponse.json(
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
{ status: STATUS_BY_CODE[code] },
);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* CollectFlow — Validation des paramètres de l'API `/api/v1` (zod).
*
* Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
* de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
* le détail zod, pour que l'appelant sache quoi corriger.
*/
import { z } from "zod";
import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store";
const SORT_KEYS = GRID_SORT_KEYS as readonly string[];
export const paginationShape = {
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(500).default(100),
};
const sortShape = {
sort: z
.string()
.refine((v) => SORT_KEYS.includes(v), {
message: `Tri inconnu. Valeurs acceptées : ${SORT_KEYS.join(", ")}`,
})
.optional(),
order: z.enum(["asc", "desc"]).default("desc"),
/** Liste de champs de ProductRow à conserver, séparés par des virgules. */
fields: z.string().optional(),
/**
* `1` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
* de l'appel. `0` : sert l'instantané brut, un peu plus rapide.
*/
enrich: z.enum(["0", "1"]).default("1"),
};
/** `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search). */
export const gridQuerySchema = z.object({
fournisseur: z.string().min(1, "Paramètre 'fournisseur' requis"),
gamme: z.string().min(1).optional(),
code1: z.string().min(1).optional(),
code2: z.string().min(1).optional(),
code3: z.string().min(1).optional(),
search: z.string().min(1).optional(),
/**
* `1` (défaut) : si le fournisseur n'a pas encore d'instantané, l'API le calcule
* à la demande au lieu de répondre `not_ready`. C'est ce qui permet à une app ou
* à un agent externe d'interroger n'importe quel fournisseur sans qu'un humain
* ait ouvert la Grille avant. Le premier appel est alors plus lent (plusieurs
* secondes) ; les suivants sont servis depuis l'instantané.
* `0` : comportement strict, échoue vite en `not_ready`.
*/
compute: z.enum(["0", "1"]).default("1"),
...sortShape,
...paginationShape,
});
/** `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale. */
export const productSearchSchema = z.object({
q: z.string().min(1, "Paramètre 'q' requis").max(120),
fournisseur: z.string().min(1).optional(),
gamme: z.string().min(1).optional(),
...sortShape,
...paginationShape,
});
export const fournisseursQuerySchema = z.object({
search: z.string().min(1).optional(),
/** true = uniquement les fournisseurs déjà présents dans l'instantané de grille. */
withData: z.enum(["0", "1"]).optional(),
});
export const productDetailSchema = z.object({
fournisseur: z.string().min(1).optional(),
/**
* `1` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
* encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
*/
compute: z.enum(["0", "1"]).default("1"),
});
/**
* Projection de champs : `?fields=codein,libelle1,totalCa`.
*
* Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
* et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
* lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
*/
export function pickFields<T extends object>(row: T, fields?: string): Partial<T> {
if (!fields) return row;
const wanted = new Set(
fields.split(",").map((f) => f.trim()).filter(Boolean),
);
if (wanted.size === 0) return row;
wanted.add("codein");
const out: Record<string, unknown> = {};
for (const key of wanted) {
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
}
return out as Partial<T>;
}
/** Normalise le tri validé vers la clé attendue par grid-store. */
export function toSortKey(sort?: string): GridSortKey | undefined {
return sort as GridSortKey | undefined;
}
+274
View File
@@ -0,0 +1,274 @@
/**
* CollectFlow — Instantané persisté de la grille (table `grid_rows`).
*
* `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
* miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
* ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
* bord quand quelqu'un ouvre la Grille.
*
* On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
* qui existe déjà, et il survit désormais aux redémarrages.
*/
import "server-only";
import { db } from "@/db";
import { gridRows } from "@/db/schema";
import { and, asc, desc, eq, ilike, or, sql, lt, type SQL } from "drizzle-orm";
import type { ProductRow } from "@/types/grid";
/** Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute. */
const SORTABLE = {
libelle1: gridRows.libelle1,
codein: gridRows.codein,
codeFournisseur: gridRows.codeFournisseur,
codeGamme: gridRows.codeGamme,
totalCa: gridRows.totalCa,
totalQuantite: gridRows.totalQuantite,
totalMarge: gridRows.totalMarge,
tauxMarge: gridRows.tauxMarge,
stockActuel: gridRows.stockActuel,
caReseau: gridRows.caReseau,
qteReseau: gridRows.qteReseau,
nbMagasinsReseau: gridRows.nbMagasinsReseau,
caParMagasinReseau: gridRows.caParMagasinReseau,
computedAt: gridRows.computedAt,
} as const;
export type GridSortKey = keyof typeof SORTABLE;
export const GRID_SORT_KEYS = Object.keys(SORTABLE) as GridSortKey[];
export interface GridQuery {
codeFournisseur?: string;
gamme?: string;
code1?: string;
code2?: string;
code3?: string;
/** Recherche libre : libellé, codein, GTIN, référence, code centrale. */
search?: string;
sort?: GridSortKey;
order?: "asc" | "desc";
page?: number;
limit?: number;
}
export interface GridQueryResult {
rows: ProductRow[];
total: number;
/** Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. */
computedAt: string | null;
}
/**
* Persiste les lignes calculées pour un fournisseur.
*
* Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
* celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
* disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
*/
export async function upsertGridRows(codeFournisseur: string, rows: ProductRow[]): Promise<number> {
if (!codeFournisseur || rows.length === 0) return 0;
const computedAt = new Date();
const values = rows
.filter((r) => r.codein)
.map((r) => ({
codeFournisseur,
codein: r.codein,
nomFournisseur: r.nomFournisseur ?? null,
libelle1: r.libelle1 ?? null,
gtin: r.gtin ?? null,
reference: r.reference ?? null,
codeCentrale: r.codeCentrale ?? null,
code1: r.code1 ?? null,
code2: r.code2 ?? null,
code3: r.code3 ?? null,
codeGamme: r.codeGamme ?? null,
codeGammeInit: r.codeGammeInit ?? null,
totalCa: r.totalCa != null ? String(r.totalCa) : null,
totalQuantite: r.totalQuantite != null ? String(r.totalQuantite) : null,
totalMarge: r.totalMarge != null ? String(r.totalMarge) : null,
tauxMarge: r.tauxMarge != null ? String(r.tauxMarge) : null,
stockActuel: r.stockActuel != null ? String(r.stockActuel) : null,
caReseau: r.caReseau != null ? String(r.caReseau) : null,
qteReseau: r.qteReseau != null ? String(r.qteReseau) : null,
nbMagasinsReseau: r.nbMagasinsReseau ?? null,
caParMagasinReseau: r.caParMagasinReseau != null ? String(r.caParMagasinReseau) : null,
margePctReseau: r.margePctReseau != null ? String(r.margePctReseau) : null,
payload: r,
computedAt,
}));
if (values.length === 0) return 0;
const CHUNK = 200; // payload jsonb volumineux : lots plus petits que pour des scalaires
let written = 0;
for (let i = 0; i < values.length; i += CHUNK) {
const batch = values.slice(i, i + CHUNK);
await db
.insert(gridRows)
.values(batch)
.onConflictDoUpdate({
target: [gridRows.codeFournisseur, gridRows.codein],
set: {
nomFournisseur: sql`excluded.nom_fournisseur`,
libelle1: sql`excluded.libelle1`,
gtin: sql`excluded.gtin`,
reference: sql`excluded.reference`,
codeCentrale: sql`excluded.code_centrale`,
code1: sql`excluded.code1`,
code2: sql`excluded.code2`,
code3: sql`excluded.code3`,
codeGamme: sql`excluded.code_gamme`,
codeGammeInit: sql`excluded.code_gamme_init`,
totalCa: sql`excluded.total_ca`,
totalQuantite: sql`excluded.total_quantite`,
totalMarge: sql`excluded.total_marge`,
tauxMarge: sql`excluded.taux_marge`,
stockActuel: sql`excluded.stock_actuel`,
caReseau: sql`excluded.ca_reseau`,
qteReseau: sql`excluded.qte_reseau`,
nbMagasinsReseau: sql`excluded.nb_magasins_reseau`,
caParMagasinReseau: sql`excluded.ca_par_magasin_reseau`,
margePctReseau: sql`excluded.marge_pct_reseau`,
payload: sql`excluded.payload`,
computedAt: sql`excluded.computed_at`,
},
});
written += batch.length;
}
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
await db.delete(gridRows).where(
and(eq(gridRows.codeFournisseur, codeFournisseur), lt(gridRows.computedAt, computedAt)),
);
return written;
}
/** Construit la clause WHERE commune à la grille et à la recherche transversale. */
function buildWhere(q: GridQuery): SQL | undefined {
const clauses: SQL[] = [];
if (q.codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, q.codeFournisseur));
if (q.gamme) clauses.push(eq(gridRows.codeGamme, q.gamme));
if (q.code1) clauses.push(eq(gridRows.code1, q.code1));
if (q.code2) clauses.push(eq(gridRows.code2, q.code2));
if (q.code3) clauses.push(eq(gridRows.code3, q.code3));
const search = q.search?.trim();
if (search) {
const pattern = `%${search}%`;
const alt = or(
ilike(gridRows.libelle1, pattern),
ilike(gridRows.codein, pattern),
ilike(gridRows.gtin, pattern),
ilike(gridRows.reference, pattern),
ilike(gridRows.codeCentrale, pattern),
);
if (alt) clauses.push(alt);
}
return clauses.length ? and(...clauses) : undefined;
}
/**
* Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
* Ne déclenche aucun recalcul et ne contacte jamais Qlik.
*/
export async function queryGridRows(q: GridQuery): Promise<GridQueryResult> {
const page = Math.max(1, q.page ?? 1);
const limit = Math.min(500, Math.max(1, q.limit ?? 100));
const where = buildWhere(q);
const sortCol = SORTABLE[q.sort ?? "totalCa"] ?? gridRows.totalCa;
// NULLS LAST dans les deux sens : un produit sans CA ne doit jamais occuper la tête.
const orderBy = q.order === "asc" ? asc(sortCol) : desc(sortCol);
const [countRow] = await db
.select({ total: sql<number>`count(*)::int` })
.from(gridRows)
.where(where);
const found = await db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(where)
.orderBy(orderBy, asc(gridRows.codein))
.limit(limit)
.offset((page - 1) * limit);
let oldest: Date | null = null;
for (const r of found) {
if (r.computedAt && (!oldest || r.computedAt < oldest)) oldest = r.computedAt;
}
return {
rows: found.map((r) => r.payload as ProductRow),
total: countRow?.total ?? 0,
computedAt: oldest ? oldest.toISOString() : null,
};
}
/** Fiche complète d'un produit. `codeFournisseur` lève l'ambiguïté d'un article multi-fournisseurs. */
export async function getGridRowByCodein(
codein: string,
codeFournisseur?: string,
): Promise<{ row: ProductRow; computedAt: string | null } | null> {
const clauses: SQL[] = [eq(gridRows.codein, codein)];
if (codeFournisseur) clauses.push(eq(gridRows.codeFournisseur, codeFournisseur));
const [found] = await db
.select({ payload: gridRows.payload, computedAt: gridRows.computedAt })
.from(gridRows)
.where(and(...clauses))
.orderBy(desc(gridRows.computedAt))
.limit(1);
if (!found) return null;
return {
row: found.payload as ProductRow,
computedAt: found.computedAt ? found.computedAt.toISOString() : null,
};
}
/**
* Indique si un fournisseur a déjà été calculé au moins une fois.
* Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
*/
export async function getGridFreshness(
codeFournisseur: string,
): Promise<{ rowCount: number; computedAt: string | null } | null> {
const [found] = await db
.select({
rowCount: sql<number>`count(*)::int`,
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
})
.from(gridRows)
.where(eq(gridRows.codeFournisseur, codeFournisseur));
if (!found || found.rowCount === 0) return null;
return {
rowCount: found.rowCount,
computedAt: found.computedAt ? new Date(found.computedAt).toISOString() : null,
};
}
/** Fournisseurs présents dans l'instantané, avec leur fraîcheur. */
export async function listGridSuppliers(): Promise<
Array<{ codeFournisseur: string; nomFournisseur: string | null; rowCount: number; computedAt: string | null }>
> {
const found = await db
.select({
codeFournisseur: gridRows.codeFournisseur,
nomFournisseur: sql<string | null>`max(${gridRows.nomFournisseur})`,
rowCount: sql<number>`count(*)::int`,
computedAt: sql<Date | null>`max(${gridRows.computedAt})`,
})
.from(gridRows)
.groupBy(gridRows.codeFournisseur)
.orderBy(asc(gridRows.codeFournisseur));
return found.map((r) => ({
codeFournisseur: r.codeFournisseur,
nomFournisseur: r.nomFournisseur,
rowCount: r.rowCount,
computedAt: r.computedAt ? new Date(r.computedAt).toISOString() : null,
}));
}
+82
View File
@@ -157,6 +157,55 @@ export async function pgGetArticlesByFournisseur(codefou: string): Promise<PgArt
return (result.rows as unknown as PgArticle[]).filter(r => r.codein);
}
/** Article local (FF Nancy) résolu depuis un code centrale — page Recherche réseau. */
export interface PgArticleLocal {
codein: string;
codeCentrale: string;
libelle1?: string;
codefou?: string;
nomfou?: string;
pa?: number;
pv_central?: number;
}
/**
* Résout des codes centraux vers NOS articles locaux.
*
* Sert à distinguer, dans les résultats de recherche Qlik, les produits que nous
* référençons (« Chez moi ») de ceux qui n'existent que dans le réseau. Un code
* absent de la Map = produit inconnu de FF Nancy.
*/
export async function pgGetArticlesByCodeCentrale(codes: string[]): Promise<Map<string, PgArticleLocal>> {
const unique = [...new Set(codes.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.artcentrale)
TRIM(a.codein::text) AS codein,
a.artcentrale AS "codeCentrale",
a.libelle1,
af.code AS codefou,
fi.nom AS nomfou,
pa.pa,
ai.prix_vente_mini AS pv_central
FROM articles a
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id
LEFT JOIN fouident fi ON fi.code = af.code
LEFT JOIN cube_pa pa ON pa.artnoid = a.no_id
LEFT JOIN article_infosup ai ON ai.artnoid = a.no_id
WHERE TRIM(a.artcentrale::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
AND a.codein IS NOT NULL
ORDER BY a.artcentrale, af.no_id
`);
const map = new Map<string, PgArticleLocal>();
for (const r of result.rows as unknown as PgArticleLocal[]) {
if (r.codeCentrale) map.set(String(r.codeCentrale).trim(), r);
}
console.log(`[pg-ff] pgGetArticlesByCodeCentrale: ${map.size}/${unique.length} codes connus localement`);
return map;
}
// ---------------------------------------------------------------------------
// 2. Données mensuelles (ventes + stock + réceptions)
// ---------------------------------------------------------------------------
@@ -253,6 +302,39 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise<Map<str
return map;
}
/**
* Gammes **serveur** pour une liste de codein, tous fournisseurs confondus.
*
* Variante de `pgGetGammesByFournisseur` sans la jointure `artfou1` : la recherche
* de l'API `/api/v1` est transversale, on ne connaît donc pas le fournisseur.
* C'est la gamme telle qu'elle existe en base — non modifiée, indépendante des
* snapshots de session qui peuvent surcharger `codeGamme` dans la Grille.
*/
export async function pgGetGammesByCodeins(codeins: string[]): Promise<Map<string, string>> {
const unique = [...new Set(codeins.map(c => String(c ?? "").trim()).filter(Boolean))];
if (unique.length === 0) return new Map();
// DISTINCT ON (codein) → 1 gamme par article, saison la plus récente en premier.
const result = await pgNoParallel(sql`
SELECT DISTINCT ON (a.codein)
TRIM(a.codein::text) AS codein,
g.code AS gamme_code
FROM art_gamme_saison ags
JOIN articles a ON a.no_id = ags.artnoid
JOIN gammes g ON g.no_id = ags.idgamme
JOIN saisons s ON s.no_id = ags.idsaison
WHERE TRIM(a.codein::text) IN (${sql.join(unique.map(c => sql`${c}`), sql`, `)})
ORDER BY a.codein, s.no_id DESC
`);
const map = new Map<string, string>();
for (const row of result.rows as unknown as { codein: string; gamme_code: string }[]) {
if (row.codein && row.gamme_code) map.set(row.codein, String(row.gamme_code).trim());
}
console.log(`[pg-ff] pgGetGammesByCodeins: ${map.size}/${unique.length} articles avec gamme`);
return map;
}
// ---------------------------------------------------------------------------
// 4. Nomenclature (famille / sous-famille)
// ---------------------------------------------------------------------------
+14
View File
@@ -123,6 +123,20 @@ export interface QlikMonthMetrics {
margePct?: number;
}
/**
* Produit réseau tel que renvoyé par la **recherche** Qlik (page Recherche réseau).
*
* Différence clé avec `NetworkMetric` : ces produits viennent du catalogue Qlik
* du réseau, pas de notre catalogue local. Ils peuvent donc être totalement
* inconnus de FF Nancy — d'où le libellé, qui n'est pas récupérable en base.
*/
export interface NetworkProduct extends NetworkMetric {
/** Libellé article tel qu'il apparaît dans Qlik (dimension « Article »). */
libelle: string;
fournisseur?: string;
famille?: string;
}
function makeXrfkey(): string {
return randomBytes(12).toString("base64").replace(/[^a-zA-Z0-9]/g, "").slice(0, 16).padEnd(16, "0");
}
+7
View File
@@ -10,10 +10,17 @@ export default auth((req) => {
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public/");
// L'API publique s'authentifie elle-même (clé d'API ou session) et rend ses
// propres erreurs JSON. Sans cette exemption, un script non authentifié
// recevrait une redirection 302 + la page HTML de login au lieu d'un 401.
const isPublicApiRoute = nextUrl.pathname.startsWith("/api/v1");
// 1. Laisser passer les requêtes d'auth API
if (isApiAuthRoute) return NextResponse.next();
// 1 bis. Laisser passer /api/v1 — l'authentification est faite dans les handlers
if (isPublicApiRoute) return NextResponse.next();
// 2. Rediriger vers /login si non connecté et route non publique
if (!isLoggedIn && !isPublicRoute) {
return NextResponse.redirect(new URL("/login", nextUrl));