mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 06:28:09 +02:00
merge: intègre main — main prioritaire, abandon de la page en doublon
main a beaucoup avancé (53 commits) pendant que cette branche construisait l'API. La fusion a révélé que /produits, sur main, fait la même chose que la page Recherche réseau d'ici, en plus abouti : fiche produit, opportunités, URL partageables, et sa propre chaîne Qlik (lib/qlik-search, pgSearchProduits). Résolution, main prioritaire : - sidebar, heatmap-grid, qlik-playwright : version de main telle quelle. Son travail sur la tendance et Qlik est postérieur et plus complet. - get-product-rows : import NB_MAGASINS_RESEAU de main, plus l'appel upsertGridRows d'ici — sans lui /api/v1 n'aurait aucune donnée à servir. - settings : les deux côtés (ServerLogs de main + les sections API d'ici). Suppression du doublon : page recherche-reseau, /api/qlik/search, ses types et composants. searchNetworkProductsPlaywright disparaît avec la version main de qlik-playwright ; rien d'autre ne l'utilisait. api-enrich se branche désormais sur le module de tendance de main (features/grid/lib/network-trend) plutôt que sur le mien, supprimé : sa mesure est meilleure — variation sur la quantité par magasin vendeur, ce qui distingue « vend mieux » de « est distribué plus largement » — et l'API renvoie ainsi exactement ce qu'affiche la Grille. Le champ trend gagne surQteParMagasin et nouveau. Apport conservé de la branche : l'API /api/v1, absente de main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
This commit is contained in:
50 files changed
+8488
-2017
No files matched your search
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* CollectFlow — Journaux serveur des jobs (liste + téléchargement).
|
||||
*
|
||||
* `GET /api/logs` → liste des journaux disponibles (JSON)
|
||||
* `GET /api/logs?id=<jobId>` → le journal complet en pièce jointe (text/plain)
|
||||
*
|
||||
* Réservé aux admins : un journal d'extraction contient les diagnostics internes
|
||||
* du modèle Qlik et les codes articles manipulés. Aucun secret n'y transite —
|
||||
* l'extracteur ne journalise que le NOM du cookie de session, jamais sa valeur,
|
||||
* et jamais le mot de passe Qlik.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { listCaptures, readCapture } from "@/lib/log-capture";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function requireAdmin(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const refus = await requireAdmin();
|
||||
if (refus) return refus;
|
||||
|
||||
const id = req.nextUrl.searchParams.get("id");
|
||||
if (!id) {
|
||||
return NextResponse.json({ success: true, logs: await listCaptures() });
|
||||
}
|
||||
|
||||
const contenu = await readCapture(id);
|
||||
if (contenu == null) {
|
||||
return NextResponse.json({ error: "Journal introuvable ou expiré" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Nom de fichier assaini : l'id vient d'un job interne, mais il finit dans
|
||||
// un en-tête HTTP.
|
||||
const nom = `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`;
|
||||
return new NextResponse(contenu, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${nom}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits";
|
||||
|
||||
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
|
||||
// des jobs vit dans la mémoire du process.
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Recherche produit **Qlik d'abord** — API asynchrone.
|
||||
*
|
||||
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
|
||||
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
|
||||
* puis le résultat.
|
||||
*
|
||||
* Pourquoi asynchrone : une recherche enchaîne deux allers-retours Qlik (dont une
|
||||
* extraction mensuelle) et peut dépasser la minute. Une requête HTTP maintenue
|
||||
* aussi longtemps se fait couper par le reverse proxy, qui répond une page
|
||||
* d'erreur **HTML** — le client échouait alors sur « Unexpected token '<' … is
|
||||
* not valid JSON ». Même schéma que `POST /api/qlik/sync`.
|
||||
*
|
||||
* Le middleware Next ne protège pas `/api/*` : contrôle de session explicite.
|
||||
*/
|
||||
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
function lireTerme(req: NextRequest): string | null {
|
||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||
return q.length >= 3 ? q : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
const force = req.nextUrl.searchParams.get("force") === "1";
|
||||
try {
|
||||
const job = demarrerRecherche(q, { force });
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] POST", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const q = lireTerme(req);
|
||||
if (!q) return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
||||
|
||||
try {
|
||||
return NextResponse.json({ success: true, ...etatRecherche(q) });
|
||||
} catch (e) {
|
||||
const message = (e as Error).message ?? String(e);
|
||||
console.error("[api/produits/search] GET", message.slice(0, 300));
|
||||
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { searchNetworkProductsPlaywright } from "@/lib/qlik-playwright";
|
||||
import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByCodeCentrale, pgGetStockForCodeins, type StockBySite } from "@/lib/pg-ff-client";
|
||||
import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range";
|
||||
import type { NetworkSearchJobState, NetworkSearchRow } from "@/types/network-search";
|
||||
|
||||
// Comme la sync : l'ouverture de l'app Qlik + les cubes peuvent prendre plusieurs
|
||||
// dizaines de secondes. On rend la main immédiatement et le client polle GET.
|
||||
export const maxDuration = 300;
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SearchJob extends NetworkSearchJobState {
|
||||
jobId: string;
|
||||
status: "running" | "success" | "error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Store en mémoire (même parti pris que /api/qlik/sync : simple, perdu au restart).
|
||||
*
|
||||
* `activeJobId` sérialise les recherches : le moteur Qlik sature facilement, on ne
|
||||
* lance donc **qu'une extraction à la fois** pour toute l'application.
|
||||
*/
|
||||
const jobs = new Map<string, SearchJob>();
|
||||
let activeJobId: string | null = null;
|
||||
|
||||
/** Ne garde que les N derniers jobs pour éviter une fuite mémoire. */
|
||||
function pruneJobs(keep = 20): void {
|
||||
if (jobs.size <= keep) return;
|
||||
const ordered = [...jobs.values()].sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
||||
for (const j of ordered.slice(0, jobs.size - keep)) {
|
||||
if (j.jobId !== activeJobId) jobs.delete(j.jobId);
|
||||
}
|
||||
}
|
||||
|
||||
function newJobId(): string {
|
||||
return `qsearch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/** Vérifie qu'une session est ouverte. Renvoie une 401 sinon. */
|
||||
async function requireSession(): Promise<NextResponse | null> {
|
||||
const session = await auth();
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduit une erreur Qlik en message actionnable. La saturation mémoire du serveur
|
||||
* Qlik est le cas le plus fréquent et n'est PAS un bug CollectFlow : l'application
|
||||
* Qlik ne se charge même pas.
|
||||
*/
|
||||
function humanizeQlikError(msg: string): string {
|
||||
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')) {
|
||||
return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard (ou faites libérer / augmenter la RAM du serveur Qlik).";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/** Exécute la recherche Qlik puis enrichit avec le catalogue local. */
|
||||
async function runJob(job: SearchJob, maxProducts: number): Promise<void> {
|
||||
try {
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} query="${job.query}" fenêtre=${dateFilter.label}`);
|
||||
|
||||
const res = await searchNetworkProductsPlaywright(job.query, { maxProducts }, undefined, dateFilter);
|
||||
job.mode = res.mode;
|
||||
job.matchCount = res.matchCount;
|
||||
job.periode = res.periode;
|
||||
|
||||
if (res.tooMany) {
|
||||
job.tooMany = true;
|
||||
job.results = [];
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} recherche trop large (${res.matchCount} > ${maxProducts})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const products = res.products;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ← Qlik: ${products.length} produits`);
|
||||
|
||||
// Mise en cache : accélère les recherches suivantes ET enrichit la Grille
|
||||
// pour les codes que nous référençons.
|
||||
if (products.length) {
|
||||
try {
|
||||
const n = await upsertNetworkMetrics(products);
|
||||
console.log(`[api/qlik/search] job=${job.jobId} ${n} lignes mises en cache`);
|
||||
} catch (e) {
|
||||
// Non fatal : la recherche reste exploitable sans cache.
|
||||
console.error(`[api/qlik/search] job=${job.jobId} cache KO:`, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Enrichissement local : qui connaît-on déjà, et avec quel stock ?
|
||||
const locaux = await pgGetArticlesByCodeCentrale(products.map((p) => p.codeCentrale));
|
||||
const codeins = [...locaux.values()].map((a) => a.codein).filter(Boolean);
|
||||
const stocks: Map<string, StockBySite> = codeins.length
|
||||
? await pgGetStockForCodeins(codeins)
|
||||
: new Map<string, StockBySite>();
|
||||
|
||||
const rows: NetworkSearchRow[] = products.map((p) => {
|
||||
const local = locaux.get(p.codeCentrale);
|
||||
const stock = local?.codein ? stocks.get(local.codein) : undefined;
|
||||
return {
|
||||
codeCentrale: p.codeCentrale,
|
||||
libelle: p.libelle || local?.libelle1 || p.codeCentrale,
|
||||
fournisseur: p.fournisseur,
|
||||
famille: p.famille,
|
||||
caReseau: p.caReseau,
|
||||
qteReseau: p.qteReseau,
|
||||
nbMagasinsReseau: p.nbMagasinsReseau,
|
||||
caParMagasinReseau: p.caParMagasinReseau,
|
||||
margePctReseau: p.margePctReseau,
|
||||
qteByMonth: p.qteByMonth ?? null,
|
||||
chezMoi: Boolean(local),
|
||||
codein: local?.codein,
|
||||
libelleLocal: local?.libelle1,
|
||||
codefou: local?.codefou,
|
||||
nomfou: local?.nomfou,
|
||||
pa: local?.pa != null ? Number(local.pa) : undefined,
|
||||
pvCentral: local?.pv_central != null ? Number(local.pv_central) : undefined,
|
||||
stock292: stock?.stock292,
|
||||
stock579: stock?.stock579,
|
||||
stockTotal: stock?.stockTotal,
|
||||
};
|
||||
});
|
||||
|
||||
job.results = rows;
|
||||
job.status = "success";
|
||||
job.finishedAt = new Date().toISOString();
|
||||
console.log(`[api/qlik/search] job=${job.jobId} terminé — ${rows.length} lignes (${rows.filter((r) => r.chezMoi).length} déjà référencées)`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId}`, msg);
|
||||
job.status = "error";
|
||||
job.error = humanizeQlikError(msg);
|
||||
job.finishedAt = new Date().toISOString();
|
||||
} finally {
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
pruneJobs();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/qlik/search?jobId=XXX
|
||||
* État + résultats du job. Sans jobId, renvoie le job actif s'il y en a un.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
const jobId = req.nextUrl.searchParams.get("jobId");
|
||||
const job = jobId ? jobs.get(jobId) : (activeJobId ? jobs.get(activeJobId) : undefined);
|
||||
if (!job) {
|
||||
const idle: NetworkSearchJobState = { jobId: null, status: "idle", query: "" };
|
||||
return NextResponse.json({ success: true, ...idle });
|
||||
}
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/qlik/search body: { query: string, maxProducts?: number }
|
||||
* Démarre une recherche en arrière-plan et retourne immédiatement le job.
|
||||
* Refuse (409) si une recherche tourne déjà — le moteur Qlik ne supporte pas
|
||||
* plusieurs extractions concurrentes.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
const denied = await requireSession();
|
||||
if (denied) return denied;
|
||||
|
||||
let body: { query?: unknown; maxProducts?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Corps JSON invalide" }, { status: 400 });
|
||||
}
|
||||
|
||||
const query = String(body.query ?? "").trim();
|
||||
if (!query) return NextResponse.json({ error: "Param 'query' requis" }, { status: 400 });
|
||||
if (query.length > 120) return NextResponse.json({ error: "Recherche trop longue (120 caractères max)" }, { status: 400 });
|
||||
|
||||
const rawMax = Number(body.maxProducts);
|
||||
const maxProducts = Number.isFinite(rawMax) ? Math.min(2000, Math.max(1, Math.trunc(rawMax))) : 300;
|
||||
|
||||
const active = activeJobId ? jobs.get(activeJobId) : undefined;
|
||||
if (active && active.status === "running") {
|
||||
return NextResponse.json(
|
||||
{ error: `Une recherche est déjà en cours ("${active.query}"). Attendez qu'elle se termine.`, ...active },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const job: SearchJob = {
|
||||
jobId: newJobId(),
|
||||
status: "running",
|
||||
query,
|
||||
maxProducts,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
jobs.set(job.jobId, job);
|
||||
activeJobId = job.jobId;
|
||||
console.log(`[api/qlik/search] job=${job.jobId} démarré — query="${query}" max=${maxProducts}`);
|
||||
|
||||
// Fire-and-forget : le client polle GET. runJob capture ses propres erreurs.
|
||||
void runJob(job, maxProducts).catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[api/qlik/search] job=${job.jobId} crash non géré`, msg);
|
||||
job.status = "error";
|
||||
job.error = msg;
|
||||
job.finishedAt = new Date().toISOString();
|
||||
if (activeJobId === job.jobId) activeJobId = null;
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...job });
|
||||
}
|
||||
+191
-29
@@ -5,11 +5,9 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
|
||||
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
|
||||
import {
|
||||
buildGridNetworkQlikDateFilter,
|
||||
envMonthsBack,
|
||||
QLIK_MONTHS_BACK_DEFAULT,
|
||||
QLIK_MONTHS_BACK_MAX,
|
||||
QLIK_MONTHS_BACK_MIN,
|
||||
} from "@/lib/qlik-date-range";
|
||||
import { startCapture, stopCapture } from "@/lib/log-capture";
|
||||
|
||||
// Tâche d'extraction Qlik potentiellement très longue (hypercube paginé).
|
||||
// On accepte quand même 5 min côté plateforme Next.js, mais on rend la main au
|
||||
@@ -26,7 +24,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 +49,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 +86,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 +101,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 +135,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".
|
||||
*
|
||||
@@ -172,6 +216,10 @@ function filterCentralCodes(rawCodes: Array<string | null | undefined>): {
|
||||
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
|
||||
*/
|
||||
async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
// Journal téléchargeable depuis Paramètres. Ouvert AVANT le premier log du
|
||||
// job pour ne pas perdre l'entête (choix des champs, carte du modèle Qlik) —
|
||||
// c'est précisément la partie qu'un terminal tronque.
|
||||
startCapture(job.jobId, `sync fournisseur=${job.fournisseur} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
|
||||
// Filtre strict des codes (trim, dedupe, exclusion vides/`-`/codes trop suspects).
|
||||
@@ -189,18 +237,20 @@ async function runJob(job: QlikSyncJob): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via
|
||||
// QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective
|
||||
// pour audit.
|
||||
const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT);
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack);
|
||||
// Contrat métier strict : toujours 12 mois complets glissants.
|
||||
// Une variable d'environnement ne doit pas pouvoir raccourcir la série.
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
job.periode = dateFilter.label;
|
||||
job.dateDebut = dateFilter.dateDebut;
|
||||
job.dateFin = dateFilter.dateFin;
|
||||
console.log(
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`,
|
||||
`[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
|
||||
);
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||
// Le code fournisseur est transmis en plus des codes : s'il désigne dans
|
||||
// Qlik le même périmètre (couverture vérifiée côté extracteur), une seule
|
||||
// sélection remplace les dizaines de milliers de `SelectValues` par code.
|
||||
// Les codes restent le repli et le contrôle de couverture.
|
||||
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur);
|
||||
job.fetched = metrics.size;
|
||||
console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`);
|
||||
const count = await upsertNetworkMetrics([...metrics.values()]);
|
||||
@@ -209,34 +259,99 @@ 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);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
startCapture(job.jobId, `sync produit=${job.codeCentrale} démarrée le ${job.startedAt}`);
|
||||
try {
|
||||
const code = job.codeCentrale!;
|
||||
job.requested = 1;
|
||||
|
||||
const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT);
|
||||
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);
|
||||
} finally {
|
||||
await stopCapture(job.jobId, `statut final=${job.status}${job.error ? ` — ${job.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +367,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);
|
||||
|
||||
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
|
||||
*
|
||||
* Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik n'est
|
||||
* déclenché. Pour interroger Qlik en direct (y compris sur des produits que nous ne
|
||||
* référençons pas), c'est `/api/qlik/search` — bien plus lent, et volontairement
|
||||
* référençons pas), c'est la page Produits — bien plus lente, et volontairement
|
||||
* séparé de cette API.
|
||||
*/
|
||||
export async function GET(
|
||||
|
||||
Reference in new issue
Block a user