mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-15 11:18:42 +02:00
feat(grid): replace ranking/AI/score with Qlik network data
Remove ranking, AI analysis (routes + ai-copilot module), and score-engine from the grid. Add Qlik Sense network metrics (CA, Qte, nb magasins per product) joined by code centrale (articles.artcentrale). - qlik-client.ts: NTLM + ticket SSO auth, QIX hypercube extraction (master items) - qlik-network-cache.ts + qlik_network_metrics table (db-init): cache layer - POST /api/qlik/sync?fournisseur=: per-supplier sync into cache - SyncQlikButton: per-supplier sync, grid reload, last-update date display - get-product-rows Phase 8: enrich rows with cached network metrics - heatmap-grid: CA reseau / Qte reseau / Magasins (/270) / % presence columns - discovery: app 9872ee6e, master items CA N / Quantite N / Magasin Ventes Nb N - deps: httpntlm, ws Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
02a10caa4b
commit
aee968d30f
34 files changed
+1043
-2925
No files matched your search
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Analysis Engine v6
|
||||
*
|
||||
* 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";
|
||||
import type { ProductContextProfile } from "./context-profiler";
|
||||
|
||||
export class AnalysisEngine {
|
||||
// -----------------------------------------------------------------------
|
||||
// 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).
|
||||
Tu analyses des produits d'un même fournisseur pour recommander A (garder) ou Z (sortir).
|
||||
|
||||
⚠️ 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.
|
||||
|
||||
📦 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 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 à la Règle 2.
|
||||
|
||||
--- 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 :
|
||||
|
||||
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.
|
||||
|
||||
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 ⚠️ SI LE SCORE EST ≥ 55, TU NE PEUX JAMAIS DÉGRADER EN Z
|
||||
→ Les 3 conditions doivent être remplies SIMULTANÉMENT.
|
||||
→ Un score élevé (≥ 55) INTERDIT toute dégradation, quelle que soit la tendance.
|
||||
|
||||
Si AUCUNE condition d'ajustement n'est remplie → CONFIRME le verdict pré-calculé.
|
||||
|
||||
--- 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
|
||||
• Si un RANKING est fourni, mentionne la position et le percentile du produit (ex: "classé 45e / 5 000 produits, top 1%")
|
||||
→ Le ranking est un signal contextuel qui RENFORCE le verdict :
|
||||
- Top 5% du réseau → signal fort pour garder en A
|
||||
- Top 20% du réseau → signal modéré pour garder en A
|
||||
- Bottom 30% du réseau → signal défavorable, cohérent avec Z
|
||||
→ Le ranking seul ne change PAS le verdict, mais combiné avec d'autres signaux il peut justifier un ajustement
|
||||
|
||||
--- FORMAT OBLIGATOIRE ---
|
||||
JSON uniquement, sans markdown.
|
||||
{
|
||||
"rule_applies": boolean,
|
||||
"recommendation": "A" | "B" | "C" | "D" | "Z",
|
||||
"justification": "2-3 phrases max. Cite le score et le signal clé."
|
||||
}
|
||||
|
||||
IMPORTANT — Gammes disponibles :
|
||||
- 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).`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// USER MESSAGE
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static generateUserMessage(p: ProductAnalysisInput): string {
|
||||
if (p.contextProfile) {
|
||||
return AnalysisEngine.buildContextualMessage(p, p.contextProfile);
|
||||
}
|
||||
return AnalysisEngine.buildLegacyMessage(p);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message contextuel enrichi (v6 — verdict pré-calculé)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private static buildContextualMessage(
|
||||
p: ProductAnalysisInput,
|
||||
ctx: ProductContextProfile
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const storeLabel = ctx.storeCount > 1 ? `${ctx.storeCount} magasins` : `1 magasin`;
|
||||
|
||||
// En-tête produit
|
||||
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
|
||||
if (p.supplierContext) {
|
||||
lines.push(`🎯 ═══════════════════════════════════════════════════════════════`);
|
||||
lines.push(`🎯 🎯 🎯 RÈGLE MANAGER (PRIORITÉ ABSOLUE) 🎯 🎯 🎯`);
|
||||
lines.push(`═══════════════════════════════════════════════════════════════`);
|
||||
lines.push(``);
|
||||
lines.push(`RÈGLE DÉFINIE PAR LE MANAGER :`);
|
||||
lines.push(`"${p.supplierContext}"`);
|
||||
lines.push(``);
|
||||
lines.push(`PRODUIT ANALYSÉ : "${ctx.libelle1}"`);
|
||||
lines.push(`CATÉGORIE : ${ctx.libelleNiveau2}`);
|
||||
lines.push(``);
|
||||
lines.push(`⚠️ INSTRUCTION CRITIQUE :`);
|
||||
lines.push(`1. Analyse si CE produit est concerné par la règle ci-dessus`);
|
||||
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 la Règle 2`);
|
||||
lines.push(`3. Si NON :`);
|
||||
lines.push(` - rule_applies = false`);
|
||||
lines.push(` - Applique la Règle 2 normalement`);
|
||||
lines.push(`═══════════════════════════════════════════════════════════════`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// 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("");
|
||||
|
||||
// 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("");
|
||||
|
||||
// Ranking réseau & magasin (avec percentile contextuel)
|
||||
if (p.rankingCa != null || p.rankingQte != null || p.rankingMagCa != null || p.rankingMagQte != null) {
|
||||
const total = p.totalRankedProducts ?? 0;
|
||||
const totalLabel = total > 0 ? ` / ${total.toLocaleString('fr-FR')} produits` : "";
|
||||
lines.push(`--- RANKING RÉSEAU (${total > 0 ? total.toLocaleString('fr-FR') : "?"} produits vendus sur la période) ---`);
|
||||
if (p.rankingCa != null) {
|
||||
const pctCa = total > 0 ? ((p.rankingCa / total) * 100).toFixed(1) : "?";
|
||||
lines.push(`• Classement CA réseau : ${p.rankingCa}e${totalLabel} (top ${pctCa}%)`);
|
||||
}
|
||||
if (p.rankingQte != null) {
|
||||
const pctQte = total > 0 ? ((p.rankingQte / total) * 100).toFixed(1) : "?";
|
||||
lines.push(`• Classement Qté réseau : ${p.rankingQte}e${totalLabel} (top ${pctQte}%)`);
|
||||
}
|
||||
if (p.rankingMagCa != null) lines.push(`• Classement CA magasin : ${p.rankingMagCa}e`);
|
||||
if (p.rankingMagQte != null) lines.push(`• Classement Qté magasin : ${p.rankingMagQte}e`);
|
||||
lines.push(`• Contexte : ce fournisseur a ${ctx.lotSize} produits dans le lot analysé`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Signaux pré-calculés (si données mensuelles disponibles)
|
||||
if (p.siteMonthlyData && p.siteMonthlyData.length > 0) {
|
||||
const signals = AnalysisEngine.computeSignals(p.siteMonthlyData);
|
||||
|
||||
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 ---`);
|
||||
if (ctx.stockCoverage > 0) {
|
||||
lines.push(`• Couverture stock : ${ctx.stockCoverage.toFixed(1)} mois de ventes en stock`);
|
||||
}
|
||||
if (ctx.isLowStock) {
|
||||
lines.push(`• ⚠️ Stock faible : moins d'un PCB disponible`);
|
||||
}
|
||||
if (ctx.isDeadInventory) {
|
||||
lines.push(`• ⚠️ Stock dormant : pas de vente depuis > 90 jours`);
|
||||
}
|
||||
if (ctx.isCommandeEnCours) {
|
||||
lines.push(`• Commande en cours : réapprovisionnement prévu`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`Génère UNIQUEMENT le JSON :`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 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;
|
||||
|
||||
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 ?? "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)}%` : ""}
|
||||
${p.rankingCa != null || p.rankingQte != null ? `\n--- RANKING ---\n${p.rankingCa != null ? `• Classement CA réseau : ${p.rankingCa}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingCa / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingQte != null ? `• Classement Qté réseau : ${p.rankingQte}e${p.totalRankedProducts ? ` / ${p.totalRankedProducts} produits (top ${((p.rankingQte / p.totalRankedProducts) * 100).toFixed(1)}%)` : ""}\n` : ""}${p.rankingMagCa != null ? `• Classement CA magasin : ${p.rankingMagCa}e\n` : ""}${p.rankingMagQte != null ? `• Classement Qté magasin : ${p.rankingMagQte}e` : ""}` : ""}${contextRules}
|
||||
Génère UNIQUEMENT le JSON :`;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Utilitaires de parsing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static extractRecommendation(content: string): "A" | "B" | "C" | "D" | "Z" | null {
|
||||
const match = content.match(/\b([ABCDZ])\b/i);
|
||||
if (match) return match[1].toUpperCase() as "A" | "B" | "C" | "D" | "Z";
|
||||
return null;
|
||||
}
|
||||
|
||||
static cleanInsight(content: string): string {
|
||||
let cleaned = content;
|
||||
cleaned = cleaned.replace(/^\[?[ABCDZ]\]?\s*[:\s-]+\s*/i, "");
|
||||
cleaned = cleaned.replace(
|
||||
/^(justification|explication|pourquoi|justification courte|raison|avis)\s*[:\s-]+\s*/i,
|
||||
""
|
||||
);
|
||||
return cleaned.trim();
|
||||
}
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
/**
|
||||
* CollectFlow — Context Profiler (v3 — Normalisation multi-magasin)
|
||||
*
|
||||
* Génère une fiche de contexte normalisée et adaptative pour chaque produit
|
||||
* AVANT de le soumettre à l'IA.
|
||||
*
|
||||
* v3 — Correctifs :
|
||||
* - Normalisation par `storeCount` : tous les calculs de percentile, poids
|
||||
* et quadrant utilisent les valeurs PAR MAGASIN (CA/store, QTÉ/store).
|
||||
* Cela évite qu'un produit en 2 magasins soit mécaniquement favorisé
|
||||
* dans les comparaisons par rapport à un produit en 1 seul magasin.
|
||||
* - Le profil expose caPerStore et qtyPerStore pour que Mary voie les
|
||||
* deux dimensions : réeau brut ET performance par magasin.
|
||||
*/
|
||||
|
||||
import type { ProductAnalysisInput } from "../models/ai-analysis.types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constante : seuil minimal de produits dans un rayon pour activer les signaux
|
||||
// Trafic/Marge. En dessous = statistiques non significatives.
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIN_RAYON_SIZE = 6;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Quadrant = "STAR" | "TRAFIC" | "MARGE" | "WATCH";
|
||||
|
||||
export interface ContextProfilerCache {
|
||||
allCaPerStoreSorted: number[];
|
||||
allQtyPerStoreSorted: number[];
|
||||
allMargeValuesSorted: number[];
|
||||
|
||||
totalCaFournisseur: number;
|
||||
totalQtyFournisseur: number;
|
||||
|
||||
top20CaThreshold: number;
|
||||
top20QtyThreshold: number;
|
||||
|
||||
medianQtyPerStore: number;
|
||||
medianMarge: number;
|
||||
|
||||
marge40: number;
|
||||
qty60PerStore: number;
|
||||
|
||||
rayonStats: Map<string, { totalCa: number; totalQty: number; count: number }>;
|
||||
}
|
||||
|
||||
export interface ProductContextProfile {
|
||||
// Identité
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
libelleNiveau2: string;
|
||||
|
||||
// Profil Quadrant (basé sur valeurs PAR MAGASIN pour comparaison équitable)
|
||||
quadrant: Quadrant;
|
||||
quadrantLabel: string;
|
||||
quadrantEmoji: string;
|
||||
|
||||
// Nombre de magasins référençant le produit
|
||||
storeCount: number;
|
||||
|
||||
// Valeurs brutes réseau
|
||||
totalCaRaw: number;
|
||||
totalQtyRaw: number;
|
||||
|
||||
// Valeurs normalisées PAR MAGASIN (pour comparaisons justes)
|
||||
caPerStore: number;
|
||||
qtyPerStore: number;
|
||||
|
||||
// Percentiles dans le lot fournisseur (0 = plus faible, 100 = meilleur)
|
||||
// Calculés sur les valeurs normalisées par magasin
|
||||
percentileCa: number;
|
||||
percentileQty: number;
|
||||
percentileMarge: number;
|
||||
percentileComposite: number;
|
||||
|
||||
// Poids réels dans le lot fournisseur
|
||||
// Calculés sur les valeurs brutes réseau (représentativité réelle du chiffre)
|
||||
weightCaFournisseur: number; // % du CA total fournisseur
|
||||
weightQtyFournisseur: number; // % des QTÉ totales fournisseur
|
||||
|
||||
// Poids réels dans le rayon (Niveau 2 de nomenclature)
|
||||
weightCaRayon: number;
|
||||
weightQtyRayon: number;
|
||||
|
||||
// Santé temporelle
|
||||
tauxMarge: number;
|
||||
inactivityMonths: number;
|
||||
regularityScore: number;
|
||||
|
||||
// Contexte du lot
|
||||
lotSize: number;
|
||||
rayonSize: number;
|
||||
|
||||
// Signaux positifs (calculés sur la distribution réelle)
|
||||
isAboveMedianComposite: boolean;
|
||||
isTop20Ca: boolean; // Top 20% sur valeur PAR MAGASIN
|
||||
isTop20Qty: boolean; // Top 20% sur valeur PAR MAGASIN
|
||||
/**
|
||||
* Fort volume ET marge < P40 du lot → rôle de "locomotive".
|
||||
* Désactivé si rayonSize < MIN_RAYON_SIZE.
|
||||
*/
|
||||
isHighVolumeWithLowMargin: boolean;
|
||||
/**
|
||||
* Marge > P70 du lot même si volume faible → capital rentabilité.
|
||||
* Désactivé si rayonSize < MIN_RAYON_SIZE.
|
||||
*/
|
||||
isMargePure: boolean;
|
||||
|
||||
// Signal négatif fort
|
||||
/**
|
||||
* Le produit appartient aux 30% les moins performants en CA et Quantité.
|
||||
*/
|
||||
isLowContribution: boolean;
|
||||
/**
|
||||
* Produit avec des statistiques absolues dérisoires (ex: < 100€ CA ou < 20 unités au réseau)
|
||||
*/
|
||||
isDeadStock: boolean;
|
||||
|
||||
// Signal positif fort — Locomotive de rayon
|
||||
/**
|
||||
* Le produit est une "locomotive" de sa nomenclature (poids CA rayon > 15% OU poids QTÉ rayon > 15%)
|
||||
* → Pilier structurant de l'offre dans sa catégorie
|
||||
*/
|
||||
isLocomotiveRayon: boolean;
|
||||
|
||||
// Signaux stock & approvisionnement (API FF Nancy)
|
||||
/** Stock actuel ≤ PCB → moins d'un conditionnement disponible */
|
||||
isLowStock: boolean;
|
||||
/** Commande fournisseur en cours */
|
||||
isCommandeEnCours: boolean;
|
||||
/** Stock > 0 mais pas de vente depuis > 90 jours */
|
||||
isDeadInventory: boolean;
|
||||
/** Couverture en mois : stockActuel / (totalQuantite / 12) */
|
||||
stockCoverage: number;
|
||||
|
||||
// Gardes-fous (issus du ScoringEngine)
|
||||
isProtected: boolean;
|
||||
protectionReason: string;
|
||||
|
||||
// Règle absolue
|
||||
scoreCritique: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers statistiques (purs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computePercentileFromSorted(value: number, sortedDistribution: number[]): number {
|
||||
if (sortedDistribution.length <= 1) return 100;
|
||||
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
|
||||
for (let i = 0; i < sortedDistribution.length; i++) {
|
||||
const current = sortedDistribution[i];
|
||||
if (Math.abs(current - value) < 0.00001) { // Floating point protection
|
||||
if (first === -1) first = i;
|
||||
last = i;
|
||||
} else if (current > value) {
|
||||
if (first === -1) return Math.round((i / Math.max(1, sortedDistribution.length - 1)) * 100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (first !== -1) {
|
||||
const avgRank = (first + last) / 2;
|
||||
return Math.round((avgRank / (sortedDistribution.length - 1)) * 100);
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
|
||||
function computeMedianFromSorted(sorted: number[]): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function valueAtPercentileFromSorted(sorted: number[], p: number): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const idx = Math.max(0, Math.ceil((p / 100) * sorted.length) - 1);
|
||||
return sorted[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait la clé de groupement pour le rayon au niveau 2 de nomenclature.
|
||||
* Priorité : `codeNomenclatureN2` (4 premiers chiffres) > extraction numérique
|
||||
* depuis libelleNiveau2 > valeur brute de libelleNiveau2.
|
||||
*/
|
||||
function getRayonKey(p: ProductAnalysisInput): string {
|
||||
if (p.codeNomenclatureN2) {
|
||||
return p.codeNomenclatureN2;
|
||||
}
|
||||
// Fallback : extraire les 4 premiers chiffres si le libellé commence par un code numérique
|
||||
const numericPrefix = p.libelleNiveau2?.match(/^(\d{4})/);
|
||||
if (numericPrefix) {
|
||||
return numericPrefix[1];
|
||||
}
|
||||
return p.libelleNiveau2 ?? "default";
|
||||
}
|
||||
|
||||
const getNormStoreCount = (p: ProductAnalysisInput) => Math.max(1, p.storeCount ?? 1);
|
||||
const normCa = (p: ProductAnalysisInput) => (p.totalCa ?? 0) / getNormStoreCount(p);
|
||||
const normQty = (p: ProductAnalysisInput) => (p.totalQuantite ?? 0) / getNormStoreCount(p);
|
||||
const getWeightedCa = (p: ProductAnalysisInput) => p.weightedTotalCa ?? p.totalCa ?? 0;
|
||||
const getWeightedQty = (p: ProductAnalysisInput) => p.weightedTotalQuantite ?? p.totalQuantite ?? 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profiler principal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ContextProfiler {
|
||||
|
||||
/**
|
||||
* Prépare un cache contextuel basé sur tous les produits pour éviter des tris
|
||||
* répétés en mode "Bulk Analyze". Réduit la latence de manière dramatique (O(N) au lieu de O(N^2)).
|
||||
*/
|
||||
static prepareCache(allProds: ProductAnalysisInput[]): ContextProfilerCache {
|
||||
const allCaPerStore = allProds.map(normCa);
|
||||
const allQtyPerStore = allProds.map(normQty);
|
||||
const allMargeValues = allProds.map(p => p.tauxMarge ?? 0);
|
||||
|
||||
// On trie une seule et unique fois
|
||||
const allCaPerStoreSorted = [...allCaPerStore].sort((a, b) => a - b);
|
||||
const allQtyPerStoreSorted = [...allQtyPerStore].sort((a, b) => a - b);
|
||||
const allMargeValuesSorted = [...allMargeValues].sort((a, b) => a - b);
|
||||
|
||||
const totalCaFournisseur = allProds.reduce((s, p) => s + getWeightedCa(p), 0);
|
||||
const totalQtyFournisseur = allProds.reduce((s, p) => s + getWeightedQty(p), 0);
|
||||
|
||||
const rayonStats = new Map<string, { totalCa: number; totalQty: number; count: number }>();
|
||||
allProds.forEach(p => {
|
||||
const key = getRayonKey(p);
|
||||
if (!rayonStats.has(key)) rayonStats.set(key, { totalCa: 0, totalQty: 0, count: 0 });
|
||||
const s = rayonStats.get(key)!;
|
||||
s.totalCa += getWeightedCa(p);
|
||||
s.totalQty += getWeightedQty(p);
|
||||
s.count += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
allCaPerStoreSorted,
|
||||
allQtyPerStoreSorted,
|
||||
allMargeValuesSorted,
|
||||
totalCaFournisseur,
|
||||
totalQtyFournisseur,
|
||||
top20CaThreshold: valueAtPercentileFromSorted(allCaPerStoreSorted, 80),
|
||||
top20QtyThreshold: valueAtPercentileFromSorted(allQtyPerStoreSorted, 80),
|
||||
medianQtyPerStore: computeMedianFromSorted(allQtyPerStoreSorted),
|
||||
medianMarge: computeMedianFromSorted(allMargeValuesSorted),
|
||||
marge40: valueAtPercentileFromSorted(allMargeValuesSorted, 40),
|
||||
qty60PerStore: valueAtPercentileFromSorted(allQtyPerStoreSorted, 60),
|
||||
rayonStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère le profil contextuel d'un produit au sein de son lot fournisseur.
|
||||
*/
|
||||
static buildProfile(
|
||||
target: ProductAnalysisInput,
|
||||
allProds: ProductAnalysisInput[],
|
||||
score: number,
|
||||
cache?: ContextProfilerCache
|
||||
): ProductContextProfile {
|
||||
if (allProds.length === 0) {
|
||||
throw new Error("[ContextProfiler] Le lot de produits est vide.");
|
||||
}
|
||||
|
||||
// Utiliser le cache s'il est fourni (mode Bulk) pour éviter de recalculer,
|
||||
// sinon le générer à la volée (mode single item)
|
||||
const ctx: ContextProfilerCache = cache ?? ContextProfiler.prepareCache(allProds);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Normalisation par magasin — cœur de la v3
|
||||
// ---------------------------------------------------------------------------
|
||||
const targetStoreCount = getNormStoreCount(target);
|
||||
const targetCaPerStore = normCa(target);
|
||||
const targetQtyPerStore = normQty(target);
|
||||
|
||||
// 1. Totaux fournisseur
|
||||
const { totalCaFournisseur, totalQtyFournisseur } = ctx;
|
||||
|
||||
// 2. Totaux du rayon (Niveau 2 de nomenclature)
|
||||
const targetRayonKey = getRayonKey(target);
|
||||
const rayonStat = ctx.rayonStats.get(targetRayonKey) ?? { totalCa: 0, totalQty: 0, count: 0 };
|
||||
const totalCaRayon = rayonStat.totalCa;
|
||||
const totalQtyRayon = rayonStat.totalQty;
|
||||
const rayonSizeForSignals = rayonStat.count;
|
||||
|
||||
// 3. Percentiles (0-100) — sur distribution PAR MAGASIN
|
||||
const pCa = computePercentileFromSorted(targetCaPerStore, ctx.allCaPerStoreSorted);
|
||||
const pQty = computePercentileFromSorted(targetQtyPerStore, ctx.allQtyPerStoreSorted);
|
||||
const pMarge = computePercentileFromSorted(target.tauxMarge ?? 0, ctx.allMargeValuesSorted);
|
||||
const pComposite = score;
|
||||
|
||||
// 4. Tops 20%
|
||||
const isTop20Ca = targetCaPerStore >= ctx.top20CaThreshold;
|
||||
const isTop20Qty = targetQtyPerStore >= ctx.top20QtyThreshold;
|
||||
const isAboveMedianComposite = pComposite >= 50;
|
||||
|
||||
// 5. Poids pondérés
|
||||
const weightCaFournisseur =
|
||||
totalCaFournisseur > 0
|
||||
? Math.round((getWeightedCa(target) / totalCaFournisseur) * 1000) / 10
|
||||
: 0;
|
||||
const weightQtyFournisseur =
|
||||
totalQtyFournisseur > 0
|
||||
? Math.round((getWeightedQty(target) / totalQtyFournisseur) * 1000) / 10
|
||||
: 0;
|
||||
|
||||
const isLowContribution = pCa <= 30 && pQty <= 30 && pMarge <= 70;
|
||||
const isDeadStock = (target.totalCa ?? 0) < 150 && (target.totalQuantite ?? 0) < 30;
|
||||
|
||||
// 5b. Locomotive de rayon — poids significatif dans la nomenclature
|
||||
const weightCaRayonNum = totalCaRayon > 0
|
||||
? (getWeightedCa(target) / totalCaRayon) * 100
|
||||
: 0;
|
||||
const weightQtyRayonNum = totalQtyRayon > 0
|
||||
? (getWeightedQty(target) / totalQtyRayon) * 100
|
||||
: 0;
|
||||
const isLocomotiveRayon = weightCaRayonNum > 15 || weightQtyRayonNum > 15;
|
||||
|
||||
// 6. Signaux Trafic / Marge + Quadrant
|
||||
const signalsActive = rayonSizeForSignals >= MIN_RAYON_SIZE;
|
||||
|
||||
let isHighVolumeWithLowMargin = false;
|
||||
let isMargePure = false;
|
||||
|
||||
{
|
||||
if (signalsActive) {
|
||||
isHighVolumeWithLowMargin =
|
||||
targetQtyPerStore >= ctx.qty60PerStore &&
|
||||
(target.tauxMarge ?? 0) < ctx.marge40;
|
||||
}
|
||||
|
||||
isMargePure =
|
||||
(target.tauxMarge ?? 0) > ctx.medianMarge &&
|
||||
targetQtyPerStore < ctx.medianQtyPerStore;
|
||||
}
|
||||
|
||||
// 7. Quadrant
|
||||
const { quadrant, quadrantLabel, quadrantEmoji } = ContextProfiler.resolveQuadrant(
|
||||
targetQtyPerStore,
|
||||
target.tauxMarge ?? 0,
|
||||
ctx.medianQtyPerStore,
|
||||
ctx.medianMarge
|
||||
);
|
||||
|
||||
// 8. Gardes-fous (signaux, pas des verdicts — Mary décide au final)
|
||||
// Les flags isRecent/isTop30Supplier/isLastProduct sont désormais
|
||||
// injectés directement sur ProductRow par score-engine.ts.
|
||||
// On les lit depuis le scoring payload transmis par le bulk analyzer.
|
||||
const targetExt = target as ProductAnalysisInput & { isRecent?: boolean; isTop30Supplier?: boolean; isLastProduct?: boolean };
|
||||
const isRecentFlag = targetExt.isRecent ?? false;
|
||||
const isTop30Flag = targetExt.isTop30Supplier ?? false;
|
||||
const isLastFlag = targetExt.isLastProduct ?? false;
|
||||
|
||||
const isProtected = isRecentFlag || isTop30Flag || isLastFlag;
|
||||
|
||||
let protectionReason = "";
|
||||
if (isRecentFlag) protectionReason = "Nouveauté (< 3 mois de données)";
|
||||
else if (isTop30Flag) protectionReason = "Top 30% CA Fournisseur";
|
||||
else if (isLastFlag) protectionReason = "Dernière référence du fournisseur";
|
||||
|
||||
// 9. Règle absolue
|
||||
const scoreCritique = score < 20;
|
||||
|
||||
return {
|
||||
codein: target.codein,
|
||||
libelle1: target.libelle1,
|
||||
libelleNiveau2: target.libelleNiveau2 ?? "Général",
|
||||
|
||||
quadrant, quadrantLabel, quadrantEmoji,
|
||||
|
||||
storeCount: targetStoreCount,
|
||||
totalCaRaw: target.totalCa ?? 0,
|
||||
totalQtyRaw: target.totalQuantite ?? 0,
|
||||
caPerStore: targetCaPerStore,
|
||||
qtyPerStore: targetQtyPerStore,
|
||||
|
||||
percentileCa: pCa,
|
||||
percentileQty: pQty,
|
||||
percentileMarge: pMarge,
|
||||
percentileComposite: pComposite,
|
||||
|
||||
weightCaFournisseur,
|
||||
weightQtyFournisseur,
|
||||
weightCaRayon:
|
||||
totalCaRayon > 0
|
||||
? Math.round((getWeightedCa(target) / totalCaRayon) * 1000) / 10
|
||||
: 0,
|
||||
weightQtyRayon:
|
||||
totalQtyRayon > 0
|
||||
? Math.round((getWeightedQty(target) / totalQtyRayon) * 1000) / 10
|
||||
: 0,
|
||||
|
||||
tauxMarge: target.tauxMarge ?? 0,
|
||||
inactivityMonths: target.inactivityMonths ?? 0,
|
||||
regularityScore: target.regularityScore ?? 0,
|
||||
|
||||
lotSize: allProds.length,
|
||||
rayonSize: rayonSizeForSignals,
|
||||
|
||||
isAboveMedianComposite,
|
||||
isTop20Ca,
|
||||
isTop20Qty,
|
||||
isHighVolumeWithLowMargin,
|
||||
isMargePure,
|
||||
isLowContribution,
|
||||
isDeadStock,
|
||||
isLocomotiveRayon,
|
||||
|
||||
isProtected,
|
||||
protectionReason,
|
||||
scoreCritique,
|
||||
|
||||
// Stock & approvisionnement
|
||||
isLowStock: (target.stockActuel !== undefined && target.pcb !== undefined && target.pcb > 0)
|
||||
? target.stockActuel <= target.pcb
|
||||
: false,
|
||||
isCommandeEnCours: (target.commandesEnCours ?? 0) > 0,
|
||||
isDeadInventory: (target.stockTotal ?? 0) > 0 && (target.nbJoursDerniereVente ?? 0) > 90,
|
||||
stockCoverage: (() => {
|
||||
const avgMonthlyQty = (target.totalQuantite ?? 0) / 12;
|
||||
return avgMonthlyQty > 0 ? (target.stockActuel ?? 0) / avgMonthlyQty : 0;
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
private static resolveQuadrant(
|
||||
qty: number,
|
||||
marge: number,
|
||||
medianQty: number,
|
||||
medianMarge: number
|
||||
): { quadrant: Quadrant; quadrantLabel: string; quadrantEmoji: string } {
|
||||
if (qty > medianQty && marge > medianMarge) {
|
||||
return { quadrant: "STAR", quadrantLabel: "Star (Vol élevé, Marge élevée)", quadrantEmoji: "⭐" };
|
||||
}
|
||||
if (qty > medianQty && marge <= medianMarge) {
|
||||
return { quadrant: "TRAFIC", quadrantLabel: "Générateur de Trafic (Vol élevé, Marge faible)", quadrantEmoji: "🚶" };
|
||||
}
|
||||
if (qty <= medianQty && marge > medianMarge) {
|
||||
return { quadrant: "MARGE", quadrantLabel: "Contributeur de Marge (Vol faible, Marge élevée)", quadrantEmoji: "💎" };
|
||||
}
|
||||
return { quadrant: "WATCH", quadrantLabel: "Sous-performant (Vol faible, Marge faible)", quadrantEmoji: "⚠️" };
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Bot, Sparkles, TrendingUp, ShieldAlert, History } from "lucide-react";
|
||||
|
||||
interface AiExplanationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
productName: string;
|
||||
productCode: string;
|
||||
explanation: string;
|
||||
recommandation?: "A" | "C" | "Z" | null;
|
||||
}
|
||||
|
||||
export function AiExplanationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
productName,
|
||||
productCode,
|
||||
explanation,
|
||||
recommandation
|
||||
}: AiExplanationModalProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (isOpen) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 200);
|
||||
};
|
||||
|
||||
if (!isOpen && !isVisible) return null;
|
||||
if (!mounted) return null;
|
||||
|
||||
const getGammeStyles = (g?: string | null) => {
|
||||
switch (g) {
|
||||
case "A": return "text-[var(--accent-success)] border-[var(--accent-success-bg)] bg-[var(--accent-success-bg)]";
|
||||
case "C": return "text-[var(--accent-warning)] border-[var(--accent-warning-bg)] bg-[var(--accent-warning-bg)]";
|
||||
case "Z": return "text-[var(--accent-error)] border-[var(--accent-error-bg)] bg-[var(--accent-error-bg)]";
|
||||
default: return "text-[var(--text-muted)] border-[var(--border)] bg-[var(--bg-elevated)]";
|
||||
}
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className={`fixed inset-0 z-[9999] flex items-center justify-center p-6 transition-all duration-200 ${isVisible ? "opacity-100 backdrop-blur-sm" : "opacity-0 backdrop-blur-0"}`}
|
||||
>
|
||||
{/* Backdrop - consistent with Apple overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Panel - Using var(--bg-surface) and strict spacing */}
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl border border-[var(--border-strong)] transition-all duration-200 ${isVisible ? "scale-100 translate-y-0" : "scale-[0.98] translate-y-4"}`}
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
}}
|
||||
>
|
||||
{/* Header - Clean & Monochromatic */}
|
||||
<div className="px-6 py-5 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[var(--bg-elevated)] border border-[var(--border)] flex items-center justify-center shrink-0">
|
||||
<Bot className="w-5 h-5 text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Analyse Décisionnelle IA
|
||||
</h2>
|
||||
<p className="text-[11px] font-medium text-[var(--text-muted)] truncate max-w-[300px]">
|
||||
{productName} <span className="opacity-60 tabular-numbers">({productCode})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-md hover:bg-[var(--bg-elevated)] text-[var(--text-muted)] transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Recommendation Row */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-[var(--text-muted)]">Arbitrage suggéré</span>
|
||||
<div className={`px-2.5 py-1 rounded-md border text-[11px] font-bold flex items-center gap-1.5 ${getGammeStyles(recommandation)}`}>
|
||||
{recommandation === "A" && <Sparkles className="w-3 h-3" />}
|
||||
{recommandation === "C" && <TrendingUp className="w-3 h-3" />}
|
||||
{recommandation === "Z" && <ShieldAlert className="w-3 h-3" />}
|
||||
{recommandation ? `GAMME ${recommandation}` : "NON DÉFINI"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explanation Box - Monospace for figures */}
|
||||
<div
|
||||
className="p-5 rounded-xl border border-[var(--border)] bg-[var(--bg-base)]/50 relative overflow-hidden"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
<History className="w-4 h-4 mt-0.5 text-[var(--accent)] shrink-0 opacity-80" />
|
||||
<div className="text-[13px] leading-relaxed text-[var(--text-secondary)] font-medium">
|
||||
<span className="font-mono-nums leading-relaxed whitespace-pre-wrap">
|
||||
{explanation}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta Info */}
|
||||
<div className="flex items-center justify-center gap-4 py-2 border-t border-[var(--border)]">
|
||||
<div className="flex items-center gap-2 text-[10px] text-[var(--text-muted)] font-medium italic">
|
||||
<span>Basé sur CA, Marge & Volumes réels</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Action */}
|
||||
<div className="px-6 py-4 bg-[var(--bg-elevated)]/50 border-t border-[var(--border)] flex justify-end">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="apple-btn-secondary px-6"
|
||||
>
|
||||
Fermer l'Analyse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.font-mono-nums {
|
||||
font-family: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Loader2, AlertCircle, Sparkles, Maximize2 } from "lucide-react";
|
||||
import { AiExplanationModal } from "./ai-explanation-modal";
|
||||
import { useAiCopilotStore } from "../store/use-ai-copilot-store";
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { ProductRow } from "@/types/grid";
|
||||
|
||||
|
||||
interface AiInsightBlockProps {
|
||||
row: ProductRow;
|
||||
}
|
||||
|
||||
export function AiInsightBlock({ row }: AiInsightBlockProps) {
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = (session?.user as any)?.role === "admin";
|
||||
const insight = useAiCopilotStore((s: any) => s.insights[row.codein]);
|
||||
const analyzeProduct = useAiCopilotStore((s: any) => s.analyzeProduct);
|
||||
const setLoading = useAiCopilotStore((s: any) => s.setLoading);
|
||||
const setDraftGamme = useGridStore((s: any) => s.setDraftGamme);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const status = insight?.status ?? "idle";
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (status === "loading") return;
|
||||
|
||||
// Produit sans aucune vente sur 12 mois → Z direct, pas d'appel IA
|
||||
if (row.totalQuantite === 0) {
|
||||
setDraftGamme(row.codein, "Z");
|
||||
useAiCopilotStore.getState().setInsight(row.codein, "Aucune vente sur 12 mois — produit classé Z automatiquement.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, aucune analyse IA
|
||||
if ((row.totalCa ?? 0) < 100 && (row.totalQuantite ?? 0) < 30) {
|
||||
setDraftGamme(row.codein, "Z");
|
||||
useAiCopilotStore.getState().setInsight(row.codein, `CA < 100€ (${(row.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${row.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset Visuel Immédiat
|
||||
setLoading(row.codein);
|
||||
setDraftGamme(row.codein, "Aucune");
|
||||
|
||||
// Fetch supplier context
|
||||
let supplierContext = "";
|
||||
if (row.codeFournisseur) {
|
||||
try {
|
||||
const ctxRes = await fetch(`/api/ai/context?fournisseur=${row.codeFournisseur}`);
|
||||
if (ctxRes.ok) {
|
||||
const data = await ctxRes.json();
|
||||
supplierContext = data.context || "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load supplier context for AI", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul régularité et inactivité
|
||||
const regScore = Object.values(row.sales12m || {}).filter((v: any) => v > 0).length;
|
||||
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);
|
||||
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
||||
let inactivity = 0;
|
||||
if (referenceMonth && lastMonth) {
|
||||
const refY = parseInt(referenceMonth.substring(0, 4));
|
||||
const refM = parseInt(referenceMonth.substring(4, 6));
|
||||
const lastY = parseInt(lastMonth.substring(0, 4));
|
||||
const lastM = parseInt(lastMonth.substring(4, 6));
|
||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
||||
}
|
||||
|
||||
// Score et verdict pré-calculés par score-engine.ts
|
||||
const score = row.score ?? 0;
|
||||
const verdict: "A" | "Z" = score >= 45 ? "A" : "Z";
|
||||
|
||||
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: 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,
|
||||
shareMarge: row.shareMarge,
|
||||
shareQty: row.shareQty,
|
||||
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: {
|
||||
score,
|
||||
verdict,
|
||||
isRecent: row.isRecent ?? false,
|
||||
isLastProduct: row.isLastProduct ?? false,
|
||||
isTop30Supplier: row.isTop30Supplier ?? false,
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (status === "idle") {
|
||||
if (!isAdmin) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="flex items-center gap-1.5 text-[11px] font-medium transition-colors group"
|
||||
style={{ color: "var(--text-secondary)" }}
|
||||
>
|
||||
<Sparkles className="w-3 h-3 text-indigo-500 dark:text-indigo-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors" />
|
||||
<span className="group-hover:text-indigo-600 dark:group-hover:text-indigo-300 transition-colors">Analyser</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span className="italic">Analyse IA...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-[11px] text-rose-600 dark:text-rose-500 cursor-help"
|
||||
title={insight?.insight || "Une erreur inconnue est survenue"}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>Erreur</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1 group/insight" title="Cliquer pour re-analyser">
|
||||
<div className="flex-1 min-w-0 pr-1 group">
|
||||
<p
|
||||
className="text-[11px] leading-snug font-medium text-[var(--text-secondary)] line-clamp-3 cursor-pointer hover:text-[var(--text-primary)] transition-colors relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{insight.insight}
|
||||
<span className="inline-flex ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Maximize2 className="w-2.5 h-2.5 text-[var(--accent)]" />
|
||||
</span>
|
||||
</p>
|
||||
</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
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AiExplanationModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
productName={row.libelle1}
|
||||
productCode={row.codein}
|
||||
explanation={insight?.insight || ""}
|
||||
recommandation={insight?.recommandation}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { AnalysisEngine } from "../business/analysis-engine";
|
||||
import { ProductAnalysisInput, AnalysisResult } from "../models/ai-analysis.types";
|
||||
|
||||
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
export interface OpenRouterConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export class OpenRouterClient {
|
||||
constructor(private config: OpenRouterConfig) { }
|
||||
|
||||
async analyzeProduct(p: ProductAnalysisInput): Promise<AnalysisResult> {
|
||||
// Timeout de 50 secondes : évite le blocage indéfini si OpenRouter est lent,
|
||||
// tout en laissant assez de temps aux modèles complexes pour répondre (maxDuration serveur = 55s).
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 50_000);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(OPENROUTER_URL, {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://collectflow.app",
|
||||
"X-Title": "CollectFlow AI Copilot",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: AnalysisEngine.generateSystemPrompt() },
|
||||
{ role: "user", content: AnalysisEngine.generateUserMessage(p) },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 150,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw new Error("timeout"); // Géré proprement plus haut
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new Error("rate_limited");
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`OpenRouter error: ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
let reco: "A" | "B" | "C" | "D" | "Z" | null = null;
|
||||
let cleanInsight = "Erreur de génération.";
|
||||
|
||||
try {
|
||||
// Some models might wrap JSON in markdown blocks despite instructions
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonText = jsonMatch ? jsonMatch[0] : content;
|
||||
const parsed = JSON.parse(jsonText);
|
||||
|
||||
reco = parsed.recommendation as "A" | "B" | "C" | "D" | "Z";
|
||||
cleanInsight = parsed.justification || "";
|
||||
|
||||
const ruleApplies = parsed.rule_applies === true;
|
||||
|
||||
// Garde-fou de sécurité : On n'autorise B, C ou D QUE si une règle manager s'applique.
|
||||
if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) {
|
||||
reco = "A";
|
||||
}
|
||||
|
||||
// Override recommendation if it doesn't match extracted reco for safety
|
||||
if (!reco || !["A", "B", "C", "D", "Z"].includes(reco)) {
|
||||
reco = AnalysisEngine.extractRecommendation(cleanInsight) || "A";
|
||||
// Ré-appliquer le garde-fou pour la reco extraite du texte.
|
||||
if (!ruleApplies && (reco === "B" || reco === "C" || reco === "D")) reco = "A";
|
||||
}
|
||||
|
||||
// Garde-fou anti-dégradation : l'IA ne peut PAS dégrader A → Z
|
||||
// si le score pré-calculé est ≥ 55 (sauf si règle manager).
|
||||
// Corrige les hallucinations du LLM qui ignore la condition "score < 55".
|
||||
const preScore = p.scoring?.score ?? p.score ?? 0;
|
||||
const preVerdict = p.scoring?.verdict ?? (preScore >= 45 ? "A" : "Z");
|
||||
if (!ruleApplies && preVerdict === "A" && reco === "Z" && preScore >= 55) {
|
||||
console.warn(`[AI Guardrail] Blocked A→Z degradation for ${p.codein} (score ${preScore} ≥ 55)`);
|
||||
reco = "A";
|
||||
cleanInsight += " [Garde-fou : score élevé, dégradation bloquée]";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse AI JSON response:", content, e);
|
||||
// Fallback to text parsing
|
||||
reco = AnalysisEngine.extractRecommendation(content);
|
||||
cleanInsight = AnalysisEngine.cleanInsight(content);
|
||||
}
|
||||
|
||||
return {
|
||||
insight: reco ? `[${reco}] ${cleanInsight}` : cleanInsight,
|
||||
codein: p.codein,
|
||||
recommandation: reco
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { ProductContextProfile } from "../business/context-profiler";
|
||||
|
||||
export interface SiteMonthlyData {
|
||||
site: string; // "Frouard" ou "Houdemont"
|
||||
mois: string; // "YYYY-MM"
|
||||
ventes_qte: number;
|
||||
ventes_ca: number;
|
||||
marge: number;
|
||||
stock_fin_mois: number; // peut être négatif (validation tardive de commande)
|
||||
receptions_qte: number;
|
||||
}
|
||||
|
||||
export interface ProductAnalysisInput {
|
||||
codein: string;
|
||||
libelle1: string;
|
||||
libelleNiveau2?: string;
|
||||
/**
|
||||
* Code de nomenclature au niveau 2 (4 premiers chiffres du code à 6 chiffres).
|
||||
* Utilisé pour calculer les poids rayon sur le bon périmètre (pas trop fin = niveau 3).
|
||||
* Correspond à `code2` dans ProductRow.
|
||||
*/
|
||||
codeNomenclatureN2?: string;
|
||||
totalCa: number;
|
||||
tauxMarge: number;
|
||||
totalQuantite: number;
|
||||
weightedTotalQuantite?: number;
|
||||
weightedTotalCa?: number;
|
||||
avgTotalQuantite?: number;
|
||||
avgQtyRayon?: number;
|
||||
avgQtyFournisseur?: number;
|
||||
/** Poids relatifs (%) */
|
||||
shareCa?: number;
|
||||
shareMarge?: number;
|
||||
shareQty?: number;
|
||||
/** Référentiels */
|
||||
totalFournisseurCa?: number;
|
||||
storeCount: number;
|
||||
sales12m: Record<string, number>;
|
||||
codeGamme: string | null;
|
||||
score: number;
|
||||
regularityScore: number;
|
||||
/** Projection sur 12 mois si le produit est récent (Run Rate) */
|
||||
projectedTotalQuantite?: number;
|
||||
projectedTotalCa?: number;
|
||||
/** Analyse de saisonnalité */
|
||||
lastMonthWithSale?: string;
|
||||
inactivityMonths?: number;
|
||||
/** Contexte Fournisseur */
|
||||
codeFournisseur?: string;
|
||||
totalMagasins?: number;
|
||||
isLastProductOfSupplier?: boolean;
|
||||
/** Scoring metadata (unifié v4) */
|
||||
scoring?: {
|
||||
score: number; // 0-100, score hybride unifié
|
||||
verdict: "A" | "Z"; // pré-calculé par TypeScript
|
||||
quadrant?: string; // STAR/TRAFIC/MARGE/WATCH
|
||||
isRecent: boolean;
|
||||
isLastProduct: boolean;
|
||||
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 */
|
||||
noid?: number;
|
||||
/** Données mensuelles per-site (ventes, stock, réceptions) */
|
||||
siteMonthlyData?: SiteMonthlyData[];
|
||||
/** Données stock & approvisionnement (API FF Nancy) */
|
||||
stockActuel?: number;
|
||||
stockTotal?: number;
|
||||
pcb?: number;
|
||||
commandesEnCours?: number;
|
||||
nbJoursDerniereVente?: number;
|
||||
derniereVente?: string;
|
||||
|
||||
/** Ranking réseau (classement global tous magasins) */
|
||||
rankingCa?: number;
|
||||
rankingQte?: number;
|
||||
/** Ranking magasin (classement local) */
|
||||
rankingMagCa?: number;
|
||||
rankingMagQte?: number;
|
||||
/** Nombre total de produits classés (avec ventes sur la période) */
|
||||
totalRankedProducts?: number;
|
||||
|
||||
/**
|
||||
* Fiche de contexte enrichie générée par le ContextProfiler.
|
||||
* Transmise au prompt de l'IA pour une analyse multi-dimensionnelle.
|
||||
*/
|
||||
contextProfile?: ProductContextProfile;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
insight: string;
|
||||
codein: string;
|
||||
recommandation: "A" | "B" | "C" | "D" | "Z" | null;
|
||||
scoring?: any; // On peut typer plus finement si nécessaire
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface AiInsight {
|
||||
codein: string;
|
||||
insight: string;
|
||||
status: "idle" | "loading" | "done" | "error";
|
||||
isDuplicate?: boolean;
|
||||
}
|
||||
|
||||
interface AiCopilotState {
|
||||
insights: Record<string, AiInsight>;
|
||||
setInsight: (codein: string, insight: string, isDuplicate?: boolean) => void;
|
||||
batchSetInsights: (entries: { codein: string; insight: string }[]) => void;
|
||||
setLoading: (codein: string) => void;
|
||||
batchSetLoading: (codeins: string[]) => void;
|
||||
setError: (codein: string, error: string) => void;
|
||||
analyzeProduct: (payload: {
|
||||
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;
|
||||
lastMonthWithSale?: string;
|
||||
inactivityMonths?: number;
|
||||
weightedTotalQuantite?: number;
|
||||
weightedTotalCa?: number;
|
||||
avgQtyFournisseur?: number;
|
||||
avgQtyRayon?: number;
|
||||
shareCa?: number;
|
||||
shareMarge?: number;
|
||||
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?: {
|
||||
score: number;
|
||||
verdict: "A" | "Z";
|
||||
isRecent: boolean;
|
||||
isLastProduct: boolean;
|
||||
isTop30Supplier: boolean;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
resetInsights: () => void;
|
||||
}
|
||||
|
||||
export const useAiCopilotStore = create<AiCopilotState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
insights: {},
|
||||
|
||||
setInsight: (codein, insight, isDuplicate = false) => {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight, status: "done", isDuplicate },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
batchSetInsights: (entries) => {
|
||||
set((s) => {
|
||||
const nextInsights = { ...s.insights };
|
||||
entries.forEach(({ codein, insight }) => {
|
||||
nextInsights[codein] = { codein, insight, status: "done" };
|
||||
});
|
||||
return { insights: nextInsights };
|
||||
});
|
||||
},
|
||||
|
||||
setLoading: (codein) => {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: "", status: "loading" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
batchSetLoading: (codeins) => {
|
||||
set((s) => {
|
||||
const nextInsights = { ...s.insights };
|
||||
codeins.forEach(codein => {
|
||||
nextInsights[codein] = { codein, insight: "", status: "loading" };
|
||||
});
|
||||
return { insights: nextInsights };
|
||||
});
|
||||
},
|
||||
|
||||
setError: (codein, error) => {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: error, status: "error" },
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
resetInsights: () => {
|
||||
set({ insights: {} });
|
||||
},
|
||||
|
||||
analyzeProduct: async (payload) => {
|
||||
const { codein } = payload;
|
||||
|
||||
// Mark as loading
|
||||
set((s) => ({
|
||||
insights: { ...s.insights, [codein]: { codein, insight: "", status: "loading" } },
|
||||
}));
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: data.insight, status: "done" },
|
||||
},
|
||||
}));
|
||||
} catch {
|
||||
set((s) => ({
|
||||
insights: {
|
||||
...s.insights,
|
||||
[codein]: { codein, insight: "Erreur lors de l'analyse.", status: "error" },
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "collectflow-ai-storage",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,7 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import type { ProductRow, GammeCode, GridFilters } from "@/types/grid";
|
||||
import { computeProductScores } from "@/lib/score-engine";
|
||||
import { buildLast12MonthsRange, getMensuelByArticles } from "@/lib/api-ff-client";
|
||||
import {
|
||||
pgGetArticlesByFournisseur,
|
||||
@@ -9,13 +8,15 @@ import {
|
||||
pgGetGammesByFournisseur,
|
||||
pgGetNomenclatureByFournisseur,
|
||||
pgGetStockByFournisseur,
|
||||
pgGetRankingByFournisseur,
|
||||
pgGetCommandesByFournisseur,
|
||||
type PgStockRow,
|
||||
} from "@/lib/pg-ff-client";
|
||||
import { db } from "@/db";
|
||||
import { sessionSnapshots } from "@/db/schema";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { getNetworkMetricsByCodeCentrale } from "@/lib/qlik-network-cache";
|
||||
|
||||
const NB_MAGASINS_RESEAU = 270;
|
||||
|
||||
interface GetProductRowsInput {
|
||||
codeFournisseur: string;
|
||||
@@ -86,7 +87,6 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
gammeMap,
|
||||
nomMap,
|
||||
stockMap,
|
||||
rankingResult,
|
||||
commandesMap,
|
||||
] = await Promise.all([
|
||||
pgGetArticlesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetArticlesByFournisseur ERROR:", e); return []; }),
|
||||
@@ -94,12 +94,10 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
pgGetGammesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetGammesByFournisseur ERROR:", e); return new Map<string, string>(); }),
|
||||
pgGetNomenclatureByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetNomenclatureByFournisseur ERROR:", e); return new Map(); }),
|
||||
pgGetStockByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetStockByFournisseur ERROR:", e); return new Map<string, PgStockRow[]>(); }),
|
||||
pgGetRankingByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetRankingByFournisseur ERROR:", e); return { rankings: new Map(), totalRankedProducts: 0 }; }),
|
||||
pgGetCommandesByFournisseur(codeFournisseur).catch(e => { console.error("[getProductRows] pgGetCommandesByFournisseur ERROR:", e); return new Map<string, number>(); }),
|
||||
]);
|
||||
|
||||
const { rankings: rankingMap, totalRankedProducts } = rankingResult;
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes, ${rankingMap.size} rankings`);
|
||||
console.log(`[getProductRows] ${articles.length} articles, ${mensuelRows.length} mensuel rows, ${gammeMap.size} gammes`);
|
||||
|
||||
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
|
||||
const now = new Date();
|
||||
@@ -123,6 +121,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
libelle1: art.libelle1 ?? "",
|
||||
gtin: art.gtin ?? "",
|
||||
reference: art.reference ?? "",
|
||||
codeCentrale: art.codeCentrale ? String(art.codeCentrale).trim() : undefined,
|
||||
code1: "", libelleNiveau1: "",
|
||||
code2: "", libelleNiveau2: "",
|
||||
code3: "", libelle3: "",
|
||||
@@ -136,9 +135,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
totalCa: 0,
|
||||
totalMarge: 0,
|
||||
tauxMarge: 0,
|
||||
score: 0,
|
||||
workingStores: [],
|
||||
aiRecommendation: null,
|
||||
noid: art.no_id ? Number(art.no_id) : undefined,
|
||||
pcb: art.pcb ? Number(art.pcb) : undefined,
|
||||
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
|
||||
@@ -313,16 +310,8 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase 8 : Ranking réseau ────────────────────────────────────────
|
||||
for (const [codein, ranking] of rankingMap.entries()) {
|
||||
const product = productMap.get(codein);
|
||||
if (!product) continue;
|
||||
product.rankingCa = ranking.ranking_ca ? Number(ranking.ranking_ca) : undefined;
|
||||
product.rankingQte = ranking.ranking_qte ? Number(ranking.ranking_qte) : undefined;
|
||||
product.rankingMagCa = ranking.ranking_mag_ca ? Number(ranking.ranking_mag_ca) : undefined;
|
||||
product.rankingMagQte = ranking.ranking_mag_qte ? Number(ranking.ranking_mag_qte) : undefined;
|
||||
product.totalRankedProducts = totalRankedProducts;
|
||||
}
|
||||
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
|
||||
await enrichWithNetworkMetrics(productMap);
|
||||
|
||||
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
|
||||
try {
|
||||
@@ -348,14 +337,14 @@ async function buildProductRows(input: GetProductRowsInput): Promise<ProductRow[
|
||||
console.error("[getProductRows] Snapshot restore error:", snapErr);
|
||||
}
|
||||
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes + compute scores ─────────
|
||||
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
|
||||
const allRows = Array.from(productMap.values());
|
||||
const rows = allRows.filter(p => p.codeGamme !== "Y" || p.totalQuantite > 0);
|
||||
await reconcileSelectedStoreFromMensuelApi(rows, magasin, dateDebut, dateFin, sortedPeriods);
|
||||
const excludedY = allRows.length - rows.length;
|
||||
console.log(`[getProductRows] ${rows.length} produits (${excludedY} gamme Y sans ventes exclus), ${mensuelByCodein.size} avec ventes`);
|
||||
|
||||
return computeProductScores(rows);
|
||||
return rows;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[getProductRows] Error for ${codeFournisseur}:`, error);
|
||||
@@ -467,3 +456,36 @@ async function reconcileSelectedStoreFromMensuelApi(
|
||||
console.error("[getProductRows] Mensuel API reconciliation error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrichit les produits avec les metriques reseau Qlik (cache qlik_network_metrics),
|
||||
* jointes par code centrale. Degradation propre si la sync Qlik n'a jamais tourne
|
||||
* ou si le code centrale n'est pas encore disponible.
|
||||
*/
|
||||
async function enrichWithNetworkMetrics(productMap: Map<string, ProductRow>): Promise<void> {
|
||||
try {
|
||||
const byCodeCentrale = new Map<string, ProductRow[]>();
|
||||
for (const product of productMap.values()) {
|
||||
const cc = product.codeCentrale;
|
||||
if (!cc) continue;
|
||||
if (!byCodeCentrale.has(cc)) byCodeCentrale.set(cc, []);
|
||||
byCodeCentrale.get(cc)!.push(product);
|
||||
}
|
||||
if (byCodeCentrale.size === 0) return;
|
||||
|
||||
const metrics = await getNetworkMetricsByCodeCentrale([...byCodeCentrale.keys()]);
|
||||
for (const [cc, products] of byCodeCentrale.entries()) {
|
||||
const m = metrics.get(cc);
|
||||
if (!m) continue;
|
||||
for (const product of products) {
|
||||
product.caReseau = m.caReseau;
|
||||
product.qteReseau = m.qteReseau;
|
||||
product.nbMagasinsReseau = m.nbMagasinsReseau;
|
||||
product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU;
|
||||
product.networkFetchedAt = m.fetchedAt ?? undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[getProductRows] enrichWithNetworkMetrics error:", error);
|
||||
}
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { ContextProfiler } from "@/features/ai-copilot/business/context-profiler";
|
||||
import { Sparkles, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { GammeCode } from "@/types/grid";
|
||||
import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types";
|
||||
import { SupplierAiContextModal } from "./supplier-ai-context-modal";
|
||||
|
||||
export function BulkAiAnalyzer() {
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const supplierCode = rows.length > 0 ? rows[0].codeFournisseur : null;
|
||||
const supplierName = rows.length > 0 ? rows[0].nomFournisseur : null;
|
||||
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, message: "", errors: 0 });
|
||||
const isCancelledRef = useRef(false);
|
||||
|
||||
const batchSetDraftGamme = useGridStore((s) => s.batchSetDraftGamme);
|
||||
const setDraftGamme = useGridStore((s) => s.setDraftGamme);
|
||||
const { setInsight, batchSetInsights, batchSetLoading, setError } = useAiCopilotStore();
|
||||
|
||||
const prevSupplierRef = useRef(supplierCode);
|
||||
useEffect(() => {
|
||||
if (supplierCode !== prevSupplierRef.current) {
|
||||
if (!isAnalyzing) {
|
||||
setProgress({ current: 0, total: 0, message: "", errors: 0 });
|
||||
}
|
||||
prevSupplierRef.current = supplierCode;
|
||||
}
|
||||
}, [supplierCode, isAnalyzing]);
|
||||
|
||||
const handleStop = () => {
|
||||
isCancelledRef.current = true;
|
||||
setProgress((prev) => ({ ...prev, message: "Arrêt en cours..." }));
|
||||
};
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
const { rows } = useGridStore.getState();
|
||||
isCancelledRef.current = false;
|
||||
|
||||
// Show immediate visual feedback before any async work
|
||||
setIsAnalyzing(true);
|
||||
setProgress({ current: 0, total: 0, message: "Chargement du contexte...", errors: 0 });
|
||||
|
||||
// 1. Fetch AI Context for the Supplier
|
||||
let supplierContext = "";
|
||||
if (supplierCode) {
|
||||
try {
|
||||
const ctxRes = await fetch(`/api/ai/context?fournisseur=${supplierCode}`);
|
||||
if (ctxRes.ok) {
|
||||
const data = await ctxRes.json();
|
||||
supplierContext = data.context || "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load supplier context for AI", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2a. Produits sans aucune vente → Z direct, sans appel IA (avant de construire les payloads)
|
||||
const zeroSalesRows = rows.filter((r) => (r.totalQuantite || 0) === 0);
|
||||
if (zeroSalesRows.length > 0) {
|
||||
const zChanges: Record<string, GammeCode> = {};
|
||||
zeroSalesRows.forEach(r => { zChanges[r.codein] = "Z"; });
|
||||
batchSetDraftGamme(zChanges);
|
||||
batchSetInsights(zeroSalesRows.map(r => ({
|
||||
codein: r.codein,
|
||||
insight: "Aucune vente sur 12 mois — produit classé Z automatiquement.",
|
||||
})));
|
||||
}
|
||||
|
||||
// Construire les payloads uniquement pour les produits avec ventes
|
||||
const rowsWithSales = rows.filter((r) => (r.totalQuantite || 0) > 0);
|
||||
const initialPayloads: ProductAnalysisInput[] = rowsWithSales.map((r) => {
|
||||
const sc = r.workingStores?.length || 1;
|
||||
const weight = sc === 1 ? 2 : 1;
|
||||
|
||||
const allMonths = Object.keys(r.sales12m || {});
|
||||
const referenceMonth = allMonths.length > 0 ? Math.max(...allMonths.map((m) => parseInt(m))).toString() : "";
|
||||
const salesMonths = Object.entries(r.sales12m || {})
|
||||
.filter(([_, qty]) => qty > 0)
|
||||
.map(([m]) => parseInt(m))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
const lastMonth = salesMonths.length > 0 ? salesMonths[0].toString() : "";
|
||||
let inactivity = 0;
|
||||
if (referenceMonth && lastMonth) {
|
||||
const refY = parseInt(referenceMonth.substring(0, 4));
|
||||
const refM = parseInt(referenceMonth.substring(4, 6));
|
||||
const lastY = parseInt(lastMonth.substring(0, 4));
|
||||
const lastM = parseInt(lastMonth.substring(4, 6));
|
||||
inactivity = (refY - lastY) * 12 + (refM - lastM);
|
||||
}
|
||||
|
||||
const regScore = Object.values(r.sales12m || {}).filter((v) => v > 0).length;
|
||||
|
||||
return {
|
||||
codein: r.codein,
|
||||
noid: r.noid,
|
||||
libelle1: r.libelle1 || "",
|
||||
libelleNiveau2: r.libelleNiveau2 || "Général",
|
||||
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,
|
||||
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,
|
||||
lastMonthWithSale: lastMonth,
|
||||
inactivityMonths: inactivity,
|
||||
codeFournisseur: r.codeFournisseur || undefined,
|
||||
avgQtyFournisseur: r.avgQtyFournisseur,
|
||||
avgQtyRayon: r.avgQtyRayon,
|
||||
shareCa: r.shareCa,
|
||||
shareMarge: r.shareMarge,
|
||||
shareQty: r.shareQty,
|
||||
totalFournisseurCa: r.totalFournisseurCa,
|
||||
supplierContext: supplierContext,
|
||||
};
|
||||
});
|
||||
|
||||
// 2b. Stock mort absolu : CA < 100€ ET quantité < 30 → Z direct, sans appel IA
|
||||
const deadStockRows = rows.filter(r =>
|
||||
(r.totalQuantite || 0) > 0 &&
|
||||
(r.totalCa ?? 0) < 100 && (r.totalQuantite ?? 0) < 30
|
||||
);
|
||||
if (deadStockRows.length > 0) {
|
||||
const zChanges: Record<string, GammeCode> = {};
|
||||
deadStockRows.forEach(r => { zChanges[r.codein] = "Z"; });
|
||||
batchSetDraftGamme(zChanges);
|
||||
batchSetInsights(deadStockRows.map(r => ({
|
||||
codein: r.codein,
|
||||
insight: `CA < 100€ (${(r.totalCa ?? 0).toFixed(0)}€) et quantité < 30 (${r.totalQuantite} uté) — stock mort absolu, classé Z automatiquement.`,
|
||||
})));
|
||||
}
|
||||
const deadStockCodes = new Set(deadStockRows.map(r => r.codein));
|
||||
|
||||
// 2c. Top 20 réseau → A direct, sans appel IA
|
||||
const top20Rows = rows.filter(r =>
|
||||
(r.totalQuantite || 0) > 0 &&
|
||||
!deadStockCodes.has(r.codein) &&
|
||||
((r.rankingCa != null && r.rankingCa <= 20) || (r.rankingQte != null && r.rankingQte <= 20))
|
||||
);
|
||||
if (top20Rows.length > 0) {
|
||||
const aChanges: Record<string, GammeCode> = {};
|
||||
top20Rows.forEach(r => { aChanges[r.codein] = "A"; });
|
||||
batchSetDraftGamme(aChanges);
|
||||
batchSetInsights(top20Rows.map(r => {
|
||||
const rkCa = r.rankingCa != null ? `${r.rankingCa}e CA` : "";
|
||||
const rkQte = r.rankingQte != null ? `${r.rankingQte}e Qté` : "";
|
||||
const total = r.totalRankedProducts ? ` / ${r.totalRankedProducts.toLocaleString("fr-FR")} produits` : "";
|
||||
return {
|
||||
codein: r.codein,
|
||||
insight: `Top 20 réseau (${[rkCa, rkQte].filter(Boolean).join(", ")}${total}) — classé A automatiquement.`,
|
||||
};
|
||||
}));
|
||||
}
|
||||
const top20Codes = new Set(top20Rows.map(r => r.codein));
|
||||
|
||||
// Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente et CA/quantité suffisants
|
||||
const payloadsWithSales = initialPayloads.filter(p =>
|
||||
(p.totalQuantite || 0) > 0 && !deadStockCodes.has(p.codein) && !top20Codes.has(p.codein)
|
||||
);
|
||||
|
||||
// 2c. Context profiling en micro-batches asynchrones
|
||||
// Le score est déjà calculé par score-engine.ts (champ row.score sur chaque ProductRow).
|
||||
setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du contexte...", errors: 0 });
|
||||
|
||||
const SCORING_BATCH_SIZE = 25;
|
||||
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 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, payloadsWithSales, score, contextCache);
|
||||
} catch (err) {
|
||||
console.warn(`[BulkAnalyzer] ContextProfiler failed for ${p.codein}:`, err);
|
||||
contextProfile = undefined;
|
||||
}
|
||||
|
||||
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,
|
||||
rankingCa: row?.rankingCa,
|
||||
rankingQte: row?.rankingQte,
|
||||
rankingMagCa: row?.rankingMagCa,
|
||||
rankingMagQte: row?.rankingMagQte,
|
||||
totalRankedProducts: row?.totalRankedProducts,
|
||||
contextProfile,
|
||||
scoring: {
|
||||
score,
|
||||
verdict,
|
||||
quadrant: contextProfile?.quadrant,
|
||||
isRecent: row?.isRecent ?? false,
|
||||
isLastProduct: row?.isLastProduct ?? false,
|
||||
isTop30Supplier: row?.isTop30Supplier ?? false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Yield au navigateur entre chaque batch pour éviter le freeze
|
||||
setProgress(prev => ({
|
||||
...prev,
|
||||
current: Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length),
|
||||
message: `Contexte : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`,
|
||||
}));
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
if (isCancelledRef.current) {
|
||||
setIsAnalyzing(false);
|
||||
return;
|
||||
}
|
||||
let completed = 0;
|
||||
let errorsCount = 0;
|
||||
|
||||
const totalItems = productPayloads.length;
|
||||
setProgress({ current: 0, total: totalItems, message: "Envoi aux modèles IA...", errors: 0 });
|
||||
|
||||
// Reset global ATOMIQUE et IMMÉDIAT
|
||||
const codeins = productPayloads.map(p => p.codein);
|
||||
batchSetLoading(codeins);
|
||||
|
||||
const gammeChanges: Record<string, GammeCode> = {};
|
||||
codeins.forEach(c => gammeChanges[c] = "Aucune");
|
||||
batchSetDraftGamme(gammeChanges);
|
||||
|
||||
const CONCURRENCY = 2;
|
||||
const remaining = [...productPayloads];
|
||||
|
||||
const processBatch = async () => {
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, remaining.length) }, async () => {
|
||||
while (remaining.length > 0 && !isCancelledRef.current) {
|
||||
const payload = remaining.shift();
|
||||
if (!payload) break;
|
||||
|
||||
try {
|
||||
let res: Response | null = null;
|
||||
// Jusqu'à 3 tentatives par produit
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (isCancelledRef.current) break;
|
||||
try {
|
||||
res = await fetch("/api/ai/analyze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) break; // Succès → sortie de boucle
|
||||
|
||||
if (res.status === 429) {
|
||||
// Rate-limit : on attend avant de réessayer
|
||||
const retryData = await res.json().catch(() => ({}));
|
||||
const wait = (retryData.retryAfter ?? 30) * 1000;
|
||||
console.warn(`[BulkAI] Rate limited, retry in ${wait / 1000}s`);
|
||||
await new Promise((r) => setTimeout(r, Math.min(wait, 15_000)));
|
||||
} else if (res.status === 504 || res.status === 502 || res.status === 503 || res.status === 529) {
|
||||
// Timeout ou réseau surchargé : on tente de réessayer de suite
|
||||
console.warn(`[BulkAI] Network issue (${res.status}) for ${payload.codein}, retry #${attempt + 1}`);
|
||||
await new Promise((r) => setTimeout(r, 2_000 * (attempt + 1))); // Exponential backoff (2s, 4s, 6s)
|
||||
} else {
|
||||
// Autre erreur (500 local, auth, etc.) : ne pas réessayer inutilement
|
||||
break;
|
||||
}
|
||||
} catch (fetchErr) {
|
||||
console.error(`[BulkAI] Fetch error (attempt ${attempt + 1}) for ${payload.codein}`, fetchErr);
|
||||
await new Promise((r) => setTimeout(r, 1_000)); // Petite pause avant retry réseau
|
||||
}
|
||||
}
|
||||
|
||||
if (!res || !res.ok) {
|
||||
let errorMsg = res?.status === 504 ? "Timeout IA" : (res?.status === 502 ? "API Surchargée" : `Erreur HTTP ${res?.status}`);
|
||||
|
||||
// On tente d'extraire le message original s'il y en a un
|
||||
if (res && res.headers.get("content-type")?.includes("application/json")) {
|
||||
try {
|
||||
const errData = await res.json();
|
||||
if (errData.error) errorMsg += ` - ${errData.error}`;
|
||||
if (errData.detail) errorMsg += ` (${errData.detail})`;
|
||||
} catch (e) { /* ignore JSON parse error on error */ }
|
||||
}
|
||||
|
||||
setError(payload.codein, errorMsg);
|
||||
errorsCount++;
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setInsight(payload.codein, data.insight, data.isDuplicate);
|
||||
if (data.recommandation) {
|
||||
setDraftGamme(payload.codein, data.recommandation as GammeCode);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BulkAI] Error processing ${payload.codein}`, err);
|
||||
setError(payload.codein, "Erreur");
|
||||
errorsCount++;
|
||||
} finally {
|
||||
completed++;
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
current: completed,
|
||||
message: `Analyse: ${completed}/${totalItems}`,
|
||||
errors: errorsCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(workers);
|
||||
};
|
||||
|
||||
await processBatch();
|
||||
|
||||
// Nettoyer les drafts "Aucune" restants (produits en erreur dont l'IA n'a pas retourné A/Z)
|
||||
// Pour éviter que "Valider" ne sauvegarde "Aucune" comme gamme réelle
|
||||
const currentDrafts = useGridStore.getState().draftChanges;
|
||||
const aucuneCodeins = codeins.filter(c => currentDrafts[c] === "Aucune");
|
||||
if (aucuneCodeins.length > 0) {
|
||||
const { clearDrafts } = useGridStore.getState();
|
||||
clearDrafts(aucuneCodeins);
|
||||
}
|
||||
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
message: isCancelledRef.current ? `Analyse interrompue (${completed}/${totalItems})` : `Analyse terminée ! (${errorsCount > 0 ? errorsCount + " erreurs" : "succès"})`,
|
||||
}));
|
||||
|
||||
setTimeout(() => setIsAnalyzing(false), 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<SupplierAiContextModal codeFournisseur={supplierCode} nomFournisseur={supplierName} />
|
||||
|
||||
{isAnalyzing ? (
|
||||
<div className="flex items-center gap-4 h-10 px-4 rounded-xl bg-[var(--accent-bg)] border border-[var(--accent-border)] shadow-sm">
|
||||
<Loader2 className="w-4 h-4 text-[var(--accent)] animate-spin" />
|
||||
<div className="flex flex-col min-w-[150px]">
|
||||
<span className="text-[10px] font-bold text-[var(--accent)] leading-none">
|
||||
{progress.message}
|
||||
</span>
|
||||
<div className="w-full bg-[var(--bg-elevated)] rounded-full h-1 mt-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-[var(--accent)] h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(5, (progress.current / progress.total) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-1 hover:bg-[var(--bg-elevated)] rounded-lg transition-colors text-[var(--text-muted)] hover:text-[var(--accent-error)]"
|
||||
title="Arrêter l'analyse"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : progress.current > 0 ? (
|
||||
<div className="apple-btn-primary opacity-80 cursor-default">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>
|
||||
{progress.errors > 0 ? `Terminé (${progress.current - progress.errors}/${progress.total})` : `Analyse Terminée`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
className="apple-btn-secondary"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
Analyse IA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
doc.text(`Export généré le : ${new Date().toLocaleDateString("fr-FR")}`, 14, 30);
|
||||
|
||||
const head = [
|
||||
["Gencode", "Code In", "Réf.", "Libellé", "Score", "Vol.", "CA", "Marge", "Gamme"]
|
||||
["Gencode", "Code In", "Réf.", "Libellé", "Mag. Rés.", "Vol.", "CA", "Marge", "Gamme"]
|
||||
];
|
||||
|
||||
const body = rows.map(r => {
|
||||
@@ -128,7 +128,7 @@ export function ExportDropdown({ nomFournisseur }: { nomFournisseur?: string })
|
||||
r.codein,
|
||||
r.reference || "-",
|
||||
r.libelle1 ? r.libelle1.substring(0, 60) + (r.libelle1.length > 60 ? "..." : "") : "",
|
||||
r.score.toString(),
|
||||
r.nbMagasinsReseau != null ? r.nbMagasinsReseau.toString() : "-",
|
||||
Math.round(r.totalQuantite).toLocaleString("fr-FR"),
|
||||
`${Math.round(r.totalCa).toLocaleString("fr-FR")} €`,
|
||||
`${Math.round(r.totalMarge).toLocaleString("fr-FR")} €\n(${r.tauxMarge.toFixed(1)}%)`,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||
import { useAiCopilotStore } from "@/features/ai-copilot/store/use-ai-copilot-store";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
import { BulkAiAnalyzer } from "./bulk-ai-analyzer";
|
||||
import { SyncQlikButton } from "./sync-qlik-button";
|
||||
import { useSaveDrafts } from "@/features/grid/hooks/use-save-drafts";
|
||||
import { Loader2, CheckCircle, AlertCircle, RotateCcw, Camera, ChevronDown } from "lucide-react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
@@ -35,7 +34,6 @@ export function FloatingSummaryBar() {
|
||||
const rows = useGridStore((s) => s.rows);
|
||||
const filters = useGridStore((s) => s.filters);
|
||||
const draftChanges = useGridStore((s) => s.draftChanges);
|
||||
const { resetInsights } = useAiCopilotStore();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isSavingSnapshot, setIsSavingSnapshot] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
@@ -48,6 +46,15 @@ export function FloatingSummaryBar() {
|
||||
const visibleCodeins = useMemo(() => rows.map(r => r.codein), [rows]);
|
||||
const { save, hasDrafts, count } = useSaveDrafts(filters.magasin || "TOTAL", visibleCodeins);
|
||||
|
||||
const supplierCode = filters.codeFournisseur || rows[0]?.codeFournisseur;
|
||||
const lastQlikUpdate = useMemo(() => {
|
||||
let max: string | null = null;
|
||||
for (const r of rows) {
|
||||
if (r.networkFetchedAt && (!max || r.networkFetchedAt > max)) max = r.networkFetchedAt;
|
||||
}
|
||||
return max;
|
||||
}, [rows]);
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
const result = await save();
|
||||
@@ -57,9 +64,8 @@ export function FloatingSummaryBar() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes et analyses IA) ?")) {
|
||||
if (window.confirm("Es-tu sûr de vouloir annuler tous les changements non enregistrés (gammes) ?")) {
|
||||
resetDrafts();
|
||||
resetInsights();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,7 +188,7 @@ export function FloatingSummaryBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 items-center">
|
||||
{isAdmin && <BulkAiAnalyzer />}
|
||||
{isAdmin && <SyncQlikButton codeFournisseur={supplierCode} lastUpdate={lastQlikUpdate} />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -26,7 +26,6 @@ import { GammeSelect } from "@/features/grid/components/gamme-select";
|
||||
import { HeatmapCell } from "@/features/grid/components/heatmap-cell";
|
||||
import type { ProductRow, GammeCode } from "@/types/grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AiInsightBlock } from "@/features/ai-copilot/components/ai-insight-block";
|
||||
|
||||
function getLast12Months(): string[] {
|
||||
const months: string[] = [];
|
||||
@@ -178,8 +177,8 @@ const GridRow = React.memo(({ virtualRow, row, rowHeight, isSelected, columnVisi
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<ProductRow, unknown>) => {
|
||||
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "ai" || cell.column.id === "libelle3";
|
||||
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "score" || cell.column.id === "gamme";
|
||||
const isFlexible = cell.column.id === "libelle1" || cell.column.id === "libelle3";
|
||||
const isCenter = cell.column.id === "totalQuantite" || cell.column.id === "totalCa" || cell.column.id === "totalMarge" || cell.column.id.startsWith("month_") || cell.column.id === "gammeInitial" || cell.column.id === "caReseau" || cell.column.id === "qteReseau" || cell.column.id === "nbMagasinsReseau" || cell.column.id === "tauxPresenceReseau" || cell.column.id === "gamme";
|
||||
const size = cell.column.getSize();
|
||||
return (
|
||||
<td
|
||||
@@ -507,67 +506,56 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "score",
|
||||
header: "Score",
|
||||
size: 60,
|
||||
accessorKey: "caReseau",
|
||||
header: () => <div className="text-center w-full">CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 90,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number>();
|
||||
const color = val >= 80 ? "text-emerald-500" : val >= 50 ? "text-amber-500" : "text-rose-500";
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold text-emerald-600 dark:text-emerald-400">
|
||||
{val != null ? val.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }) : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "qteReseau",
|
||||
header: () => <div className="text-center w-full">Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? Math.round(val).toLocaleString("fr-FR") : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "nbMagasinsReseau",
|
||||
header: () => <div className="text-center w-full">Magasins<br/><span className="text-[9px] opacity-60">/ 270</span></div>,
|
||||
size: 80,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "tauxPresenceReseau",
|
||||
header: () => <div className="text-center w-full">% Prés.<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
if (val == null) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
|
||||
const pct = Math.round(val * 100);
|
||||
const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500";
|
||||
return (
|
||||
<div className={cn("text-center font-black text-[13px] tabular-nums", color)}>
|
||||
{val}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rankingCa",
|
||||
header: () => <div className="text-center w-full">Rk CA<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rankingQte",
|
||||
header: () => <div className="text-center w-full">Rk Qté<br/><span className="text-[9px] opacity-60">Réseau</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rankingMagCa",
|
||||
header: () => <div className="text-center w-full">Rk CA<br/><span className="text-[9px] opacity-60">Mag.</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "rankingMagQte",
|
||||
header: () => <div className="text-center w-full">Rk Qté<br/><span className="text-[9px] opacity-60">Mag.</span></div>,
|
||||
size: 70,
|
||||
cell: ({ getValue }) => {
|
||||
const val = getValue<number | undefined>();
|
||||
return (
|
||||
<div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
|
||||
{val != null ? val : "-"}
|
||||
{pct}%
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -679,17 +667,6 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
size: 110,
|
||||
cell: ({ row }) => <GammeCell row={row.original} isAdmin={isAdmin} />,
|
||||
},
|
||||
{
|
||||
id: "ai",
|
||||
header: () => <div className="print:hidden">Recommandation IA</div>,
|
||||
size: 230,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="print:hidden w-full h-full">
|
||||
<AiInsightBlock row={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [MONTHS_12, activeMagasin, isAdmin]); // activeMagasin déclenche re-render des cellules mensuelles et totaux
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -800,8 +777,8 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id} className="flex w-full">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "ai" || header.column.id === "libelle3";
|
||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "score" || header.column.id === "gamme";
|
||||
const isFlexible = header.column.id === "libelle1" || header.column.id === "libelle3";
|
||||
const isCenter = header.column.id === "totalQuantite" || header.column.id === "totalCa" || header.column.id === "totalMarge" || header.column.id.startsWith("month_") || header.column.id === "gammeInitial" || header.column.id === "caReseau" || header.column.id === "qteReseau" || header.column.id === "nbMagasinsReseau" || header.column.id === "tauxPresenceReseau" || header.column.id === "gamme";
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
|
||||
@@ -110,7 +110,7 @@ export function NoSalesTab() {
|
||||
<div className="sticky top-0 z-10 grid grid-cols-[90px_minmax(220px,1fr)_120px_76px_130px_70px_90px_110px_70px]"
|
||||
style={{ background: "var(--bg-elevated)", borderBottom: "1px solid var(--border)" }}
|
||||
>
|
||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Score"].map(col => (
|
||||
{["Code", "Libellé", "Réf.", "Gamme", "Dernière vente", "Jours", "Stock", "CA 12m", "Mag."].map(col => (
|
||||
<div key={col}
|
||||
className="px-3 py-2 text-left font-semibold whitespace-nowrap"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
@@ -159,8 +159,8 @@ export function NoSalesTab() {
|
||||
{row.totalCa > 0 ? formatCa(row.totalCa) : "—"}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-right tabular-nums font-semibold"
|
||||
style={{ color: row.score >= 50 ? "var(--color-amber, #f59e0b)" : "var(--text-muted)" }}>
|
||||
{row.score > 0 ? row.score : "—"}
|
||||
style={{ color: "var(--text-secondary)" }}>
|
||||
{row.nbMagasinsReseau != null ? row.nbMagasinsReseau : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Brain } from "lucide-react";
|
||||
|
||||
interface SupplierAiContextModalProps {
|
||||
codeFournisseur: string | null;
|
||||
nomFournisseur: string | null;
|
||||
}
|
||||
|
||||
export function SupplierAiContextModal({ codeFournisseur, nomFournisseur }: SupplierAiContextModalProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [context, setContext] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && codeFournisseur) {
|
||||
fetchContext();
|
||||
}
|
||||
}, [open, codeFournisseur]);
|
||||
|
||||
const fetchContext = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/ai/context?fournisseur=${codeFournisseur}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setContext(data.context || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load AI context", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!codeFournisseur) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/ai/context", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeFournisseur, context }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setOpen(false);
|
||||
} else {
|
||||
console.error("Failed to save AI context");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save AI context", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!codeFournisseur) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="apple-btn-rules">
|
||||
<Brain className="w-4 h-4" />
|
||||
Règles IA
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contexte IA - {nomFournisseur || codeFournisseur}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Définissez ici les règles métier absolues pour ce fournisseur.
|
||||
Mary (l'IA) respectera ces consignes en priorité lors de ses recommandations (ex: "Les calendriers vont en gamme C").
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
placeholder="Ex: Les produits dont le nom contient 'Agenda' doivent toujours être classés en [A]..."
|
||||
value={context}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setContext(e.target.value)}
|
||||
disabled={isLoading}
|
||||
className="min-h-[150px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button className="apple-btn-secondary" onClick={() => setOpen(false)} disabled={isSaving}>
|
||||
Annuler
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={isSaving || isLoading} className="apple-btn-primary">
|
||||
{isSaving ? "Enregistrement..." : "Enregistrer les règles"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
interface SyncQlikButtonProps {
|
||||
/** Code fournisseur affiché (pour sync ciblée) */
|
||||
codeFournisseur?: string;
|
||||
/** Date ISO de dernière maj Qlik pour ce fournisseur (max des lignes) */
|
||||
lastUpdate?: string | null;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "jamais";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "jamais";
|
||||
return d.toLocaleString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
|
||||
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
|
||||
*/
|
||||
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!codeFournisseur) {
|
||||
setStatus("error");
|
||||
setMessage("Aucun fournisseur sélectionné");
|
||||
return;
|
||||
}
|
||||
setStatus("loading");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
setStatus("success");
|
||||
setMessage(`${data.upserted} produits réseau synchronisés`);
|
||||
// Recharge la grille pour afficher les nouvelles données réseau
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("_refresh", String(Date.now()));
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
} catch (e) {
|
||||
setStatus("error");
|
||||
setMessage(e instanceof Error ? e.message : String(e));
|
||||
setTimeout(() => setStatus("idle"), 6000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={status === "loading"}
|
||||
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
|
||||
title={message || "Synchroniser les données réseau Qlik pour ce fournisseur"}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : status === "success" ? (
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||
) : status === "error" ? (
|
||||
<AlertCircle className="w-3.5 h-3.5 text-rose-500" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Sync Qlik
|
||||
</button>
|
||||
<span className="text-[10px] text-slate-500 mt-0.5">
|
||||
{status === "error" ? message : `MAJ Qlik : ${formatDate(lastUpdate)}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user