mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
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
147 lines
6.7 KiB
TypeScript
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;
|
|
});
|
|
}
|