From a45e09777b0a913246d5dc392bc8b466f9cda9d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 09:52:55 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20m=C3=A9triques=20Qlik=20+=20gamme?= =?UTF-8?q?=20serveur=20=C3=A0=20jour,=20et=20doc=20de=20connexion=20dans?= =?UTF-8?q?=20Param=C3=A8tres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675 --- src/app/(dashboard)/settings/page.tsx | 12 +- src/app/api/v1/grid/route.ts | 7 +- src/app/api/v1/openapi.json/route.ts | 33 +++- src/app/api/v1/products/[codein]/route.ts | 22 +-- src/app/api/v1/products/search/route.ts | 7 +- .../admin/components/api-connection-info.tsx | 152 ++++++++++++++++++ src/lib/api-enrich.ts | 94 +++++++++++ src/lib/api-schemas.ts | 10 +- src/lib/pg-ff-client.ts | 33 ++++ 9 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 src/features/admin/components/api-connection-info.tsx create mode 100644 src/lib/api-enrich.ts diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 3116e8a..001ac14 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -9,6 +9,7 @@ import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig, s 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"; interface OpenRouterModel { id: string; name: string; free: boolean; } @@ -617,10 +618,17 @@ export default function SettingsPage() { - {/* Clés de l'API publique /api/v1 */} + {/* API publique /api/v1 — connexion puis gestion des clés */} +
+ +
+
diff --git a/src/app/api/v1/grid/route.ts b/src/app/api/v1/grid/route.ts index 1c9a5db..066c57a 100644 --- a/src/app/api/v1/grid/route.ts +++ b/src/app/api/v1/grid/route.ts @@ -3,6 +3,7 @@ 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"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -50,8 +51,11 @@ export async function GET(req: NextRequest) { 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( - result.rows.map((r) => pickFields(r, q.fields)), + rows.map((r) => pickFields(r, q.fields)), { pagination: buildPagination(q.page, q.limit, result.total), meta: { @@ -59,6 +63,7 @@ export async function GET(req: NextRequest) { computedAt: result.computedAt, snapshotComputedAt: freshness.computedAt, snapshotRowCount: freshness.rowCount, + enrichi: q.enrich === "1", }, }, ); diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index 970edd7..c56afee 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -29,6 +29,14 @@ export async function GET(req: NextRequest) { schema: { type: "string" }, description: "Champs de ProductRow à conserver, séparés par des virgules (allège fortement la réponse). `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, légèrement plus rapide.", + }, ]; const spec = { @@ -42,7 +50,10 @@ export async function GET(req: NextRequest) { + "**Aucun endpoint ne déclenche de recalcul ni d'appel à Qlik.** Toutes les réponses proviennent " + "de données déjà persistées : l'instantané de grille (`grid_rows`), rempli quand la Grille est " + "ouverte dans l'application, et le cache des métriques réseau (`qlik_network_metrics`). " - + "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.", + + "Un fournisseur jamais ouvert renvoie donc `202 not_ready` au lieu d'imposer une attente.\n\n" + + "Deux informations sont relues à chaque appel car elles évoluent indépendamment du calcul de " + + "la grille : les **métriques réseau Qlik** (`network`, `null` quand il n'y en a pas) et la " + + "**gamme serveur non modifiée** (`codeGammeServeur`). Passer `enrich=0` pour s'en dispenser.", }, servers: [{ url: "/api/v1" }], security: [{ ApiKeyAuth: [] }, { BearerAuth: [] }], @@ -87,7 +98,25 @@ export async function GET(req: NextRequest) { libelle1: { type: "string" }, gtin: { type: "string" }, codeCentrale: { type: "string", description: "Clé de jointure avec Qlik (format 10000XXXXXX)." }, - codeGamme: { type: ["string", "null"] }, + codeGamme: { type: ["string", "null"], description: "Gamme courante — peut être surchargée par un snapshot de session." }, + codeGammeServeur: { + type: ["string", "null"], + description: "Gamme **non modifiée**, telle qu'elle existe sur le serveur PostgreSQL. Relue à chaque appel (sauf `enrich=0`). `null` = aucune gamme en base.", + }, + codeGammeInit: { type: ["string", "null"], description: "Alias historique de `codeGammeServeur`, maintenu aligné." }, + network: { + type: ["object", "null"], + description: "Métriques réseau Qlik en cache, `null` si le produit n'en a pas.", + properties: { + caReseau: { type: "number" }, + qteReseau: { type: "number" }, + nbMagasinsReseau: { type: "integer" }, + caParMagasinReseau: { type: "number" }, + margePctReseau: { type: "number", description: "Ratio brut Qlik (0.32 = 32 %)." }, + qteByMonth: { type: ["object", "null"], description: "Quantités par mois, clés YYYY-MM." }, + fetchedAt: { type: ["string", "null"], format: "date-time" }, + }, + }, totalCa: { type: "number" }, totalQuantite: { type: "number" }, totalMarge: { type: "number" }, diff --git a/src/app/api/v1/products/[codein]/route.ts b/src/app/api/v1/products/[codein]/route.ts index 76dc5ab..6e48105 100644 --- a/src/app/api/v1/products/[codein]/route.ts +++ b/src/app/api/v1/products/[codein]/route.ts @@ -3,7 +3,7 @@ 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 { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache"; +import { enrichRows } from "@/lib/api-enrich"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -41,15 +41,15 @@ export async function GET( ); } - // Métriques réseau à jour depuis le cache (le payload peut dater du dernier calcul). - let network = null; - if (found.row.codeCentrale) { - const metrics = await getNetworkMetricsByCodeCentrale([found.row.codeCentrale]); - network = metrics.get(found.row.codeCentrale) ?? null; - } + // 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( - { ...found.row, network }, - { meta: { computedAt: found.computedAt, networkFetchedAt: network?.fetchedAt ?? null } }, - ); + return ok(enriched, { + meta: { + computedAt: found.computedAt, + networkFetchedAt: enriched.network?.fetchedAt ?? null, + hasNetwork: enriched.network !== null, + }, + }); } diff --git a/src/app/api/v1/products/search/route.ts b/src/app/api/v1/products/search/route.ts index 911b363..352a5f8 100644 --- a/src/app/api/v1/products/search/route.ts +++ b/src/app/api/v1/products/search/route.ts @@ -3,6 +3,7 @@ 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"; @@ -38,14 +39,18 @@ export async function GET(req: NextRequest) { 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( - result.rows.map((r) => pickFields(r, q.fields)), + 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", }, }, ); diff --git a/src/features/admin/components/api-connection-info.tsx b/src/features/admin/components/api-connection-info.tsx new file mode 100644 index 0000000..5febf0c --- /dev/null +++ b/src/features/admin/components/api-connection-info.tsx @@ -0,0 +1,152 @@ +"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 } from "lucide-react"; + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function CodeLine({ children, copy }: { children: string; copy?: string }) { + return ( +
+ + {children} + + +
+ ); +} + +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" }, +]; + +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 ( +
+ {/* Connexion */} +
+

URL de base

+ {base} +

Authentification

+

+ Une clé créée ci-dessous, dans l'un ou l'autre de ces en-têtes. Depuis un navigateur + connecté, la session suffit — aucune clé n'est nécessaire. +

+ X-API-Key: VOTRE_CLE + Authorization: Bearer VOTRE_CLE +
+ + {/* Endpoints */} +
+

Endpoints

+
+ + + {ENDPOINTS.map((e, i) => ( + + + + + + ))} + +
{e.method}{e.path}{e.desc}
+
+
+ + {/* Paramètres */} +
+

Paramètres communs

+
+ + + {PARAMS.map((p, i) => ( + + + + + ))} + +
{p.name}{p.desc}
+
+
+ + {/* Exemples */} +
+

Exemples

+ {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`} + {`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`} +
+ + {/* Comportement à connaître */} +
+

Aucun recalcul. L'API lit l'instantané de la grille, écrit quand la Grille + est ouverte dans l'application. Un fournisseur jamais consulté renvoie 202{" "} + not_ready plutôt que de faire attendre.

+

Métriques Qlik (network) et{" "} + gamme serveur non modifiée (codeGammeServeur) sont + relues à chaque appel. network vaut{" "} + null quand le produit n'a pas de données réseau.

+

Erreurs : {`{ "error": { "code", "message" } }`} avec{" "} + 401 (clé absente/invalide/révoquée),{" "} + 400, 404.

+
+ + + Ouvrir la spécification OpenAPI + +
+ ); +} diff --git a/src/lib/api-enrich.ts b/src/lib/api-enrich.ts new file mode 100644 index 0000000..b928309 --- /dev/null +++ b/src/lib/api-enrich.ts @@ -0,0 +1,94 @@ +/** + * 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"; + +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; +} + +/** + * 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 { + 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(); + }) + : Promise.resolve(new Map()), + codeins.length + ? pgGetGammesByCodeins(codeins).catch((e) => { + console.error("[api-enrich] gammes serveur KO:", (e as Error).message?.slice(0, 160)); + return new Map(); + }) + : Promise.resolve(new Map()), + ]); + + 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, + }; + + // `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; + } + + return enriched; + }); +} diff --git a/src/lib/api-schemas.ts b/src/lib/api-schemas.ts index b18dc8d..a167aba 100644 --- a/src/lib/api-schemas.ts +++ b/src/lib/api-schemas.ts @@ -8,7 +8,6 @@ import { z } from "zod"; import { GRID_SORT_KEYS, type GridSortKey } from "@/lib/grid-store"; -import type { ProductRow } from "@/types/grid"; const SORT_KEYS = GRID_SORT_KEYS as readonly string[]; @@ -27,6 +26,11 @@ const sortShape = { 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). */ @@ -67,7 +71,7 @@ export const productDetailSchema = z.object({ * 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(row: ProductRow, fields?: string): Partial { +export function pickFields(row: T, fields?: string): Partial { if (!fields) return row; const wanted = new Set( fields.split(",").map((f) => f.trim()).filter(Boolean), @@ -78,7 +82,7 @@ export function pickFields(row: ProductRow, fields?: string): Partial)[key]; } - return out as Partial; + return out as Partial; } /** Normalise le tri validé vers la clé attendue par grid-store. */ diff --git a/src/lib/pg-ff-client.ts b/src/lib/pg-ff-client.ts index ef69a85..9b5da6c 100644 --- a/src/lib/pg-ff-client.ts +++ b/src/lib/pg-ff-client.ts @@ -253,6 +253,39 @@ export async function pgGetGammesByFournisseur(codefou: string): Promise> { + 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(); + 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) // ---------------------------------------------------------------------------