mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
7 files changed
+234
-163
No files matched your search
@@ -148,9 +148,8 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
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 trend = useMemo(() => computeNetworkTrend(reseau?.qteByMonth ?? null), [reseau]);
|
||||
/** Nombre de magasins vendeurs par mois — deuxième courbe de la carte tendance. */
|
||||
const magasinsParMois = useMemo(() => {
|
||||
/** Magasins vendeurs par mois — dénominateur de la tendance ET 3e bande du graphique. */
|
||||
const nbMagByMonth = useMemo(() => {
|
||||
const detailMensuel = reseau?.metricsByMonth;
|
||||
if (!detailMensuel) return null;
|
||||
const parMois: Record<string, number> = {};
|
||||
@@ -161,8 +160,13 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
const nb = Number(mesures?.nbMag);
|
||||
parMois[mois] = Number.isFinite(nb) ? nb : 0;
|
||||
}
|
||||
return computeStoresSeries(parMois);
|
||||
return parMois;
|
||||
}, [reseau]);
|
||||
const trend = useMemo(
|
||||
() => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth),
|
||||
[reseau, nbMagByMonth],
|
||||
);
|
||||
const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]);
|
||||
const comparatif = useMemo(() => computeComparatif(fiche), [fiche]);
|
||||
const verdict = indiceVerdict(comparatif.indiceQte);
|
||||
|
||||
@@ -292,13 +296,14 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba
|
||||
{trend.hasData && (
|
||||
<div className="mt-3">
|
||||
<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'abord, les volumes en contexte
|
||||
</div>
|
||||
<NetworkLineChart
|
||||
labels={trend.labels}
|
||||
values={trend.values}
|
||||
color={TREND_COLOR[trend.direction]}
|
||||
stores={magasinsParMois?.values}
|
||||
perStore={trend.perStore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -313,7 +313,7 @@ function ResultTable({ rows, query }: { rows: ProduitRechercheRow[]; query: stri
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const trend = computeNetworkTrend(r.qteByMonth);
|
||||
const trend = computeNetworkTrend(r.qteByMonth, r.nbMagByMonth);
|
||||
const href = r.codein
|
||||
? `/produits?codein=${encodeURIComponent(r.codein)}&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. */
|
||||
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 color = TREND_COLOR[direction];
|
||||
// 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 }}>
|
||||
{pct != null && <>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% · </>}
|
||||
{trendLabel(pct, trend.nouveau)}
|
||||
{trend.surQteParMagasin && (
|
||||
<span className="font-normal" style={{ color: "var(--text-muted)" }}> (qté/magasin)</span>
|
||||
)}
|
||||
</span></>
|
||||
)}
|
||||
</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.
|
||||
</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>
|
||||
);
|
||||
@@ -564,7 +567,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
{
|
||||
id: "tendanceReseau",
|
||||
accessorFn: (row) => {
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth);
|
||||
const t = computeNetworkTrend(row.qteReseauByMonth, row.nbMagReseauByMonth);
|
||||
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
|
||||
@@ -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>,
|
||||
size: 78,
|
||||
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>;
|
||||
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
|
||||
return (
|
||||
|
||||
@@ -50,197 +50,195 @@ export function TrendSparkline({ trend }: { trend: NetworkTrend }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Couleur par défaut de la courbe « magasins vendeurs » : indigo franc. */
|
||||
export const STORES_COLOR = "#6366f1";
|
||||
/** Repli chaud, quand la courbe des quantités est elle-même dans les tons froids. */
|
||||
export const STORES_COLOR_ALT = "#f59e0b";
|
||||
/** Courbe des quantités vendues (contexte) — bleu ciel. */
|
||||
export const QTY_COLOR = "#0ea5e9";
|
||||
/** Courbe des magasins vendeurs (contexte) — violet. */
|
||||
export const STORES_COLOR = "#8b5cf6";
|
||||
|
||||
/**
|
||||
* Couleur de la courbe « magasins » garantie DISTINCTE de celle des quantités.
|
||||
*
|
||||
* La courbe des quantités prend la couleur de la tendance : vert (#22c55e),
|
||||
* rouge (#ef4444) ou gris-bleu (#94a3b8) quand elle est stable. L'indigo se
|
||||
* détache nettement du vert et du rouge, mais il est trop proche du gris-bleu —
|
||||
* or « stable » est le cas le plus fréquent. Dans ce cas précis on bascule sur
|
||||
* l'ambre : gris froid contre orange chaud, impossible à confondre.
|
||||
*/
|
||||
export function storesColorFor(couleurQuantites: string): string {
|
||||
return couleurQuantites.toLowerCase() === TREND_COLOR.flat.toLowerCase()
|
||||
? STORES_COLOR_ALT
|
||||
: STORES_COLOR;
|
||||
/** Une bande du graphique : une série, son échelle, son style. */
|
||||
interface Bande {
|
||||
titre: string;
|
||||
valeurs: number[];
|
||||
couleur: string;
|
||||
hauteur: number;
|
||||
/** Aire douce sous la courbe (réservée à la bande principale). */
|
||||
aire?: boolean;
|
||||
/** Trait pointillé + points évidés (séries de contexte). */
|
||||
pointille?: boolean;
|
||||
/** Toutes les valeurs écrites, ou seulement la dernière. */
|
||||
etiquettes: "toutes" | "derniere";
|
||||
/** 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
|
||||
* 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.
|
||||
* ⚠️ DES BANDES, PAS DES COURBES SUPERPOSÉES. Deux échelles superposées
|
||||
* plaçaient la courbe des magasins au-dessus de celle des quantités, ce qui se
|
||||
* lit comme « il y a plus de magasins que de ventes » — un contresens. Chaque
|
||||
* série a sa bande, alignée sur le même axe des mois : on compare des formes,
|
||||
* jamais des hauteurs.
|
||||
*
|
||||
* ⚠️ LA DENSITÉ EST LE VRAI ENNEMI. Une première version imprimait les 24
|
||||
* valeurs (12 quantités + 12 magasins) et titrait chaque bande dans le SVG :
|
||||
* titres par-dessus les courbes, nombres des extrémités coupés par le bord,
|
||||
* étiquettes des magasins collées au trait. Illisible. D'où :
|
||||
* - les titres sortent du dessin (HTML au-dessus, plus aucun recouvrement) ;
|
||||
* - la bande des magasins ne porte plus que sa dernière valeur, son niveau se
|
||||
* 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.
|
||||
* ⚠️ LA DENSITÉ EST L'ENNEMI. Une version imprimait les 24 valeurs et titrait
|
||||
* chaque bande dans le SVG : titres par-dessus les courbes, nombres des bords
|
||||
* tronqués, étiquettes collées au trait. D'où les titres en HTML au-dessus du
|
||||
* dessin, les étiquettes de bord ancrées vers l'intérieur, les bandes de
|
||||
* contexte réduites à leur dernière valeur, et une infobulle par mois qui donne
|
||||
* les chiffres exacts sans rien encombrer.
|
||||
*/
|
||||
export function NetworkLineChart({
|
||||
labels,
|
||||
values,
|
||||
color,
|
||||
stores,
|
||||
perStore,
|
||||
}: {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
color: string;
|
||||
/** Nombre de magasins vendeurs, même longueur et même ordre que `values`. */
|
||||
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 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 padL = 14, padR = 14;
|
||||
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 padL = 14, padR = 14, moisH = 18, hautPremiere = 16, ecart = 15;
|
||||
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 H = hautPremiere + bandes.reduce((t, b) => t + b.hauteur, 0) + ecart * (bandes.length - 1) + moisH;
|
||||
|
||||
const x = (i: number) => (n > 1 ? padL + (i / (n - 1)) * plotW : padL + plotW / 2);
|
||||
const y = (v: number) => hautQ + bandeQ - ((v - minQ) / etendueQ) * bandeQ;
|
||||
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. */
|
||||
/** Aux extrémités on ancre vers l'intérieur : sinon l'étiquette sort du cadre. */
|
||||
const ancrage = (i: number): "start" | "middle" | "end" =>
|
||||
i === 0 ? "start" : i === n - 1 ? "end" : "middle";
|
||||
|
||||
const linePts = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
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)},${yM(v).toFixed(1)}`).join(" ")
|
||||
: "";
|
||||
const fmt = (v: number) => Math.round(v).toLocaleString("fr-FR");
|
||||
// Position verticale de chaque bande, empilée de haut en bas.
|
||||
let curseur = hautPremiere;
|
||||
const disposees = bandes.map((b) => {
|
||||
const haut = curseur;
|
||||
curseur += b.hauteur + ecart;
|
||||
// Les quantités Qlik peuvent être NÉGATIVES (retours > ventes sur un
|
||||
// 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 (
|
||||
<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">
|
||||
<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 {fmt(maxQ)}</span>
|
||||
</span>
|
||||
{avecMagasins && (
|
||||
<span className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
{disposees.map((b) => (
|
||||
<span key={b.titre} className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<span
|
||||
className="inline-block w-3 h-0 rounded-full"
|
||||
style={{ borderTop: `3px dashed ${storesColor}` }}
|
||||
className="inline-block w-3"
|
||||
style={b.pointille
|
||||
? { borderTop: `3px dashed ${b.couleur}` }
|
||||
: { height: 3, borderRadius: 9999, background: b.couleur }}
|
||||
/>
|
||||
Magasins vendeurs
|
||||
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(maxM)}</span>
|
||||
{b.titre}
|
||||
<span className="tabular-nums" style={{ color: "var(--text-muted)" }}>· max {fmt(b.max, b.decimales ?? 0)}</span>
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="w-full"
|
||||
style={{ minWidth: 420 }}
|
||||
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 ─────────────────────────────── */}
|
||||
<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} />}
|
||||
{n > 1 && <polyline points={linePts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />}
|
||||
{values.map((v, i) => (
|
||||
<g key={`qte-${labels[i]}`}>
|
||||
<circle cx={x(i)} cy={y(v)} r={2.6} fill={color} />
|
||||
<text
|
||||
x={x(i)}
|
||||
y={v >= 0 ? y(v) - 6 : y(v) + 12}
|
||||
textAnchor={ancrage(i)}
|
||||
fontSize={8.5}
|
||||
fontWeight={700}
|
||||
fill="var(--text-primary)"
|
||||
>
|
||||
{fmt(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{disposees.map((b) => {
|
||||
const pts = b.valeurs.map((v, i) => `${x(i).toFixed(1)},${b.y(v).toFixed(1)}`).join(" ");
|
||||
const base = b.y(Math.min(0, ...b.valeurs));
|
||||
return (
|
||||
<g key={b.titre}>
|
||||
<line x1={padL} y1={base} x2={padL + plotW} y2={base} stroke="var(--border)" strokeWidth={1} />
|
||||
{b.aire && n > 1 && (
|
||||
<polygon
|
||||
points={`${padL},${base.toFixed(1)} ${pts} ${(padL + plotW).toFixed(1)},${base.toFixed(1)}`}
|
||||
fill={b.couleur}
|
||||
opacity={0.08}
|
||||
/>
|
||||
)}
|
||||
{n > 1 && (
|
||||
<polyline
|
||||
points={pts}
|
||||
fill="none"
|
||||
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 ─────────────────────── */}
|
||||
{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 ────────────── */}
|
||||
{/* Axe des mois, commun à toutes les bandes */}
|
||||
{labels.map((lab, i) => (
|
||||
<text key={`mois-${lab}`} x={x(i)} y={H - 5} textAnchor={ancrage(i)} fontSize={8} fill="var(--text-muted)">
|
||||
{fmtMonthShort(lab)}
|
||||
@@ -258,7 +256,8 @@ export function NetworkLineChart({
|
||||
fill="transparent"
|
||||
>
|
||||
<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>
|
||||
</rect>
|
||||
))}
|
||||
|
||||
@@ -15,10 +15,20 @@ 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;
|
||||
/** 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;
|
||||
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),
|
||||
* 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: [], 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;
|
||||
const labels = buildRolling12QlikMonths(now);
|
||||
const complet = labels.every((label) =>
|
||||
@@ -113,10 +141,20 @@ export function computeNetworkTrend(
|
||||
|
||||
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, 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 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));
|
||||
const debut = moyenne(serie.slice(0, taille));
|
||||
const fin = moyenne(serie.slice(n - taille));
|
||||
|
||||
let pct: number | null = null;
|
||||
let nouveau = false;
|
||||
@@ -134,7 +172,7 @@ export function computeNetworkTrend(
|
||||
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). */
|
||||
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. */
|
||||
function cacheReseauComplet(
|
||||
cache: NetworkMetricCached | undefined,
|
||||
@@ -341,6 +360,7 @@ async function executerRecherche(
|
||||
margePctReseau: normalizeMargePct(margePct),
|
||||
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
|
||||
qteByMonth: fiable ? cache.qteByMonth : null,
|
||||
nbMagByMonth: fiable ? nbMagParMois(cache.metricsByMonth) : null,
|
||||
periode: qlik.periode,
|
||||
};
|
||||
});
|
||||
@@ -398,6 +418,7 @@ function rowDepuisCatalogueSeul(r: PgProduitSearchRow): ProduitRechercheRow {
|
||||
prixMoyenReseau: null,
|
||||
margePctReseau: null,
|
||||
qteByMonth: null,
|
||||
nbMagByMonth: null,
|
||||
periode: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface ProduitRechercheRow {
|
||||
margePctReseau: number | null;
|
||||
/** Quantité réseau par mois `"YYYY-MM"` — alimente la sparkline de tendance. */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user