mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Introduce AI copilot for bulk product analysis and integrate a new product scoring engine.
This commit is contained in:
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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user