Carte tendance : deux bandes separees, indicateur lisible, courbe magasins reparee

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-08-01 09:41:25 +00:00
1 parent 28fe78a646
commit bf6fef6a14
6 files changed
+191 -105

No files matched your search

+5 -2
View File
@@ -155,8 +155,11 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
if (!detailMensuel) return null;
const parMois: Record<string, number> = {};
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
<Stat
label="Tendance 12 m"
value={trend.pct != null ? `${trend.pct >= 0 ? "+" : ""}${Math.round(trend.pct * 100)} %` : "—"}
hint={trendLabel(trend.pct)}
hint={trendLabel(trend.pct, trend.nouveau)}
color={TREND_COLOR[trend.direction]}
/>
</div>
+7 -3
View File
@@ -496,9 +496,13 @@ function nbMagParMois(
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
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;
}
@@ -211,9 +211,10 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Ventes réseau · {labels.length > 0 ? `${labels.length} derniers mois` : "12 derniers mois"}
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
{pct != null && (
{(pct != null || trend.nouveau) && (
<> · <span className="font-bold" style={{ color }}>
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · {trendLabel(pct)}
{pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · </>}
{trendLabel(pct, trend.nouveau)}
</span></>
)}
</p>
@@ -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: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
size: 78,
+114 -74
View File
@@ -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 <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
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 (
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null ? `Tendance ${pct >= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% sur 12 mois (régression) — cliquer pour le détail` : "Tendance réseau — cliquer pour le détail"}>
<div className="flex flex-col items-center justify-center gap-0.5" title={pct != null
? `Tendance ${pct >= 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"}>
<div className="flex items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="shrink-0" aria-hidden>
{n > 1 && <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />}
@@ -39,6 +43,9 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%
</span>
)}
{pct == null && nouveau && (
<span className="text-[9px] font-bold leading-none" style={{ color }}>Nouveau</span>
)}
</div>
);
}
@@ -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 (
<div className="mt-3 w-full overflow-x-auto">
{avecMagasins && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1">
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: color }} />
Quantités vendues <span className="tabular-nums" style={{ color: "var(--text-muted)" }}>(max {maxV.toLocaleString("fr-FR")})</span>
</span>
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: storesColor }} />
Magasins vendeurs <span className="tabular-nums" style={{ color: "var(--text-muted)" }}>(max {maxS.toLocaleString("fr-FR")})</span>
</span>
</div>
)}
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
@@ -130,55 +145,80 @@ export function NetworkLineChart({
role="img"
aria-label={avecMagasins ? "Ventes réseau mensuelles et nombre de magasins vendeurs" : "Ventes réseau mensuelles"}
>
{/* ligne de base */}
<line x1={padL} y1={padT + plotH} x2={padL + plotW} y2={padT + plotH} stroke="var(--border)" strokeWidth={1} />
{/* aire douce sous la courbe */}
{/* ─── Bande 1 : quantités ─────────────────────────────── */}
<text x={padL} y={titreH} fontSize={9} fontWeight={700} fill={color}>
QUANTITÉS VENDUES
</text>
<line x1={padL} y1={zero} x2={padL + plotW} y2={zero} stroke="var(--border)" strokeWidth={1} />
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />}
{/* courbe des magasins, dessinée SOUS celle des quantités (série de contexte) */}
{avecMagasins && n > 1 && (
<polyline
points={storePts}
fill="none"
stroke={storesColor}
strokeWidth={1.75}
strokeDasharray="4 3"
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{/* points évidés : distincts des points pleins des quantités, même sans couleur */}
{avecMagasins && stores!.map((v, i) => (
<circle
key={`mag-${labels[i]}`}
cx={x(i)}
cy={yS(v)}
r={2.4}
fill="var(--bg-surface)"
stroke={storesColor}
strokeWidth={1.4}
/>
))}
{/* courbe */}
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
{values.map((v, i) => (
<g key={labels[i]}>
<g key={`qte-${labels[i]}`}>
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
{/* quantité au-dessus du point */}
<text x={x(i)} y={y(v) - 6} textAnchor="middle" fontSize={9} fontWeight={700} fill="var(--text-primary)">
<text
x={x(i)}
y={v >= 0 ? y(v) - 6 : y(v) + 12}
textAnchor="middle"
fontSize={9}
fontWeight={700}
fill="var(--text-primary)"
>
{Math.round(v).toLocaleString("fr-FR")}
</text>
{/* mois sous l'axe */}
<text x={x(i)} y={H - 10} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(labels[i])}
</text>
{/* nb de magasins sous l'axe, juste au-dessus du mois */}
{avecMagasins && (
<text x={x(i)} y={H - 20} textAnchor="middle" fontSize={8} fontWeight={600} fill={storesColor}>
{Math.round(stores![i]).toLocaleString("fr-FR")}
</text>
)}
</g>
))}
{/* ─── Bande 2 : magasins vendeurs ─────────────────────── */}
{avecMagasins && (
<>
<text x={padL} y={hautM - 3} fontSize={9} fontWeight={700} fill={storesColor}>
MAGASINS VENDEURS
<tspan fontWeight={400} fill="var(--text-muted)"> · {maxM.toLocaleString("fr-FR")} au maximum</tspan>
</text>
<line
x1={padL}
y1={hautM + bandeM}
x2={padL + plotW}
y2={hautM + bandeM}
stroke="var(--border)"
strokeWidth={1}
/>
{n > 1 && (
<polyline
points={storePts}
fill="none"
stroke={storesColor}
strokeWidth={1.75}
strokeDasharray="4 3"
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{stores!.map((v, i) => (
<g key={`mag-${labels[i]}`}>
{/* point évidé : lisible même sans couleur */}
<circle
cx={x(i)}
cy={yM(v)}
r={2.4}
fill="var(--bg-surface)"
stroke={storesColor}
strokeWidth={1.4}
/>
<text x={x(i)} y={yM(v) - 5} textAnchor="middle" fontSize={8} fontWeight={600} fill={storesColor}>
{Math.round(v).toLocaleString("fr-FR")}
</text>
</g>
))}
</>
)}
{/* ─── Axe des mois, commun aux deux bandes ────────────── */}
{labels.map((lab, i) => (
<text key={`mois-${lab}`} x={x(i)} y={H - 7} textAnchor="middle" fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(lab)}
</text>
))}
</svg>
</div>
);
+51 -22
View File
@@ -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<NetworkTrend["direction"], string> = {
};
/**
* 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<NetworkTrend["direction"], string> = {
* 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<string, number> | 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";
+5 -1
View File
@@ -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;