/** * CollectFlow — Tendance des ventes réseau Qlik sur 12 mois. * * Extrait de `heatmap-grid.tsx` pour être partagé entre la Grille (sparkline de * la colonne « Tendance / Réseau ») et la fiche produit (`/produits`). * Aucun changement de comportement. */ /** * Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux * de présence. Constante métier : source unique pour la Grille et la fiche produit. */ export const NB_MAGASINS_RESEAU = 270; export type NetworkTrend = { values: number[]; // quantités mensuelles, ordre chronologique labels: string[]; // "YYYY-MM" /** * Quantité moyenne par magasin vendeur, mois par mois. `null` si le nombre * de magasins n'est pas connu sur les 12 mois. */ perStore: number[] | null; /** * `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se * rabattre sur les quantités brutes faute de nombre de magasins. */ surQteParMagasin: boolean; direction: "up" | "down" | "flat"; /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ pct: number | null; /** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */ nouveau: boolean; /** * `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik * n'a pas encore été resynchronisé depuis le changement de mois. */ enRetard: boolean; hasData: boolean; }; /** * Nombre de mois comparés à chaque bout de la fenêtre. * * Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute * la tendance de deux points, donc du hasard d'un réassort ou d'une opération. */ export const TREND_WINDOW = 4; /** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ export const TREND_STRONG = 0.25; /** * Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au * plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer * écraserait systématiquement la tendance vers le bas. * * Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que * `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent * rester alignés. */ export function buildRolling12QlikMonths(now: Date = new Date()): string[] { const months: string[] = []; for (let i = 12; i >= 1; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); } return months; } /** Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%). */ export const TREND_FLAT = 0.08; export const TREND_COLOR: Record = { up: "#22c55e", down: "#ef4444", flat: "#94a3b8", }; /** Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ? */ function fenetreComplete(serie: Record, labels: string[]): boolean { return labels.every((label) => Object.prototype.hasOwnProperty.call(serie, label) && Number.isFinite(Number(serie[label])), ); } /** * Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour, * sinon celle qui s'arrête un mois plus tôt. * * Le repli couvre les jours qui suivent un changement de mois : la fenêtre a * glissé, mais le cache Qlik n'est resynchronisé que fournisseur par * fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un * coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun * mois n'est inventé — simplement décalés d'un cran. `null` si aucune des deux * fenêtres n'est complète. */ export function fenetreDisponible( serie: Record | null | undefined, now: Date = new Date(), ): { labels: string[]; enRetard: boolean } | null { if (!serie) return null; const labels = buildRolling12QlikMonths(now); if (fenetreComplete(serie, labels)) return { labels, enRetard: false }; const precedente = buildRolling12QlikMonths(new Date(now.getFullYear(), now.getMonth() - 1, 1)); if (fenetreComplete(serie, precedente)) return { labels: precedente, enRetard: true }; return null; } /** * Tendance réseau : variation entre les **4 premiers** et les **4 derniers** * mois de la fenêtre de 12 mois glissants, mois en cours exclu. * * La fenêtre est reconstruite à partir de la date du jour (et non des clés * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance * réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois, * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un * mois tronqué. Seule tolérance : un cache pas encore resynchronisé depuis le * changement de mois, lu sur ses 12 mois complets (cf. `fenetreDisponible`). * * Une série n'est affichée que si les **12 clés sont explicitement présentes**. * L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé * la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle * ne peut donc plus être présentée comme une tendance réelle. * * Cette exigence du « tout ou rien » évite deux écueils observés en production : * une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un * article vendu depuis mai), et des mois non extraits comptés comme des mois * sans vente. * * ⚠️ CHANGEMENT D'INDICATEUR. La version précédente rapportait la pente d'une * régression linéaire à la moyenne de la série. Mathématiquement défendable, * mais illisible en pratique — constaté en production : * * - « −124 % », alors qu'une baisse ne peut pas dépasser −100 % ; * - « +508 % » sur des dizaines de lignes d'affilée, toujours la même valeur : * c'est la signature arithmétique d'une série nulle partout sauf le dernier * mois, où le rapport pente/moyenne ne dépend même plus des quantités ; * - « +1 062 % » pour 2 unités vendues une seule fois dans l'année. * * On compare donc désormais deux moyennes de 4 mois, ce qui donne une variation * qui se lit comme telle, plancher à −100 %, et reste insensible au bruit d'un * mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois), * aucun pourcentage n'est inventé : le produit est signalé « nouveau ». * * ⚠️ LA TENDANCE PORTE SUR LA QUANTITÉ PAR MAGASIN, PAS SUR LA QUANTITÉ BRUTE. * * La quantité brute confond deux phénomènes distincts : un produit peut vendre * davantage simplement parce qu'il est référencé dans plus de magasins, sans * mieux se vendre nulle part. Inversement, un produit retiré de 60 magasins peut * s'effondrer en volume tout en performant mieux là où il reste. * * C'est donc `quantité / magasins vendeurs` qui dit si un produit marche — et * c'est sur elle que l'évolution est calculée. La courbe des quantités reste * affichée, mais comme contexte, pas comme indicateur. * * Repli : sans nombre de magasins sur les 12 mois, on retombe sur les quantités * brutes et `surQteParMagasin` vaut `false`, pour que l'UI puisse le dire. * * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. */ export function computeNetworkTrend( qteByMonth?: Record | null, nbMagByMonth?: Record | null, now: Date = new Date(), ): NetworkTrend { const empty: NetworkTrend = { values: [], labels: [], perStore: null, surQteParMagasin: false, direction: "flat", pct: null, nouveau: false, enRetard: false, hasData: false, }; const fenetre = fenetreDisponible(qteByMonth, now); if (!qteByMonth || !fenetre) return empty; const { labels, enRetard } = fenetre; const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; // Quantité par magasin vendeur : l'indicateur qui isole la performance du // produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le // seul choix cohérent avec une quantité elle-même nulle. const magasins = computeStoresSeries(nbMagByMonth, labels); const perStore = magasins ? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0)) : null; const serie = perStore ?? values; const taille = Math.min(TREND_WINDOW, Math.floor(n / 2)); const moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0); const debut = moyenne(serie.slice(0, taille)); const fin = moyenne(serie.slice(n - taille)); let pct: number | null = null; let nouveau = false; let direction: NetworkTrend["direction"] = "flat"; if (debut > 0) { // Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans // retours massifs, et la division par une base positive garde le sens. pct = (fin - debut) / debut; direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat"; } else if (fin > 0) { // Rien au départ, des ventes à l'arrivée : la variation relative n'existe // pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé. nouveau = true; direction = "up"; } return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, enRetard, hasData: true }; } /** * Série « nombre de magasins vendeurs » sur la MÊME fenêtre que * `computeNetworkTrend`, pour la superposer à la courbe des quantités. * * Pourquoi cette deuxième courbe : une quantité qui monte parce que le produit * est diffusé dans plus de magasins ne raconte pas la même histoire qu'une * quantité qui monte à diffusion constante. Sans elle, la tendance seule ne * permet pas de distinguer un succès produit d'un simple élargissement. * * Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent * être explicitement présentes, sinon `null` — un mois non extrait ne doit pas * se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion. */ export function computeStoresSeries( nbMagByMonth?: Record | null, /** Mois de la courbe des quantités (`trend.labels`), pour rester alignés. */ labels?: string[], ): { values: number[]; labels: string[] } | null { if (!nbMagByMonth || !labels || labels.length === 0) return null; if (!fenetreComplete(nbMagByMonth, labels)) return null; return { values: labels.map((l) => Number(nbMagByMonth[l])), labels }; } /** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */ export function trendLabel(pct: number | null, nouveau = false): string { // Un produit sans base de comparaison n'est pas « stable » : il est nouveau. if (nouveau) return "Nouveau"; if (pct == null) return "Stable"; if (pct > TREND_STRONG) return "Forte hausse"; if (pct > TREND_FLAT) return "Hausse"; if (pct < -TREND_STRONG) return "Forte baisse"; if (pct < -TREND_FLAT) return "Baisse"; return "Stable"; }