mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 14:38:10 +02:00
feat: implement AI analysis engine for generating retail product recommendation prompts.
This commit is contained in:
1 parent
2d3a90d6c7
commit
38e8fc1eac
4 files changed
+166
-86
No files matched your search
@@ -42,10 +42,12 @@ export default function SettingsPage() {
|
|||||||
const displayDensity = useGridStore((s) => s.displayDensity);
|
const displayDensity = useGridStore((s) => s.displayDensity);
|
||||||
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
const setDisplayDensity = useGridStore((s) => s.setDisplayDensity);
|
||||||
|
|
||||||
const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort);
|
const weightCA = useScoreSettingsStore((s) => s.weightCA);
|
||||||
const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe);
|
const weightVolume = useScoreSettingsStore((s) => s.weightVolume);
|
||||||
const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort);
|
const weightMarge = useScoreSettingsStore((s) => s.weightMarge);
|
||||||
const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe);
|
const setWeightCA = useScoreSettingsStore((s) => s.setWeightCA);
|
||||||
|
const setWeightVolume = useScoreSettingsStore((s) => s.setWeightVolume);
|
||||||
|
const setWeightMarge = useScoreSettingsStore((s) => s.setWeightMarge);
|
||||||
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
|
const resetScoreDefaults = useScoreSettingsStore((s) => s.resetDefaults);
|
||||||
|
|
||||||
const [showKey, setShowKey] = useState(false);
|
const [showKey, setShowKey] = useState(false);
|
||||||
@@ -329,29 +331,38 @@ export default function SettingsPage() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Score Produit */}
|
{/* Score Produit */}
|
||||||
<Section title="Score Produit" subtitle="Paramètres de l'algorithme d'évaluation des performances">
|
<Section title="Score Produit" subtitle="Pondération du score de contribution composite (CA + Volume + Marge)">
|
||||||
|
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 mb-4">
|
||||||
|
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||||
|
<strong>Formule :</strong> Score = (Pondération CA × Score CA) + (Pondération Volume × Score Volume) + (Pondération Marge × Score Marge)
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[var(--text-muted)] mt-1">
|
||||||
|
Chaque axe est normalisé par rapport à la moyenne du fournisseur. Score 100 = performance moyenne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={`Seuil d'axe fort : ${seuilAxeFort.toFixed(1)}%`}
|
label={`Pondération CA : ${(weightCA * 100).toFixed(0)}%`}
|
||||||
hint="Pourcentage minimum du poids fournisseur pour qu'une dimension (Quantité, CA, Marge) soit considérée comme un point fort du produit."
|
hint="Poids du chiffre d'affaires dans le calcul du score final."
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="5"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={seuilAxeFort}
|
value={weightCA * 100}
|
||||||
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value))}
|
onChange={(e) => setWeightCA(parseFloat(e.target.value) / 100)}
|
||||||
className="flex-1 accent-emerald-500 cursor-pointer"
|
className="flex-1 accent-emerald-500 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<div className="relative w-20">
|
<div className="relative w-20">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="5"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={seuilAxeFort}
|
value={(weightCA * 100).toFixed(0)}
|
||||||
onChange={(e) => setSeuilAxeFort(parseFloat(e.target.value) || 30.0)}
|
onChange={(e) => setWeightCA(parseFloat(e.target.value) / 100 || 0.40)}
|
||||||
className="apple-input font-mono-nums text-right pr-6"
|
className="apple-input font-mono-nums text-right pr-6"
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
|
||||||
@@ -360,40 +371,79 @@ export default function SettingsPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={`Bonus par axe : +${bonusParAxe} pts`}
|
label={`Pondération Volume : ${(weightVolume * 100).toFixed(0)}%`}
|
||||||
hint="Points supplémentaires accordés au score final pour chaque axe dépassant le seuil."
|
hint="Poids des quantités vendues (rotation) dans le calcul du score final."
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
max="20"
|
max="100"
|
||||||
step="1"
|
step="5"
|
||||||
value={bonusParAxe}
|
value={weightVolume * 100}
|
||||||
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10))}
|
onChange={(e) => setWeightVolume(parseFloat(e.target.value) / 100)}
|
||||||
className="flex-1 accent-emerald-500 cursor-pointer"
|
className="flex-1 accent-emerald-500 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<div className="w-20">
|
<div className="relative w-20">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="20"
|
max="100"
|
||||||
step="1"
|
step="5"
|
||||||
value={bonusParAxe}
|
value={(weightVolume * 100).toFixed(0)}
|
||||||
onChange={(e) => setBonusParAxe(parseInt(e.target.value, 10) || 0)}
|
onChange={(e) => setWeightVolume(parseFloat(e.target.value) / 100 || 0.35)}
|
||||||
className="apple-input font-mono-nums text-right"
|
className="apple-input font-mono-nums text-right pr-6"
|
||||||
/>
|
/>
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={`Pondération Marge : ${(weightMarge * 100).toFixed(0)}%`}
|
||||||
|
hint="Poids du taux de marge dans le calcul du score final."
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={weightMarge * 100}
|
||||||
|
onChange={(e) => setWeightMarge(parseFloat(e.target.value) / 100)}
|
||||||
|
className="flex-1 accent-emerald-500 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="relative w-20">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
value={(weightMarge * 100).toFixed(0)}
|
||||||
|
onChange={(e) => setWeightMarge(parseFloat(e.target.value) / 100 || 0.25)}
|
||||||
|
className="apple-input font-mono-nums text-right pr-6"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[12px]" style={{ color: "var(--text-muted)" }}>%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-3 mt-4">
|
||||||
|
<p className="text-[11px] text-yellow-600 dark:text-yellow-400 font-medium">
|
||||||
|
⚠️ Total actuel : {((weightCA + weightVolume + weightMarge) * 100).toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[var(--text-muted)] mt-1">
|
||||||
|
La somme des pondérations devrait idéalement faire 100%. Ajustez les valeurs en conséquence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={resetScoreDefaults}
|
onClick={resetScoreDefaults}
|
||||||
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
Rétablir les valeurs par défaut
|
Rétablir les valeurs par défaut (40/35/25)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -20,14 +20,22 @@ Tu analyses des produits pour recommander A (garder) ou Z (sortir).
|
|||||||
|
|
||||||
IMPORTANT : La gamme C est RÉSERVÉE aux produits saisonniers et gérée MANUELLEMENT par l'acheteur. Tu ne dois JAMAIS recommander C.
|
IMPORTANT : La gamme C est RÉSERVÉE aux produits saisonniers et gérée MANUELLEMENT par l'acheteur. Tu ne dois JAMAIS recommander C.
|
||||||
|
|
||||||
--- LOGIQUE SIMPLIFIÉE (3 RÈGLES SEULEMENT) ---
|
--- ORDRE DE PRIORITÉ (STRICT) ---
|
||||||
|
|
||||||
⛔ RÈGLE 1 — STOCK MORT ABSOLU (priorité absolue):
|
📝 RÈGLE 1 — RÈGLE MANAGER (PRIORITÉ ABSOLUE):
|
||||||
|
Si une règle manager est définie (section "RÈGLE MANAGER" dans le message) ET que le produit est concerné:
|
||||||
|
→ Appliquer la consigne À LA LETTRE, même si elle contredit les autres règles.
|
||||||
|
→ Mettre rule_applies = true et suivre EXACTEMENT la recommandation de la règle.
|
||||||
|
→ Les règles 2 et 3 ci-dessous NE S'APPLIQUENT PAS si une règle manager est active.
|
||||||
|
|
||||||
|
Si AUCUNE règle manager n'est fournie → Passer directement aux règles 2 et 3.
|
||||||
|
|
||||||
|
⛔ RÈGLE 2 — STOCK MORT ABSOLU:
|
||||||
SI CA réseau < 100€ ET Quantité réseau < 30 unités SIMULTANÉMENT → Z IMMÉDIAT, STOP.
|
SI CA réseau < 100€ ET Quantité réseau < 30 unités SIMULTANÉMENT → Z IMMÉDIAT, STOP.
|
||||||
Les DEUX conditions doivent être vraies en même temps.
|
Les DEUX conditions doivent être vraies en même temps.
|
||||||
Un produit à 30€ de CA sur 2 magasins (15€/magasin/an) coûte plus en gestion qu'il ne rapporte.
|
Un produit à 30€ de CA sur 2 magasins (15€/magasin/an) coûte plus en gestion qu'il ne rapporte.
|
||||||
|
|
||||||
📊 RÈGLE 2 — PERFORMANCE RELATIVE AU FOURNISSEUR:
|
📊 RÈGLE 3 — PERFORMANCE RELATIVE AU FOURNISSEUR:
|
||||||
Le produit doit être évalué PAR RAPPORT à la moyenne de son lot fournisseur.
|
Le produit doit être évalué PAR RAPPORT à la moyenne de son lot fournisseur.
|
||||||
|
|
||||||
ÉTAPE 1: Calculer la moyenne du lot
|
ÉTAPE 1: Calculer la moyenne du lot
|
||||||
@@ -48,10 +56,6 @@ RATIONALE : Cette logique s'adapte automatiquement à TOUS les fournisseurs:
|
|||||||
- Gros fournisseur (50 produits) : moyenne faible, mais comparaison équitable
|
- Gros fournisseur (50 produits) : moyenne faible, mais comparaison équitable
|
||||||
- Même score relatif = même décision, indépendamment de la taille du fournisseur
|
- Même score relatif = même décision, indépendamment de la taille du fournisseur
|
||||||
|
|
||||||
📝 RÈGLE 3 — RÈGLE MANAGER (optionnel):
|
|
||||||
Si le manager a défini une consigne ET que le produit est concerné → Appliquer la consigne À LA LETTRE.
|
|
||||||
Si AUCUNE règle manager n'est fournie → IGNORER cette section.
|
|
||||||
|
|
||||||
--- FORMAT OBLIGATOIRE ---
|
--- FORMAT OBLIGATOIRE ---
|
||||||
JSON uniquement, sans markdown.
|
JSON uniquement, sans markdown.
|
||||||
{
|
{
|
||||||
@@ -89,6 +93,20 @@ JSON uniquement, sans markdown.
|
|||||||
lines.push(`DISTRIBUTION : ${storeLabel}`);
|
lines.push(`DISTRIBUTION : ${storeLabel}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
|
// ⚠️ RÈGLE MANAGER EN PRIORITÉ 1 — doit être visible AVANT toute analyse
|
||||||
|
if (p.supplierContext) {
|
||||||
|
lines.push(`🎯 ═══════════════════════════════════════════════════════════════`);
|
||||||
|
lines.push(` ⚠️ RÈGLE MANAGER (PRIORITÉ ABSOLUE)`);
|
||||||
|
lines.push(`═══════════════════════════════════════════════════════════════`);
|
||||||
|
lines.push(`"${p.supplierContext}"`);
|
||||||
|
lines.push(``);
|
||||||
|
lines.push(`→ Ce produit ("${ctx.libelle1}") est-il concerné par cette règle ?`);
|
||||||
|
lines.push(` Si OUI : rule_applies = true ET applique la consigne À LA LETTRE.`);
|
||||||
|
lines.push(` Si NON : rule_applies = false ET applique les règles 2-3 normalement.`);
|
||||||
|
lines.push(`═══════════════════════════════════════════════════════════════`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
// KPIs bruts
|
// KPIs bruts
|
||||||
lines.push(`--- PERFORMANCE ABSOLUE ---`);
|
lines.push(`--- PERFORMANCE ABSOLUE ---`);
|
||||||
lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€`);
|
lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€`);
|
||||||
@@ -117,14 +135,6 @@ JSON uniquement, sans markdown.
|
|||||||
lines.push(` → ${scoreRelatif >= 100 ? "✓ Au-dessus de la moyenne" : scoreRelatif >= 50 ? "⚠️ Entre 50% et 100% de la moyenne" : "✗ Très en dessous de la moyenne"}`);
|
lines.push(` → ${scoreRelatif >= 100 ? "✓ Au-dessus de la moyenne" : scoreRelatif >= 50 ? "⚠️ Entre 50% et 100% de la moyenne" : "✗ Très en dessous de la moyenne"}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// Règle manager
|
|
||||||
if (p.supplierContext) {
|
|
||||||
lines.push(`--- RÈGLE MANAGER ---`);
|
|
||||||
lines.push(`"${p.supplierContext}"`);
|
|
||||||
lines.push(`→ Ce produit ("${ctx.libelle1}") est-il concerné ? rule_applies = true/false.`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`Génère UNIQUEMENT le JSON :`);
|
lines.push(`Génère UNIQUEMENT le JSON :`);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { persist } from "zustand/middleware";
|
|||||||
import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine";
|
import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine";
|
||||||
|
|
||||||
interface ScoreSettingsState extends ScoreSettings {
|
interface ScoreSettingsState extends ScoreSettings {
|
||||||
setSeuilAxeFort: (v: number) => void;
|
setWeightCA: (v: number) => void;
|
||||||
setBonusParAxe: (v: number) => void;
|
setWeightVolume: (v: number) => void;
|
||||||
|
setWeightMarge: (v: number) => void;
|
||||||
resetDefaults: () => void;
|
resetDefaults: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,8 +16,9 @@ export const useScoreSettingsStore = create<ScoreSettingsState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
...DEFAULT_SCORE_SETTINGS,
|
...DEFAULT_SCORE_SETTINGS,
|
||||||
|
|
||||||
setSeuilAxeFort: (v) => set({ seuilAxeFort: v }),
|
setWeightCA: (v) => set({ weightCA: v }),
|
||||||
setBonusParAxe: (v) => set({ bonusParAxe: v }),
|
setWeightVolume: (v) => set({ weightVolume: v }),
|
||||||
|
setWeightMarge: (v) => set({ weightMarge: v }),
|
||||||
resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }),
|
resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
|
|||||||
+59
-41
@@ -1,28 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* CollectFlow — Score Engine (Proposition C: Score Composite Pondéré)
|
* CollectFlow — Score Engine v2 (Simplifié - Score de Contribution Composite)
|
||||||
*
|
*
|
||||||
* Calcule le score de performance d'un produit en fonction de son poids
|
* Calcule le score de performance d'un produit basé sur sa contribution RELATIVE
|
||||||
* relatif chez le fournisseur sur 3 axes : CA, Quantité, Marge.
|
* à la moyenne de son lot fournisseur sur 3 axes : CA, Quantité, Marge.
|
||||||
*
|
*
|
||||||
* Formule :
|
* Formule :
|
||||||
* score_axe = produit.metric / max_metric_fournisseur × 100
|
* Score = (40% × Score_CA) + (35% × Score_Volume) + (25% × Score_Marge)
|
||||||
* base = (scoreCa × 45%) + (scoreQty × 35%) + (scoreMarge × 20%)
|
*
|
||||||
* bonus = count(poids > seuil) × bonusParAxe
|
* Où chaque score d'axe est :
|
||||||
* score = MIN(base + bonus, 100)
|
* Score_Axe = (Valeur Produit / Moyenne Fournisseur) × 100
|
||||||
|
*
|
||||||
|
* Exemples :
|
||||||
|
* - Score = 100 → Le produit fait exactement sa part
|
||||||
|
* - Score = 150 → Le produit surperforme de 50%
|
||||||
|
* - Score = 50 → Le produit sous-performe de 50%
|
||||||
|
*
|
||||||
|
* Avantages :
|
||||||
|
* - Simple et intuitif
|
||||||
|
* - Auto-normalisé (fonctionne avec 5 ou 50 produits)
|
||||||
|
* - Reflète la VRAIE contribution du produit
|
||||||
|
* - Aligné avec l'analyse IA v5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ProductRow } from "@/types/grid";
|
import type { ProductRow } from "@/types/grid";
|
||||||
|
|
||||||
export interface ScoreSettings {
|
export interface ScoreSettings {
|
||||||
/** Seuil en % pour considérer un axe comme "fort" (défaut: 2.0) */
|
/** Pondération CA (défaut: 40%) */
|
||||||
seuilAxeFort: number;
|
weightCA: number;
|
||||||
/** Points bonus par axe dépassant le seuil (défaut: 5) */
|
/** Pondération Volume (défaut: 35%) */
|
||||||
bonusParAxe: number;
|
weightVolume: number;
|
||||||
|
/** Pondération Marge (défaut: 25%) */
|
||||||
|
weightMarge: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
|
export const DEFAULT_SCORE_SETTINGS: ScoreSettings = {
|
||||||
seuilAxeFort: 30.0,
|
weightCA: 0.40,
|
||||||
bonusParAxe: 10,
|
weightVolume: 0.35,
|
||||||
|
weightMarge: 0.25,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,66 +49,70 @@ export function computeProductScores(
|
|||||||
): ProductRow[] {
|
): ProductRow[] {
|
||||||
if (rows.length === 0) return rows;
|
if (rows.length === 0) return rows;
|
||||||
|
|
||||||
// 1. Calculer les valeurs pondérées pour tout le monde et trouver les max
|
// 1. Normalisation multi-magasins : pondérer les produits en 1 magasin (×2)
|
||||||
|
// pour comparer équitablement avec ceux en 2 magasins
|
||||||
const weightedData = rows.map((r) => {
|
const weightedData = rows.map((r) => {
|
||||||
const weight = (r.workingStores?.length || 1) === 1 ? 2 : 1;
|
const storeCount = r.workingStores?.length || 1;
|
||||||
|
const weight = storeCount === 1 ? 2 : 1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
row: r,
|
row: r,
|
||||||
wQty: (r.totalQuantite ?? 0) * weight,
|
wQty: (r.totalQuantite ?? 0) * weight,
|
||||||
wCa: (r.totalCa ?? 0) * weight,
|
wCa: (r.totalCa ?? 0) * weight,
|
||||||
wMarge: (r.totalMarge ?? 0) * weight,
|
wMarge: (r.totalMarge ?? 0) * weight,
|
||||||
|
tauxMarge: r.tauxMarge ?? 0, // Taux de marge en % (pas pondéré)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const maxQty = weightedData.reduce((max, d) => Math.max(max, d.wQty), 0);
|
// 2. Calculer les MOYENNES du fournisseur (pas les MAX !)
|
||||||
const maxCa = weightedData.reduce((max, d) => Math.max(max, d.wCa), 0);
|
|
||||||
const maxMarge = weightedData.reduce((max, d) => Math.max(max, d.wMarge), 0);
|
|
||||||
|
|
||||||
// 2. Calculer les moyennes et totaux globaux
|
|
||||||
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0);
|
||||||
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0);
|
||||||
const totalMarge = weightedData.reduce((acc, d) => acc + d.wMarge, 0);
|
const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0);
|
||||||
|
|
||||||
const avgQtyFournisseur = rows.length > 0 ? totalQty / rows.length : 0;
|
const avgCa = rows.length > 0 ? totalCa / rows.length : 0;
|
||||||
|
const avgQty = rows.length > 0 ? totalQty / rows.length : 0;
|
||||||
|
const avgMarge = rows.length > 0 ? totalMarge / rows.length : 0;
|
||||||
|
|
||||||
const rayonTotals = new Map<string, { total: number, count: number }>();
|
// 3. Calculer les totaux pour les poids (%)
|
||||||
weightedData.forEach(d => {
|
const rayonTotals = new Map<string, { total: number; count: number }>();
|
||||||
|
weightedData.forEach((d) => {
|
||||||
const r2 = d.row.code2 || "default";
|
const r2 = d.row.code2 || "default";
|
||||||
const current = rayonTotals.get(r2) || { total: 0, count: 0 };
|
const current = rayonTotals.get(r2) || { total: 0, count: 0 };
|
||||||
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Pour chaque produit, calculer le score relatif et injecter les contextes (poids, moyennes)
|
// 4. Pour chaque produit : calculer le score composite
|
||||||
for (const data of weightedData) {
|
for (const data of weightedData) {
|
||||||
// Injection des métriques de poids et contextes
|
// Injection des métriques de contexte (pour l'IA et l'affichage)
|
||||||
data.row.avgQtyFournisseur = avgQtyFournisseur;
|
data.row.avgQtyFournisseur = avgQty;
|
||||||
data.row.totalFournisseurCa = totalCa;
|
data.row.totalFournisseurCa = totalCa;
|
||||||
|
|
||||||
// Calcul des poids (%)
|
// Calcul des poids (%)
|
||||||
data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 100 : 0;
|
data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 100 : 0;
|
||||||
data.row.shareCa = totalCa > 0 ? (data.wCa / totalCa) * 100 : 0;
|
data.row.shareCa = totalCa > 0 ? (data.wCa / totalCa) * 100 : 0;
|
||||||
data.row.shareMarge = totalMarge > 0 ? (data.wMarge / totalMarge) * 100 : 0;
|
data.row.shareMarge = totalMarge > 0 ? (data.tauxMarge / totalMarge) * 100 : 0;
|
||||||
|
|
||||||
const r2 = data.row.code2 || "default";
|
const r2 = data.row.code2 || "default";
|
||||||
const rayonStat = rayonTotals.get(r2);
|
const rayonStat = rayonTotals.get(r2);
|
||||||
data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0;
|
||||||
|
|
||||||
// Score de 0 à 100 sur chaque axe pondéré
|
// ================================================================
|
||||||
const scoreQty = maxQty > 0 ? (data.wQty / maxQty) * 100 : 0;
|
// SCORE DE CONTRIBUTION COMPOSITE
|
||||||
const scoreCa = maxCa > 0 ? (data.wCa / maxCa) * 100 : 0;
|
// ================================================================
|
||||||
const scoreMarge = maxMarge > 0 ? (data.wMarge / maxMarge) * 100 : 0;
|
|
||||||
|
|
||||||
// Base = Moyenne pondérée (CA: 45%, Qty: 35%, Marge: 20%)
|
// Score de chaque axe = (Valeur Produit / Moyenne Fournisseur) × 100
|
||||||
const base = (scoreCa * 0.45) + (scoreQty * 0.35) + (scoreMarge * 0.20);
|
const scoreCa = avgCa > 0 ? (data.wCa / avgCa) * 100 : 0;
|
||||||
|
const scoreVolume = avgQty > 0 ? (data.wQty / avgQty) * 100 : 0;
|
||||||
|
const scoreMarge = avgMarge > 0 ? (data.tauxMarge / avgMarge) * 100 : 0;
|
||||||
|
|
||||||
let axesForts = 0;
|
// Score final = moyenne pondérée
|
||||||
if (scoreQty > settings.seuilAxeFort) axesForts++;
|
const scoreComposite =
|
||||||
if (scoreCa > settings.seuilAxeFort) axesForts++;
|
settings.weightCA * scoreCa +
|
||||||
if (scoreMarge > settings.seuilAxeFort) axesForts++;
|
settings.weightVolume * scoreVolume +
|
||||||
|
settings.weightMarge * scoreMarge;
|
||||||
|
|
||||||
const bonus = axesForts * settings.bonusParAxe;
|
// Arrondi à 1 décimale
|
||||||
|
data.row.score = Math.round(scoreComposite * 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