feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide

This commit is contained in:
Michael committed 2026-02-21 04:00:51 +01:00
1 parent f540e4f2b3
commit 3544c0c898
67 files changed
+19476 -15

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github"; // Example provider
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
});
+89
View File
@@ -0,0 +1,89 @@
import type { ProductRow, GammeCode } from "@/types/grid";
import { computeProductScores } from "@/lib/score-engine";
const FOURNISSEURS = Array.from({ length: 125 }).map((_, i) => ({
code: `F${String(i + 1).padStart(3, "0")}`,
nom: [
"HENKEL DISTRIBUTION", "GLAMA INTERNATIONAL", "FRANCO & FILS", "L'ORÉAL PARIS", "UNILEVER SERVICES",
"PROCTER & GAMBLE", "JOHNSON & JOHNSON", "NESTLÉ RETAIL", "DANONE DISTRIBUTION", "FERRERO FRANCE"
][i % 10] + (i > 9 ? ` - Entrepôt ${Math.floor(i / 10)}` : "")
}));
const GAMMES: GammeCode[] = ["A", "A", "A", "B", "B", "C", "Z"];
const NOMENCLATURES = [
{ code: "320211", libelle: "Entretien Maison" },
{ code: "310105", libelle: "Hygiène Corporelle" },
{ code: "410203", libelle: "Cosmétiques Femme" },
{ code: "220401", libelle: "Alimentation Bio" },
{ code: "510302", libelle: "Bricolage & Outillage" },
];
const PRODUITS = [
"Lessive Liquide 3L", "Gel Douche Vanille 500ml", "Shampoing Éclat 400ml",
"Crème Hydratante SPF50", "Désinfectant Multi-Surface 1L", "Liquide Vaisselle Citron 750ml",
"Déodorant Bille 50ml", "Après-Shampooing 300ml", "Mousse Rasage 200ml",
"Dentifrice Blancheur 100ml", "Savon Solide Surgras 100g", "Crème Mains Nourrissante 75ml",
"Nettoyant WC 500ml", "Assouplissant Fleurs Blanches 2L", "Spray Cuisine Dégraissant",
"Gel Hydroalcoolique 500ml", "Lingettes Bébé x72", "Couches T4 x44",
"Lait Corps Karité 400ml", "Masque Visage Argile 100ml", "Fond de Teint N°03",
"Rouge à Lèvres Corail", "Mascara Allongeant Noir", "Eyeliner Fin Brun",
"Vernis Ongles Rose", "Blush Pêche Doux", "Palette Ombres 12 teintes",
"Huile Corps Éclat 150ml", "Sérum Anti-Âge 30ml", "Contour Yeux 15ml",
];
function rnd(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateSales12m(): Record<string, number> {
const sales: Record<string, number> = {};
const now = new Date(2026, 1, 1); // Feb 2026
for (let i = 11; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}`;
// Simulate seasonality + some zeros
const base = rnd(0, 200);
sales[key] = base < 30 ? 0 : base;
}
return sales;
}
export function generateMockRows(codeFournisseur: string = "F001", count: number = 80): ProductRow[] {
const rows: ProductRow[] = [];
const fournisseur = FOURNISSEURS.find((f) => f.code === codeFournisseur) ?? FOURNISSEURS[0];
for (let i = 0; i < count; i++) {
const sales12m = generateSales12m();
const totalQuantite = Object.values(sales12m).reduce((s, v) => s + v, 0);
const prixUnitaire = rnd(3, 45);
const totalCa = totalQuantite * prixUnitaire * (1 + rnd(0, 30) / 100);
const tauxMarge = rnd(12, 58);
const totalMarge = totalCa * tauxMarge / 100;
const nomenclature = NOMENCLATURES[i % NOMENCLATURES.length];
const gamme = GAMMES[Math.floor(Math.random() * GAMMES.length)];
rows.push({
codein: `${fournisseur.code}-${String(i + 1).padStart(4, "0")}`,
codeFournisseur: fournisseur.code,
nomFournisseur: fournisseur.nom,
libelle1: PRODUITS[i % PRODUITS.length],
gtin: `340${String(rnd(100000000, 999999999))}`,
reference: `REF${String(rnd(10000, 99999))}`,
code3: nomenclature.code,
libelle3: nomenclature.libelle,
codeGamme: gamme,
codeGammeDraft: null,
sales12m,
totalQuantite,
totalCa: Math.round(totalCa * 100) / 100,
totalMarge: Math.round(totalMarge * 100) / 100,
tauxMarge,
score: 0, // Computed below by computeProductScores
aiRecommendation: null,
});
}
return computeProductScores(rows);
}
export { FOURNISSEURS };
+69
View File
@@ -0,0 +1,69 @@
/**
* CollectFlow — Score Engine (Proposition B: MAX + Bonus polyvalence)
*
* Calcule le score de performance d'un produit en fonction de son poids
* relatif chez le fournisseur sur 3 axes : Quantité, CA, Marge.
*
* Formule :
* poids = produit.metric / total_fournisseur × 100
* base = MAX(poids_qty, poids_ca, poids_marge)
* bonus = count(poids > seuil) × bonusParAxe
* score = MIN(base + bonus, 100)
*/
import type { ProductRow } from "@/types/grid";
export interface ScoreSettings {
/** Seuil en % pour considérer un axe comme "fort" (défaut: 2.0) */
seuilAxeFort: number;
/** Points bonus par axe dépassant le seuil (défaut: 5) */
bonusParAxe: number;
}
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
seuilAxeFort: 30.0,
bonusParAxe: 10,
};
/**
* Calcule les scores pour un ensemble de produits d'un même fournisseur.
* Les rows sont modifiées en place (champ `score`) et retournées.
*/
export function computeProductScores(
rows: ProductRow[],
settings: ScoreSettings = DEFAULT_SCORE_SETTINGS
): ProductRow[] {
if (rows.length === 0) return rows;
// 1. Trouver les valeurs maximales (les "Top 1") pour indexer 0-100
const maxQty = Math.max(...rows.map((r) => r.totalQuantite ?? 0));
const maxCa = Math.max(...rows.map((r) => r.totalCa ?? 0));
const maxMarge = Math.max(...rows.map((r) => r.totalMarge ?? 0));
// 2. Pour chaque produit, calculer le score relatif au Top 1
for (const row of rows) {
// Score de 0 à 100 sur chaque axe (le Top 1 a 100)
const scoreQty = maxQty > 0 ? ((row.totalQuantite ?? 0) / maxQty) * 100 : 0;
const scoreCa = maxCa > 0 ? ((row.totalCa ?? 0) / maxCa) * 100 : 0;
const scoreMarge = maxMarge > 0 ? ((row.totalMarge ?? 0) / maxMarge) * 100 : 0;
// Base = meilleur score d'axe
const base = Math.max(scoreQty, scoreCa, scoreMarge);
// Bonus polyvalence : combien d'axes dépassent le seuil demandé (en %, c.a.d en score d'axe)
// Attention : settings.seuilAxeFort était pensé pour un % du Total (~2%).
// Maintenant le score max étant 100, un "axe fort" devrait plutôt être un paramètre indexé (ex: > 30% du max).
// On va garder la règle, mais l'utilisateur devra probablement ajuster le seuil dans les paramètres.
let axesForts = 0;
if (scoreQty > settings.seuilAxeFort) axesForts++;
if (scoreCa > settings.seuilAxeFort) axesForts++;
if (scoreMarge > settings.seuilAxeFort) axesForts++;
const bonus = axesForts * settings.bonusParAxe;
row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
}
return rows;
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}