Files
CollectFlow/src/features/produits/lib/compare-reseau.ts
T
Claude eeaae09282 feat(produits): page de recherche produit + fiche 360° local/réseau
Nouvelle page /produits : recherche par libellé ou par code centrale, puis
fiche complète d'un produit — identité, prix, stock par magasin, ventes sur
12 mois glissants, et l'intégralité des métriques réseau Qlik.

Jusqu'ici les données produit n'étaient accessibles que par fournisseur
(la Grille) ou par classement (Hit Parade) : aucune route n'acceptait un
codein. Les données réseau Qlik n'étaient visibles que dans six colonnes
de la grille d'arbitrage.

Deux analyses ajoutées :
- comparatif local vs réseau, ramené au magasin moyen (indice de
  performance sur les quantités, seule base commune fiable — le CA local
  est TTC et la base du CA Qlik n'est pas documentée, donc indicatif) ;
- opportunités de la même famille : références que le réseau vend bien et
  que nous vendons peu, écart calculé par magasin.

Détail mensuel réseau enrichi : le cube Qlik renvoyait déjà CA, nb de
magasins, CA/magasin et marge % par (article, mois) mais seule la quantité
était conservée. Ces mesures sont désormais stockées dans une nouvelle
colonne metrics_by_month, sans requête Qlik supplémentaire. qte_by_month
reste inchangée — la colonne « Tendance / Réseau » de la Grille en dépend.

L'extraction Qlik peut désormais cibler un seul code centrale
(/api/qlik/sync?codeCentrale=…), ouverte à tout utilisateur connecté et
plafonnée à 3 extractions simultanées ; le mode fournisseur reste
réservé aux admins.

Refactor sans changement de comportement : les helpers de mois, le calcul
de tendance et les graphes SVG quittent heatmap-grid.tsx pour des modules
partagés, et le polling des jobs Qlik devient le hook useQlikSyncJob,
réutilisé par la Grille et par la fiche.

Les calculs de ventes reprennent le SQL canonique de la Grille (genremvt=3
avec quantités niées pour déduire les retours, stock de fin de mois pris
sur le dernier mouvement, report sur les mois sans mouvement), afin que
les chiffres des deux pages soient identiques par construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
2026-07-27 14:26:08 +00:00

148 lines
5.8 KiB
TypeScript

/**
* CollectFlow — Comparatif local vs réseau pour un produit.
*
* Principe : rapporter les deux périmètres à un **magasin moyen**, sinon on
* compare 2 magasins Nancy à ~270 magasins réseau et le chiffre ne veut rien dire.
*
* ⚠️ La comparaison de référence porte sur les **quantités**. Le CA local vient
* de `mvtart.mntmvtttc` (TTC) alors que la base de la mesure Qlik « CA N » n'est
* pas documentée : les montants sont donc affichés côte à côte à titre indicatif,
* sans indice calculé.
*/
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { ffMonthToQlik } from "@/features/grid/lib/months";
import type { ProduitFiche } from "@/features/produits/types";
export interface ComparatifMois {
/** Clé de mois FF "YYYYMM". */
mois: string;
/** Quantité vendue par magasin, chez nous. */
local: number;
/** Quantité vendue par magasin, au réseau. */
reseau: number;
}
export interface ComparatifReseau {
/** false si le produit n'a pas encore de données réseau en cache. */
hasReseau: boolean;
/** Nombre de nos magasins ayant vendu le produit (min. 1 pour éviter /0). */
nbMagasinsLocaux: number;
nbMagasinsReseau: number;
/** Taux de présence réseau (0..1), ou null sans données. */
tauxPresence: number | null;
/** Quantité 12 mois, par magasin. */
qteLocaleParMagasin: number;
qteReseauParMagasin: number;
/**
* Indice de performance en % : 100 = on vend autant qu'un magasin moyen du
* réseau, 150 = 50 % de mieux. `null` si le réseau ne vend rien.
*/
indiceQte: number | null;
/** CA 12 mois par magasin — indicatif (bases de calcul non alignées). */
caLocalParMagasin: number;
caReseauParMagasin: number | null;
/** Taux de marge en %, local et réseau (indicatif). */
tauxMargeLocal: number;
tauxMargeReseau: number | null;
/** Série mensuelle par magasin, alignée sur les 12 mois de la fiche. */
monthly: ComparatifMois[];
/** true si au moins un mois réseau est disponible. */
hasMonthly: boolean;
}
/**
* Le taux de marge Qlik est un ratio brut (0.32 = 32 %) mais certaines
* configurations renvoient déjà des points de pourcentage. Même normalisation
* que la colonne « Marge % / Réseau » de la Grille.
*/
export function normalizeMargePct(raw: number | null | undefined): number | null {
if (raw == null) return null;
const v = Number(raw);
if (!Number.isFinite(v) || v === 0) return null;
return Math.abs(v) <= 1 ? v * 100 : v;
}
/** Calcule le comparatif local vs réseau à partir d'une fiche produit. */
export function computeComparatif(fiche: ProduitFiche): ComparatifReseau {
const { reseau, totaux, sitesActifs, months, mensuelTotal } = fiche;
// Nos magasins actifs sur la période. Si aucun n'a vendu, on garde 1 pour
// que la division reste définie — l'indice vaudra alors 0, ce qui est le
// message voulu : « le réseau vend, nous non ».
const nbMagasinsLocaux = Math.max(sitesActifs.length, 1);
const qteLocaleParMagasin = totaux.qte / nbMagasinsLocaux;
const caLocalParMagasin = totaux.ca / nbMagasinsLocaux;
if (!reseau || !reseau.nbMagasinsReseau) {
return {
hasReseau: false,
nbMagasinsLocaux,
nbMagasinsReseau: 0,
tauxPresence: null,
qteLocaleParMagasin,
qteReseauParMagasin: 0,
indiceQte: null,
caLocalParMagasin,
caReseauParMagasin: null,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: null,
monthly: [],
hasMonthly: false,
};
}
const nbMagasinsReseau = reseau.nbMagasinsReseau;
const qteReseauParMagasin = reseau.qteReseau / nbMagasinsReseau;
const caReseauParMagasin = reseau.caReseau / nbMagasinsReseau;
// Série mensuelle : quantité par magasin de part et d'autre.
// Le nb de magasins réseau du mois est utilisé quand il est disponible
// (`metricsByMonth`), sinon on retombe sur le total de la période.
const monthly: ComparatifMois[] = [];
let hasMonthly = false;
for (const mois of months) {
const qlikKey = ffMonthToQlik(mois);
const mensuel = reseau.metricsByMonth?.[qlikKey];
const qteReseauMois = mensuel?.qte ?? reseau.qteByMonth?.[qlikKey];
const nbMagMois = mensuel?.nbMag && mensuel.nbMag > 0 ? mensuel.nbMag : nbMagasinsReseau;
if (qteReseauMois != null) hasMonthly = true;
monthly.push({
mois,
local: (mensuelTotal[mois]?.qte ?? 0) / nbMagasinsLocaux,
reseau: qteReseauMois != null ? qteReseauMois / nbMagMois : 0,
});
}
return {
hasReseau: true,
nbMagasinsLocaux,
nbMagasinsReseau,
tauxPresence: nbMagasinsReseau / NB_MAGASINS_RESEAU,
qteLocaleParMagasin,
qteReseauParMagasin,
indiceQte: qteReseauParMagasin > 0 ? (qteLocaleParMagasin / qteReseauParMagasin) * 100 : null,
caLocalParMagasin,
caReseauParMagasin,
tauxMargeLocal: totaux.tauxMarge,
tauxMargeReseau: normalizeMargePct(reseau.margePctReseau),
monthly,
hasMonthly,
};
}
/** Lecture métier de l'indice de performance. */
export function indiceVerdict(indice: number | null): { label: string; color: string } {
if (indice == null) return { label: "Non comparable", color: "var(--text-muted)" };
if (indice >= 150) return { label: "Forte surperformance", color: "#22c55e" };
if (indice >= 110) return { label: "Surperformance", color: "#22c55e" };
if (indice >= 90) return { label: "Dans la moyenne réseau", color: "var(--text-secondary)" };
if (indice >= 50) return { label: "Sous-performance", color: "#f59e0b" };
return { label: "Forte sous-performance", color: "#ef4444" };
}