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

@@ -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<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 :`);
return lines.join("\n");
}
@@ -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,
@@ -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;
@@ -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<string, unknown>;
}) => Promise<void>;
resetInsights: () => void;
}
@@ -73,6 +73,7 @@ export async function getProductRows(input: GetProductRowsInput): Promise<Produc
score: 0,
workingStores: [],
aiRecommendation: null,
noid: art.noid ? Number(art.noid) : undefined,
pcb: art.pcb ? Number(art.pcb) : undefined,
prixVente: art.pv_central ? Number(art.pv_central) : undefined,
});
@@ -112,6 +112,7 @@ export function BulkAiAnalyzer() {
return {
codein: r.codein,
noid: r.noid,
libelle1: r.libelle1 || "",
libelleNiveau2: r.libelleNiveau2 || "Général",
// 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").
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<string, ReturnType<typeof ScoringEngine.analyzeRayon>>();
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));
}