feat(produits): page de recherche produit + fiche 360° local/réseau

Nouvelle page /produits : recherche par libellé ou par code centrale, puis
fiche complète d'un produit — identité, prix, stock par magasin, ventes sur
12 mois glissants, et l'intégralité des métriques réseau Qlik.

Jusqu'ici les données produit n'étaient accessibles que par fournisseur
(la Grille) ou par classement (Hit Parade) : aucune route n'acceptait un
codein. Les données réseau Qlik n'étaient visibles que dans six colonnes
de la grille d'arbitrage.

Deux analyses ajoutées :
- comparatif local vs réseau, ramené au magasin moyen (indice de
  performance sur les quantités, seule base commune fiable — le CA local
  est TTC et la base du CA Qlik n'est pas documentée, donc indicatif) ;
- opportunités de la même famille : références que le réseau vend bien et
  que nous vendons peu, écart calculé par magasin.

Détail mensuel réseau enrichi : le cube Qlik renvoyait déjà CA, nb de
magasins, CA/magasin et marge % par (article, mois) mais seule la quantité
était conservée. Ces mesures sont désormais stockées dans une nouvelle
colonne metrics_by_month, sans requête Qlik supplémentaire. qte_by_month
reste inchangée — la colonne « Tendance / Réseau » de la Grille en dépend.

L'extraction Qlik peut désormais cibler un seul code centrale
(/api/qlik/sync?codeCentrale=…), ouverte à tout utilisateur connecté et
plafonnée à 3 extractions simultanées ; le mode fournisseur reste
réservé aux admins.

Refactor sans changement de comportement : les helpers de mois, le calcul
de tendance et les graphes SVG quittent heatmap-grid.tsx pour des modules
partagés, et le polling des jobs Qlik devient le hook useQlikSyncJob,
réutilisé par la Grille et par la fiche.

Les calculs de ventes reprennent le SQL canonique de la Grille (genremvt=3
avec quantités niées pour déduire les retours, stock de fin de mois pris
sur le dernier mouvement, report sur les mois sans mouvement), afin que
les chiffres des deux pages soient identiques par construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XR8cMbsdjXWChX8seL9NCP
This commit is contained in:
Claude committed 2026-07-27 14:26:08 +00:00
1 parent 86cdc86963
commit eeaae09282
25 files changed
+2694 -465

No files matched your search

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { pgGetOpportunitesFamille } from "@/lib/pg-ff-client";
import { buildLast12MonthsRange } from "@/lib/api-ff-client";
export const dynamic = "force-dynamic";
/**
* GET /api/produits/opportunites?nomNoId=123
*
* Produits de la même nomenclature, classés par écart entre performance réseau
* et performance locale (quantités par magasin). Chargé à la demande depuis la
* fiche produit : la requête agrège `mvtart` sur 12 mois pour toute la famille,
* on ne veut pas la payer au chargement de la page.
*
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
*/
export async function GET(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const raw = req.nextUrl.searchParams.get("nomNoId");
const nomNoId = Number(raw);
if (!raw || !Number.isInteger(nomNoId) || nomNoId <= 0) {
return NextResponse.json({ error: "Param 'nomNoId' invalide" }, { status: 400 });
}
try {
const { dateDebut, dateFin } = buildLast12MonthsRange();
const rows = await pgGetOpportunitesFamille(nomNoId, dateDebut, dateFin);
return NextResponse.json({ success: true, rows });
} catch (e) {
console.error("[api/produits/opportunites]", (e as Error).message?.slice(0, 250));
return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
}
}
+173 -19
View File
@@ -26,7 +26,10 @@ type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
/** Code fournisseur (mode fournisseur) — chaîne vide en mode produit. */
fournisseur: string;
/** Code centrale (mode produit) — absent en mode fournisseur. */
codeCentrale?: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
@@ -48,6 +51,33 @@ interface QlikSyncJob {
*/
const jobs = new Map<string, QlikSyncJob>();
/**
* Clé de job. Le mode fournisseur garde sa clé historique (le code fournisseur
* brut) ; le mode produit est préfixé pour ne jamais entrer en collision.
*/
function jobKeyForProduct(codeCentrale: string): string {
return `cc:${codeCentrale.toUpperCase()}`;
}
/**
* Nombre maximum d'extractions produit simultanées.
*
* Le mode produit est ouvert à tous les utilisateurs connectés (contrairement au
* mode fournisseur, réservé aux admins). Une extraction sur 1 seul code est très
* légère, mais le serveur Qlik est sujet aux saturations mémoire : on plafonne
* la concurrence plutôt que de lui laisser prendre N requêtes en parallèle.
*/
const MAX_CONCURRENT_PRODUCT_JOBS = 3;
/** Compte les extractions produit actuellement en cours. */
function runningProductJobs(): number {
let n = 0;
for (const [key, job] of jobs) {
if (key.startsWith("cc:") && job.status === "running") n++;
}
return n;
}
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
@@ -58,6 +88,7 @@ function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
codeCentrale: job.codeCentrale,
status: job.status,
requested: job.requested,
fetched: job.fetched,
@@ -72,11 +103,12 @@ function publicJob(job: QlikSyncJob) {
};
}
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
function idleJob(fournisseur: string) {
/** État "idle" sérialisé (pas de job connu pour cette cible). */
function idleJob(fournisseur: string, codeCentrale?: string) {
return {
jobId: null,
fournisseur,
codeCentrale,
status: "idle" as const,
requested: 0,
fetched: 0,
@@ -105,6 +137,20 @@ async function requireAdmin(): Promise<NextResponse | null> {
return null;
}
/**
* Vérifie qu'une session existe (sans exiger le rôle admin).
*
* Utilisé par le mode produit : le middleware Next ne couvre PAS les routes
* `/api/*`, le contrôle doit donc être fait ici explicitement.
*/
async function requireSession(): Promise<NextResponse | null> {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
/**
* Regex de validation d'un code centrale "raisonnable".
*
@@ -209,34 +255,95 @@ async function runJob(job: QlikSyncJob): Promise<void> {
job.status = "success";
job.finishedAt = new Date().toISOString();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
// Message explicite quand le serveur Qlik est saturé (OOM à l'ouverture de
// l'app) : ce n'est PAS un bug CollectFlow, l'app Qlik ne se charge même pas.
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
} else {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
applyJobError(job, e);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX
* Retourne l'état courant du job pour ce fournisseur. Si aucun job n'est
* Inscrit une erreur dans le job, en traduisant la saturation mémoire du
* serveur Qlik en message actionnable (ce n'est PAS un bug CollectFlow :
* l'app Qlik ne se charge même pas).
*/
function applyJobError(job: QlikSyncJob, e: unknown): void {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
const lower = msg.toLowerCase();
if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes("file corrupted") || lower.includes('"code":6') || lower.includes('"code":3002')) {
job.error = "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
} else {
job.error = msg;
}
job.finishedAt = new Date().toISOString();
}
/**
* Extraction Qlik pour UN seul code centrale (fiche produit).
*
* Beaucoup plus légère que la sync fournisseur : un seul code au lieu de
* plusieurs centaines, sur la même fenêtre 12 mois glissants. Réutilise
* exactement le même extracteur, donc le même détail mensuel.
*/
async function runProductJob(job: QlikSyncJob): Promise<void> {
try {
const code = job.codeCentrale!;
job.requested = 1;
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
job.periode = dateFilter.label;
job.dateDebut = dateFilter.dateDebut;
job.dateFin = dateFilter.dateFin;
console.log(
`[api/qlik/sync] job=${job.jobId} → extraction Qlik produit (1 code) — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright([code], undefined, dateFilter);
job.fetched = metrics.size;
if (metrics.size === 0) {
job.status = "success";
job.message = "Ce code centrale est inconnu de Qlik (aucune vente réseau sur la période)";
job.finishedAt = new Date().toISOString();
return;
}
const count = await upsertNetworkMetrics([...metrics.values()]);
job.upserted = count;
job.status = "success";
job.message = "Données réseau récupérées";
job.finishedAt = new Date().toISOString();
console.log(`[api/qlik/sync] job=${job.jobId} produit ${code} — ${count} ligne(s) upsert`);
} catch (e) {
applyJobError(job, e);
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX ou ?codeCentrale=YYY
* Retourne l'état courant du job pour cette cible. Si aucun job n'est
* connu, renvoie un état "idle" (avec un message explicite). Permet au
* frontend de reprendre l'état après un reload et de poller en arrière-plan.
*/
export async function GET(req: NextRequest) {
const codeCentrale = req.nextUrl.searchParams.get("codeCentrale");
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
// Mode produit : ouvert à tout utilisateur connecté.
if (codeCentrale) {
const denied = await requireSession();
if (denied) return denied;
const job = jobs.get(jobKeyForProduct(codeCentrale));
if (!job) {
return NextResponse.json({ success: true, ...idleJob("", codeCentrale) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
// Mode fournisseur : admin uniquement (inchangé).
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const job = jobs.get(fournisseur);
if (!job) {
@@ -252,12 +359,59 @@ export async function GET(req: NextRequest) {
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
const codeCentraleParam = req.nextUrl.searchParams.get("codeCentrale");
// ─── Mode produit : 1 code centrale, ouvert à tout utilisateur connecté ───
if (codeCentraleParam) {
const denied = await requireSession();
if (denied) return denied;
// Même validation que la sync fournisseur (trim, rejet des vides / "-" /
// codes hors charset) pour ne jamais envoyer de saleté à l'Engine Qlik.
const { accepted } = filterCentralCodes([codeCentraleParam]);
if (accepted.length === 0) {
return NextResponse.json({ error: "Code centrale invalide" }, { status: 400 });
}
const code = accepted[0];
const key = jobKeyForProduct(code);
const existingProduct = jobs.get(key);
if (existingProduct && existingProduct.status === "running") {
return NextResponse.json({ success: true, ...publicJob(existingProduct) });
}
if (runningProductJobs() >= MAX_CONCURRENT_PRODUCT_JOBS) {
return NextResponse.json(
{ error: "Trop d'extractions Qlik en cours. Réessayez dans quelques instants." },
{ status: 429 },
);
}
const productJob: QlikSyncJob = {
jobId: newJobId(),
fournisseur: "",
codeCentrale: code,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(key, productJob);
console.log(`[api/qlik/sync] job=${productJob.jobId} démarré pour codeCentrale=${code}`);
void runProductJob(productJob).catch((e) => applyJobError(productJob, e));
return NextResponse.json({ success: true, ...publicJob(productJob) });
}
// ─── Mode fournisseur : admin uniquement (inchangé) ──────────────────────
const denied = await requireAdmin();
if (denied) return denied;
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
return NextResponse.json({ error: "Param 'fournisseur' ou 'codeCentrale' requis" }, { status: 400 });
}
const existing = jobs.get(fournisseur);