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.
+ );
+}
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