feat: Introduce AI copilot for bulk product analysis and integrate a new product scoring engine.

This commit is contained in:
Michael committed 2026-02-22 12:28:50 +01:00
1 parent 425d9e8088
commit 335cf15ffa
4 files changed
+62 -43

No files matched your search

@@ -9,14 +9,14 @@ En analysant les données de ventes fournies, génère une recommandation de gam
- [C] - SAISONNIER : Produit dont les ventes sont concentrées sur des périodes spécifiques (pics saisonniers).
- [Z] - SORTIE : Produit en fin de cycle de vie, sans rotation significative ou en chute libre, devant être retiré du référencement.
Critères de pondération et Règles Métier :
1. Volume Relatif (Flux de stock) : Si le volume est élevé (très supérieur à la moyenne), c'est un produit "fond de rayon" qui génère du trafic. ON LE GARDE (A ou C) même si son CA individuel est faible.
2. Mix CA/Marge (Rentabilité) : Si le volume est faible mais que le produit génère un CA conséquent et une BONNE MARGE, c'est une pépite. ON LE GARDE EN A.
3. Poids Relatif : Analyse toujours la performance du produit PAR RAPPORT AU RESTE (moyenne fournisseur). Un produit doit être Z uniquement s'il est faible sur TOUS les indicateurs (Volume, CA, Marge).
4. Contexte Magasins (Crucial) : Compare le produit à ses pairs. Si le produit est dans 1 magasin, compare son volume à la moyenne des produits à 1 magasin (avgQtyGroup1). Ne le pénalise pas parce qu'il vend moins qu'un produit présent dans 2 magasins ou plus. Un produit à 1 magasin qui performe mieux que la moyenne de son groupe est un excellent candidat A.
5. Régularité : La stabilité sur 12 mois est la signature du Permanent (A).
Critères de pondération et Règles Métier Strictes :
1. Normalisation Totale (Base 2 magasins) : TOUTES les statistiques fournies (Totaux et Historique Mensuel) sont PONDÉRÉES par 2 si le produit n'est présent que dans 1 magasin. L'objectif est de simuler une performance sur une base réseau de 2 magasins.
2. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an).
3. Seuil "Sortie" (Z) : Un produit avec moins de 10 unités pondérées par an et un CA faible est un candidat naturel à la sortie (Z).
4. Volume/CA/Marge : Un produit "A" doit justifier sa place en rayon par son flux (volume) OU sa rentabilité brute (CA/Marge). S'il est faible sur les deux, c'est un Z.
5. Poids Relatif : Analyse la performance par rapport aux moyennes fournies.
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Ta réponse doit être courte, directe et sans complaisance.
Format impératif : "[Recommandation]: [Justification courte]"`;
}
@@ -25,17 +25,20 @@ Format impératif : "[Recommandation]: [Justification courte]"`;
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
.join(", ");
const volumeContext = p.avgTotalQuantite
? `- Volume vs Moyenne Fournisseur : ${Math.round(p.totalQuantite)}u (Moyenne: ${Math.round(p.avgTotalQuantite)}u)`
: `- Volume total : ${Math.round(p.totalQuantite)} unités`;
const volumeInfo = `Stats Réelles : ${Math.round(p.totalQuantite)}u (${p.totalCa.toFixed(2)}€) sur ${p.storeCount} mag.
Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u (${(p.weightedTotalCa || 0).toFixed(2)}€)`;
const benchmarks = p.avgQtyGroup1 !== undefined && p.avgQtyGroup2 !== undefined
? `\nBenchmarks (Volumes moyens) :
- Moyenne mag unique : ${Math.round(p.avgQtyGroup1)}u
- Moyenne multi-magasins : ${Math.round(p.avgQtyGroup2)}u`
: "";
return `Produit : "${p.libelle1}" (Ref: ${p.codein})
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
Contexte : Données basées sur ${p.storeCount} magasin(s).
Indicateurs 12m :
- CA : ${p.totalCa.toFixed(2)}€
${volumeInfo}${benchmarks}
Indicateurs Clés :
- Marge : ${p.tauxMarge.toFixed(1)}%
${volumeContext}
- Historique mensuel : ${monthlySummary}
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
@@ -4,6 +4,8 @@ export interface ProductAnalysisInput {
totalCa: number;
tauxMarge: number;
totalQuantite: number;
weightedTotalQuantite?: number;
weightedTotalCa?: number;
avgTotalQuantite?: number;
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin
avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+
@@ -33,19 +33,28 @@ export function BulkAiAnalyzer() {
const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0;
const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
const productPayloads: ProductAnalysisInput[] = rows.map(r => ({
codein: r.codein,
libelle1: r.libelle1 || "",
totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0,
totalQuantite: r.totalQuantite || 0,
avgTotalQuantite: avgQty,
avgQtyGroup1: avgQty1,
avgQtyGroup2: avgQty2,
storeCount: r.workingStores?.length || 1,
sales12m: r.sales12m || {},
codeGamme: r.codeGamme || null,
}));
const productPayloads: ProductAnalysisInput[] = rows.map(r => {
const sc = r.workingStores?.length || 1;
const weight = sc === 1 ? 2 : 1; // On ramène tout sur une base 2 magasins
return {
codein: r.codein,
libelle1: r.libelle1 || "",
totalCa: r.totalCa || 0,
tauxMarge: r.tauxMarge || 0,
totalQuantite: r.totalQuantite || 0,
weightedTotalQuantite: (r.totalQuantite || 0) * weight,
weightedTotalCa: (r.totalCa || 0) * weight,
avgTotalQuantite: avgQty,
avgQtyGroup1: avgQty1,
avgQtyGroup2: avgQty2,
storeCount: sc,
sales12m: Object.fromEntries(
Object.entries(r.sales12m || {}).map(([month, val]) => [month, val * weight])
),
codeGamme: r.codeGamme || null,
};
});
setIsAnalyzing(true);
let completed = 0;
+21 -16
View File
@@ -35,26 +35,31 @@ export function computeProductScores(
): 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));
// 1. Calculer les valeurs pondérées pour tout le monde et trouver les max
const weightedData = rows.map((r) => {
const weight = (r.workingStores?.length || 1) === 1 ? 2 : 1;
return {
row: r,
wQty: (r.totalQuantite ?? 0) * weight,
wCa: (r.totalCa ?? 0) * weight,
wMarge: (r.totalMarge ?? 0) * weight,
};
});
// 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;
const maxQty = Math.max(...weightedData.map((d) => d.wQty));
const maxCa = Math.max(...weightedData.map((d) => d.wCa));
const maxMarge = Math.max(...weightedData.map((d) => d.wMarge));
// 2. Pour chaque produit, calculer le score relatif aux max pondérés
for (const data of weightedData) {
// Score de 0 à 100 sur chaque axe pondéré (le Top 1 pondéré a 100)
const scoreQty = maxQty > 0 ? (data.wQty / maxQty) * 100 : 0;
const scoreCa = maxCa > 0 ? (data.wCa / maxCa) * 100 : 0;
const scoreMarge = maxMarge > 0 ? (data.wMarge / 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++;
@@ -62,7 +67,7 @@ export function computeProductScores(
const bonus = axesForts * settings.bonusParAxe;
row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
data.row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10;
}
return rows;