mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
feat(grid): replace ranking/AI/score with Qlik network data
Remove ranking, AI analysis (routes + ai-copilot module), and score-engine from the grid. Add Qlik Sense network metrics (CA, Qte, nb magasins per product) joined by code centrale (articles.artcentrale). - qlik-client.ts: NTLM + ticket SSO auth, QIX hypercube extraction (master items) - qlik-network-cache.ts + qlik_network_metrics table (db-init): cache layer - POST /api/qlik/sync?fournisseur=: per-supplier sync into cache - SyncQlikButton: per-supplier sync, grid reload, last-update date display - get-product-rows Phase 8: enrich rows with cached network metrics - heatmap-grid: CA reseau / Qte reseau / Magasins (/270) / % presence columns - discovery: app 9872ee6e, master items CA N / Quantite N / Magasin Ventes Nb N - deps: httpntlm, ws Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
02a10caa4b
commit
aee968d30f
34 files changed
+1043
-2925
No files matched your search
@@ -1,95 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||
import { OpenRouterClient } from "@/features/ai-copilot/data/open-router-client";
|
||||
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<string, string> = { "292": "Frouard", "579": "Houdemont" };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("[AI] OPENROUTER_API_KEY is missing.");
|
||||
return NextResponse.json({ error: "OPENROUTER_API_KEY not configured. Please set it in Settings." }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ProductAnalysisInput = await req.json();
|
||||
|
||||
// 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 FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
||||
let client = new OpenRouterClient({ apiKey, model });
|
||||
let result;
|
||||
try {
|
||||
result = await client.analyzeProduct(enrichedBody);
|
||||
} catch (modelErr) {
|
||||
// If configured model fails (e.g., deprecated), retry with fallback
|
||||
const msg = modelErr instanceof Error ? modelErr.message : "";
|
||||
if (model !== FALLBACK_MODEL && (msg.includes("400") || msg.includes("404") || msg.includes("OpenRouter error"))) {
|
||||
console.warn(`[AI] Model "${model}" failed (${msg}), retrying with fallback ${FALLBACK_MODEL}`);
|
||||
client = new OpenRouterClient({ apiKey, model: FALLBACK_MODEL });
|
||||
result = await client.analyzeProduct(enrichedBody);
|
||||
} else {
|
||||
throw modelErr;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "rate_limited") {
|
||||
return NextResponse.json({ error: "rate_limited", retryAfter: 30 }, { status: 429 });
|
||||
}
|
||||
// Timeout explicite déclenché par l'AbortController du client
|
||||
if (err instanceof Error && err.message === "timeout") {
|
||||
console.warn(`[AI] Timeout for product analysis — OpenRouter too slow.`);
|
||||
return NextResponse.json({ error: "timeout", retryAfter: 5 }, { status: 504 });
|
||||
}
|
||||
|
||||
const msg = err instanceof Error ? err.message : "Unknown error";
|
||||
|
||||
// Propager l'état de surcharge Open Router (502 Gateway, 503 Unavailable, 529 Overloaded)
|
||||
if (msg.includes("502") || msg.includes("503") || msg.includes("529") || msg.includes("Bad Gateway") || msg.includes("overloaded")) {
|
||||
console.warn(`[AI] External API overload detected: ${msg}`);
|
||||
return NextResponse.json({ error: "gateway_timeout", detail: msg }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||
|
||||
const BatchAnalyzeSchema = z.object({
|
||||
rayon: z.string(),
|
||||
supplierTotalCa: z.number().optional(),
|
||||
supplierTotalMarge: z.number().optional(),
|
||||
supplierStats: z.object({
|
||||
totalProducts: z.number(),
|
||||
medianScore: z.number(),
|
||||
scoreDistribution: z.object({
|
||||
above70: z.number(),
|
||||
between30and70: z.number(),
|
||||
below30: z.number(),
|
||||
}),
|
||||
maxStoreCount: z.number(),
|
||||
nomenclature2Count: z.number(),
|
||||
}).optional(),
|
||||
products: z.array(z.object({
|
||||
codein: z.string(),
|
||||
nom: z.string().nullable().optional(),
|
||||
ca: z.number().nullable().optional(),
|
||||
adjustedCaWeight: z.number().optional().default(0),
|
||||
weightInNomenclature2: z.number().optional().default(0),
|
||||
nomenclature2Weight: z.number().optional().default(0),
|
||||
ventes: z.number().nullable().optional(),
|
||||
marge: z.number().nullable().optional(),
|
||||
scorePercentile: z.number().optional().default(0),
|
||||
moisActifs: z.number().optional().default(0),
|
||||
storeCount: z.number().optional().default(1),
|
||||
nomenclature: z.string().nullable().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const config = await getSavedDatabaseConfig();
|
||||
const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey;
|
||||
const model = config?.openRouterModel || "google/gemini-2.0-flash-001";
|
||||
|
||||
if (!apiKey) {
|
||||
console.error("[batch-analyze] API key manquante.");
|
||||
return NextResponse.json({ error: "Clé API OpenRouter manquante. Configurez-la dans les Paramètres." }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = BatchAnalyzeSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Format de données invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { rayon, products, supplierTotalCa, supplierTotalMarge, supplierStats } = parsed.data;
|
||||
|
||||
const statsContext = supplierStats
|
||||
? `Fournisseur : ${supplierStats.totalProducts} produits, ${supplierStats.nomenclature2Count} categories N2, ${supplierStats.maxStoreCount} magasins.
|
||||
CA total : ${supplierTotalCa?.toLocaleString('fr-FR') ?? '?'} EUR. Marge totale : ${supplierTotalMarge?.toLocaleString('fr-FR') ?? '?'} EUR.
|
||||
Score median du fournisseur : ${supplierStats.medianScore}/100.`
|
||||
: "";
|
||||
|
||||
const systemPrompt = `Tu es un expert en assortiment retail. Categorise chaque produit : A (garder), C (saisonnier), Z (sortir).
|
||||
|
||||
${statsContext}
|
||||
|
||||
DONNEES PAR PRODUIT :
|
||||
- weightInNomenclature2 : % du CA du produit DANS sa categorie N2. C'est le critere le plus important.
|
||||
- adjustedCaWeight : % du CA du produit dans le total fournisseur (extrapole au reseau).
|
||||
- nomenclature2Weight : % du CA de toute la categorie N2 dans le fournisseur.
|
||||
- scorePercentile : rang du produit parmi tous les produits du fournisseur (0-100, 50 = median).
|
||||
- moisActifs : nombre de mois avec des ventes sur les 12 derniers mois.
|
||||
- marge : taux de marge (%).
|
||||
|
||||
REGLES DE DECISION (applique dans l'ordre) :
|
||||
|
||||
REGLE 1 — PILIER DE CATEGORIE → A
|
||||
Si weightInNomenclature2 >= 5% → le produit est un pilier de sa categorie → A.
|
||||
|
||||
REGLE 2 — ROTATION REGULIERE → A
|
||||
Si moisActifs >= 8 → produit de fond de rayon avec rotation reguliere → A.
|
||||
|
||||
REGLE 3 — BON PERFORMEUR → A
|
||||
Si scorePercentile >= 50 ET moisActifs >= 4 → au-dessus de la moyenne → A.
|
||||
|
||||
REGLE 4 — SAISONNIER → C
|
||||
Si moisActifs entre 2 et 4 ET les ventes sont concentrees sur des mois specifiques → C.
|
||||
|
||||
REGLE 5 — SORTIE → Z
|
||||
Si le produit ne remplit AUCUNE des regles 1-4, c'est un candidat Z.
|
||||
Un produit ne doit etre Z que s'il cumule : weightInNomenclature2 faible + moisActifs < 5 + scorePercentile < 30.
|
||||
|
||||
REGLE DE COHERENCE OBLIGATOIRE :
|
||||
Compare les produits ENTRE EUX dans ce lot. Si le produit X a un meilleur scorePercentile ET un meilleur weightInNomenclature2 que le produit Y, alors X doit avoir une recommandation >= Y. Ne mets JAMAIS en Z un produit meilleur qu'un autre en A.
|
||||
|
||||
REPONDS EN JSON VALIDE uniquement :
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"codein": "ID",
|
||||
"recommandationGamme": "A|C|Z",
|
||||
"isDuplicate": false,
|
||||
"justificationCourte": "N2: X%, Perc: Y, Mois: Z -> Regle N"
|
||||
}
|
||||
]
|
||||
}`;
|
||||
|
||||
const userPrompt = `${products.length} produits du rayon "${rayon}" (sur ${supplierStats?.totalProducts ?? products.length} au total).
|
||||
${JSON.stringify(products, null, 2)}`;
|
||||
|
||||
const FALLBACK_MODEL = "google/gemini-2.0-flash-001";
|
||||
const tryModel = async (modelToTry: string) => fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Authorization": `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: modelToTry,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
let response = await tryModel(model);
|
||||
|
||||
// Fallback if configured model is deprecated/not found
|
||||
if (!response.ok && response.status === 400 && model !== FALLBACK_MODEL) {
|
||||
console.warn(`[batch-analyze] Model "${model}" returned 400, retrying with fallback ${FALLBACK_MODEL}`);
|
||||
response = await tryModel(FALLBACK_MODEL);
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After") || response.headers.get("x-ratelimit-reset-requests");
|
||||
const waitSeconds = retryAfter ? parseInt(retryAfter, 10) : 60;
|
||||
console.warn(`[batch-analyze] Rate limited. Retry after ${waitSeconds}s.`);
|
||||
return NextResponse.json({ error: "rate_limited", retryAfter: waitSeconds }, { status: 429 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
console.error("OpenRouter API Error:", response.status, err);
|
||||
return NextResponse.json({ error: "Erreur lors de l'appel à OpenRouter.", status: response.status }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content: string = data.choices?.[0]?.message?.content ?? "";
|
||||
|
||||
console.log("[batch-analyze] Raw LLM response:", content.slice(0, 500));
|
||||
|
||||
if (!content) {
|
||||
return NextResponse.json({ error: "Réponse vide du modèle." }, { status: 500 });
|
||||
}
|
||||
|
||||
let jsonStr = content;
|
||||
const jsonBlock = content.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonBlock) {
|
||||
jsonStr = jsonBlock[1];
|
||||
} else {
|
||||
const start = content.indexOf("{");
|
||||
const end = content.lastIndexOf("}");
|
||||
if (start !== -1 && end !== -1) {
|
||||
jsonStr = content.slice(start, end + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const resultJson = JSON.parse(jsonStr);
|
||||
console.log("[batch-analyze] Parsed results count:", resultJson?.results?.length ?? "N/A");
|
||||
|
||||
return NextResponse.json(resultJson);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Batch Analyze Error:", error);
|
||||
return NextResponse.json({ error: "Erreur interne du serveur." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { aiSupplierContext } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// File system fallback for environments without PostgreSQL
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const FALLBACK_FILE_PATH = path.join(DATA_DIR, "ai-context.json");
|
||||
|
||||
function ensureFallbackFileExists() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(FALLBACK_FILE_PATH)) {
|
||||
fs.writeFileSync(FALLBACK_FILE_PATH, JSON.stringify({}), "utf-8");
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackContext(codeFournisseur: string): string {
|
||||
ensureFallbackFileExists();
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(FALLBACK_FILE_PATH, "utf-8"));
|
||||
return data[codeFournisseur] || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function saveFallbackContext(codeFournisseur: string, contextText: string) {
|
||||
ensureFallbackFileExists();
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(FALLBACK_FILE_PATH, "utf-8"));
|
||||
data[codeFournisseur] = contextText;
|
||||
fs.writeFileSync(FALLBACK_FILE_PATH, JSON.stringify(data, null, 2), "utf-8");
|
||||
} catch (err) {
|
||||
console.error("Error saving fallback context:", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const codeFournisseur = searchParams.get("fournisseur");
|
||||
|
||||
if (!codeFournisseur) {
|
||||
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.select()
|
||||
.from(aiSupplierContext)
|
||||
.where(eq(aiSupplierContext.codeFournisseur, codeFournisseur))
|
||||
.limit(1);
|
||||
|
||||
return NextResponse.json({ context: result[0]?.context || "" });
|
||||
} catch (error) {
|
||||
console.warn("DB fetch failed, using fallback JSON for AI context.");
|
||||
return NextResponse.json({ context: getFallbackContext(codeFournisseur) });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codeFournisseur, context } = body;
|
||||
|
||||
if (!codeFournisseur) {
|
||||
return NextResponse.json({ error: "Fournisseur manquant" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(aiSupplierContext)
|
||||
.values({
|
||||
codeFournisseur,
|
||||
context: context || "",
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: aiSupplierContext.codeFournisseur,
|
||||
set: {
|
||||
context: context || "",
|
||||
updatedAt: new Date()
|
||||
}
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.warn("DB insert failed, saving to fallback JSON.", dbError);
|
||||
saveFallbackContext(codeFournisseur, context || "");
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in AI context API:", error);
|
||||
return NextResponse.json({ error: "Erreur lors de la sauvegarde" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fetchNetworkMetrics } from "@/lib/qlik-client";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
|
||||
// Synchro potentiellement longue (extraction hypercube paginee).
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* POST /api/qlik/sync?fournisseur=XXX
|
||||
* Tire de Qlik les metriques reseau des articles du fournisseur (par code centrale)
|
||||
* et met a jour le cache local. Admin uniquement.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth();
|
||||
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
|
||||
if (!fournisseur) {
|
||||
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Codes centraux des articles de ce fournisseur
|
||||
const articles = await pgGetArticlesByFournisseur(fournisseur);
|
||||
const codes = [
|
||||
...new Set(
|
||||
articles
|
||||
.map((a) => (a.codeCentrale ? String(a.codeCentrale).trim() : ""))
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (codes.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseur,
|
||||
fetched: 0,
|
||||
upserted: 0,
|
||||
message: "Aucun article avec code centrale pour ce fournisseur",
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const metrics = await fetchNetworkMetrics(codes);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
fournisseur,
|
||||
requested: codes.length,
|
||||
fetched: metrics.size,
|
||||
upserted: count,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error("[api/qlik/sync]", msg);
|
||||
return NextResponse.json({ success: false, error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user