diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 4146834..792a41f 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -32,11 +32,17 @@ Si une règle manager est définie (section "RÈGLE MANAGER" dans le message) ET Si AUCUNE règle manager n'est fournie → Passer directement aux règles 2 et 3. -⛔ RÈGLE 2 — STOCK MORT ABSOLU: +⛔ 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. +⚠️ 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. + 📊 RÈGLE 3 — PERFORMANCE RELATIVE AU FOURNISSEUR: Le produit doit être évalué PAR RAPPORT à la moyenne de son lot fournisseur. @@ -59,11 +65,12 @@ RATIONALE : Cette logique s'adapte automatiquement à TOUS les fournisseurs: - 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) : -Si le tableau "COMPORTEMENT PAR MAGASIN" est fourni, calcule la tendance sur les 12 mois. +⚠️ 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 +• 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) : diff --git a/src/features/ai-copilot/business/context-profiler.ts b/src/features/ai-copilot/business/context-profiler.ts index 2a081be..3ea2231 100644 --- a/src/features/ai-copilot/business/context-profiler.ts +++ b/src/features/ai-copilot/business/context-profiler.ts @@ -14,7 +14,6 @@ */ import type { ProductAnalysisInput } from "../models/ai-analysis.types"; -import type { ScoringResult } from "./scoring-engine"; // --------------------------------------------------------------------------- // Constante : seuil minimal de produits dans un rayon pour activer les signaux @@ -266,7 +265,7 @@ export class ContextProfiler { static buildProfile( target: ProductAnalysisInput, allProds: ProductAnalysisInput[], - scoring: ScoringResult, + score: number, cache?: ContextProfilerCache ): ProductContextProfile { if (allProds.length === 0) { @@ -298,7 +297,7 @@ export class ContextProfiler { const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted); const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted); const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted); - const pComposite = scoring.compositeScore; + const pComposite = score; // 4. Tops 20% const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold; @@ -354,18 +353,23 @@ export class ContextProfiler { ); // 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final) - const isProtected = - scoring.decision.isRecent || - scoring.decision.isTop30Supplier || - scoring.decision.isLastProduct; + // Les flags isRecent/isTop30Supplier/isLastProduct sont désormais + // injectés directement sur ProductRow par score-engine.ts. + // On les lit depuis le scoring payload transmis par le bulk analyzer. + const targetExt = target as ProductAnalysisInput & { isRecent?: boolean; isTop30Supplier?: boolean; isLastProduct?: boolean }; + const isRecentFlag = targetExt.isRecent ?? false; + const isTop30Flag = targetExt.isTop30Supplier ?? false; + const isLastFlag = targetExt.isLastProduct ?? false; + + const isProtected = isRecentFlag || isTop30Flag || isLastFlag; let protectionReason = ""; - if (scoring.decision.isRecent) protectionReason = "Nouveauté (< 3 mois de données)"; - else if (scoring.decision.isTop30Supplier) protectionReason = "Top 30% CA Fournisseur"; - else if (scoring.decision.isLastProduct) protectionReason = "Dernière référence du fournisseur"; + if (isRecentFlag) protectionReason = "Nouveauté (< 3 mois de données)"; + else if (isTop30Flag) protectionReason = "Top 30% CA Fournisseur"; + else if (isLastFlag) protectionReason = "Dernière référence du fournisseur"; // 9. Règle absolue - const scoreCritique = (target.score ?? 0) < 20; + const scoreCritique = score < 20; return { codein: target.codein, diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index aed37c7..e7b3db2 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -35,6 +35,13 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { return; } + // Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, aucune analyse IA + if ((row.totalCa ?? 0) < 100 && (row.totalQuantite ?? 0) < 30) { + setDraftGamme(row.codein, "Z"); + useAiCopilotStore.getState().setInsight(row.codein, `CA < 100€ (${(row.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${row.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`); + return; + } + // Reset Visuel Immédiat setLoading(row.codein); setDraftGamme(row.codein, "Aucune"); diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 817f8b5..9e39214 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -153,8 +153,25 @@ export function BulkAiAnalyzer() { }); } - // Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente - const payloadsWithSales = initialPayloads.filter(p => (p.totalQuantite || 0) > 0); + // 2b. Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, sans appel IA + const deadStockRows = rows.filter(r => + (r.totalQuantite || 0) > 0 && + (r.totalCa ?? 0) < 100 && (r.totalQuantite ?? 0) < 30 + ); + if (deadStockRows.length > 0) { + const zChanges: Record = {}; + deadStockRows.forEach(r => { zChanges[r.codein] = "Z"; }); + batchSetDraftGamme(zChanges); + deadStockRows.forEach(r => { + setInsight(r.codein, `CA < 100€ (${(r.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${r.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`); + }); + } + const deadStockCodes = new Set(deadStockRows.map(r => r.codein)); + + // Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants + const payloadsWithSales = initialPayloads.filter(p => + (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"). diff --git a/src/lib/score-engine.ts b/src/lib/score-engine.ts index b8ef276..560809a 100644 --- a/src/lib/score-engine.ts +++ b/src/lib/score-engine.ts @@ -1,32 +1,62 @@ /** - * CollectFlow — Score Engine v3 (Percentile Composite) + * CollectFlow — Score Engine v4 (Hybride Absolu + Relatif) * - * Calcule le score de performance d'un produit par rang percentile au sein - * de son lot fournisseur sur 3 axes : CA, Quantité, Taux de Marge. + * Calcule le score de performance d'un produit en combinant : + * - Performance ABSOLUE : Volume (50pts), CA (30pts), Marge (20pts) + * - Ajustement RELATIF : Position dans le lot fournisseur (±10pts) + * - Pénalité de régularité : multiplicateur si inactivité prolongée * - * Formule : - * Score = (40% × Percentile_CA) + (35% × Percentile_Volume) + (25% × Percentile_Marge) - * - * Où chaque Percentile_Axe est le rang du produit dans son lot fournisseur (0-100). + * Score final : 0-100, sensible au prix unitaire. * * Exemples : - * - Score = 100 → Meilleur produit du fournisseur sur tous les axes - * - Score = 50 → Produit médian du fournisseur - * - Score = 0 → Dernier produit du fournisseur sur tous les axes - * - * Avantages : - * - Borné 0-100 (plus de scores > 150 pour des petits fournisseurs) - * - Robuste aux fournisseurs avec peu de références ou faibles volumes - * - Cohérent avec le scoring IA (scoring-engine.ts) qui utilise aussi les percentiles + * - 0.80€ × 100 unités (80€ CA) → ~53 (bon trafic malgré petit CA) + * - 40€ × 2 unités (80€ CA) → ~19 (faible rotation) + * - 500€ CA, 200u, 45% marge → ~90 (star) + * - 8€ CA, 2 unités → ~13 (stock mort) */ import type { ProductRow } from "@/types/grid"; -/** - * Calcule les rangs percentiles (0-100) pour un tableau de valeurs numériques. - * Les ex-æquo reçoivent le rang moyen de leur groupe. - * Cas limite : tableau à 1 élément → [50] (médiane). - */ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Interpolation linéaire entre deux paliers */ +function lerp(value: number, minVal: number, maxVal: number, minPts: number, maxPts: number): number { + if (value >= maxVal) return maxPts; + if (value <= minVal) return minPts; + return minPts + ((value - minVal) / (maxVal - minVal)) * (maxPts - minPts); +} + +/** Score Volume (0-50) basé sur unités / magasin / mois */ +function computeVolumeScore(unitsPerStorePerMonth: number): number { + if (unitsPerStorePerMonth >= 10) return 50; + if (unitsPerStorePerMonth >= 5) return lerp(unitsPerStorePerMonth, 5, 10, 40, 50); + if (unitsPerStorePerMonth >= 2) return lerp(unitsPerStorePerMonth, 2, 5, 30, 40); + if (unitsPerStorePerMonth >= 1) return lerp(unitsPerStorePerMonth, 1, 2, 20, 30); + if (unitsPerStorePerMonth >= 0.5) return lerp(unitsPerStorePerMonth, 0.5, 1, 10, 20); + return lerp(unitsPerStorePerMonth, 0, 0.5, 0, 10); +} + +/** Score CA (0-30) basé sur CA € / magasin / an */ +function computeCaScore(caPerStorePerYear: number): number { + if (caPerStorePerYear >= 500) return 30; + if (caPerStorePerYear >= 200) return lerp(caPerStorePerYear, 200, 500, 22, 30); + if (caPerStorePerYear >= 100) return lerp(caPerStorePerYear, 100, 200, 15, 22); + if (caPerStorePerYear >= 50) return lerp(caPerStorePerYear, 50, 100, 8, 15); + return lerp(caPerStorePerYear, 0, 50, 0, 8); +} + +/** Score Marge (0-20) basé sur taux de marge % */ +function computeMargeScore(tauxMarge: number): number { + if (tauxMarge >= 50) return 20; + if (tauxMarge >= 40) return lerp(tauxMarge, 40, 50, 16, 20); + if (tauxMarge >= 30) return lerp(tauxMarge, 30, 40, 12, 16); + if (tauxMarge >= 20) return lerp(tauxMarge, 20, 30, 8, 12); + return lerp(tauxMarge, 0, 20, 0, 8); +} + +/** Rangs percentiles (0-100) pour un tableau de valeurs numériques. */ function computePercentileRanks(values: number[]): number[] { const n = values.length; if (n === 0) return []; @@ -40,7 +70,6 @@ function computePercentileRanks(values: number[]): number[] { while (i < n) { let j = i; while (j < n && indexed[j].v === indexed[i].v) j++; - // Rang moyen pour les ex-æquo, normalisé 0-100 const avgRank = ((i + j - 1) / 2) / (n - 1) * 100; for (let k = i; k < j; k++) { ranks[indexed[k].i] = Math.round(avgRank); @@ -51,54 +80,62 @@ function computePercentileRanks(values: number[]): number[] { return ranks; } -export interface ScoreSettings { - /** 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 = { - weightCA: 0.40, - weightVolume: 0.35, - weightMarge: 0.25, -}; +// --------------------------------------------------------------------------- +// API publique +// --------------------------------------------------------------------------- /** * Calcule les scores pour un ensemble de produits d'un même fournisseur. - * Les rows sont modifiées en place (champ `score`) et retournées. + * Les rows sont modifiées en place et retournées. */ -export function computeProductScores( - rows: ProductRow[], - settings: ScoreSettings = DEFAULT_SCORE_SETTINGS -): ProductRow[] { +export function computeProductScores(rows: ProductRow[]): ProductRow[] { if (rows.length === 0) return rows; - // 1. Normalisation multi-magasins : pondérer les produits en 1 magasin (×2) - // pour comparer équitablement avec ceux en 2 magasins + // 1. Données pondérées pour normalisation multi-magasins const weightedData = rows.map((r) => { - const storeCount = r.workingStores?.length || 1; + const storeCount = Math.max(1, r.workingStores?.length || 1); const weight = storeCount === 1 ? 2 : 1; + // Régularité : nombre de mois avec au moins 1 vente + const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length; + + // Inactivité : mois sans vente en fin de fenêtre + const allMonths = Object.keys(r.sales12m || {}); + const refMonth = allMonths.length > 0 ? Math.max(...allMonths.map(m => parseInt(m))).toString() : ""; + const salesMonths = Object.entries(r.sales12m || {}) + .filter(([, qty]) => qty > 0) + .map(([m]) => parseInt(m)) + .sort((a, b) => b - a); + const lastSaleMonth = salesMonths.length > 0 ? salesMonths[0].toString() : ""; + + let inactivity = 0; + if (refMonth && lastSaleMonth) { + const refY = parseInt(refMonth.substring(0, 4)); + const refM = parseInt(refMonth.substring(4, 6)); + const lastY = parseInt(lastSaleMonth.substring(0, 4)); + const lastM = parseInt(lastSaleMonth.substring(4, 6)); + inactivity = (refY - lastY) * 12 + (refM - lastM); + } + return { row: r, + storeCount, + weight, wQty: (r.totalQuantite ?? 0) * weight, wCa: (r.totalCa ?? 0) * weight, - wMarge: (r.totalMarge ?? 0) * weight, - tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0, // Garde contre NaN + tauxMarge: Number.isFinite(r.tauxMarge) ? r.tauxMarge : 0, + regScore, + inactivity, }; }); - // 2. Calculer les MOYENNES du fournisseur (pas les MAX !) + // 2. Totaux fournisseur const totalQty = weightedData.reduce((acc, d) => acc + d.wQty, 0); const totalCa = weightedData.reduce((acc, d) => acc + d.wCa, 0); const totalMarge = weightedData.reduce((sum, d) => sum + d.tauxMarge, 0); - const avgQty = rows.length > 0 ? totalQty / rows.length : 0; - // 3. Calculer les totaux pour les poids (%) + // 3. Totaux par rayon (code2) const rayonTotals = new Map(); weightedData.forEach((d) => { const r2 = d.row.code2 || "default"; @@ -106,42 +143,69 @@ export function computeProductScores( rayonTotals.set(r2, { total: current.total + d.wQty, count: current.count + 1 }); }); - // 4. Calcul des rangs percentiles par axe (0-100, borné) + // 4. Percentiles dans le lot (pour le bonus relatif) const caPctRanks = computePercentileRanks(weightedData.map(d => d.wCa)); const qtyPctRanks = computePercentileRanks(weightedData.map(d => d.wQty)); const margePctRanks = computePercentileRanks(weightedData.map(d => d.tauxMarge)); - // Validation des pondérations (garde contre undefined/NaN) - const wCA = Number.isFinite(settings.weightCA) ? settings.weightCA : 0.40; - const wVol = Number.isFinite(settings.weightVolume) ? settings.weightVolume : 0.35; - const wMarge = Number.isFinite(settings.weightMarge) ? settings.weightMarge : 0.25; + // 5. Seuil top 30% CA pour le flag isTop30Supplier + const sortedCa = [...weightedData.map(d => d.wCa)].sort((a, b) => b - a); + const top30Idx = Math.max(0, Math.ceil(sortedCa.length * 0.3) - 1); + const top30CaThreshold = sortedCa[top30Idx] ?? 0; - // 5. Pour chaque produit : injecter contexte + score composite percentile + // 6. Score de chaque produit for (let idx = 0; idx < weightedData.length; idx++) { - const data = weightedData[idx]; + const d = weightedData[idx]; + const r = d.row; - // Métriques de contexte (pour l'IA et l'affichage) - data.row.avgQtyFournisseur = avgQty; - data.row.totalFournisseurCa = totalCa; + // --- Métriques de contexte (inchangées, pour l'IA et l'affichage) --- + r.avgQtyFournisseur = avgQty; + r.totalFournisseurCa = totalCa; + r.shareQty = totalQty > 0 ? (d.wQty / totalQty) * 100 : 0; + r.shareCa = totalCa > 0 ? (d.wCa / totalCa) * 100 : 0; + r.shareMarge = totalMarge > 0 ? (d.tauxMarge / totalMarge) * 100 : 0; - // Poids relatifs (%) - 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.tauxMarge / totalMarge) * 100 : 0; - - const r2 = data.row.code2 || "default"; + const r2 = r.code2 || "default"; const rayonStat = rayonTotals.get(r2); - data.row.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0; + r.avgQtyRayon = rayonStat ? rayonStat.total / rayonStat.count : 0; - // ================================================================ - // SCORE COMPOSITE PERCENTILE (0-100) - // ================================================================ - const scoreComposite = - wCA * caPctRanks[idx] + - wVol * qtyPctRanks[idx] + - wMarge * margePctRanks[idx]; + // --- KPIs normalisés (sensibles au prix) --- + const activeMonths = Math.max(d.regScore, 3); // plancher 3 mois + const unitsPerStorePerMonth = d.wQty / (d.storeCount * activeMonths); + const caPerStorePerYear = d.wCa / d.storeCount; - data.row.score = Number.isFinite(scoreComposite) ? Math.round(scoreComposite * 10) / 10 : 0; + r.unitsPerStorePerMonth = Math.round(unitsPerStorePerMonth * 100) / 100; + r.caPerStorePerYear = Math.round(caPerStorePerYear * 100) / 100; + + // --- Score absolu (Volume + CA + Marge = 0-100 pts) --- + const volumeScore = computeVolumeScore(unitsPerStorePerMonth); + const caScore = computeCaScore(caPerStorePerYear); + const margeScore = computeMargeScore(d.tauxMarge); + + // --- Bonus relatif (±10 pts) --- + const pctComposite = (caPctRanks[idx] * 0.40 + qtyPctRanks[idx] * 0.35 + margePctRanks[idx] * 0.25); + const relativeBonus = ((pctComposite - 50) / 50) * 10; + + // --- Pénalité de régularité --- + let regularityMultiplier = 1; + if (d.regScore <= 2 && d.inactivity >= 6) { + regularityMultiplier = 0.3; + } else if (d.regScore <= 2 && d.inactivity >= 3) { + regularityMultiplier = 0.5; + } else if (d.inactivity >= 6) { + regularityMultiplier = 0.5; + } else if (d.inactivity >= 3) { + regularityMultiplier = 0.7; + } + + // --- Score final --- + const rawScore = (volumeScore + caScore + margeScore + relativeBonus) * regularityMultiplier; + r.score = Math.round(Math.max(0, Math.min(100, rawScore)) * 10) / 10; + + // --- Flags de protection --- + r.isRecent = d.regScore <= 3; + r.isLastProduct = rows.length === 1; + r.isTop30Supplier = d.wCa >= top30CaThreshold && top30CaThreshold > 0; } return rows; diff --git a/src/types/grid.ts b/src/types/grid.ts index 6e5c47e..96b0d62 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -56,9 +56,19 @@ export interface ProductRow { totalCa: number; totalMarge: number; tauxMarge: number; - /** Combined performance score (0-10) */ + /** Score de performance hybride (0-100) */ score: number; workingStores: string[]; + /** Rotation normalisée : unités vendues / magasin / mois */ + unitsPerStorePerMonth?: number; + /** CA normalisé : € / magasin / an */ + caPerStorePerYear?: number; + /** Produit récent (≤ 3 mois de données) */ + isRecent?: boolean; + /** Seul produit du fournisseur dans le lot */ + isLastProduct?: boolean; + /** Top 30% CA du lot fournisseur */ + isTop30Supplier?: boolean; aiRecommendation?: string | null; /** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */ noid?: number;