From bf6fef6a1443464885b70acda08131198f2a611f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 09:41:25 +0000 Subject: [PATCH] Carte tendance : deux bandes separees, indicateur lisible, courbe magasins reparee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois defauts signales, trois causes distinctes. 1. LA COURBE DES MAGASINS N'APPARAISSAIT PRESQUE JAMAIS. L'extracteur ecrivait `{ qte: 0 }` sur les mois sans vente, sans `nbMag`. La serie exigeant les 12 mois, elle disparaissait des qu'un produit avait un mois creux — c'est-a-dire presque toujours. Un mois PRESENT dans le detail est un mois extrait : s'il n'a pas de nbMag, c'est que personne n'a vendu, donc zero. Corrige a la source ET a la lecture, pour que les caches deja ecrits repartent sans re-sync. 2. LES DEUX COURBES ETAIENT ILLISIBLES SUPERPOSEES. Deux echelles independantes placaient la courbe des magasins AU-DESSUS de celle des quantites : cela se lit spontanement comme « il y a plus de magasins que de ventes », un contresens. Les series sont desormais tracees dans DEUX BANDES distinctes alignees sur le meme axe des mois. On compare la forme de l'une avec la forme de l'autre, sans jamais suggerer un rapport de grandeur. 3. LES POURCENTAGES N'AVAIENT PAS DE SENS. La pente d'une regression rapportee a la moyenne donnait « -124 % » (une baisse ne peut pas depasser -100 %), « +1 062 % » pour 2 unites vendues une fois dans l'annee, et surtout « +508 % » sur des dizaines de lignes d'affilee — la signature arithmetique d'une serie nulle partout sauf le dernier mois, ou le rapport ne depend meme plus des quantites. On compare maintenant deux moyennes de 4 mois : variation qui se lit comme telle, plancher a -100 %, insensible au bruit d'un mois isole. Sans base de comparaison, aucun pourcentage n'est invente — le produit est marque « Nouveau », et le tri le remonte en tete au lieu de l'enterrer. Le graphique accepte aussi des quantites negatives (retours superieurs aux ventes sur un mois) : l'echelle part du minimum reel, le point ne sort plus du cadre. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/fiche.tsx | 7 +- src/features/grid/api/get-product-rows.ts | 10 +- src/features/grid/components/heatmap-grid.tsx | 12 +- .../grid/components/network-charts.tsx | 188 +++++++++++------- src/features/grid/lib/network-trend.ts | 73 +++++-- src/lib/qlik-playwright.ts | 6 +- 6 files changed, 191 insertions(+), 105 deletions(-) diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index ebbb27d..e764601 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -155,8 +155,11 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba if (!detailMensuel) return null; const parMois: Record = {}; for (const [mois, mesures] of Object.entries(detailMensuel)) { + // Mois présent sans `nbMag` = mois extrait sans vente = zéro magasin + // (cf. get-product-rows.ts) : sinon la série est amputée et la courbe + // disparaît sur les anciens caches. const nb = Number(mesures?.nbMag); - if (Number.isFinite(nb)) parMois[mois] = nb; + parMois[mois] = Number.isFinite(nb) ? nb : 0; } return computeStoresSeries(parMois); }, [reseau]); @@ -271,7 +274,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba = 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"} - hint={trendLabel(trend.pct)} + hint={trendLabel(trend.pct, trend.nouveau)} color={TREND_COLOR[trend.direction]} /> diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 1e36831..b8a280f 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -496,9 +496,13 @@ function nbMagParMois( ): Record | null { if (!metricsByMonth) return null; const parMois: Record = {}; - for (const [mois, mesures] of Object.entries(metricsByMonth)) { - const nb = Number(mesures?.nbMag); - if (Number.isFinite(nb)) parMois[mois] = nb; + for (const [mois] of Object.entries(metricsByMonth)) { + // Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de + // `nbMag`, c'est qu'aucun magasin n'a vendu, donc zéro. Le traiter comme + // « manquant » amputait la série et faisait disparaître toute la courbe + // (les anciens caches n'écrivaient `nbMag` que sur les mois avec vente). + const nb = Number(metricsByMonth[mois]?.nbMag); + parMois[mois] = Number.isFinite(nb) ? nb : 0; } return Object.keys(parMois).length > 0 ? parMois : null; } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index 5873980..b604cdc 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -211,9 +211,10 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>

Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"} {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {pct != null && ( + {(pct != null || trend.nouveau) && ( <> · - {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)} + {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · } + {trendLabel(pct, trend.nouveau)} )}

@@ -564,7 +565,12 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { id: "tendanceReseau", accessorFn: (row) => { const t = computeNetworkTrend(row.qteReseauByMonth); - return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY; + if (!t.hasData) return Number.NEGATIVE_INFINITY; + // Un produit « nouveau » n'a pas de pourcentage mais c'est la plus + // forte progression possible : il doit remonter en tête du tri, pas + // tomber au fond avec les lignes sans données. + if (t.nouveau) return Number.POSITIVE_INFINITY; + return t.pct ?? Number.NEGATIVE_INFINITY; }, header: () =>
Tendance
Réseau · 12 m
, size: 78, diff --git a/src/features/grid/components/network-charts.tsx b/src/features/grid/components/network-charts.tsx index 14c6382..5f0b434 100644 --- a/src/features/grid/components/network-charts.tsx +++ b/src/features/grid/components/network-charts.tsx @@ -14,7 +14,7 @@ import { TREND_COLOR, type NetworkTrend } from "@/features/grid/lib/network-tren /** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { if (!trend.hasData) return
-
; - const { values, direction, pct } = trend; + const { values, direction, pct, nouveau } = trend; const color = TREND_COLOR[direction]; const W = 46, H = 18, n = values.length; const max = Math.max(...values, 1), min = Math.min(...values, 0); @@ -26,7 +26,11 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { }).join(" "); const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; return ( -
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}> +
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% : 4 derniers mois vs 4 premiers — cliquer pour le détail` + : nouveau + ? "Nouveau : aucune vente sur les 4 premiers mois de la fenêtre — cliquer pour le détail" + : "Tendance réseau — cliquer pour le détail"}>
{n > 1 && } @@ -39,6 +43,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) { {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% )} + {pct == null && nouveau && ( + Nouveau + )}
); } @@ -64,18 +71,20 @@ export function storesColorFor(couleurQuantites: string): string { } /** - * Ventes réseau mensuelles, avec en option la courbe du nombre de magasins. + * Ventes réseau mensuelles, avec en option le nombre de magasins vendeurs. * - * ⚠️ DEUX ÉCHELLES INDÉPENDANTES. Les quantités se comptent en milliers, les - * magasins plafonnent à ~270 : sur une échelle commune la courbe des magasins - * serait écrasée sur l'axe et ne montrerait plus rien. Chaque série est donc - * normalisée sur son propre maximum, rappelé en haut du graphique — c'est la - * FORME des deux courbes qu'on compare, pas leurs hauteurs. + * ⚠️ DEUX BANDES SÉPARÉES, PAS DEUX COURBES SUPERPOSÉES. * - * Les deux séries se distinguent par TROIS canaux, et pas seulement la couleur - * (qui ne suffit ni en impression noir et blanc, ni pour un daltonien) : teinte - * garantie différente (`storesColorFor`), trait pointillé contre trait plein, et - * points évidés contre points pleins. + * La superposition a été essayée et rejetée à l'usage : les quantités se + * comptent en milliers, les magasins plafonnent à ~270. Sur une échelle commune + * la courbe des magasins s'écrase sur l'axe ; sur deux échelles superposées elle + * passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il + * y a plus de magasins que de ventes » — un contresens. + * + * Les deux séries sont donc tracées dans deux bandes distinctes, alignées sur le + * même axe des mois. On compare ce qui est comparable — la forme de l'une avec + * la forme de l'autre, mois par mois — sans jamais suggérer un rapport de + * grandeur entre elles. * * Lecture : quantités qui montent avec les magasins = élargissement de la * diffusion ; quantités qui montent à magasins constants = vraie accélération @@ -94,35 +103,41 @@ export function NetworkLineChart({ stores?: number[] | null; }) { const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0; - const W = 460, H = avecMagasins ? 224 : 200; - const padL = 10, padR = 10, padT = avecMagasins ? 40 : 22, padB = 30; - const plotW = W - padL - padR, plotH = H - padT - padB; const n = values.length; - const maxV = Math.max(...values, 1); - const maxS = avecMagasins ? Math.max(...stores!, 1) : 1; + + const W = 460; + const padL = 10, padR = 10, padB = 22; + const titreH = 12; // hauteur réservée au titre d'une bande + const bandeQ = 120; // bande des quantités + const bandeM = avecMagasins ? 46 : 0; // bande des magasins + const ecart = avecMagasins ? 18 : 0; + const hautQ = titreH + 10; // sommet de la bande des quantités + const hautM = hautQ + bandeQ + ecart + titreH; + const H = hautQ + bandeQ + ecart + (avecMagasins ? titreH + bandeM : 0) + padB; + const plotW = W - padL - padR; + + // Les quantités Qlik peuvent être NÉGATIVES (retours supérieurs aux ventes + // sur un mois) : l'échelle part du minimum réel, sinon le point sortirait + // du cadre par le bas. + const minQ = Math.min(0, ...values); + const maxQ = Math.max(...values, 1); + const etendueQ = maxQ - minQ || 1; + const maxM = avecMagasins ? Math.max(...stores!, 1) : 1; + const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2); - const y = (v: number) => padT + plotH - (v / maxV) * plotH; - const yS = (v: number) => padT + plotH - (v / maxS) * plotH; + const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; + const yM = (v: number) => hautM + bandeM - (v / maxM) * bandeM; + const zero = y(0); + const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); - const areaPts = `${padL},${padT + plotH} ${linePts} ${(padL + plotW).toFixed(1)},${(padT + plotH).toFixed(1)}`; + const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; const storesColor = storesColorFor(color); const storePts = avecMagasins - ? stores!.map((v, i) => `${x(i).toFixed(1)},${yS(v).toFixed(1)}`).join(" ") + ? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") : ""; + return (
- {avecMagasins && ( -
- - - Quantités vendues (max {maxV.toLocaleString("fr-FR")}) - - - - Magasins vendeurs (max {maxS.toLocaleString("fr-FR")}) - -
- )} - {/* ligne de base */} - - {/* aire douce sous la courbe */} + {/* ─── Bande 1 : quantités ─────────────────────────────── */} + + QUANTITÉS VENDUES + + {n > 1 && } - {/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */} - {avecMagasins && n > 1 && ( - - )} - {/* points évidés : distincts des points pleins des quantités, même sans couleur */} - {avecMagasins && stores!.map((v, i) => ( - - ))} - {/* courbe */} {n > 1 && } {values.map((v, i) => ( - + - {/* quantité au-dessus du point */} - + = 0 ? y(v) - 6 : y(v) + 12} + textAnchor="middle" + fontSize={9} + fontWeight={700} + fill="var(--text-primary)" + > {Math.round(v).toLocaleString("fr-FR")} - {/* mois sous l'axe */} - - {fmtMonthShort(labels[i])} - - {/* nb de magasins sous l'axe, juste au-dessus du mois */} - {avecMagasins && ( - - {Math.round(stores![i]).toLocaleString("fr-FR")} - - )} ))} + + {/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} + {avecMagasins && ( + <> + + MAGASINS VENDEURS + · {maxM.toLocaleString("fr-FR")} au maximum + + + {n > 1 && ( + + )} + {stores!.map((v, i) => ( + + {/* point évidé : lisible même sans couleur */} + + + {Math.round(v).toLocaleString("fr-FR")} + + + ))} + + )} + + {/* ─── Axe des mois, commun aux deux bandes ────────────── */} + {labels.map((lab, i) => ( + + {fmtMonthShort(lab)} + + ))}
); diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 34e637b..8069f42 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -16,10 +16,21 @@ export type NetworkTrend = { values: number[]; // quantités mensuelles, ordre chronologique labels: string[]; // "YYYY-MM" direction: "up" | "down" | "flat"; - pct: number | null; // variation modélisée (régression linéaire) sur 12 mois + /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ + pct: number | null; + /** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ + nouveau: 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; @@ -51,8 +62,8 @@ export const TREND_COLOR: Record = { }; /** - * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les **12 mois - * complets glissants, mois en cours exclu**. + * 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 @@ -70,15 +81,28 @@ export const TREND_COLOR: Record = { * article vendu depuis mai), et des mois non extraits comptés comme des mois * sans vente. * - * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la - * variation modélisée sur la période (pente × durée) rapportée à la moyenne. + * ⚠️ 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 ». + * * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. */ export function computeNetworkTrend( qteByMonth?: Record | null, now: Date = new Date(), ): NetworkTrend { - const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; + const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, nouveau: false, hasData: false }; if (!qteByMonth) return empty; const labels = buildRolling12QlikMonths(now); const complet = labels.every((label) => @@ -89,25 +113,28 @@ export function computeNetworkTrend( const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; + 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(values.slice(0, taille)); + const fin = moyenne(values.slice(n - taille)); + let pct: number | null = null; + let nouveau = false; let direction: NetworkTrend["direction"] = "flat"; - if (n >= 3) { - const mx = (n - 1) / 2; - const my = values.reduce((s, v) => s + v, 0) / n; - let num = 0, den = 0; - for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); } - const slope = den ? num / den : 0; - if (my > 0) { - pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne - direction = pct > TREND_FLAT ? "up" : pct < -TREND_FLAT ? "down" : "flat"; - } else if (slope > 0) { - direction = "up"; - } - } else if (n === 2 && values[0] > 0) { - pct = (values[1] - values[0]) / values[0]; + + 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, direction, pct, hasData: true }; + + return { values, labels, direction, pct, nouveau, hasData: true }; } /** @@ -138,7 +165,9 @@ export function computeStoresSeries( } /** Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »… */ -export function trendLabel(pct: number | null): string { +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"; diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index eb20117..75848d5 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -2412,7 +2412,11 @@ export async function fetchNetworkMetricsPlaywright( // information, au même titre qu'une vente. Un mois non couvert // reste absent et sera ignoré par la tendance. qteByMonth[mois] = Number(v?.qte) || 0; - metricsByMonth[mois] = v ?? { qte: 0 }; + // ⚠️ Toutes les mesures à zéro, pas seulement la quantité. Écrire + // `{ qte: 0 }` seul laissait `nbMag` absent sur les mois sans + // vente : la courbe « magasins vendeurs » exigeant les 12 mois, + // elle disparaissait dès qu'un produit avait un mois creux. + metricsByMonth[mois] = v ?? { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; } metric.qteByMonth = qteByMonth; metric.metricsByMonth = metricsByMonth;