fix(qlik): track async sync job status

This commit is contained in:
R0m1k3 committed 2026-06-21 19:08:04 +00:00
1 parent e7bcba169c
commit 850852912f
2 files changed
+406 -57

No files matched your search

+186 -38
View File
@@ -5,28 +5,107 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache";
import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client";
import { buildGridNetworkQlikDateFilter } from "@/lib/qlik-date-range";
// Synchro potentiellement longue (extraction hypercube paginee).
// 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
// client tout de suite via le state job ci-dessous.
export const maxDuration = 300;
// Forcer le runtime Node.js : Playwright/Chromium ne tourne pas en edge et on a
// besoin d'un process persistant pour conserver l'état de job en mémoire.
export const runtime = "nodejs";
// Toujours exécuter dynamiquement (sinon Next peut essayer de cacher des POST).
export const dynamic = "force-dynamic";
/** État d'un job de sync Qlik pour un fournisseur donné. */
type QlikSyncStatus = "idle" | "running" | "success" | "error";
interface QlikSyncJob {
jobId: string;
fournisseur: string;
status: QlikSyncStatus;
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt: string;
finishedAt?: string;
error?: string;
message?: string;
}
/**
* 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.
* Store en mémoire au niveau module. Une Map par fournisseur car on ne lance
* qu'une extraction à la fois par fournisseur. C'est volontairement simple :
* si le process redémarre, les jobs en cours sont perdus → GET renverra idle
* et l'utilisateur pourra relancer. Suffisant pour corriger le bug UX.
*/
export async function POST(req: NextRequest) {
const jobs = new Map<string, QlikSyncJob>();
/** Construit un jobId court et lisible. */
function newJobId(): string {
return `qlik_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Sérialise un job pour la réponse HTTP. */
function publicJob(job: QlikSyncJob) {
return {
jobId: job.jobId,
fournisseur: job.fournisseur,
status: job.status,
requested: job.requested,
fetched: job.fetched,
upserted: job.upserted,
periode: job.periode,
dateDebut: job.dateDebut,
dateFin: job.dateFin,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
error: job.error,
message: job.message,
};
}
/** État "idle" sérialisé (pas de job connu pour ce fournisseur). */
function idleJob(fournisseur: string) {
return {
jobId: null,
fournisseur,
status: "idle" as const,
requested: 0,
fetched: 0,
upserted: 0,
periode: undefined,
dateDebut: undefined,
dateFin: undefined,
startedAt: undefined,
finishedAt: undefined,
error: undefined,
message: undefined,
};
}
/**
* Vérifie l'auth admin. Renvoie une réponse 403/401 si refusée, sinon null.
*/
async function requireAdmin(): Promise<NextResponse | null> {
const session = await auth();
if (!session || (session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const fournisseur = req.nextUrl.searchParams.get("fournisseur");
if (!fournisseur) {
return NextResponse.json({ error: "Param 'fournisseur' requis" }, { status: 400 });
if ((session.user as { role?: string } | undefined)?.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
return null;
}
/**
* Exécute réellement l'extraction + l'upsert, en mettant à jour le job
* au fur et à mesure (compteur fetched, puis upserted, puis finishedAt).
*/
async function runJob(job: QlikSyncJob): Promise<void> {
try {
// Codes centraux des articles de ce fournisseur
const articles = await pgGetArticlesByFournisseur(fournisseur);
const articles = await pgGetArticlesByFournisseur(job.fournisseur);
const codes = [
...new Set(
articles
@@ -34,38 +113,107 @@ export async function POST(req: NextRequest) {
.filter(Boolean),
),
];
console.log(`[api/qlik/sync] fournisseur=${fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`);
job.requested = codes.length;
console.log(
`[api/qlik/sync] job=${job.jobId} fournisseur=${job.fournisseur} — ${articles.length} articles, ${codes.length} codes centraux uniques. Échantillon: ${JSON.stringify(codes.slice(0, 5))}`,
);
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(),
});
job.status = "success";
job.message = "Aucun article avec code centrale pour ce fournisseur";
job.finishedAt = new Date().toISOString();
return;
}
const dateFilter = buildGridNetworkQlikDateFilter();
console.log(`[api/qlik/sync] → lancement extraction Qlik pour ${codes.length} codes — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`);
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 — période ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`,
);
const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
console.log(`[api/qlik/sync] ← Qlik a renvoyé ${metrics.size} produits réseau`);
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()]);
console.log(`[api/qlik/sync] ${count} lignes upsert dans le cache`);
return NextResponse.json({
success: true,
fournisseur,
requested: codes.length,
fetched: metrics.size,
upserted: count,
periode: dateFilter.label,
dateDebut: dateFilter.dateDebut,
dateFin: dateFilter.dateFin,
fetchedAt: new Date().toISOString(),
});
job.upserted = count;
console.log(`[api/qlik/sync] job=${job.jobId} ${count} lignes upsert dans le cache`);
job.status = "success";
job.finishedAt = 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 });
console.error(`[api/qlik/sync] job=${job.jobId}`, msg);
job.status = "error";
job.error = msg;
job.finishedAt = new Date().toISOString();
}
}
/**
* GET /api/qlik/sync?fournisseur=XXX
* Retourne l'état courant du job pour ce fournisseur. 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 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 });
}
const job = jobs.get(fournisseur);
if (!job) {
return NextResponse.json({ success: true, ...idleJob(fournisseur) });
}
return NextResponse.json({ success: true, ...publicJob(job) });
}
/**
* POST /api/qlik/sync?fournisseur=XXX
* Démarre (ou réutilise si déjà running) un job d'extraction en arrière-plan
* et retourne *immédiatement* { success, status, jobId, ... }. Le client doit
* ensuite poller GET pour suivre l'avancement et être notifié de la fin.
*/
export async function POST(req: NextRequest) {
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 });
}
const existing = jobs.get(fournisseur);
if (existing && existing.status === "running") {
// Un job tourne déjà pour ce fournisseur : on le réutilise.
return NextResponse.json({ success: true, ...publicJob(existing) });
}
const job: QlikSyncJob = {
jobId: newJobId(),
fournisseur,
status: "running",
requested: 0,
fetched: 0,
upserted: 0,
startedAt: new Date().toISOString(),
};
jobs.set(fournisseur, job);
console.log(`[api/qlik/sync] job=${job.jobId} démarré pour fournisseur=${fournisseur}`);
// Lancement fire-and-forget. On ne `await` PAS l'extraction ici : on
// renvoie tout de suite au client et il polle GET. Les erreurs sont
// capturées dans runJob et inscrites dans le job.
void runJob(job).catch((e) => {
// Filet de sécurité (runJob ne devrait pas throw, mais on ne veut
// jamais qu'une exception non capturée crashe le process).
const msg = e instanceof Error ? e.message : String(e);
console.error(`[api/qlik/sync] job=${job.jobId} crash non géré`, msg);
job.status = "error";
job.error = msg;
job.finishedAt = new Date().toISOString();
});
return NextResponse.json({ success: true, ...publicJob(job) });
}
+220 -19
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from "lucide-react";
@@ -11,6 +11,23 @@ interface SyncQlikButtonProps {
lastUpdate?: string | null;
}
/** État serveur renvoyé par GET/POST /api/qlik/sync */
interface SyncJobState {
jobId: string | null;
fournisseur: string;
status: "idle" | "running" | "success" | "error";
requested: number;
fetched: number;
upserted: number;
periode?: string;
dateDebut?: string;
dateFin?: string;
startedAt?: string;
finishedAt?: string;
error?: string;
message?: string;
}
function formatDate(iso?: string | null): string {
if (!iso) return "jamais";
const d = new Date(iso);
@@ -21,34 +38,213 @@ function formatDate(iso?: string | null): string {
/**
* Bouton admin : synchronise depuis Qlik les métriques réseau du fournisseur affiché
* (POST /api/qlik/sync?fournisseur=…) puis recharge la grille. Affiche la dernière maj.
*
* UX : le POST démarre un job serveur en arrière-plan et revient immédiatement.
* Le composant poll ensuite GET pour suivre l'avancement. Au mount, on appelle
* GET pour reprendre l'état d'un éventuel job en cours (par ex. après reload de
* la page) et continuer à afficher le spinner correctement.
*/
export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonProps) {
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
const [status, setStatus] = useState<"idle" | "running" | "success" | "error">("idle");
const [message, setMessage] = useState<string>("");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** true tant qu'on doit suivre le job courant (false après succès/erreur traité). */
const trackingRef = useRef<boolean>(false);
const clearPollTimer = useCallback(() => {
if (pollTimerRef.current) {
clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
/**
* Met à jour le label visible à droite du bouton selon l'état courant.
*/
const applyJobToUi = useCallback((j: SyncJobState | null) => {
if (!j) return;
if (j.status === "running") {
setStatus("running");
// Texte informatif pendant l'extraction
const requestedTxt = j.requested > 0 ? ` (${j.requested} codes)` : "";
setMessage(`Extraction Qlik en cours${requestedTxt}…`);
} else if (j.status === "success") {
setStatus("success");
if (j.message) setMessage(j.message);
else setMessage(`${j.upserted} produits réseau synchronisés`);
} else if (j.status === "error") {
setStatus("error");
setMessage(j.error || "Erreur sync Qlik");
} else {
setStatus("idle");
}
}, []);
/**
* Force le refresh de la grille (avec cache-buster) et de la barre de résumé.
* C'est l'équivalent de ce que faisait l'ancien code après un POST réussi.
*/
const refreshGrid = useCallback(() => {
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
router.refresh();
}, [pathname, router, searchParams]);
/**
* Polling GET pendant qu'un job tourne. S'arrête tout seul quand le job
* passe en success/error, ou après un timeout de sécurité.
*/
const pollOnce = useCallback(async (): Promise<boolean> => {
if (!codeFournisseur) return false;
if (!trackingRef.current) return false;
try {
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "GET", cache: "no-store" },
);
if (!res.ok) {
// En cas d'erreur HTTP transitoire on retente au tour suivant.
return true;
}
const data: SyncJobState = await res.json();
applyJobToUi(data);
if (data.status === "running") {
// Continue à poller
return true;
}
if (data.status === "success" || data.status === "error") {
// Terminé : on rafraîchit la grille, on coupe le polling.
trackingRef.current = false;
if (data.status === "success") {
refreshGrid();
}
// Petit délai pour laisser l'utilisateur lire le message vert/rouge
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
return false;
}
// idle : rien à faire
trackingRef.current = false;
return false;
} catch {
// Erreur réseau transitoire, on laisse le polling réessayer.
return true;
}
}, [applyJobToUi, codeFournisseur, refreshGrid]);
/**
* Boucle de polling : s'auto-réarme tant que pollOnce indique qu'il faut
* continuer. Délai un peu plus long que d'habitude (3 s) pour ne pas
* marteler le serveur pendant une extraction qui dure plusieurs minutes.
*/
const startPolling = useCallback(() => {
clearPollTimer();
const tick = async () => {
const keepGoing = await pollOnce();
if (keepGoing && trackingRef.current) {
pollTimerRef.current = setTimeout(tick, 3000);
} else {
pollTimerRef.current = null;
}
};
// Premier appel quasi immédiat
pollTimerRef.current = setTimeout(tick, 200);
}, [clearPollTimer, pollOnce]);
/**
* Au mount : si on a un fournisseur, on interroge l'API pour savoir si
* un job tourne déjà (ex. reload de la page pendant l'extraction). Si
* oui, on reprend l'état et on relance le polling.
*/
useEffect(() => {
if (!codeFournisseur) return;
let cancelled = false;
trackingRef.current = false;
clearPollTimer();
(async () => {
try {
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "GET", cache: "no-store" },
);
if (cancelled) return;
if (!res.ok) return;
const data: SyncJobState = await res.json();
if (cancelled) return;
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
} else if (data.status === "success" || data.status === "error") {
// Job terminé en amont (avant le mount) : on l'affiche brièvement
applyJobToUi(data);
setTimeout(() => {
setStatus((cur) => (cur === "success" || cur === "error" ? "idle" : cur));
}, 4000);
}
} catch {
/* ignore : on n'empêche pas l'UI d'afficher le bouton */
}
})();
return () => {
cancelled = true;
trackingRef.current = false;
clearPollTimer();
};
// On veut interroger une seule fois au mount (et quand le fournisseur change).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeFournisseur, clearPollTimer]);
/**
* Cleanup : annule un timer de polling éventuel au démontage.
*/
useEffect(() => {
return () => {
clearPollTimer();
};
}, [clearPollTimer]);
const handleSync = async () => {
if (!codeFournisseur) {
setStatus("error");
setMessage("Aucun fournisseur sélectionné");
setTimeout(() => setStatus("idle"), 6000);
return;
}
setStatus("loading");
setMessage("");
// Empêche un double-clic pendant qu'un job tourne déjà.
if (status === "running") return;
setStatus("running");
setMessage("Démarrage de l'extraction Qlik…");
try {
const res = await fetch(`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`, { method: "POST" });
const data = await res.json();
if (!res.ok || !data.success) throw new Error(data.error || `HTTP ${res.status}`);
setStatus("success");
setMessage(`${data.upserted} produits réseau synchronisés`);
// Bump _refresh → grid-client refetch /api/grid/rows avec refresh=1 (forceRefresh,
// bypass cache 10 min de getProductRows) pour afficher les nouvelles métriques réseau.
const params = new URLSearchParams(searchParams.toString());
params.set("_refresh", String(Date.now()));
router.replace(`${pathname}?${params.toString()}`);
router.refresh();
const res = await fetch(
`/api/qlik/sync?fournisseur=${encodeURIComponent(codeFournisseur)}`,
{ method: "POST" },
);
const data: SyncJobState & { success?: boolean } = await res.json();
if (!res.ok || (data as { success?: boolean }).success === false) {
throw new Error(data.error || `HTTP ${res.status}`);
}
// Si le serveur nous dit que c'est déjà en cours (jobId existant),
// on reprend juste le suivi.
if (data.status === "running" && data.jobId) {
trackingRef.current = true;
applyJobToUi(data);
startPolling();
return;
}
// Status terminal direct (cas rare : 0 codes → success immédiat)
applyJobToUi(data);
if (data.status === "success") {
refreshGrid();
}
setTimeout(() => setStatus("idle"), 4000);
} catch (e) {
setStatus("error");
@@ -62,16 +258,21 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
<span
className="text-[10px] leading-tight text-right whitespace-nowrap hidden lg:block"
style={{ color: status === "error" ? "var(--accent-error)" : "var(--text-muted)" }}
title={message || undefined}
>
{status === "error" ? message : `MAJ Qlik · ${formatDate(lastUpdate)}`}
{status === "error"
? message
: status === "running"
? (message || `Extraction Qlik…`)
: `MAJ Qlik · ${formatDate(lastUpdate)}`}
</span>
<button
onClick={handleSync}
disabled={status === "loading"}
disabled={status === "running"}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60"
title={message || `Synchroniser les données réseau Qlik · MAJ ${formatDate(lastUpdate)}`}
>
{status === "loading" ? (
{status === "running" ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : status === "success" ? (
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
@@ -80,7 +281,7 @@ export function SyncQlikButton({ codeFournisseur, lastUpdate }: SyncQlikButtonPr
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
Sync Qlik
{status === "running" ? "Sync en cours…" : "Sync Qlik"}
</button>
</div>
);