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
This commit is contained in:
Claude committed 2026-08-05 10:14:21 +00:00
1 parent e79d7e51da
commit a45e09777b
9 files changed
+350 -20

No files matched your search

+10 -2
View File
@@ -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() {
<UserManagement />
</Section>
{/* Clés de l'API publique /api/v1 */}
{/* 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="Accès programmatique en lecture à la grille et à la recherche (/api/v1)"
subtitle="Créer et révoquer les clés d'accès à /api/v1"
>
<ApiKeyManagement />
</Section>
+6 -1
View File
@@ -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",
},
},
);
+31 -2
View File
@@ -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" },
+11 -11
View File
@@ -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,
},
});
}
+6 -1
View File
@@ -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",
},
},
);