Files
CollectFlow/src/lib/api-enrich.ts
T
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

147 lines
6.7 KiB
TypeScript

/**
* 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;
});
}