mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: Introduce AI Copilot feature with product analysis engine, state management, and UI components for insights.
This commit is contained in:
1 parent
95795ce7e3
commit
d8a9329075
10 files changed
+293
-677
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user