mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 13:38:08 +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).
|
- [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.
|
- [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 :
|
Critères de pondération et Règles Métier Strictes :
|
||||||
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.
|
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. 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.
|
2. Seuil "Permanent" (A) : Pour être A, un produit doit avoir un volume PONDÉRÉ significatif (ex: > 30-50 unités/an).
|
||||||
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).
|
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. 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.
|
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. Régularité : La stabilité sur 12 mois est la signature du Permanent (A).
|
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]"`;
|
Format impératif : "[Recommandation]: [Justification courte]"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,17 +25,20 @@ Format impératif : "[Recommandation]: [Justification courte]"`;
|
|||||||
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
|
.map(([k, v]) => `${k}: ${Math.round(v)}u`)
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
const volumeContext = p.avgTotalQuantite
|
const volumeInfo = `Stats Réelles : ${Math.round(p.totalQuantite)}u (${p.totalCa.toFixed(2)}€) sur ${p.storeCount} mag.
|
||||||
? `- Volume vs Moyenne Fournisseur : ${Math.round(p.totalQuantite)}u (Moyenne: ${Math.round(p.avgTotalQuantite)}u)`
|
Stats Pondérées (Base 2 mag) : ${Math.round(p.weightedTotalQuantite || 0)}u (${(p.weightedTotalCa || 0).toFixed(2)}€)`;
|
||||||
: `- Volume total : ${Math.round(p.totalQuantite)} unités`;
|
|
||||||
|
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})
|
return `Produit : "${p.libelle1}" (Ref: ${p.codein})
|
||||||
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
|
Gamme actuelle : ${p.codeGamme ?? "Non définie"}
|
||||||
Contexte : Données basées sur ${p.storeCount} magasin(s).
|
${volumeInfo}${benchmarks}
|
||||||
Indicateurs 12m :
|
Indicateurs Clés :
|
||||||
- CA : ${p.totalCa.toFixed(2)}€
|
|
||||||
- Marge : ${p.tauxMarge.toFixed(1)}%
|
- Marge : ${p.tauxMarge.toFixed(1)}%
|
||||||
${volumeContext}
|
|
||||||
- Historique mensuel : ${monthlySummary}
|
- Historique mensuel : ${monthlySummary}
|
||||||
|
|
||||||
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
|
Quelle est ta recommandation (A, C ou Z) et pourquoi ?`;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export interface ProductAnalysisInput {
|
|||||||
totalCa: number;
|
totalCa: number;
|
||||||
tauxMarge: number;
|
tauxMarge: number;
|
||||||
totalQuantite: number;
|
totalQuantite: number;
|
||||||
|
weightedTotalQuantite?: number;
|
||||||
|
weightedTotalCa?: number;
|
||||||
avgTotalQuantite?: number;
|
avgTotalQuantite?: number;
|
||||||
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin
|
avgQtyGroup1?: number; // Moyenne volume pour produits à 1 magasin
|
||||||
avgQtyGroup2?: number; // Moyenne volume pour produits à 2 magasins+
|
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 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 avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: any) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0;
|
||||||
|
|
||||||
const productPayloads: ProductAnalysisInput[] = rows.map(r => ({
|
const productPayloads: ProductAnalysisInput[] = rows.map(r => {
|
||||||
codein: r.codein,
|
const sc = r.workingStores?.length || 1;
|
||||||
libelle1: r.libelle1 || "",
|
const weight = sc === 1 ? 2 : 1; // On ramène tout sur une base 2 magasins
|
||||||
totalCa: r.totalCa || 0,
|
|
||||||
tauxMarge: r.tauxMarge || 0,
|
return {
|
||||||
totalQuantite: r.totalQuantite || 0,
|
codein: r.codein,
|
||||||
avgTotalQuantite: avgQty,
|
libelle1: r.libelle1 || "",
|
||||||
avgQtyGroup1: avgQty1,
|
totalCa: r.totalCa || 0,
|
||||||
avgQtyGroup2: avgQty2,
|
tauxMarge: r.tauxMarge || 0,
|
||||||
storeCount: r.workingStores?.length || 1,
|
totalQuantite: r.totalQuantite || 0,
|
||||||
sales12m: r.sales12m || {},
|
weightedTotalQuantite: (r.totalQuantite || 0) * weight,
|
||||||
codeGamme: r.codeGamme || null,
|
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);
|
setIsAnalyzing(true);
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
|||||||
+21
-16
@@ -35,26 +35,31 @@ export function computeProductScores(
|
|||||||
): ProductRow[] {
|
): ProductRow[] {
|
||||||
if (rows.length === 0) return rows;
|
if (rows.length === 0) return rows;
|
||||||
|
|
||||||
// 1. Trouver les valeurs maximales (les "Top 1") pour indexer 0-100
|
// 1. Calculer les valeurs pondérées pour tout le monde et trouver les max
|
||||||
const maxQty = Math.max(...rows.map((r) => r.totalQuantite ?? 0));
|
const weightedData = rows.map((r) => {
|
||||||
const maxCa = Math.max(...rows.map((r) => r.totalCa ?? 0));
|
const weight = (r.workingStores?.length || 1) === 1 ? 2 : 1;
|
||||||
const maxMarge = Math.max(...rows.map((r) => r.totalMarge ?? 0));
|
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
|
const maxQty = Math.max(...weightedData.map((d) => d.wQty));
|
||||||
for (const row of rows) {
|
const maxCa = Math.max(...weightedData.map((d) => d.wCa));
|
||||||
// Score de 0 à 100 sur chaque axe (le Top 1 a 100)
|
const maxMarge = Math.max(...weightedData.map((d) => d.wMarge));
|
||||||
const scoreQty = maxQty > 0 ? ((row.totalQuantite ?? 0) / maxQty) * 100 : 0;
|
|
||||||
const scoreCa = maxCa > 0 ? ((row.totalCa ?? 0) / maxCa) * 100 : 0;
|
// 2. Pour chaque produit, calculer le score relatif aux max pondérés
|
||||||
const scoreMarge = maxMarge > 0 ? ((row.totalMarge ?? 0) / maxMarge) * 100 : 0;
|
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
|
// Base = meilleur score d'axe
|
||||||
const base = Math.max(scoreQty, scoreCa, scoreMarge);
|
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;
|
let axesForts = 0;
|
||||||
if (scoreQty > settings.seuilAxeFort) axesForts++;
|
if (scoreQty > settings.seuilAxeFort) axesForts++;
|
||||||
if (scoreCa > settings.seuilAxeFort) axesForts++;
|
if (scoreCa > settings.seuilAxeFort) axesForts++;
|
||||||
@@ -62,7 +67,7 @@ export function computeProductScores(
|
|||||||
|
|
||||||
const bonus = axesForts * settings.bonusParAxe;
|
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;
|
return rows;
|
||||||
|
|||||||
Reference in new issue
Block a user