mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 14:38:10 +02:00
feat: Implement AI Copilot for product analysis and add comprehensive product grid data fetching.
This commit is contained in:
1 parent
cefe6060d0
commit
aa02d019bd
8 files changed
+119
-14
No files matched your search
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user