From aa02d019bdd5e399a2b2f5b49b777dbbbd053147 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 18 Mar 2026 21:25:53 +0100 Subject: [PATCH] feat: Implement AI Copilot for product analysis and add comprehensive product grid data fetching. --- src/app/api/ai/analyze/route.ts | 40 ++++++++++++++++--- .../ai-copilot/business/analysis-engine.ts | 33 ++++++++++++++- .../components/ai-insight-block.tsx | 8 ++++ .../ai-copilot/models/ai-analysis.types.ts | 14 +++++++ .../ai-copilot/store/use-ai-copilot-store.ts | 4 ++ src/features/grid/api/get-product-rows.ts | 1 + .../grid/components/bulk-ai-analyzer.tsx | 31 ++++++++++---- src/types/grid.ts | 2 + 8 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index 89fc4af..91cd9d8 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -1,12 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { getSavedDatabaseConfig } from "@/features/settings/actions"; import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client"; -import { ProductAnalysisInput } from "@/features/ai-copilot/models/ai-analysis.types"; +import { ProductAnalysisInput, SiteMonthlyData } from "@/features/ai-copilot/models/ai-analysis.types"; +import { getMensuelByArticles, buildLast12MonthsRange } from "@/lib/api-ff-client"; // Limite max acceptable pour la route (Node.js self-hosted). // Evite que le process tourne indéfiniment en cas de deadlock. export const maxDuration = 55; +const SITE_LABELS: Record = { "292": "Frouard", "579": "Houdemont" }; + export async function POST(req: NextRequest) { const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; @@ -20,12 +23,39 @@ export async function POST(req: NextRequest) { try { const body: ProductAnalysisInput = await req.json(); - // Le supplierContext est déjà injecté par le client (BulkAiAnalyzer) - // via une requête unique avant de lancer le batch. - // Cela nous évite une requête DB N+1 pour chaque produit analysé. + // Si noid est fourni, enrichir avec les données mensuelles per-site depuis FF Nancy + let enrichedBody = body; + if (body.noid) { + try { + const { dateDebut, dateFin } = buildLast12MonthsRange(); + const mensuelMap = await getMensuelByArticles( + [{ codein: body.codein, noid: body.noid, libelle1: body.libelle1, codefou: body.codeFournisseur ?? "" }], + dateDebut, dateFin, 1 + ); + const entries = mensuelMap.get(body.codein) ?? []; + const siteMonthlyData: SiteMonthlyData[] = entries + .filter(e => e.site === "292" || e.site === "579") + .map(e => ({ + site: SITE_LABELS[e.site] ?? e.site, + mois: e.mois, + ventes_qte: Math.abs(parseFloat(e.ventes?.qte_vendue ?? "0") || 0), + ventes_ca: Math.abs(parseFloat(e.ventes?.ca_ht ?? "0") || 0), + marge: parseFloat(e.ventes?.marge ?? "0") || 0, + stock_fin_mois: parseFloat(e.stock_fin_mois ?? "0") || 0, + receptions_qte: Math.abs(parseFloat(e.receptions?.qte_recue ?? "0") || 0), + })); + if (siteMonthlyData.length > 0) { + enrichedBody = { ...body, siteMonthlyData }; + console.log(`[AI] Enriched ${body.codein} with ${siteMonthlyData.length} site-monthly entries`); + } + } catch (err) { + console.warn(`[AI] Failed to fetch mensuel for ${body.codein}:`, err); + // Dégradation gracieuse — continuer sans données mensuelles + } + } const client = new OpenRouterClient({ apiKey, model }); - const result = await client.analyzeProduct(body); + const result = await client.analyzeProduct(enrichedBody); return NextResponse.json(result); } catch (err) { diff --git a/src/features/ai-copilot/business/analysis-engine.ts b/src/features/ai-copilot/business/analysis-engine.ts index 6c89d7c..720ac04 100644 --- a/src/features/ai-copilot/business/analysis-engine.ts +++ b/src/features/ai-copilot/business/analysis-engine.ts @@ -5,7 +5,7 @@ * Principe: Tout est relatif à la moyenne du lot fournisseur. */ -import type { ProductAnalysisInput } from "../models/ai-analysis.types"; +import type { ProductAnalysisInput, SiteMonthlyData } from "../models/ai-analysis.types"; import type { ProductContextProfile } from "./context-profiler"; export class AnalysisEngine { @@ -18,6 +18,9 @@ export class AnalysisEngine { Ton modèle économique repose sur la rotation rapide, le volume, et la rentabilité de l'espace en rayon. Tu analyses des produits pour recommander A (garder) ou Z (sortir). +📦 RÈGLE STOCK NÉGATIF (s'applique toujours) : +Un stock négatif signifie que la commande fournisseur a été validée APRÈS la mise en vente du produit en magasin. La mise à jour informatique du stock est faite a posteriori. Ce n'est PAS une anomalie — c'est un indicateur que le magasin a vendu avant d'avoir reçu le stock officiellement. Ne pas pénaliser un produit pour un stock négatif. + --- ORDRE DE PRIORITÉ (STRICT) --- 📝 RÈGLE 1 — RÈGLE MANAGER (PRIORITÉ ABSOLUE): @@ -173,6 +176,34 @@ SI AUCUNE RÈGLE MANAGER n'est définie, tu recommandes UNIQUEMENT A ou Z (jamai lines.push(""); } + // Bloc comportement par magasin (si données mensuelles disponibles) + if (p.siteMonthlyData && p.siteMonthlyData.length > 0) { + lines.push(`--- COMPORTEMENT PAR MAGASIN (12 mois) ---`); + lines.push(`⚠️ Stock négatif = commande validée avant réception informatique (normal en retail).`); + lines.push(""); + + const bySite = new Map(); + for (const row of p.siteMonthlyData) { + if (!bySite.has(row.site)) bySite.set(row.site, []); + bySite.get(row.site)!.push(row); + } + + for (const [site, rows] of bySite.entries()) { + lines.push(`${site} :`); + lines.push(`Mois | Ventes Qté | CA HT | Marge | Stock fin mois | Réceptions`); + rows.sort((a, b) => a.mois.localeCompare(b.mois)).forEach(r => { + const mois = r.mois.padEnd(10); + const qty = String(r.ventes_qte).padStart(10); + const ca = `${r.ventes_ca.toFixed(2)}€`.padStart(11); + const mg = `${r.marge.toFixed(2)}€`.padStart(10); + const stk = String(r.stock_fin_mois).padStart(14); + const rec = String(r.receptions_qte).padStart(10); + lines.push(`${mois} |${qty} |${ca} |${mg} |${stk} |${rec}`); + }); + lines.push(""); + } + } + lines.push(`Génère UNIQUEMENT le JSON :`); return lines.join("\n"); } diff --git a/src/features/ai-copilot/components/ai-insight-block.tsx b/src/features/ai-copilot/components/ai-insight-block.tsx index 2b00270..aed37c7 100644 --- a/src/features/ai-copilot/components/ai-insight-block.tsx +++ b/src/features/ai-copilot/components/ai-insight-block.tsx @@ -28,6 +28,13 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { 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; + } + // Reset Visuel Immédiat setLoading(row.codein); setDraftGamme(row.codein, "Aucune"); @@ -90,6 +97,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) { analyzeProduct({ codein: row.codein, + noid: row.noid, libelle1: row.libelle1, totalCa: row.totalCa, tauxMarge: row.tauxMarge, diff --git a/src/features/ai-copilot/models/ai-analysis.types.ts b/src/features/ai-copilot/models/ai-analysis.types.ts index 753d732..305e5b3 100644 --- a/src/features/ai-copilot/models/ai-analysis.types.ts +++ b/src/features/ai-copilot/models/ai-analysis.types.ts @@ -1,5 +1,15 @@ 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; @@ -51,6 +61,10 @@ export interface ProductAnalysisInput { }; /** 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; diff --git a/src/features/ai-copilot/store/use-ai-copilot-store.ts b/src/features/ai-copilot/store/use-ai-copilot-store.ts index b72733c..033de69 100644 --- a/src/features/ai-copilot/store/use-ai-copilot-store.ts +++ b/src/features/ai-copilot/store/use-ai-copilot-store.ts @@ -18,6 +18,7 @@ interface AiCopilotState { setError: (codein: string, error: string) => void; analyzeProduct: (payload: { codein: string; + noid?: number; libelle1: string; totalCa: number; tauxMarge: number; @@ -36,6 +37,9 @@ interface AiCopilotState { shareMarge?: number; shareQty?: number; totalFournisseurCa?: number; + codeFournisseur?: string; + supplierContext?: string; + scoring?: Record; }) => Promise; resetInsights: () => void; } diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index ab8d1da..001d55b 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -73,6 +73,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise (r.totalQuantite || 0) === 0); + if (zeroSalesRows.length > 0) { + const zChanges: Record = {}; + zeroSalesRows.forEach(r => { zChanges[r.codein] = "Z"; }); + batchSetDraftGamme(zChanges); + zeroSalesRows.forEach(r => { + setInsight(r.codein, "Aucune vente sur 12 mois — produit classé Z automatiquement."); + }); + } + + // Filtrer : ne soumettre à l'IA que les produits avec au moins 1 vente + const payloadsWithSales = initialPayloads.filter(p => (p.totalQuantite || 0) > 0); + + // 2b. Calculer scoring + context profiling en micro-batches asynchrones // pour ne pas bloquer le thread UI (Chrome "Page ne répondant pas"). setIsAnalyzing(true); - setProgress({ current: 0, total: initialPayloads.length, message: "Calcul du scoring...", errors: 0 }); + setProgress({ current: 0, total: payloadsWithSales.length, message: "Calcul du scoring...", errors: 0 }); const SCORING_BATCH_SIZE = 25; const scoringResults = new Map>(); const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; // Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes - const contextCache = ContextProfiler.prepareCache(initialPayloads); + const contextCache = ContextProfiler.prepareCache(payloadsWithSales); - for (let i = 0; i < initialPayloads.length; i += SCORING_BATCH_SIZE) { + for (let i = 0; i < payloadsWithSales.length; i += SCORING_BATCH_SIZE) { if (isCancelledRef.current) break; - const batch = initialPayloads.slice(i, i + SCORING_BATCH_SIZE); + const batch = payloadsWithSales.slice(i, i + SCORING_BATCH_SIZE); for (const p of batch) { - const scoringRes = ScoringEngine.analyzeRayon(p, initialPayloads); + const scoringRes = ScoringEngine.analyzeRayon(p, payloadsWithSales); scoringResults.set(p.codein, scoringRes); let contextProfile: ProductAnalysisInput["contextProfile"]; @@ -188,8 +203,8 @@ export function BulkAiAnalyzer() { // Yield au navigateur entre chaque batch pour éviter le freeze setProgress(prev => ({ ...prev, - current: Math.min(i + SCORING_BATCH_SIZE, initialPayloads.length), - message: `Scoring : ${Math.min(i + SCORING_BATCH_SIZE, initialPayloads.length)}/${initialPayloads.length}`, + current: Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length), + message: `Scoring : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`, })); await new Promise(r => setTimeout(r, 0)); } diff --git a/src/types/grid.ts b/src/types/grid.ts index 310fb66..6e5c47e 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -60,6 +60,8 @@ export interface ProductRow { score: number; workingStores: string[]; aiRecommendation?: string | null; + /** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */ + noid?: number; /** Données stock & approvisionnement (API FF Nancy) */ pcb?: number; // Conditionnement (pack size) prixAchat?: number; // PA / PRMP