feat: Implement AI Copilot for product analysis and add comprehensive product grid data fetching.

This commit is contained in:
Michael committed 2026-03-18 21:25:53 +01:00
1 parent cefe6060d0
commit aa02d019bd
8 files changed
+119 -14

No files matched your search

+35 -5
View File
@@ -1,12 +1,15 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSavedDatabaseConfig } from "@/features/settings/actions"; import { getSavedDatabaseConfig } from "@/features/settings/actions";
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client"; 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). // Limite max acceptable pour la route (Node.js self-hosted).
// Evite que le process tourne indéfiniment en cas de deadlock. // Evite que le process tourne indéfiniment en cas de deadlock.
export const maxDuration = 55; export const maxDuration = 55;
const SITE_LABELS: Record<string, string> = { "292": "Frouard", "579": "Houdemont" };
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const config = await getSavedDatabaseConfig(); const config = await getSavedDatabaseConfig();
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
@@ -20,12 +23,39 @@ export async function POST(req: NextRequest) {
try { try {
const body: ProductAnalysisInput = await req.json(); const body: ProductAnalysisInput = await req.json();
// Le supplierContext est déjà injecté par le client (BulkAiAnalyzer) // Si noid est fourni, enrichir avec les données mensuelles per-site depuis FF Nancy
// via une requête unique avant de lancer le batch. let enrichedBody = body;
// Cela nous évite une requête DB N+1 pour chaque produit analysé. 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 client = new OpenRouterClient({ apiKey, model });
const result = await client.analyzeProduct(body); const result = await client.analyzeProduct(enrichedBody);
return NextResponse.json(result); return NextResponse.json(result);
} catch (err) { } catch (err) {
@@ -5,7 +5,7 @@
* Principe: Tout est relatif à la moyenne du lot fournisseur. * 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"; import type { ProductContextProfile } from "./context-profiler";
export class AnalysisEngine { 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. Ton modèle économique repose sur la rotation rapide, le volume, et la rentabilité de l'espace en rayon.
Tu analyses des produits pour recommander A (garder) ou Z (sortir). Tu analyses des produits 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) --- --- ORDRE DE PRIORITÉ (STRICT) ---
📝 RÈGLE 1 — RÈGLE MANAGER (PRIORITÉ ABSOLUE): 📝 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(""); 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<string, SiteMonthlyData[]>();
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 :`); lines.push(`Génère UNIQUEMENT le JSON :`);
return lines.join("\n"); return lines.join("\n");
} }
@@ -28,6 +28,13 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
const handleAnalyze = async () => { const handleAnalyze = async () => {
if (status === "loading") return; 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 // Reset Visuel Immédiat
setLoading(row.codein); setLoading(row.codein);
setDraftGamme(row.codein, "Aucune"); setDraftGamme(row.codein, "Aucune");
@@ -90,6 +97,7 @@ export function AiInsightBlock({ row }: AiInsightBlockProps) {
analyzeProduct({ analyzeProduct({
codein: row.codein, codein: row.codein,
noid: row.noid,
libelle1: row.libelle1, libelle1: row.libelle1,
totalCa: row.totalCa, totalCa: row.totalCa,
tauxMarge: row.tauxMarge, tauxMarge: row.tauxMarge,
@@ -1,5 +1,15 @@
import type { ProductContextProfile } from "../business/context-profiler"; 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 { export interface ProductAnalysisInput {
codein: string; codein: string;
libelle1: string; libelle1: string;
@@ -51,6 +61,10 @@ export interface ProductAnalysisInput {
}; };
/** Optional context rules for the supplier */ /** Optional context rules for the supplier */
supplierContext?: string; 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) */ /** Données stock & approvisionnement (API FF Nancy) */
stockActuel?: number; stockActuel?: number;
stockTotal?: number; stockTotal?: number;
@@ -18,6 +18,7 @@ interface AiCopilotState {
setError: (codein: string, error: string) => void; setError: (codein: string, error: string) => void;
analyzeProduct: (payload: { analyzeProduct: (payload: {
codein: string; codein: string;
noid?: number;
libelle1: string; libelle1: string;
totalCa: number; totalCa: number;
tauxMarge: number; tauxMarge: number;
@@ -36,6 +37,9 @@ interface AiCopilotState {
shareMarge?: number; shareMarge?: number;
shareQty?: number; shareQty?: number;
totalFournisseurCa?: number; totalFournisseurCa?: number;
codeFournisseur?: string;
supplierContext?: string;
scoring?: Record<string, unknown>;
}) => Promise<void>; }) => Promise<void>;
resetInsights: () => void; resetInsights: () => void;
} }
@@ -73,6 +73,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
score: 0, score: 0,
workingStores: [], workingStores: [],
aiRecommendation: null, aiRecommendation: null,
noid: art.noid ? Number(art.noid) : undefined,
pcb: art.pcb ? Number(art.pcb) : undefined, pcb: art.pcb ? Number(art.pcb) : undefined,
prixVente: art.pv_central ? Number(art.pv_central) : undefined, prixVente: art.pv_central ? Number(art.pv_central) : undefined,
}); });
@@ -112,6 +112,7 @@ export function BulkAiAnalyzer() {
return { return {
codein: r.codein, codein: r.codein,
noid: r.noid,
libelle1: r.libelle1 || "", libelle1: r.libelle1 || "",
libelleNiveau2: r.libelleNiveau2 || "Général", libelleNiveau2: r.libelleNiveau2 || "Général",
// Code nomenclature niveau 2 (4 premiers chiffres) — utilisé par le ContextProfiler // Code nomenclature niveau 2 (4 premiers chiffres) — utilisé par le ContextProfiler
@@ -141,25 +142,39 @@ export function BulkAiAnalyzer() {
}); });
// 2. Calculer scoring + context profiling en micro-batches asynchrones // 2a. Produits sans aucune vente → Z direct, sans appel IA
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);
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"). // pour ne pas bloquer le thread UI (Chrome "Page ne répondant pas").
setIsAnalyzing(true); 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 SCORING_BATCH_SIZE = 25;
const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>(); const scoringResults = new Map<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
const productPayloads: (ProductAnalysisInput & { scoring: any })[] = []; const productPayloads: (ProductAnalysisInput & { scoring: any })[] = [];
// Pre-compute context thresholds to prevent O(N^2) and O(N^2 log N) performance freezes // 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; 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) { for (const p of batch) {
const scoringRes = ScoringEngine.analyzeRayon(p, initialPayloads); const scoringRes = ScoringEngine.analyzeRayon(p, payloadsWithSales);
scoringResults.set(p.codein, scoringRes); scoringResults.set(p.codein, scoringRes);
let contextProfile: ProductAnalysisInput["contextProfile"]; let contextProfile: ProductAnalysisInput["contextProfile"];
@@ -188,8 +203,8 @@ export function BulkAiAnalyzer() {
// Yield au navigateur entre chaque batch pour éviter le freeze // Yield au navigateur entre chaque batch pour éviter le freeze
setProgress(prev => ({ setProgress(prev => ({
...prev, ...prev,
current: Math.min(i + SCORING_BATCH_SIZE, initialPayloads.length), current: Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length),
message: `Scoring : ${Math.min(i + SCORING_BATCH_SIZE, initialPayloads.length)}/${initialPayloads.length}`, message: `Scoring : ${Math.min(i + SCORING_BATCH_SIZE, payloadsWithSales.length)}/${payloadsWithSales.length}`,
})); }));
await new Promise(r => setTimeout(r, 0)); await new Promise(r => setTimeout(r, 0));
} }
+2
View File
@@ -60,6 +60,8 @@ export interface ProductRow {
score: number; score: number;
workingStores: string[]; workingStores: string[];
aiRecommendation?: string | null; aiRecommendation?: string | null;
/** SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel */
noid?: number;
/** Données stock & approvisionnement (API FF Nancy) */ /** Données stock & approvisionnement (API FF Nancy) */
pcb?: number; // Conditionnement (pack size) pcb?: number; // Conditionnement (pack size)
prixAchat?: number; // PA / PRMP prixAchat?: number; // PA / PRMP