Files
CollectFlow/src/features/grid/lib/network-trend.ts
T
Claude 48860271a5 Tendance réseau : de retour dans la Grille après le changement de mois
- Grille servie depuis l'instantané (lot 4) : les colonnes réseau sont
  relues dans le cache Qlik, comme le calcul en direct. Elles restaient
  figées à la date du calcul de l'instantané.
- Tendance : si la fenêtre du jour est incomplète mais que les 12 mois
  précédents le sont (cache Qlik pas encore resynchronisé depuis le
  changement de mois), la tendance porte sur ces 12 mois au lieu de
  disparaître. Le modal le signale.
- Courbe des magasins vendeurs alignée sur les mois de la tendance
  (modal Grille et fiche produit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm
2026-10-04 07:29:29 +00:00

245 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<NetworkTrend["direction"], string> = {
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<string, number>, 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<string, number> | 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<string, number> | null,
nbMagByMonth?: Record<string, number> | 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<string, number> | 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";
}