feat: Introduce AI Copilot feature with product analysis engine, state management, and UI components for insights.

This commit is contained in:
Michael committed 2026-03-19 07:53:56 +01:00
1 parent 95795ce7e3
commit d8a9329075
10 files changed
+293 -677

No files matched your search

+2 -1
View File
@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git push:*)"
"Bash(git push:*)",
"Bash(npx next:*)"
]
}
}
+12 -118
View File
@@ -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() {
</Section>
{/* Score Produit */}
<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">
<Section title="Score Produit" subtitle="Formule hybride absolue + relative (v4)">
<div className="bg-[var(--surface-tertiary)] rounded-lg p-3 space-y-2">
<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)
<strong>Formule :</strong> Volume (50pts) + CA (30pts) + Marge (20pts) + Bonus relatif (±10pts)
</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 className="text-[11px] text-[var(--text-muted)]">
Le score est basé sur des paliers absolus sensibles au prix unitaire, avec un ajustement relatif selon la position dans le lot fournisseur.
</p>
</div>
<Field
label={`Pondération CA : ${(weightCA * 100).toFixed(0)}%`}
hint="Poids du chiffre d'affaires dans le calcul du score final."
>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="100"
step="5"
value={weightCA * 100}
onChange={(e) => setWeightCA(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={(weightCA * 100).toFixed(0)}
onChange={(e) => setWeightCA(parseFloat(e.target.value) / 100 || 0.40)}
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 className="border-t border-[var(--border-subtle)] pt-2 mt-2 space-y-1">
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Volume</strong> (0-50 pts) — unités / magasin / mois</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>CA</strong> (0-30 pts) — € / magasin / an</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Marge</strong> (0-20 pts) — taux de marge %</p>
<p className="text-[11px] text-[var(--text-secondary)]"><strong>Bonus relatif</strong> (±10 pts) — percentile composite dans le lot</p>
</div>
</Field>
<Field
label={`Pondération Volume : ${(weightVolume * 100).toFixed(0)}%`}
hint="Poids des quantités vendues (rotation) dans le calcul du score final."
>
<div className="flex items-center gap-4">
<input
type="range"
min="0"
max="100"
step="5"
value={weightVolume * 100}
onChange={(e) => setWeightVolume(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={(weightVolume * 100).toFixed(0)}
onChange={(e) => setWeightVolume(parseFloat(e.target.value) / 100 || 0.35)}
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>
<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 className="text-[11px] text-[var(--text-muted)] mt-2">
Pénalité de régularité appliquée si inactivité prolongée. Score final borné 0-100.
</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">
<button
onClick={resetScoreDefaults}
className="apple-btn-secondary h-8 px-3 text-[11px] opacity-80 hover:opacity-100"
>
<RotateCcw className="w-3 h-3" />
Rétablir les valeurs par défaut (40/35/25)
</button>
</div>
</Section>
@@ -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<string, number> = {};
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<string, SiteMonthlyData[]>();
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<string, number> = {};
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 :`;
}
@@ -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);
}
}
@@ -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) {
</div>
{insight?.isDuplicate && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 px-1.5 py-0.5 rounded-full w-fit">
⚠️ Doublon probable
Doublon probable
</span>
)}
</div>
@@ -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 */
@@ -20,17 +20,19 @@ interface AiCopilotState {
codein: string;
noid?: number;
libelle1: string;
libelleNiveau2?: string;
totalCa: number;
tauxMarge: number;
totalQuantite: number;
storeCount: number;
sales12m: Record<string, number>;
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<string, unknown>;
scoring?: {
score: number;
verdict: "A" | "Z";
isRecent: boolean;
isLastProduct: boolean;
isTop30Supplier: boolean;
};
}) => Promise<void>;
resetInsights: () => void;
}
@@ -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<string, { qty1: number[]; qty2: number[] }>();
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<string, { avg1: number; avg2: number }>();
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<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
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));
}
+2 -7
View File
@@ -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<string[]>([]);
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 () => {
@@ -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<ScoreSettingsState>()(
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",