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

+94
View File
@@ -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<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,
};
// `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;
});
}
+7 -3
View File
@@ -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<ProductRow> {
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),
@@ -78,7 +82,7 @@ export function pickFields(row: ProductRow, fields?: string): Partial<ProductRow
for (const key of wanted) {
if (key in row) out[key] = (row as unknown as Record<string, unknown>)[key];
}
return out as Partial<ProductRow>;
return out as Partial<T>;
}
/** Normalise le tri validé vers la clé attendue par grid-store. */
+33
View File
@@ -253,6 +253,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)
// ---------------------------------------------------------------------------