Tendance : la calculer sur la QTE PAR MAGASIN, pas sur le volume brut

C'est le bon indicateur, et le volume brut ne pouvait pas le remplacer :
il confond la performance du produit et sa diffusion. Un produit monte en
volume des qu'on le reference ailleurs, sans mieux se vendre nulle part ;
un produit retire de 60 magasins s'effondre en volume tout en performant
mieux la ou il reste. Seule la qte/magasin repond a « ce produit est-il
tendance ».

- computeNetworkTrend prend le nombre de magasins par mois et calcule
  l'evolution sur qte/magasins vendeurs. Sans cette serie, repli sur le
  volume brut et `surQteParMagasin` a false, pour que l'UI le dise.
- La serie remonte jusqu'a tous les appelants : grille, fiche produit et
  recherche produits (nbMagByMonth ajoute au resultat de recherche).
- La carte affiche desormais trois bandes empilees sur le meme axe :
  qte/magasin en PRINCIPALE (couleur de la tendance, aire pleine),
  quantites vendues et magasins vendeurs en contexte. La teinte
  « tendance » ne designe plus qu'une seule chose dans le graphique ; les
  bandes de contexte ont des couleurs fixes.
- Le pourcentage de la carte precise « (qte/magasin) » : un chiffre de
  tendance sans son assiette se prete a tous les malentendus.

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:50:41 +00:00
1 parent e6e79dfe06
commit eaac945345
7 files changed
+234 -163

No files matched your search

+10 -5
View File
@@ -148,9 +148,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {}); const serie = magasin === "TOTAL" ? fiche.mensuelTotal : (fiche.mensuelParSite[magasin] ?? {});
const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 }); const totauxAffiches = magasin === "TOTAL" ? fiche.totaux : (fiche.totauxParSite[magasin] ?? { qte: 0, ca: 0, marge: 0, tauxMarge: 0 });
const trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]); /** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */
/** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */ const nbMagByMonth = useMemo(() => {
const magasinsParMois = useMemo(() => {
const detailMensuel = reseau?.metricsByMonth; const detailMensuel = reseau?.metricsByMonth;
if (!detailMensuel) return null; if (!detailMensuel) return null;
const parMois: Record<string, number> = {}; const parMois: Record<string, number> = {};
@@ -161,8 +160,13 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
const nb = Number(mesures?.nbMag); const nb = Number(mesures?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0; parMois[mois] = Number.isFinite(nb) ? nb : 0;
} }
return computeStoresSeries(parMois); return parMois;
}, [reseau]); }, [reseau]);
const trend = useMemo(
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
[reseau, nbMagByMonth],
);
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
const verdict = indiceVerdict(comparatif.indiceQte); const verdict = indiceVerdict(comparatif.indiceQte);
@@ -292,13 +296,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
{trend.hasData && ( {trend.hasData && (
<div className="mt-3"> <div className="mt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}> <div className="text-[10px] font-semibold uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
Quantités vendues réseau, mois par mois Tendance réseau — la qté/magasin d&apos;abord, les volumes en contexte
</div> </div>
<NetworkLineChart <NetworkLineChart
labels={trend.labels} labels={trend.labels}
values={trend.values} values={trend.values}
color={TREND_COLOR[trend.direction]} color={TREND_COLOR[trend.direction]}
stores={magasinsParMois?.values} stores={magasinsParMois?.values}
perStore={trend.perStore}
/> />
</div> </div>
)} )}
+1 -1
View File
@@ -313,7 +313,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri
</thead> </thead>
<tbody> <tbody>
{rows.map((r) => { {rows.map((r) => {
const trend = computeNetworkTrend(r.qteByMonth); const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
const href = r.codein const href = r.codein
? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}` ? `/produits?codein=${encodeURIComponent(r.codein)}&q=${encodeURIComponent(query)}`
: `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`; : `/produits?cc=${encodeURIComponent(r.codeCentrale)}&q=${encodeURIComponent(query)}`;
@@ -196,7 +196,7 @@ interface CellDetailData {
/** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */ /** Modal : quantités vendues réseau sur les 12 derniers mois, en une seule courbe. */
function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) {
const trend = computeNetworkTrend(row.qteReseauByMonth); const trend = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
const { values, labels, direction, pct } = trend; const { values, labels, direction, pct } = trend;
const color = TREND_COLOR[direction]; const color = TREND_COLOR[direction];
// Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une // Deuxième courbe : le nombre de magasins vendeurs, pour distinguer une
@@ -215,6 +215,9 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
<> · <span className="font-bold" style={{ color }}> <> · <span className="font-bold" style={{ color }}>
{pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · </>} {pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · </>}
{trendLabel(pct, trend.nouveau)} {trendLabel(pct, trend.nouveau)}
{trend.surQteParMagasin && (
<span className="font-normal" style={{ color: "var(--text-muted)" }}> (qté/magasin)</span>
)}
</span></> </span></>
)} )}
</p> </p>
@@ -224,7 +227,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir. Pas encore de détail mensuel pour ce produit.<br />Relancez un Sync Qlik pour le remplir.
</div> </div>
) : ( ) : (
<NetworkLineChart labels={labels} values={values} color={color} stores={magasins?.values} /> <NetworkLineChart labels={labels} values={values} color={color} stores={magasins?.values} perStore={trend.perStore} />
)} )}
</DialogContent> </DialogContent>
); );
@@ -564,7 +567,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
{ {
id: "tendanceReseau", id: "tendanceReseau",
accessorFn: (row) => { accessorFn: (row) => {
const t = computeNetworkTrend(row.qteReseauByMonth); const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
if (!t.hasData) return Number.NEGATIVE_INFINITY; if (!t.hasData) return Number.NEGATIVE_INFINITY;
// Un produit « nouveau » n'a pas de pourcentage mais c'est la plus // 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 // forte progression possible : il doit remonter en tête du tri, pas
@@ -575,7 +578,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>, header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
size: 78, size: 78,
cell: ({ row }) => { cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth); const trend = computeNetworkTrend(row.original.qteReseauByMonth, row.original.nbMagReseauByMonth);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>; if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois). // Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return ( return (
+147 -148
View File
@@ -50,197 +50,195 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
); );
} }
/** Couleur par défaut de la courbe « magasins vendeurs » : indigo franc. */ /** Courbe des quantités vendues (contexte) — bleu ciel. */
export const STORES_COLOR = "#6366f1"; export const QTY_COLOR = "#0ea5e9";
/** Repli chaud, quand la courbe des quantités est elle-même dans les tons froids. */ /** Courbe des magasins vendeurs (contexte) — violet. */
export const STORES_COLOR_ALT = "#f59e0b"; export const STORES_COLOR = "#8b5cf6";
/** /** Une bande du graphique : une série, son échelle, son style. */
* Couleur de la courbe « magasins » garantie DISTINCTE de celle des quantités. interface Bande {
* titre: string;
* La courbe des quantités prend la couleur de la tendance : vert (#22c55e), valeurs: number[];
* rouge (#ef4444) ou gris-bleu (#94a3b8) quand elle est stable. L'indigo se couleur: string;
* détache nettement du vert et du rouge, mais il est trop proche du gris-bleu — hauteur: number;
* or « stable » est le cas le plus fréquent. Dans ce cas précis on bascule sur /** Aire douce sous la courbe (réservée à la bande principale). */
* l'ambre : gris froid contre orange chaud, impossible à confondre. aire?: boolean;
*/ /** Trait pointillé + points évidés (séries de contexte). */
export function storesColorFor(couleurQuantites: string): string { pointille?: boolean;
return couleurQuantites.toLowerCase() === TREND_COLOR.flat.toLowerCase() /** Toutes les valeurs écrites, ou seulement la dernière. */
? STORES_COLOR_ALT etiquettes: "toutes" | "derniere";
: STORES_COLOR; /** Décimales des étiquettes (la qté/magasin est souvent < 10). */
decimales?: number;
/** Suffixe de la dernière étiquette (« mag. »). */
suffixe?: string;
} }
/** /**
* Ventes réseau mensuelles, avec en option le nombre de magasins vendeurs. * Ventes réseau mensuelles : jusqu'à trois bandes empilées sur le même axe.
* *
* ⚠️ DEUX BANDES SÉPARÉES, PAS DEUX COURBES SUPERPOSÉES. * ⚠️ LA BANDE PRINCIPALE EST LA QUANTITÉ PAR MAGASIN, pas la quantité brute.
* La quantité brute confond la performance du produit et sa diffusion : elle
* monte dès qu'on référence le produit ailleurs, sans qu'il se vende mieux nulle
* part. C'est `qté / magasins vendeurs` qui dit si un produit marche ; les deux
* autres bandes sont là pour expliquer ses mouvements, pas pour les juger.
* *
* La superposition a été essayée et rejetée à l'usage : les quantités se * ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées
* comptent en milliers, les magasins plafonnent à ~270. Sur une échelle commune * plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se
* la courbe des magasins s'écrase sur l'axe ; sur deux échelles superposées elle * lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque
* passe AU-DESSUS de celle des quantités, ce qui se lit spontanément comme « il * série a sa bande, alignée sur le même axe des mois : on compare des formes,
* y a plus de magasins que de ventes » — un contresens. * jamais des hauteurs.
* *
* ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24 * ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait
* valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG : * chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords
* titres par-dessus les courbes, nombres des extrémités coupés par le bord, * tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du
* étiquettes des magasins collées au trait. Illisible. D'où : * dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de
* - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ; * contexte réduites à leur dernière valeur, et une infobulle par mois qui donne
* - la bande des magasins ne porte plus que sa dernière valeur, son niveau se * les chiffres exacts sans rien encombrer.
* lisant à la forme et au maximum rappelé dans la légende ;
* - les étiquettes des bords sont ancrées vers l'intérieur, jamais tronquées ;
* - chaque mois expose une infobulle native avec les valeurs exactes.
*
* Lecture : quantités qui montent avec les magasins = élargissement de la
* diffusion ; quantités qui montent à magasins constants = vraie accélération
* des ventes ; quantités qui baissent alors que les magasins tiennent = essoufflement.
*/ */
export function NetworkLineChart({ export function NetworkLineChart({
labels, labels,
values, values,
color, color,
stores, stores,
perStore,
}: { }: {
labels: string[]; labels: string[];
values: number[]; values: number[];
color: string; color: string;
/** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */ /** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */
stores?: number[] | null; stores?: number[] | null;
/** Quantité par magasin vendeur — la vraie tendance, en bande principale. */
perStore?: number[] | null;
}) { }) {
const avecMagasins = Array.isArray(stores) && stores.length === values.length && stores.length > 0;
const n = values.length; const n = values.length;
const memeTaille = (s?: number[] | null) => Array.isArray(s) && s.length === n && n > 0;
const avecParMag = memeTaille(perStore);
const avecMagasins = memeTaille(stores);
const fmt = (v: number, d = 0) =>
v.toLocaleString("fr-FR", { minimumFractionDigits: d, maximumFractionDigits: d });
// La bande principale porte la couleur de la tendance ; les bandes de
// contexte ont des couleurs fixes, pour que la teinte « tendance » ne
// désigne jamais qu'une seule chose dans le graphique.
const bandes: Bande[] = avecParMag
? [
{ titre: "Qté / magasin", valeurs: perStore!, couleur: color, hauteur: 96, aire: true, etiquettes: "toutes", decimales: perStore!.some((v) => v > 0 && v < 10) ? 1 : 0 },
{ titre: "Quantités vendues", valeurs: values, couleur: QTY_COLOR, hauteur: 74, etiquettes: "toutes" },
]
: [
{ titre: "Quantités vendues", valeurs: values, couleur: color, hauteur: 116, aire: true, etiquettes: "toutes" },
];
if (avecMagasins) {
bandes.push({ titre: "Magasins vendeurs", valeurs: stores!, couleur: STORES_COLOR, hauteur: 42, pointille: true, etiquettes: "derniere", suffixe: " mag." });
}
const W = 520; const W = 520;
const padL = 14, padR = 14; const padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15;
const hautQ = 16; // marge haute : place aux étiquettes du sommet
const bandeQ = 116;
const ecart = avecMagasins ? 12 : 0;
const bandeM = avecMagasins ? 44 : 0;
const moisH = 18;
const H = hautQ + bandeQ + ecart + bandeM + moisH;
const plotW = W - padL - padR; const plotW = W - padL - padR;
const H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH;
// 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 x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ; /** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */
const yM = (v: number) => hautQ + bandeQ + ecart + bandeM - (v / maxM) * bandeM;
const zero = y(0);
/** Aux extrémités, on ancre l'étiquette vers l'intérieur : sinon elle sort du cadre. */
const ancrage = (i: number): "start" | "middle" | "end" => const ancrage = (i: number): "start" | "middle" | "end" =>
i === 0 ? "start" : i === n - 1 ? "end" : "middle"; i === 0 ? "start" : i === n - 1 ? "end" : "middle";
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" "); // Position verticale de chaque bande, empilée de haut en bas.
const areaPts = `${padL},${zero.toFixed(1)} ${linePts} ${(padL + plotW).toFixed(1)},${zero.toFixed(1)}`; let curseur = hautPremiere;
const storesColor = storesColorFor(color); const disposees = bandes.map((b) => {
const storePts = avecMagasins const haut = curseur;
? stores!.map((v, i) => `${x(i).toFixed(1)},${yM(v).toFixed(1)}`).join(" ") curseur += b.hauteur + ecart;
: ""; // Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR"); // mois) : l'échelle part du minimum réel, sinon le point sort du cadre.
const min = Math.min(0, ...b.valeurs);
const max = Math.max(...b.valeurs, min + 1);
const etendue = max - min || 1;
const y = (v: number) => haut + b.hauteur - ((v - min) / etendue) * b.hauteur;
return { ...b, haut, max, y };
});
return ( return (
<div className="mt-3 w-full overflow-x-auto"> <div className="mt-3 w-full overflow-x-auto">
{/* Titres HORS du dessin : plus rien ne recouvre les courbes. */} {/* Titres HORS du dessin : plus aucun recouvrement possible. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1.5"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 mb-1.5">
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}> {disposees.map((b) => (
<span className="inline-block w-3 h-[3px] rounded-full" style={{ background: color }} /> <span key={b.titre} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
Quantités vendues
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(maxQ)}</span>
</span>
{avecMagasins && (
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
<span <span
className="inline-block w-3 h-0 rounded-full" className="inline-block w-3"
style={{ borderTop: `3px dashed ${storesColor}` }} style={b.pointille
? { borderTop: `3px dashed ${b.couleur}` }
: { height: 3, borderRadius: 9999, background: b.couleur }}
/> />
Magasins vendeurs {b.titre}
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(maxM)}</span> <span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(b.max, b.decimales ?? 0)}</span>
</span> </span>
)} ))}
</div> </div>
<svg <svg
viewBox={`0 0 ${W} ${H}`} viewBox={`0 0 ${W} ${H}`}
className="w-full" className="w-full"
style={{ minWidth: 420 }} style={{ minWidth: 420 }}
role="img" role="img"
aria-label={avecMagasins ? "Ventes réseau mensuelles et nombre de magasins vendeurs" : "Ventes réseau mensuelles"} aria-label={"Ventes réseau mensuelles : " + disposees.map((b) => b.titre).join(", ")}
> >
{/* ─── Bande 1 : quantités ─────────────────────────────── */} {disposees.map((b) => {
<line x1={padL} y1={zero} x2={padL + plotW} y2={zero} stroke="var(--border)" strokeWidth={1} /> const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" ");
{n > 1 && <polygon points={areaPts} fill={color} opacity={0.08} />} const base = b.y(Math.min(0, ...b.valeurs));
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />} return (
{values.map((v, i) => ( <g key={b.titre}>
<g key={`qte-${labels[i]}`}> <line x1={padL} y1={base} x2={padL + plotW} y2={base} stroke="var(--border)" strokeWidth={1} />
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} /> {b.aire && n > 1 && (
<text <polygon
x={x(i)} points={`${padL},${base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${base.toFixed(1)}`}
y={v >= 0 ? y(v) - 6 : y(v) + 12} fill={b.couleur}
textAnchor={ancrage(i)} opacity={0.08}
fontSize={8.5} />
fontWeight={700} )}
fill="var(--text-primary)" {n > 1 && (
> <polyline
{fmt(v)} points={pts}
</text> fill="none"
</g> stroke={b.couleur}
))} strokeWidth={b.pointille ? 1.75 : 2}
strokeDasharray={b.pointille ? "4 3" : undefined}
strokeLinejoin="round"
strokeLinecap="round"
/>
)}
{b.valeurs.map((v, i) => (
<circle
key={`${b.titre}-${labels[i]}`}
cx={x(i)}
cy={b.y(v)}
r={b.pointille ? 2.2 : 2.6}
// Points évidés sur les séries de contexte : elles restent
// distinctes même en noir et blanc.
fill={b.pointille ? "var(--bg-surface)" : b.couleur}
stroke={b.pointille ? b.couleur : undefined}
strokeWidth={b.pointille ? 1.3 : undefined}
/>
))}
{b.valeurs.map((v, i) => {
if (b.etiquettes === "derniere" && i !== n - 1) return null;
return (
<text
key={`lab-${b.titre}-${labels[i]}`}
x={x(i)}
y={v >= 0 ? b.y(v) - 6 : b.y(v) + 12}
textAnchor={ancrage(i)}
fontSize={8.5}
fontWeight={700}
fill={b.etiquettes === "derniere" ? b.couleur : "var(--text-primary)"}
>
{fmt(v, b.decimales ?? 0)}{i === n - 1 ? b.suffixe ?? "" : ""}
</text>
);
})}
</g>
);
})}
{/* ─── Bande 2 : magasins vendeurs ─────────────────────── */} {/* Axe des mois, commun à toutes les bandes */}
{avecMagasins && (
<>
<line
x1={padL}
y1={hautQ + bandeQ + ecart + bandeM}
x2={padL + plotW}
y2={hautQ + bandeQ + ecart + 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) => (
// Point évidé : lisible même sans couleur (impression N&B, daltonisme).
<circle
key={`mag-${labels[i]}`}
cx={x(i)}
cy={yM(v)}
r={2.2}
fill="var(--bg-surface)"
stroke={storesColor}
strokeWidth={1.3}
/>
))}
{/* Seule la dernière valeur est écrite : le reste se lit à la forme. */}
<text
x={x(n - 1)}
y={yM(stores![n - 1]) - 6}
textAnchor="end"
fontSize={8.5}
fontWeight={700}
fill={storesColor}
>
{fmt(stores![n - 1])} mag.
</text>
</>
)}
{/* ─── Axe des mois, commun aux deux bandes ────────────── */}
{labels.map((lab, i) => ( {labels.map((lab, i) => (
<text key={`mois-${lab}`} x={x(i)} y={H - 5} textAnchor={ancrage(i)} fontSize={8} fill="var(--text-muted)"> <text key={`mois-${lab}`} x={x(i)} y={H - 5} textAnchor={ancrage(i)} fontSize={8} fill="var(--text-muted)">
{fmtMonthShort(lab)} {fmtMonthShort(lab)}
@@ -258,7 +256,8 @@ export function NetworkLineChart({
fill="transparent" fill="transparent"
> >
<title> <title>
{`${fmtMonthShort(lab)} · ${fmt(values[i])} vendues${avecMagasins ? ` · ${fmt(stores![i])} magasins` : ""}`} {`${fmtMonthShort(lab)} · ` +
disposees.map((b) => `${b.titre} ${fmt(b.valeurs[i], b.decimales ?? 0)}`).join(" · ")}
</title> </title>
</rect> </rect>
))} ))}
+43 -5
View File
@@ -15,10 +15,20 @@ export const NB_MAGASINS_RESEAU = 270;
export type NetworkTrend = { export type NetworkTrend = {
values: number[]; // quantités mensuelles, ordre chronologique values: number[]; // quantités mensuelles, ordre chronologique
labels: string[]; // "YYYY-MM" 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"; direction: "up" | "down" | "flat";
/** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */ /** Variation des 4 derniers mois vs les 4 premiers. `null` si aucune base. */
pct: number | null; pct: number | null;
/** Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers. */ /** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */
nouveau: boolean; nouveau: boolean;
hasData: boolean; hasData: boolean;
}; };
@@ -96,13 +106,31 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois), * mois isolé. Sans base de comparaison (rien vendu sur les 4 premiers mois),
* aucun pourcentage n'est inventé : le produit est signalé « nouveau ». * 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%. * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
*/ */
export function computeNetworkTrend( export function computeNetworkTrend(
qteByMonth?: Record<string, number> | null, qteByMonth?: Record<string, number> | null,
nbMagByMonth?: Record<string, number> | null,
now: Date = new Date(), now: Date = new Date(),
): NetworkTrend { ): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, nouveau: false, hasData: false }; const empty: NetworkTrend = {
values: [], labels: [], perStore: null, surQteParMagasin: false,
direction: "flat", pct: null, nouveau: false, hasData: false,
};
if (!qteByMonth) return empty; if (!qteByMonth) return empty;
const labels = buildRolling12QlikMonths(now); const labels = buildRolling12QlikMonths(now);
const complet = labels.every((label) => const complet = labels.every((label) =>
@@ -113,10 +141,20 @@ export function computeNetworkTrend(
const values = labels.map((l) => Number(qteByMonth[l])); const values = labels.map((l) => Number(qteByMonth[l]));
const n = values.length; 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, now);
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 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 moyenne = (xs: number[]) => (xs.length ? xs.reduce((s, v) => s + v, 0) / xs.length : 0);
const debut = moyenne(values.slice(0, taille)); const debut = moyenne(serie.slice(0, taille));
const fin = moyenne(values.slice(n - taille)); const fin = moyenne(serie.slice(n - taille));
let pct: number | null = null; let pct: number | null = null;
let nouveau = false; let nouveau = false;
@@ -134,7 +172,7 @@ export function computeNetworkTrend(
direction = "up"; direction = "up";
} }
return { values, labels, direction, pct, nouveau, hasData: true }; return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true };
} }
/** /**
@@ -37,6 +37,25 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ /** Durée de conservation d'un job terminé (le client a le temps de le relire). */
const JOB_TTL_MS = 5 * 60 * 1000; const JOB_TTL_MS = 5 * 60 * 1000;
/**
* Série « magasins vendeurs » extraite du détail mensuel du cache.
*
* Un mois PRÉSENT dans le détail est un mois extrait : sans `nbMag`, c'est
* qu'aucun magasin n'a vendu, donc zéro. Le traiter comme manquant amputerait la
* série et priverait la tendance de son dénominateur.
*/
function nbMagParMois(
metricsByMonth: Record<string, { nbMag?: number }> | null | undefined,
): Record<string, number> | null {
if (!metricsByMonth) return null;
const parMois: Record<string, number> = {};
for (const mois of Object.keys(metricsByMonth)) {
const nb = Number(metricsByMonth[mois]?.nbMag);
parMois[mois] = Number.isFinite(nb) ? nb : 0;
}
return Object.keys(parMois).length > 0 ? parMois : null;
}
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ /** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
function cacheReseauComplet( function cacheReseauComplet(
cache: NetworkMetricCached | undefined, cache: NetworkMetricCached | undefined,
@@ -341,6 +360,7 @@ async function executerRecherche(
margePctReseau: normalizeMargePct(margePct), margePctReseau: normalizeMargePct(margePct),
// Le mensuel ne vient que du cache : le cube de recherche est agrégé. // Le mensuel ne vient que du cache : le cube de recherche est agrégé.
qteByMonth: fiable ? cache.qteByMonth : null, qteByMonth: fiable ? cache.qteByMonth : null,
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
periode: qlik.periode, periode: qlik.periode,
}; };
}); });
@@ -398,6 +418,7 @@ function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
prixMoyenReseau: null, prixMoyenReseau: null,
margePctReseau: null, margePctReseau: null,
qteByMonth: null, qteByMonth: null,
nbMagByMonth: null,
periode: null, periode: null,
}; };
} }
+5
View File
@@ -49,6 +49,11 @@ export interface ProduitRechercheRow {
margePctReseau: number | null; margePctReseau: number | null;
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */ /** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
qteByMonth: Record<string, number> | null; qteByMonth: Record<string, number> | null;
/**
* Magasins vendeurs par mois `"YYYY-MM"`. La tendance porte sur la
* qté/magasin : sans cette série, elle se rabat sur les quantités brutes.
*/
nbMagByMonth: Record<string, number> | null;
periode: string | null; periode: string | null;
} }