feat(grid): ajout algorithme score produit avec parametrage dynamique et page d'aide

This commit is contained in:
Michael committed 2026-02-21 04:00:51 +01:00
1 parent f540e4f2b3
commit 3544c0c898
67 files changed
+19476 -15

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const AnalyzeSchema = z.object({
codein: z.string(),
libelle1: z.string(),
totalCa: z.number(),
tauxMarge: z.number(),
totalQuantite: z.number(),
sales12m: z.record(z.string(), z.number()),
codeGamme: z.string().nullable(),
});
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
const SYSTEM_PROMPT = `Tu es un expert en analyse de gammes de produits B2B pour un acheteur retail professionnel.
En analysant les données de ventes fournies, génère une recommandation de gamme concise (A=Cœur, B=Complémentaire, C=Saisonnier, Z=Sortie).
Ta réponse doit être en 1-2 phrases maximum, en français, directe et actionnable.
Format: "[Recommandation]: [Justification courte basée sur les données]"`;
export async function POST(req: NextRequest) {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured" }, { status: 503 });
}
const body = await req.json();
const parsed = AnalyzeSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload", details: parsed.error.flatten() }, { status: 400 });
}
const { codein, libelle1, totalCa, tauxMarge, totalQuantite, sales12m, codeGamme } = parsed.data;
// Format monthly sales for the prompt
const monthlySummary = Object.entries(sales12m)
.map(([k, v]) => `${k}: ${Math.round(v)} unités`)
.join(", ");
const userMessage = `Produit: "${libelle1}" (Code: ${codein})
Gamme actuelle: ${codeGamme ?? "Non définie"}
CA total 12m: ${totalCa.toFixed(0)}€ | Taux de marge: ${tauxMarge.toFixed(1)}% | Volume: ${Math.round(totalQuantite)} unités
Historique mensuel: ${monthlySummary}
Quelle gamme recommandes-tu et pourquoi ?`;
try {
const response = await fetch(OPENROUTER_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://collectflow.app",
"X-Title": "CollectFlow AI Copilot",
},
body: JSON.stringify({
model: "google/gemini-flash-1.5",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMessage },
],
max_tokens: 150,
temperature: 0.3,
}),
});
if (!response.ok) {
const err = await response.text();
return NextResponse.json({ error: `OpenRouter error: ${err}` }, { status: response.status });
}
const data = await response.json();
const insight = data.choices?.[0]?.message?.content ?? "Analyse indisponible.";
return NextResponse.json({ insight, codein });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+108
View File
@@ -0,0 +1,108 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";
import { z } from "zod";
const ExportSchema = z.object({
nomFournisseur: z.string(),
rows: z.array(z.object({
codein: z.string(),
libelle1: z.string(),
code3: z.string(),
libelle3: z.string(),
codeGamme: z.string().nullable(),
totalQuantite: z.number(),
totalCa: z.number(),
tauxMarge: z.number(),
// Before gamme for delta
gammeAvant: z.string().nullable().optional(),
})),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = ExportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
}
const { nomFournisseur, rows } = parsed.data;
const workbook = new ExcelJS.Workbook();
workbook.creator = "CollectFlow";
workbook.created = new Date();
const sheet = workbook.addWorksheet("Assortiment", {
views: [{ state: "frozen", ySplit: 1 }],
});
// Header style
const headerStyle: Partial<ExcelJS.Style> = {
font: { bold: true, color: { argb: "FFFFFFFF" } },
fill: { type: "pattern", pattern: "solid", fgColor: { argb: "FF0F172A" } },
alignment: { horizontal: "center" },
border: { bottom: { style: "thin", color: { argb: "FF334155" } } },
};
sheet.columns = [
{ header: "Code", key: "codein", width: 14 },
{ header: "Désignation", key: "libelle1", width: 40 },
{ header: "Nomenclature", key: "libelle3", width: 25 },
{ header: "Gamme Avant", key: "gammeAvant", width: 14 },
{ header: "Gamme Après", key: "codeGamme", width: 14 },
{ header: "Delta", key: "delta", width: 10 },
{ header: "Vol. 12m", key: "totalQuantite", width: 12 },
{ header: "CA 12m (€)", key: "totalCa", width: 14 },
{ header: "Marge (%)", key: "tauxMarge", width: 12 },
];
// Apply header style
sheet.getRow(1).eachCell((cell) => { Object.assign(cell, headerStyle); });
sheet.getRow(1).height = 22;
// Gamme colours
const GAMME_COLORS: Record<string, string> = {
A: "FF064E3B", B: "FF1E3A5F", C: "FF78350F", Z: "FF4C0519",
};
for (const row of rows) {
const delta = row.gammeAvant !== row.codeGamme ? `${row.gammeAvant ?? "—"} → ${row.codeGamme ?? "—"}` : "";
const excelRow = sheet.addRow({
codein: row.codein,
libelle1: row.libelle1,
libelle3: row.libelle3,
gammeAvant: row.gammeAvant ?? "—",
codeGamme: row.codeGamme ?? "—",
delta,
totalQuantite: Math.round(row.totalQuantite),
totalCa: parseFloat(row.totalCa.toFixed(2)),
tauxMarge: parseFloat(row.tauxMarge.toFixed(2)),
});
// Colour the "Gamme Après" cell
const gammeCell = excelRow.getCell("codeGamme");
if (row.codeGamme && GAMME_COLORS[row.codeGamme]) {
gammeCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: GAMME_COLORS[row.codeGamme] } };
gammeCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
}
// Highlight changed rows
if (delta) {
excelRow.getCell("delta").font = { bold: true, color: { argb: "FFFBBF24" } };
}
// Format numbers
excelRow.getCell("totalCa").numFmt = '#,##0.00 "€"';
excelRow.getCell("tauxMarge").numFmt = '0.00"%"';
}
// Generate buffer
const buffer = await workbook.xlsx.writeBuffer();
const filename = `Assortiment_${nomFournisseur.replace(/\s+/g, "_")}_${new Date().toISOString().slice(0, 10)}.xlsx`;
return new NextResponse(buffer, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const apiKey = req.headers.get("x-openrouter-key") || process.env.OPENROUTER_API_KEY;
if (!apiKey) {
return NextResponse.json({ error: "No API key provided" }, { status: 401 });
}
try {
const res = await fetch("https://openrouter.ai/api/v1/models", {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
next: { revalidate: 3600 }, // Cache 1h
});
if (!res.ok) {
return NextResponse.json({ error: "OpenRouter error", status: res.status }, { status: res.status });
}
const data = await res.json();
// Return simplified list sorted by name
const models = (data.data as Array<{ id: string; name: string; pricing?: { prompt: string } }>)
.map((m) => ({
id: m.id,
name: m.name,
free: parseFloat(m.pricing?.prompt ?? "1") === 0,
}))
.sort((a, b) => {
// Free models first, then alphabetical
if (a.free && !b.free) return -1;
if (!a.free && b.free) return 1;
return a.name.localeCompare(b.name, "fr");
});
return NextResponse.json({ models });
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: msg }, { status: 500 });
}
}