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:
MichaelandClaude Opus 4.8 committed 2026-06-20 22:52:39 +02:00
1 parent 02a10caa4b
commit aee968d30f
34 files changed
+1043 -2925

No files matched your search

-95
View File
@@ -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 });
}
}
-176
View File
@@ -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 });
}
}
-96
View File
@@ -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 });
}
}
+62
View File
@@ -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 });
}
}