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;