From 38e8fc1eace8357dde79ca240ac407d97aa96f63 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 13 Mar 2026 08:55:30 +0100 Subject: [PATCH] feat: implement AI analysis engine for generating retail product recommendation prompts. --- src/app/(dashboard)/settings/page.tsx | 102 +++++++++++++----- .../ai-copilot/business/analysis-engine.ts | 40 ++++--- .../score/store/use-score-settings-store.ts | 10 +- src/lib/score-engine.ts | 100 ++++++++++------- 4 files changed, 166 insertions(+), 86 deletions(-) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 2bcd123..b86cce2 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -42,10 +42,12 @@ export default function SettingsPage() { const displayDensity = useGridStore((s) => s.displayDensity); const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); - const seuilAxeFort = useScoreSettingsStore((s) => s.seuilAxeFort); - const bonusParAxe = useScoreSettingsStore((s) => s.bonusParAxe); - const setSeuilAxeFort = useScoreSettingsStore((s) => s.setSeuilAxeFort); - const setBonusParAxe = useScoreSettingsStore((s) => s.setBonusParAxe); + const weightCA = useScoreSettingsStore((s) => s.weightCA); + const weightVolume = useScoreSettingsStore((s) => s.weightVolume); + const weightMarge = useScoreSettingsStore((s) => s.weightMarge); + 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 [showKey, setShowKey] = useState(false); @@ -329,29 +331,38 @@ export default function SettingsPage() { {/* Score Produit */} -
+
+
+

+ Formule : Score = (Pondération CA × Score CA) + (Pondération Volume × Score Volume) + (Pondération Marge × Score Marge) +

+

+ Chaque axe est normalisé par rapport à la moyenne du fournisseur. Score 100 = performance moyenne. +

+
+
setSeuilAxeFort(parseFloat(e.target.value))} + value={weightCA * 100} + onChange={(e) => setWeightCA(parseFloat(e.target.value) / 100)} className="flex-1 accent-emerald-500 cursor-pointer" />
setSeuilAxeFort(parseFloat(e.target.value) || 30.0)} + value={(weightCA * 100).toFixed(0)} + onChange={(e) => setWeightCA(parseFloat(e.target.value) / 100 || 0.40)} className="apple-input font-mono-nums text-right pr-6" /> % @@ -360,40 +371,79 @@ export default function SettingsPage() {
setBonusParAxe(parseInt(e.target.value, 10))} + max="100" + step="5" + value={weightVolume * 100} + onChange={(e) => setWeightVolume(parseFloat(e.target.value) / 100)} className="flex-1 accent-emerald-500 cursor-pointer" /> -
+
setBonusParAxe(parseInt(e.target.value, 10) || 0)} - className="apple-input font-mono-nums text-right" + max="100" + step="5" + value={(weightVolume * 100).toFixed(0)} + onChange={(e) => setWeightVolume(parseFloat(e.target.value) / 100 || 0.35)} + className="apple-input font-mono-nums text-right pr-6" /> + %
+ +
+ setWeightMarge(parseFloat(e.target.value) / 100)} + className="flex-1 accent-emerald-500 cursor-pointer" + /> +
+ setWeightMarge(parseFloat(e.target.value) / 100 || 0.25)} + className="apple-input font-mono-nums text-right pr-6" + /> + % +
+
+
+ +
+

+ ⚠️ Total actuel : {((weightCA + weightVolume + weightMarge) * 100).toFixed(0)}% +

+

+ La somme des pondérations devrait idéalement faire 100%. Ajustez les valeurs en conséquence. +

+
+
diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 8b6db96..97fa290 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -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. ---- 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. 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. -📊 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. É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 - 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 --- JSON uniquement, sans markdown. { @@ -89,6 +93,20 @@ JSON uniquement, sans markdown. lines.push(`DISTRIBUTION : ${storeLabel}`); 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 lines.push(`--- PERFORMANCE ABSOLUE ---`); 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(""); - // 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 :`); return lines.join("\n"); } diff --git a/src/features/score/store/use-score-settings-store.ts b/src/features/score/store/use-score-settings-store.ts index 1dbcf46..bf886c7 100644 --- a/src/features/score/store/use-score-settings-store.ts +++ b/src/features/score/store/use-score-settings-store.ts @@ -5,8 +5,9 @@ import { persist } from "zustand/middleware"; import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine"; interface ScoreSettingsState extends ScoreSettings { - setSeuilAxeFort: (v: number) => void; - setBonusParAxe: (v: number) => void; + setWeightCA: (v: number) => void; + setWeightVolume: (v: number) => void; + setWeightMarge: (v: number) => void; resetDefaults: () => void; } @@ -15,8 +16,9 @@ export const useScoreSettingsStore = create()( (set) => ({ ...DEFAULT_SCORE_SETTINGS, - setSeuilAxeFort: (v) => set({ seuilAxeFort: v }), - setBonusParAxe: (v) => set({ bonusParAxe: v }), + setWeightCA: (v) => set({ weightCA: v }), + setWeightVolume: (v) => set({ weightVolume: v }), + setWeightMarge: (v) => set({ weightMarge: v }), resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }), }), { diff --git a/src/lib/score-engine.ts b/src/lib/score-engine.ts index 4f0efd0..630a570 100644 --- a/src/lib/score-engine.ts +++ b/src/lib/score-engine.ts @@ -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 - * relatif chez le fournisseur sur 3 axes : CA, Quantité, Marge. + * Calcule le score de performance d'un produit basé sur sa contribution RELATIVE + * à la moyenne de son lot fournisseur sur 3 axes : CA, Quantité, Marge. * * Formule : - * score_axe = produit.metric / max_metric_fournisseur × 100 - * base = (scoreCa × 45%) + (scoreQty × 35%) + (scoreMarge × 20%) - * bonus = count(poids > seuil) × bonusParAxe - * score = MIN(base + bonus, 100) + * Score = (40% × Score_CA) + (35% × Score_Volume) + (25% × Score_Marge) + * + * Où chaque score d'axe est : + * 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"; 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; + /** Pondération CA (défaut: 40%) */ + weightCA: number; + /** Pondération Volume (défaut: 35%) */ + weightVolume: number; + /** Pondération Marge (défaut: 25%) */ + weightMarge: number; } export const DEFAULT_SCORE_SETTINGS: ScoreSettings = { - seuilAxeFort: 30.0, - bonusParAxe: 10, + weightCA: 0.40, + weightVolume: 0.35, + weightMarge: 0.25, }; /** @@ -35,66 +49,70 @@ export function computeProductScores( ): ProductRow[] { 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 weight = (r.workingStores?.length || 1) === 1 ? 2 : 1; + const storeCount = r.workingStores?.length || 1; + const weight = storeCount === 1 ? 2 : 1; + return { row: r, wQty: (r.totalQuantite ?? 0) * weight, wCa: (r.totalCa ?? 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); - 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 + // 2. Calculer les MOYENNES du fournisseur (pas les MAX !) const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 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(); - weightedData.forEach(d => { + // 3. Calculer les totaux pour les poids (%) + const rayonTotals = new Map(); + weightedData.forEach((d) => { const r2 = d.row.code2 || "default"; const current = rayonTotals.get(r2) || { total: 0, count: 0 }; 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) { - // Injection des métriques de poids et contextes - data.row.avgQtyFournisseur = avgQtyFournisseur; + // Injection des métriques de contexte (pour l'IA et l'affichage) + data.row.avgQtyFournisseur = avgQty; data.row.totalFournisseurCa = totalCa; // Calcul des poids (%) data.row.shareQty = totalQty > 0 ? (data.wQty / totalQty) * 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 rayonStat = rayonTotals.get(r2); 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; - const scoreCa = maxCa > 0 ? (data.wCa / maxCa) * 100 : 0; - const scoreMarge = maxMarge > 0 ? (data.wMarge / maxMarge) * 100 : 0; + // ================================================================ + // SCORE DE CONTRIBUTION COMPOSITE + // ================================================================ - // Base = Moyenne pondérée (CA: 45%, Qty: 35%, Marge: 20%) - const base = (scoreCa * 0.45) + (scoreQty * 0.35) + (scoreMarge * 0.20); + // Score de chaque axe = (Valeur Produit / Moyenne Fournisseur) × 100 + 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; - if (scoreQty > settings.seuilAxeFort) axesForts++; - if (scoreCa > settings.seuilAxeFort) axesForts++; - if (scoreMarge > settings.seuilAxeFort) axesForts++; + // Score final = moyenne pondérée + const scoreComposite = + settings.weightCA * scoreCa + + settings.weightVolume * scoreVolume + + settings.weightMarge * scoreMarge; - const bonus = axesForts * settings.bonusParAxe; - - data.row.score = Math.round(Math.min(base + bonus, 100) * 10) / 10; + // Arrondi à 1 décimale + data.row.score = Math.round(scoreComposite * 10) / 10; } return rows;