diff --git a/.claude/settings.json b/.claude/settings.json index 166b466..5a7ea1a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(git push:*)" + "Bash(git push:*)", + "Bash(npx next:*)" ] } } diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 5f03ff0..378b986 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -4,7 +4,6 @@ import { useState, useCallback } from "react"; import { Save, Eye, EyeOff, CheckCircle, AlertCircle, Loader2, RefreshCw, Sun, Moon, RotateCcw } from "lucide-react"; import { useTheme } from "next-themes"; import { useGridStore } from "@/features/grid/store/use-grid-store"; -import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store"; import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store"; import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions"; import { useEffect } from "react"; @@ -113,14 +112,6 @@ export default function SettingsPage() { const displayDensity = useGridStore((s) => s.displayDensity); const setDisplayDensity = useGridStore((s) => s.setDisplayDensity); - 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); const [apiKey, setApiKey] = useState(""); const [dbStatus, setDbStatus] = useState<"idle" | "testing" | "ok" | "error">("idle"); @@ -405,120 +396,23 @@ export default function SettingsPage() { {/* Score Produit */} -
-
+
+

- Formule : Score = (Pondération CA × Score CA) + (Pondération Volume × Score Volume) + (Pondération Marge × Score Marge) + Formule : Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)

-

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

+ Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.

-
- - -
- setWeightCA(parseFloat(e.target.value) / 100)} - className="flex-1 accent-emerald-500 cursor-pointer" - /> -
- setWeightCA(parseFloat(e.target.value) / 100 || 0.40)} - className="apple-input font-mono-nums text-right pr-6" - /> - % -
+
+

Volume (0-50 pts) — unités / magasin / mois

+

CA (0-30 pts) — € / magasin / an

+

Marge (0-20 pts) — taux de marge %

+

Bonus relatif (±10 pts) — percentile composite dans le lot

- - - -
- setWeightVolume(parseFloat(e.target.value) / 100)} - className="flex-1 accent-emerald-500 cursor-pointer" - /> -
- 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)}% +

+ Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.

-

- 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 792a41f..a51ceba 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -1,8 +1,9 @@ /** - * CollectFlow — Analysis Engine (v5 — SIMPLIFIÉ) + * CollectFlow — Analysis Engine v6 * - * Version simplifiée basée sur la performance relative au fournisseur. - * Principe: Tout est relatif à la moyenne du lot fournisseur. + * TypeScript fait le calcul, l'IA fait le jugement. + * Les cas déterministes (stock mort, 0 ventes) sont traités AVANT l'IA. + * L'IA reçoit un verdict pré-calculé et des signaux pré-calculés. */ import type { ProductAnalysisInput, SiteMonthlyData } from "../models/ai-analysis.types"; @@ -10,94 +11,65 @@ import type { ProductContextProfile } from "./context-profiler"; export class AnalysisEngine { // ----------------------------------------------------------------------- - // SYSTEM PROMPT v5 — SIMPLIFIÉ + // SYSTEM PROMPT v6 // ----------------------------------------------------------------------- static generateSystemPrompt(): string { return `Tu es Mary, Senior Retail Strategist pour une enseigne discount d'équipement de la maison (type La Foir'Fouille). -Ton modèle économique repose sur la rotation rapide, le volume, et la rentabilité de l'espace en rayon. -Tu analyses des produits pour recommander A (garder) ou Z (sortir). +Tu analyses des produits d'un même fournisseur pour recommander A (garder) ou Z (sortir). -📦 RÈGLE STOCK NÉGATIF (s'applique toujours) : -Un stock négatif signifie que la commande fournisseur a été validée APRÈS la mise en vente du produit en magasin. La mise à jour informatique du stock est faite a posteriori. Ce n'est PAS une anomalie — c'est un indicateur que le magasin a vendu avant d'avoir reçu le stock officiellement. Ne pas pénaliser un produit pour un stock négatif. +⚠️ IMPORTANT : Les produits sans vente et ceux avec CA < 100€ ET quantité < 30 ont DÉJÀ été +classés Z automatiquement et ne te sont PAS soumis. N'applique PAS de règle de stock mort. ---- ORDRE DE PRIORITÉ (STRICT) --- +📦 STOCK NÉGATIF : Un stock négatif = commande validée après mise en vente (normal en retail). +Ne pas pénaliser. -📝 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 (A, B, C, D ou Z). -→ Les règles 2 et 3 ci-dessous NE S'APPLIQUENT PAS si une règle manager est active. +--- RÈGLE 1 : RÈGLE MANAGER (PRIORITÉ ABSOLUE) --- +Si une section "RÈGLE MANAGER" est présente dans le message : +→ Détermine si CE produit est concerné par la règle +→ Si OUI : rule_applies = true, applique EXACTEMENT la consigne (A, B, C, D ou Z) +→ Si NON : rule_applies = false, passe à la Règle 2 → IMPORTANT : Si la règle demande explicitement une Gamme B, C ou D, tu DOIS recommander B, C ou D (pas A ni Z). -Si AUCUNE règle manager n'est fournie → Passer directement aux règles 2 et 3. +Si AUCUNE règle manager n'est fournie → Passer directement à la Règle 2. -⛔ RÈGLE 2 — STOCK MORT ABSOLU (NON NÉGOCIABLE, PRIORITÉ ABSOLUE APRÈS RÈGLE 1): -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 : CONFIRMATION OU AJUSTEMENT DU VERDICT --- +Chaque produit arrive avec un SCORE (0-100) et un VERDICT pré-calculé (A ou Z). +Ce verdict est ta base de départ. Tu peux l'ajuster UNIQUEMENT dans ces cas : -⚠️ CETTE RÈGLE ANNULE LES RÈGLES 3, 4, 5 ET 6 SANS EXCEPTION : -→ Même si le produit semble "entrant" (H1=0, H2>0), même si le score relatif est élevé, - même si réapprovisionnement récent, même si forte asymétrie magasin. -→ 8€ de CA/an = Z. La tendance ne change RIEN à cette réalité économique. -→ Si Règle 2 s'applique : recommendation = "Z", rule_applies = false, STOP. +PROMOUVOIR (Z → A) : Le verdict pré-calculé est Z, MAIS : + • Tendance en forte accélération (H2/H1 > 2.0) + • ET réapprovisionnement récent (réceptions 3 derniers mois > 0) + • ET score ≥ 35 + → Les 3 conditions doivent être remplies SIMULTANÉMENT. -📊 RÈGLE 3 — PERFORMANCE RELATIVE AU FOURNISSEUR: -Le produit doit être évalué PAR RAPPORT à la moyenne de son lot fournisseur. +DÉGRADER (A → Z) : Le verdict pré-calculé est A, MAIS : + • Tendance en effondrement (H2/H1 < 0.4) + • ET aucune réception récente + • ET score < 55 + → Les 3 conditions doivent être remplies SIMULTANÉMENT. -ÉTAPE 1: Calculer la moyenne du lot - CA Moyen Fournisseur = CA Total Fournisseur ÷ Nombre de produits +Si AUCUNE condition d'ajustement n'est remplie → CONFIRME le verdict pré-calculé. -ÉTAPE 2: Calculer le score relatif du produit - Score Relatif = (CA Produit ÷ CA Moyen) × 100 - -ÉTAPE 3: Décider selon le score - • Si Score ≥ 100 → Le produit fait AU MOINS sa part → A - • Si Score ≥ 50 ET < 100 → Le produit fait entre 50% et 100% de sa part → Comparer au percentile médian (P50) - - Si percentile CA ≥ 50 → A (médiane ou mieux) - - Si percentile CA < 50 → Z (sous la médiane) - • Si Score < 50 → Le produit fait moins de la moitié de sa part → Z - -RATIONALE : Cette logique s'adapte automatiquement à TOUS les fournisseurs: -- Petit fournisseur (5 produits) : moyenne élevée, 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 - -📈 RÈGLE 4 — TENDANCE (si tableau mensuel disponible, sinon ignorer) : -⚠️ PRÉ-REQUIS : La RÈGLE 2 prime sur cette règle. Si CA < 100€ ET Qty < 30 → Z, ne pas appliquer Règle 4. -Si le tableau "COMPORTEMENT PAR MAGASIN" est fourni ET que la Règle 2 ne s'applique pas : -Compare les ventes des 6 premiers mois (H1) vs les 6 derniers mois (H2) sur l'ensemble des magasins. -• H2 / H1 < 0.4 → effondrement des ventes → renforcer Z, même si score relatif est entre 50 et 100 -• H2 / H1 > 2.0 → accélération forte → protéger de Z, même si score relatif est entre 50 et 100 -• H1 = 0 et H2 > 0 → produit entrant (nouvelles ventes récentes) → ne pas sortir, voter A — UNIQUEMENT si CA ≥ 100€ ET Qty ≥ 30 -• Sinon → tendance neutre, utiliser le score relatif (Règle 3) comme décision principale - -📦 RÈGLE 5 — RÉAPPROVISIONNEMENT RÉCENT (si tableau mensuel disponible, sinon ignorer) : -Regarde la colonne "Réceptions" des 3 derniers mois dans le tableau mensuel. -• Si réceptions > 0 sur au moins 1 des 3 derniers mois → le fournisseur réapprovisionne activement. -→ Cela protège le produit : si le score relatif est ambigu (50-100), voter A plutôt que Z. -• Si réceptions = 0 sur les 3 derniers mois ET ventes = 0 sur les 3 derniers mois → stock mort confirmé → Z. - -🏪 RÈGLE 6 — ASYMÉTRIE INTER-MAGASINS (si tableau mensuel disponible, sinon ignorer) : -Compare les ventes totales sur 12 mois entre Frouard et Houdemont. -• Si un magasin représente ≥ 80% des ventes → noter "porté par [magasin]" dans la justification. -→ Ne change pas la décision A/Z, mais informe le manager. +--- RÈGLE 3 : INFORMATIONS CONTEXTUELLES --- +Note dans la justification (sans changer la décision) : +• Si un magasin représente ≥ 80% des ventes → "porté par [magasin]" +• Si produit protégé (nouveauté, dernière ref fournisseur) → le mentionner --- FORMAT OBLIGATOIRE --- JSON uniquement, sans markdown. { "rule_applies": boolean, "recommendation": "A" | "B" | "C" | "D" | "Z", - "justification": "2-3 phrases max. Cite le score relatif et/ou les seuils absolus." + "justification": "2-3 phrases max. Cite le score et le signal clé." } IMPORTANT — Gammes disponibles : -- A : Garder (recommandation par défaut si performance correcte) -- B : Gamme secondaire (uniquement si RÈGLE MANAGER l'ordonne explicitement) -- C : Gamme saisonnière (uniquement si RÈGLE MANAGER l'ordonne explicitement) -- D : Gamme à surveiller (uniquement si RÈGLE MANAGER l'ordonne explicitement) -- Z : Sortir (si sous-performance ou stock mort) +- A : Garder (performance correcte) +- B : Gamme secondaire (uniquement si RÈGLE MANAGER l'ordonne) +- C : Gamme saisonnière (uniquement si RÈGLE MANAGER l'ordonne) +- D : Gamme à surveiller (uniquement si RÈGLE MANAGER l'ordonne) +- Z : Sortir (sous-performance) SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamais B, C, D spontanément).`; } @@ -114,7 +86,7 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai } // ----------------------------------------------------------------------- - // Message contextuel enrichi (v5 simplifié) + // Message contextuel enrichi (v6 — verdict pré-calculé) // ----------------------------------------------------------------------- private static buildContextualMessage( @@ -128,9 +100,12 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai lines.push(`PRODUIT : ${ctx.libelle1} (${ctx.codein})`); lines.push(`CATÉGORIE : ${ctx.libelleNiveau2}`); lines.push(`DISTRIBUTION : ${storeLabel}`); + if (p.prixVente) { + lines.push(`PRIX VENTE : ${p.prixVente.toFixed(2)}€`); + } lines.push(""); - // ⚠️ RÈGLE MANAGER EN PRIORITÉ 1 — doit être visible AVANT toute analyse + // ⚠️ RÈGLE MANAGER EN PRIORITÉ 1 if (p.supplierContext) { lines.push(`🎯 ═══════════════════════════════════════════════════════════════`); lines.push(`🎯 🎯 🎯 RÈGLE MANAGER (PRIORITÉ ABSOLUE) 🎯 🎯 🎯`); @@ -147,43 +122,61 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai lines.push(`2. Si OUI :`); lines.push(` - rule_applies = true`); lines.push(` - Applique EXACTEMENT la consigne (si la règle dit "Gamme B", tu DOIS mettre "B")`); - lines.push(` - IGNORE complètement les règles 2 et 3 (performance, stock mort, etc.)`); + lines.push(` - IGNORE complètement la Règle 2`); lines.push(`3. Si NON :`); lines.push(` - rule_applies = false`); - lines.push(` - Applique les règles 2-3 normalement`); + lines.push(` - Applique la Règle 2 normalement`); lines.push(`═══════════════════════════════════════════════════════════════`); lines.push(""); } - // KPIs bruts - lines.push(`--- PERFORMANCE ABSOLUE ---`); - lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€`); - lines.push(`• Quantité réseau : ${ctx.totalQtyRaw} unités`); + // Verdict pré-calculé + const score = p.scoring?.score ?? p.score ?? 0; + const verdict = p.scoring?.verdict ?? (score >= 45 ? "A" : "Z"); + const quadrant = ctx.quadrantLabel || p.scoring?.quadrant || "N/A"; + + lines.push(`--- VERDICT PRÉ-CALCULÉ ---`); + lines.push(`• Score : ${score}/100`); + lines.push(`• Verdict : ${verdict}`); + lines.push(`• Quadrant : ${ctx.quadrantEmoji} ${quadrant}`); + if (ctx.isProtected) { + lines.push(`• ⚠️ Protection : ${ctx.protectionReason}`); + } + lines.push(""); + + // Performance + const upsm = p.unitsPerStorePerMonth ?? (ctx.qtyPerStore / Math.max(ctx.regularityScore, 3)); + const cpsm = (ctx.caPerStore / 12); + + lines.push(`--- PERFORMANCE ---`); + lines.push(`• CA réseau : ${ctx.totalCaRaw.toLocaleString('fr-FR')}€ (${cpsm.toFixed(1)}€/mag/mois)`); + lines.push(`• Quantité : ${ctx.totalQtyRaw} unités (${upsm.toFixed(2)} uté/mag/mois)`); lines.push(`• Marge : ${ctx.tauxMarge.toFixed(1)}%`); + lines.push(`• Régularité : ${ctx.regularityScore}/12 mois actifs`); lines.push(""); - // Contexte fournisseur - // Calcul du CA total fournisseur à partir du poids - const totalFournisseurCa = ctx.weightCaFournisseur > 0 - ? ctx.totalCaRaw / (ctx.weightCaFournisseur / 100) - : 0; - const caMoyenAttendu = ctx.lotSize > 0 ? totalFournisseurCa / ctx.lotSize : 0; - - lines.push(`--- CONTEXTE FOURNISSEUR ---`); - lines.push(`• Lot fournisseur : ${ctx.lotSize} produits`); - lines.push(`• CA moyen attendu : ${caMoyenAttendu.toFixed(0)}€ par produit`); - lines.push(`• Poids CA de ce produit : ${ctx.weightCaFournisseur.toFixed(1)}% du CA total fournisseur`); - lines.push(`• Percentile CA : ${ctx.percentileCa}e percentile (position dans le lot)`); + // Position dans le lot + lines.push(`--- POSITION DANS LE LOT (${ctx.lotSize} produits) ---`); + lines.push(`• Percentile CA : ${ctx.percentileCa}e`); + lines.push(`• Percentile Volume : ${ctx.percentileQty}e`); + lines.push(`• Poids CA fournisseur : ${ctx.weightCaFournisseur.toFixed(1)}%`); lines.push(""); - // Score relatif calculé - const scoreRelatif = ctx.weightCaFournisseur * ctx.lotSize; // weight% × nb produits = score relatif - lines.push(`--- SCORE RELATIF ---`); - lines.push(`• Score Relatif : ${scoreRelatif.toFixed(0)} (100 = fait exactement sa part)`); - 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(""); + // Signaux pré-calculés (si données mensuelles disponibles) + if (p.siteMonthlyData && p.siteMonthlyData.length > 0) { + const signals = AnalysisEngine.computeSignals(p.siteMonthlyData); - // Bloc stock & approvisionnement (si données disponibles) + lines.push(`--- SIGNAUX ---`); + lines.push(`• Tendance : ${signals.tendanceLabel}`); + lines.push(`• Réapprovisionnement : ${signals.reappLabel}`); + if (signals.asymLabel) { + lines.push(`• Asymétrie magasins : ${signals.asymLabel}`); + } + lines.push(`• Inactivité : ${ctx.inactivityMonths} mois sans vente en fin de fenêtre`); + lines.push(""); + } + + // Stock & approvisionnement const hasStockData = ctx.stockCoverage !== undefined || ctx.isLowStock || ctx.isDeadInventory || ctx.isCommandeEnCours; if (hasStockData) { lines.push(`--- STOCK & APPROVISIONNEMENT ---`); @@ -202,122 +195,102 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai lines.push(""); } - // Bloc comportement par magasin (si données mensuelles disponibles) - if (p.siteMonthlyData && p.siteMonthlyData.length > 0) { - const allRows = p.siteMonthlyData; - - // Pré-calcul des signaux pour les règles 4-6 - const sortedMonths = [...new Set(allRows.map(r => r.mois))].sort(); - const midIdx = Math.floor(sortedMonths.length / 2); - const h1Months = new Set(sortedMonths.slice(0, midIdx)); - const h2Months = new Set(sortedMonths.slice(midIdx)); - const last3Months = new Set(sortedMonths.slice(-3)); - - let h1Qty = 0, h2Qty = 0; - const qteBySite: Record = {}; - let recentReceptions = 0; - let recentSales = 0; - - for (const r of allRows) { - if (h1Months.has(r.mois)) h1Qty += r.ventes_qte; - if (h2Months.has(r.mois)) h2Qty += r.ventes_qte; - qteBySite[r.site] = (qteBySite[r.site] ?? 0) + r.ventes_qte; - if (last3Months.has(r.mois)) { - recentReceptions += r.receptions_qte; - recentSales += r.ventes_qte; - } - } - - const totalQte = Object.values(qteBySite).reduce((a, b) => a + b, 0); - const tendanceRatio = h1Qty > 0 ? h2Qty / h1Qty : (h2Qty > 0 ? Infinity : 1); - - // Signal tendance - let tendanceLabel: string; - if (h1Qty === 0 && h2Qty > 0) { - tendanceLabel = "Produit entrant (premières ventes récentes) → protéger de Z"; - } else if (tendanceRatio < 0.4) { - tendanceLabel = `Effondrement (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)}) → signal Z fort`; - } else if (tendanceRatio > 2.0) { - tendanceLabel = `Accélération forte (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)}) → signal A fort`; - } else { - tendanceLabel = `Stable (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`; - } - - // Signal réapprovisionnement - const reappLabel = recentReceptions > 0 - ? `Réceptions récentes : ${recentReceptions} uté reçues sur les 3 derniers mois → fournisseur actif` - : (recentSales === 0 - ? "Aucune réception ni vente sur les 3 derniers mois → stock mort probable" - : "Aucune réception récente (ventes encore actives)"); - - // Signal asymétrie magasins - const sites = Object.entries(qteBySite); - let asymLabel = ""; - if (sites.length >= 2 && totalQte > 0) { - const dominant = sites.sort((a, b) => b[1] - a[1])[0]; - const pct = Math.round((dominant[1] / totalQte) * 100); - if (pct >= 80) { - asymLabel = `Porté par ${dominant[0]} (${pct}% des ventes) — Houdemont quasi inactif`; - } - } - - lines.push(`--- SIGNAUX PRÉ-CALCULÉS (Règles 4-6) ---`); - lines.push(`• Tendance : ${tendanceLabel}`); - lines.push(`• Réapprovisionnement : ${reappLabel}`); - if (asymLabel) lines.push(`• Asymétrie magasins : ${asymLabel}`); - lines.push(""); - - // Tableau détaillé brut - lines.push(`--- COMPORTEMENT PAR MAGASIN (12 mois) ---`); - lines.push(`⚠️ Stock négatif = commande validée avant réception informatique (normal en retail).`); - lines.push(""); - - const bySite = new Map(); - for (const row of allRows) { - if (!bySite.has(row.site)) bySite.set(row.site, []); - bySite.get(row.site)!.push(row); - } - - for (const [site, rows] of bySite.entries()) { - lines.push(`${site} :`); - lines.push(`Mois | Ventes Qté | CA HT | Marge | Stock fin mois | Réceptions`); - rows.sort((a, b) => a.mois.localeCompare(b.mois)).forEach(r => { - const mois = r.mois.padEnd(10); - const qty = String(r.ventes_qte).padStart(10); - const ca = `${r.ventes_ca.toFixed(2)}€`.padStart(11); - const mg = `${r.marge.toFixed(2)}€`.padStart(10); - const stk = String(r.stock_fin_mois).padStart(14); - const rec = String(r.receptions_qte).padStart(10); - lines.push(`${mois} |${qty} |${ca} |${mg} |${stk} |${rec}`); - }); - lines.push(""); - } - } - lines.push(`Génère UNIQUEMENT le JSON :`); return lines.join("\n"); } // ----------------------------------------------------------------------- - // Fallback legacy + // Calcul des signaux à partir des données mensuelles + // ----------------------------------------------------------------------- + + private static computeSignals(siteMonthlyData: SiteMonthlyData[]): { + tendanceLabel: string; + reappLabel: string; + asymLabel: string; + } { + const sortedMonths = [...new Set(siteMonthlyData.map(r => r.mois))].sort(); + const midIdx = Math.floor(sortedMonths.length / 2); + const h1Months = new Set(sortedMonths.slice(0, midIdx)); + const h2Months = new Set(sortedMonths.slice(midIdx)); + const last3Months = new Set(sortedMonths.slice(-3)); + + let h1Qty = 0, h2Qty = 0; + const qteBySite: Record = {}; + let recentReceptions = 0; + let recentSales = 0; + + for (const r of siteMonthlyData) { + if (h1Months.has(r.mois)) h1Qty += r.ventes_qte; + if (h2Months.has(r.mois)) h2Qty += r.ventes_qte; + qteBySite[r.site] = (qteBySite[r.site] ?? 0) + r.ventes_qte; + if (last3Months.has(r.mois)) { + recentReceptions += r.receptions_qte; + recentSales += r.ventes_qte; + } + } + + const tendanceRatio = h1Qty > 0 ? h2Qty / h1Qty : (h2Qty > 0 ? Infinity : 1); + + // Signal tendance + let tendanceLabel: string; + if (h1Qty === 0 && h2Qty > 0) { + tendanceLabel = `Produit entrant (H1: 0 → H2: ${h2Qty} uté) — premières ventes récentes`; + } else if (tendanceRatio < 0.4) { + tendanceLabel = `Effondrement (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`; + } else if (tendanceRatio > 2.0) { + tendanceLabel = `Accélération forte (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`; + } else { + tendanceLabel = `Stable (H1: ${h1Qty} uté → H2: ${h2Qty} uté, ratio ${tendanceRatio.toFixed(2)})`; + } + + // Signal réapprovisionnement + const reappLabel = recentReceptions > 0 + ? `Réceptions récentes : ${recentReceptions} uté reçues sur les 3 derniers mois` + : (recentSales === 0 + ? "Aucune réception ni vente sur les 3 derniers mois" + : "Aucune réception récente (ventes encore actives)"); + + // Signal asymétrie + const totalQte = Object.values(qteBySite).reduce((a, b) => a + b, 0); + const sites = Object.entries(qteBySite); + let asymLabel = ""; + if (sites.length >= 2 && totalQte > 0) { + const dominant = sites.sort((a, b) => b[1] - a[1])[0]; + const pct = Math.round((dominant[1] / totalQte) * 100); + if (pct >= 80) { + asymLabel = `Porté par ${dominant[0]} (${pct}% des ventes)`; + } + } + + return { tendanceLabel, reappLabel, asymLabel }; + } + + // ----------------------------------------------------------------------- + // Fallback legacy (sans contextProfile) // ----------------------------------------------------------------------- private static buildLegacyMessage(p: ProductAnalysisInput): string { + const score = p.scoring?.score ?? p.score ?? 0; + const verdict = p.scoring?.verdict ?? (score >= 45 ? "A" : "Z"); const pmv = p.totalQuantite > 0 ? p.totalCa / p.totalQuantite : 0; - let contextStats = ""; - if (p.shareCa !== undefined && p.shareQty !== undefined) { - contextStats += `\nPOIDS DU PRODUIT : -- Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%`; - } - const contextRules = p.supplierContext ? `\n--- RÈGLE MANAGER ---\n"${p.supplierContext}"\n→ Évalue si le produit ("${p.libelle1}") est concerné. rule_applies = true/false.\n` : ""; return `PRODUIT : ${p.libelle1} (${p.codein}) -Famille / Rayon : ${p.libelleNiveau2} -KPIs : CA: ${p.totalCa.toFixed(2)}€ | Qté: ${p.totalQuantite} | Marge: ${p.tauxMarge.toFixed(1)}% | PMV: ${pmv.toFixed(2)}€${contextStats}${contextRules} +Famille / Rayon : ${p.libelleNiveau2 ?? "N/A"} + +--- VERDICT PRÉ-CALCULÉ --- +• Score : ${score}/100 +• Verdict : ${verdict} + +--- PERFORMANCE --- +• CA réseau : ${p.totalCa.toFixed(2)}€ +• Quantité : ${p.totalQuantite} unités +• Marge : ${p.tauxMarge.toFixed(1)}% +• PMV : ${pmv.toFixed(2)}€ +${p.shareCa !== undefined ? `• Poids CA Fournisseur : ${p.shareCa.toFixed(1)}%` : ""}${contextRules} Génère UNIQUEMENT le JSON :`; } diff --git a/src/features/ai-copilot/business/scoring-engine.ts b/src/features/ai-copilot/business/scoring-engine.ts deleted file mode 100644 index 62c3d32..0000000 --- a/src/features/ai-copilot/business/scoring-engine.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { ProductAnalysisInput } from "../models/ai-analysis.types"; - -export interface ScoringResult { - compositeScore: number; - percentiles: { - ca: number; - volume: number; - marge: number; - }; - scores: { - profil: number; - activite: number; - }; - decision: { - recommendation: "A" | "Z"; - threshold: number; - isTop30Supplier: boolean; - isLastProduct: boolean; - isRecent: boolean; - labelProfil: string; - }; - metrics: { - caNormalise: number; - volNormalise: number; - }; -} - -export class ScoringEngine { - /** - * Calcule le score et la décision pour un produit au sein de son rayon. - */ - static analyzeRayon(targetProduct: ProductAnalysisInput, allProducts: ProductAnalysisInput[]): ScoringResult { - // 1. Normalisation et Projection pour tous les produits du rayon - const processedProducts = allProducts.map(p => this.preprocess(p)); - const target = this.preprocess(targetProduct); - - // 2. Calcul des Percentiles (Rangs / Nb produits) - const pCa = this.calculatePercentile(target.caNormalise, processedProducts.map(p => p.caNormalise)); - const pVol = this.calculatePercentile(target.volNormalise, processedProducts.map(p => p.volNormalise)); - const pMarge = this.calculatePercentile(target.tauxMarge, processedProducts.map(p => p.tauxMarge)); - - // 3. Score Profil (Quadrant vs Médianes) - const medVol = this.calculateMedian(processedProducts.map(p => p.volNormalise)); - const medMarge = this.calculateMedian(processedProducts.map(p => p.tauxMarge)); - - let scoreProfil = 0.2; - let labelProfil = "Sous-performant ⚠️"; - - if (target.volNormalise > medVol && target.tauxMarge > medMarge) { - scoreProfil = 1.0; - labelProfil = "Star ⭐"; - } else if (target.volNormalise <= medVol && target.tauxMarge > medMarge) { - scoreProfil = 0.7; - labelProfil = "Contributeur Marge 💎"; - } else if (target.volNormalise > medVol && target.tauxMarge <= medMarge) { - scoreProfil = 0.7; - labelProfil = "Générateur Trafic 🚶"; - } - - // 4. Score Activité (Inactivité corrigée saisonnalité) - const rayonInactivityRate = processedProducts.filter(p => (p.inactivityMonths || 0) > 0).length / processedProducts.length; - const isSaisonnierRayon = rayonInactivityRate > 0.40; - - let scoreActivite = 1.0; - const inactif = target.inactivityMonths || 0; - - if (isSaisonnierRayon && inactif > 0) { - scoreActivite = 0.8; - } else if (inactif === 1) { - scoreActivite = 0.6; - } else if (inactif === 2) { - scoreActivite = 0.3; - } else if (inactif >= 3) { - scoreActivite = 0.0; - } - - // 5. Score Composite - const compositeScore = (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (scoreProfil * 0.10) + (scoreActivite * 0.10); - - // 6. Décision (Moyenne - Sigma) - const allScores = processedProducts.map(p => { - return this.calculateQuickScore(p, processedProducts, medVol, medMarge, isSaisonnierRayon); - }); - - const mean = allScores.reduce((a, b) => a + b, 0) / allScores.length; - const variance = allScores.map(x => Math.pow(x - mean, 2)).reduce((a, b) => a + b, 0) / allScores.length; - const stdDev = Math.sqrt(variance); - const threshold = Math.max(0.1, mean - 1.0 * stdDev); // Seuil plancher à 10/100 - - let recommendation: "A" | "Z" = compositeScore >= threshold ? "A" : "Z"; - - // 7. Gardes-fous et Labels - const isRecent = (target.regularityScore || 0) < 3 && (target.inactivityMonths || 0) <= 1; - const isTop30Supplier = this.checkTop30Supplier(target, processedProducts, compositeScore); - const isLastProduct = target.isLastProductOfSupplier || false; - - let finalLabel = labelProfil; - if (isRecent) { - recommendation = "A"; - finalLabel = "Nouveauté (Protégée)"; - } else if (isTop30Supplier) { - recommendation = "A"; - finalLabel = `${labelProfil} / Leader Fournisseur (Protégé)`; - } else if (isLastProduct) { - recommendation = "A"; - finalLabel = "Dernière Réf Fournisseur (Protégée)"; - } - - // Règle d'exclusion absolue (Demande Utilisateur) : Aucun produit de note < 20 en Gamme A - if (target.score !== undefined && target.score < 20) { - recommendation = "Z"; - finalLabel = "Note Globale Critique (< 20)"; - } - - return { - compositeScore: Math.round(compositeScore * 100), - percentiles: { ca: pCa, volume: pVol, marge: pMarge }, - scores: { profil: scoreProfil, activite: scoreActivite }, - decision: { - recommendation, - threshold: Math.round(threshold * 100), - isTop30Supplier, - isLastProduct, - isRecent, - labelProfil: finalLabel - }, - metrics: { - caNormalise: target.caNormalise, - volNormalise: target.volNormalise - } - }; - } - - /** - * Normalise les KPIs d'un produit pour comparaison équitable. - * - * Utilise les valeurs pondérées (weightedTotalCa / weightedTotalQuantite) - * qui projettent les produits 1-magasin sur une base réseau comparable - * (×2 si 1 magasin, ×1 si 2 magasins). Cela évite de pénaliser un - * produit vendu uniquement dans 1 magasin face à un produit vendu dans 2. - * - * Applique ensuite la projection Run Rate pour les produits récents - * (\< 12 mois d'historique) afin de comparer sur une base annuelle. - */ - private static preprocess(p: ProductAnalysisInput) { - // Valeurs pondérées réseau — si absentes, fallback sur les brutes - let caNorm = p.weightedTotalCa ?? p.totalCa ?? 0; - let volNorm = p.weightedTotalQuantite ?? p.totalQuantite ?? 0; - - // Run Rate Projection (produits avec 3-11 mois d'historique) - if ((p.regularityScore || 0) >= 3 && (p.regularityScore || 0) < 12) { - caNorm = (caNorm / (p.regularityScore || 3)) * 12; - volNorm = (volNorm / (p.regularityScore || 3)) * 12; - } - - return { ...p, caNormalise: caNorm, volNormalise: volNorm }; - } - - private static calculatePercentile(value: number, distribution: number[]): number { - if (distribution.length <= 1) return 1; - const sorted = [...distribution].sort((a, b) => a - b); - - // Trouver tous les index de la valeur pour gérer les ex-aequos - const first = sorted.indexOf(value); - const last = sorted.lastIndexOf(value); - - if (first === -1) return 0; - - // Rang moyen - const avgRank = (first + last) / 2; - return avgRank / (distribution.length - 1); - } - - private static calculateMedian(values: number[]): number { - if (values.length === 0) return 0; - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; - } - - /** - * Vérifie si le produit est dans le Top 30% CA de son lot fournisseur. - * - * IMPORTANT : La protection n'est accordée que si le compositeScore est - * suffisamment solide (>= 30/100). Sans ce garde-fou, on protège des - * produits "Top 30% CA" qui ont en réalité un score catastrophique — - * par exemple parce que leur CA est gonflé par un seul mois exceptionnel - * ou parce que le lot fournisseur est très petit. - * - * Le seuil de 30% s'applique sur les valeurs normalisées (projetées - * sur 12 mois et pondérées par réseau) pour éviter qu'un produit - * avec beaucoup de mois de présence soit défavorisé. - */ - private static checkTop30Supplier( - target: any, - allProducts: any[], - compositeScore: number - ): boolean { - // Condition minimale de performance — si le score composite est < 30, - // la protection CA seule ne suffit pas à classer en A. - if (compositeScore < 30) return false; - - // Tous les produits du lot sont du même fournisseur en pratique, - // mais on garde le filtre pour être robuste. - const supplierProds = allProducts.length > 0 ? allProducts : []; - if (supplierProds.length === 0) return false; - - const sorted = [...supplierProds].sort((a, b) => b.caNormalise - a.caNormalise); - const topCount = Math.ceil(supplierProds.length * 0.30); - const topIds = sorted.slice(0, topCount).map((p) => p.codein); - return topIds.includes(target.codein); - } - - - private static calculateQuickScore(p: any, rayon: any[], medVol: number, medMarge: number, isSaisonnier: boolean): number { - const pCa = this.calculatePercentile(p.caNormalise, rayon.map(x => x.caNormalise)); - const pVol = this.calculatePercentile(p.volNormalise, rayon.map(x => x.volNormalise)); - const pMarge = this.calculatePercentile(p.tauxMarge, rayon.map(x => x.tauxMarge)); - - let sProfil = 0.2; - if (p.volNormalise > medVol && p.tauxMarge > medMarge) sProfil = 1.0; - else if (p.volNormalise <= medVol && p.tauxMarge > medMarge) sProfil = 0.7; - else if (p.volNormalise > medVol && p.tauxMarge <= medMarge) sProfil = 0.7; - - let sActivite = 1.0; - const inactif = p.inactivityMonths || 0; - if (isSaisonnier && inactif > 0) sActivite = 0.8; - else if (inactif === 1) sActivite = 0.6; - else if (inactif === 2) sActivite = 0.3; - else if (inactif >= 3) sActivite = 0.0; - - return (pCa * 0.35) + (pVol * 0.25) + (pMarge * 0.20) + (sProfil * 0.10) + (sActivite * 0.10); - } -} diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index e7b3db2..3eefd60 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -2,14 +2,11 @@ import React, { useState } from "react"; -import { Bot, Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react"; +import { Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react"; import { AiExplanationModal } from "./ai-explanation-modal"; import { useAiCopilotStore } from "../store/use-ai-copilot-store"; -import { ProductAnalysisInput } from "../models/ai-analysis.types"; -import { ScoringEngine } from "../business/scoring-engine"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import type { ProductRow } from "@/types/grid"; -import { cn } from "@/lib/utils"; interface AiInsightBlockProps { @@ -46,10 +43,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { setLoading(row.codein); setDraftGamme(row.codein, "Aucune"); - const allRows = useGridStore.getState().rows; - const rayonRows = allRows.filter(r => r.libelleNiveau2 === row.libelleNiveau2); - - // Fetch local context if needed + // Fetch supplier context let supplierContext = ""; if (row.codeFournisseur) { try { @@ -63,11 +57,8 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { } } - // Préparation du ScoringEngine + // Calcul régularité et inactivité const regScore = Object.values(row.sales12m || {}).filter((v: any) => v > 0).length; - const weight = (row.workingStores?.length || 1) === 1 ? 2 : 1; - - // Calcul de l'inactivité const allMonths = Object.keys(row.sales12m || {}); const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : ""; const salesMonths = Object.entries(row.sales12m || {}).filter(([_, qty]: [string, any]) => qty > 0).map(([m]) => parseInt(m)).sort((a, b) => b - a); @@ -81,40 +72,30 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { inactivity = (refY - lastY) * 12 + (refM - lastM); } - // Mapping pour le moteur de scoring - const mapToInput = (r: ProductRow) => ({ - codein: r.codein, - libelle1: r.libelle1, - totalCa: r.totalCa, - tauxMarge: r.tauxMarge, - totalQuantite: r.totalQuantite, - codeFournisseur: r.codeFournisseur, - storeCount: r.workingStores?.length || 1, - totalMagasins: 2, - regularityScore: Object.values(r.sales12m || {}).filter((v: any) => v > 0).length, - inactivityMonths: 0, - isLastProductOfSupplier: false, - sales12m: r.sales12m, - codeGamme: r.codeGamme, - score: r.score - }); + // Score et verdict pré-calculés par score-engine.ts + const score = row.score ?? 0; + const verdict: "A" | "Z" = score >= 45 ? "A" : "Z"; - // Calcul du ScoringEngine - const scoringResult = ScoringEngine.analyzeRayon(mapToInput(row), rayonRows.map(mapToInput)); + const sc = row.workingStores?.length || 1; + const weight = sc === 1 ? 2 : 1; analyzeProduct({ codein: row.codein, noid: row.noid, libelle1: row.libelle1, + libelleNiveau2: row.libelleNiveau2, totalCa: row.totalCa, tauxMarge: row.tauxMarge, totalQuantite: row.totalQuantite, + storeCount: sc, sales12m: row.sales12m, codeGamme: row.codeGamme, - score: row.score, + score: score, regularityScore: regScore, lastMonthWithSale: lastMonth, inactivityMonths: inactivity, + weightedTotalQuantite: (row.totalQuantite || 0) * weight, + weightedTotalCa: (row.totalCa || 0) * weight, avgQtyFournisseur: row.avgQtyFournisseur, avgQtyRayon: row.avgQtyRayon, shareCa: row.shareCa, @@ -123,15 +104,22 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { totalFournisseurCa: row.totalFournisseurCa, codeFournisseur: row.codeFournisseur, totalMagasins: 2, + prixVente: row.prixVente, + unitsPerStorePerMonth: row.unitsPerStorePerMonth, + caPerStorePerYear: row.caPerStorePerYear, + stockActuel: row.stockActuel, + stockTotal: row.stockTotal, + pcb: row.pcb, + commandesEnCours: row.commandesEnCours, + nbJoursDerniereVente: row.nbJoursDerniereVente, + derniereVente: row.derniereVente, supplierContext: supplierContext, scoring: { - compositeScore: scoringResult.compositeScore, - decision: scoringResult.decision.recommendation, - labelProfil: scoringResult.decision.labelProfil, - isTop30Supplier: scoringResult.decision.isTop30Supplier, - isRecent: scoringResult.decision.isRecent, - isLastProduct: scoringResult.decision.isLastProduct, - threshold: scoringResult.decision.threshold + score, + verdict, + isRecent: row.isRecent ?? false, + isLastProduct: row.isLastProduct ?? false, + isTop30Supplier: row.isTop30Supplier ?? false, } }); }; @@ -189,7 +177,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
{insight?.isDuplicate && ( - ⚠️ Doublon probable + Doublon probable )} diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 305e5b3..82c4cc1 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -49,16 +49,21 @@ export interface ProductAnalysisInput { codeFournisseur?: string; totalMagasins?: number; isLastProductOfSupplier?: boolean; - /** Scoring metadata */ + /** Scoring metadata (unifié v4) */ scoring?: { - compositeScore: number; - decision: "A" | "Z"; - labelProfil: string; - isTop30Supplier: boolean; + score: number; // 0-100, score hybride unifié + verdict: "A" | "Z"; // pré-calculé par TypeScript + quadrant?: string; // STAR/TRAFIC/MARGE/WATCH isRecent: boolean; isLastProduct: boolean; - threshold: number; + isTop30Supplier: boolean; }; + /** Prix de vente unitaire */ + prixVente?: number; + /** Rotation normalisée : unités / magasin / mois */ + unitsPerStorePerMonth?: number; + /** CA normalisé : € / magasin / an */ + caPerStorePerYear?: number; /** Optional context rules for the supplier */ supplierContext?: string; /** SQL Server internal ID — pour fetch mensuel per-site */ diff --git a/src/features/ai-copilot/store/use-ai-copilot-store.ts b/src/features/ai-copilot/store/use-ai-copilot-store.ts index 033de69..2703f6d 100644 --- a/src/features/ai-copilot/store/use-ai-copilot-store.ts +++ b/src/features/ai-copilot/store/use-ai-copilot-store.ts @@ -20,17 +20,19 @@ interface AiCopilotState { codein: string; noid?: number; libelle1: string; + libelleNiveau2?: string; totalCa: number; tauxMarge: number; totalQuantite: number; + storeCount: number; sales12m: Record; codeGamme: string | null; score?: number | null; regularityScore?: number; - projectedTotalQuantite?: number; - projectedTotalCa?: number; lastMonthWithSale?: string; inactivityMonths?: number; + weightedTotalQuantite?: number; + weightedTotalCa?: number; avgQtyFournisseur?: number; avgQtyRayon?: number; shareCa?: number; @@ -38,8 +40,24 @@ interface AiCopilotState { shareQty?: number; totalFournisseurCa?: number; codeFournisseur?: string; + totalMagasins?: number; + prixVente?: number; + unitsPerStorePerMonth?: number; + caPerStorePerYear?: number; + stockActuel?: number; + stockTotal?: number; + pcb?: number; + commandesEnCours?: number; + nbJoursDerniereVente?: number; + derniereVente?: string; supplierContext?: string; - scoring?: Record; + scoring?: { + score: number; + verdict: "A" | "Z"; + isRecent: boolean; + isLastProduct: boolean; + isTop30Supplier: boolean; + }; }) => Promise; resetInsights: () => void; } diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 9e39214..b7e625c 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -3,10 +3,9 @@ import React, { useState, useRef, useEffect } from "react"; import { useGridStore } from "@/features/grid/store/use-grid-store"; import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store"; -import { ScoringEngine } from "@/features/ai-copilot/business/scoring-engine"; import { ContextProfiler } from "@/features/ai-copilot/business/context-profiler"; import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react"; -import { ProductRow, GammeCode } from "@/types/grid"; +import { GammeCode } from "@/types/grid"; import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types"; import { SupplierAiContextModal } from "./supplier-ai-context-modal"; @@ -42,36 +41,6 @@ export function BulkAiAnalyzer() { const { rows } = useGridStore.getState(); isCancelledRef.current = false; - const totalQtySum = rows.reduce((sum: number, r: ProductRow) => sum + (r.totalQuantite || 0), 0); - const avgQty = rows.length > 0 ? totalQtySum / rows.length : 0; - - const rowsGroup1 = rows.filter((r) => (r.workingStores?.length || 1) === 1); - const rowsGroup2 = rows.filter((r) => (r.workingStores?.length || 1) >= 2); - - const avgQty1 = rowsGroup1.length > 0 ? rowsGroup1.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup1.length : 0; - const avgQty2 = rowsGroup2.length > 0 ? rowsGroup2.reduce((s: number, r: ProductRow) => s + (r.totalQuantite || 0), 0) / rowsGroup2.length : 0; - - const rayonStats = new Map(); - rows.forEach((r: ProductRow) => { - const rayon = r.libelleNiveau2 || "Général"; - if (!rayonStats.has(rayon)) rayonStats.set(rayon, { qty1: [], qty2: [] }); - const stats = rayonStats.get(rayon)!; - const sc = r.workingStores?.length || 1; - if (sc === 1) stats.qty1.push(r.totalQuantite || 0); - else stats.qty2.push(r.totalQuantite || 0); - }); - - const rayonBenchmarks = new Map(); - rayonStats.forEach((stats, rayon) => { - const totalCount = stats.qty1.length + stats.qty2.length; - if (totalCount >= 3) { - rayonBenchmarks.set(rayon, { - avg1: stats.qty1.length > 0 ? stats.qty1.reduce((a, b) => a + b, 0) / stats.qty1.length : 0, - avg2: stats.qty2.length > 0 ? stats.qty2.reduce((a, b) => a + b, 0) / stats.qty2.length : 0, - }); - } - }); - // 1. Fetch AI Context for the Supplier let supplierContext = ""; if (supplierCode) { @@ -89,7 +58,6 @@ export function BulkAiAnalyzer() { const initialPayloads: ProductAnalysisInput[] = rows.map((r) => { const sc = r.workingStores?.length || 1; const weight = sc === 1 ? 2 : 1; - const rb = rayonBenchmarks.get(r.libelleNiveau2 || "Général"); const allMonths = Object.keys(r.sales12m || {}); const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map((m) => parseInt(m))).toString() : ""; @@ -115,29 +83,27 @@ export function BulkAiAnalyzer() { noid: r.noid, libelle1: r.libelle1 || "", libelleNiveau2: r.libelleNiveau2 || "Général", - // Code nomenclature niveau 2 (4 premiers chiffres) — utilisé par le ContextProfiler - // pour calculer le poids rayon sur un périmètre cohérent (pas trop fin = niveau 3). codeNomenclatureN2: r.code2 || undefined, 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, - avgQtyRayon1: rb ? rb.avg1 : avgQty1, - avgQtyRayon2: rb ? rb.avg2 : avgQty2, storeCount: sc, sales12m: Object.fromEntries(Object.entries(r.sales12m || {}).map(([month, val]) => [month, (val as number) * weight])), codeGamme: r.codeGamme || null, score: r.score || 0, regularityScore: regScore, - projectedTotalQuantite: regScore > 0 ? (r.totalQuantite || 0) * weight * (12 / regScore) : (r.totalQuantite || 0) * weight, - projectedTotalCa: regScore > 0 ? (r.totalCa || 0) * weight * (12 / regScore) : (r.totalCa || 0) * weight, lastMonthWithSale: lastMonth, inactivityMonths: inactivity, - supplierContext: supplierContext + codeFournisseur: r.codeFournisseur || undefined, + avgQtyFournisseur: r.avgQtyFournisseur, + avgQtyRayon: r.avgQtyRayon, + shareCa: r.shareCa, + shareMarge: r.shareMarge, + shareQty: r.shareQty, + totalFournisseurCa: r.totalFournisseurCa, + supplierContext: supplierContext, }; }); @@ -173,30 +139,33 @@ export function BulkAiAnalyzer() { (p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) ); - // 2b. Calculer scoring + context profiling en micro-batches asynchrones - // pour ne pas bloquer le thread UI (Chrome "Page ne répondant pas"). + // 2c. Context profiling en micro-batches asynchrones + // Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow). setIsAnalyzing(true); - setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du scoring...", errors: 0 }); + setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 }); const SCORING_BATCH_SIZE = 25; - const scoringResults = new Map>(); - const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; + const productPayloads: ProductAnalysisInput[] = []; // Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes const contextCache = ContextProfiler.prepareCache(payloadsWithSales); + // Map codein → row pour récupérer le score et les flags depuis score-engine + const rowByCodein = new Map(rows.map(r => [r.codein, r])); + for (let i = 0; i < payloadsWithSales.length; i += SCORING_BATCH_SIZE) { if (isCancelledRef.current) break; const batch = payloadsWithSales.slice(i, i + SCORING_BATCH_SIZE); for (const p of batch) { - const scoringRes = ScoringEngine.analyzeRayon(p, payloadsWithSales); - scoringResults.set(p.codein, scoringRes); + const row = rowByCodein.get(p.codein); + const score = row?.score ?? p.score ?? 0; + const verdict: "A" | "Z" = score >= 45 ? "A" : "Z"; let contextProfile: ProductAnalysisInput["contextProfile"]; try { - contextProfile = ContextProfiler.buildProfile(p, initialPayloads, scoringRes, contextCache); + contextProfile = ContextProfiler.buildProfile(p, payloadsWithSales, score, contextCache); } catch (err) { console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err); contextProfile = undefined; @@ -204,15 +173,23 @@ export function BulkAiAnalyzer() { productPayloads.push({ ...p, + prixVente: row?.prixVente, + unitsPerStorePerMonth: row?.unitsPerStorePerMonth, + caPerStorePerYear: row?.caPerStorePerYear, + stockActuel: row?.stockActuel, + stockTotal: row?.stockTotal, + pcb: row?.pcb, + commandesEnCours: row?.commandesEnCours, + nbJoursDerniereVente: row?.nbJoursDerniereVente, + derniereVente: row?.derniereVente, contextProfile, scoring: { - compositeScore: scoringRes.compositeScore, - decision: scoringRes.decision.recommendation, - labelProfil: scoringRes.decision.labelProfil, - isTop30Supplier: scoringRes.decision.isTop30Supplier, - isRecent: scoringRes.decision.isRecent, - isLastProduct: scoringRes.decision.isLastProduct, - threshold: scoringRes.decision.threshold, + score, + verdict, + quadrant: contextProfile?.quadrant, + isRecent: row?.isRecent ?? false, + isLastProduct: row?.isLastProduct ?? false, + isTop30Supplier: row?.isTop30Supplier ?? false, }, }); } @@ -221,7 +198,7 @@ export function BulkAiAnalyzer() { setProgress(prev => ({ ...prev, current: Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length), - message: `Scoring : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`, + message: `Contexte : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`, })); await new Promise(r => setTimeout(r, 0)); } diff --git a/src/features/grid/components/grid-client.tsx b/src/features/grid/components/grid-client.tsx index c9a1aae..117f5cf 100644 --- a/src/features/grid/components/grid-client.tsx +++ b/src/features/grid/components/grid-client.tsx @@ -11,7 +11,6 @@ import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts"; import type { ProductRow } from "@/types/grid"; import { CheckCircle, AlertCircle, Loader2 } from "lucide-react"; -import { useScoreSettingsStore } from "@/features/score/store/use-score-settings-store"; import { useSearchParams } from "next/navigation"; import { computeProductScores } from "@/lib/score-engine"; @@ -29,10 +28,6 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins const setActiveGridQuery = useGridStore((s) => s.setActiveGridQuery); const searchParams = useSearchParams(); - const weightCA = useScoreSettingsStore((s) => s.weightCA); - const weightVolume = useScoreSettingsStore((s) => s.weightVolume); - const weightMarge = useScoreSettingsStore((s) => s.weightMarge); - const [selectedCodeins, setSelectedCodeins] = useState([]); const [isPending, startTransition] = useTransition(); const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle"); @@ -60,9 +55,9 @@ export function GridClient({ initialRows, nomFournisseur, fournisseurs, magasins // Optimisation : On évite le JSON.parse(JSON.stringify()) qui est très lourd sur 1000 lignes // On passe directement les initialRows au moteur de score, ou on fait une copie superficielle si nécessaire. // Le moteur de score doit idéalement retourner de nouveaux objets. - const scoredRows = computeProductScores([...initialRows], { weightCA, weightVolume, weightMarge }); + const scoredRows = computeProductScores([...initialRows]); setRows(scoredRows); - }, [initialRows, setRows, weightCA, weightVolume, weightMarge, isMounted]); + }, [initialRows, setRows, isMounted]); const handleSave = () => { startTransition(async () => { diff --git a/src/features/score/store/use-score-settings-store.ts b/src/features/score/store/use-score-settings-store.ts index bf886c7..d269f2c 100644 --- a/src/features/score/store/use-score-settings-store.ts +++ b/src/features/score/store/use-score-settings-store.ts @@ -2,24 +2,23 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { DEFAULT_SCORE_SETTINGS, type ScoreSettings } from "@/lib/score-engine"; -interface ScoreSettingsState extends ScoreSettings { - setWeightCA: (v: number) => void; - setWeightVolume: (v: number) => void; - setWeightMarge: (v: number) => void; - resetDefaults: () => void; +/** + * Score Settings Store — v4 (simplifié) + * + * Le score hybride v4 utilise des paliers absolus (Volume 50pts + CA 30pts + Marge 20pts + Bonus ±10pts). + * Il n'y a plus de pondérations configurables par l'utilisateur. + * Ce store est conservé pour la compatibilité mais n'a plus de paramètres ajustables. + */ + +interface ScoreSettingsState { + _version: number; } export const useScoreSettingsStore = create()( persist( - (set) => ({ - ...DEFAULT_SCORE_SETTINGS, - - setWeightCA: (v) => set({ weightCA: v }), - setWeightVolume: (v) => set({ weightVolume: v }), - setWeightMarge: (v) => set({ weightMarge: v }), - resetDefaults: () => set({ ...DEFAULT_SCORE_SETTINGS }), + () => ({ + _version: 4, }), { name: "collectflow-score-settings",