From c9ecb47cb11cb291237b68e8e263fbe367b442ae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 04:57:17 +0000 Subject: [PATCH] =?UTF-8?q?fix(produits):=20recherche=20asynchrone=20?= =?UTF-8?q?=E2=80=94=20la=20requ=C3=AAte=20=C3=A9tait=20coup=C3=A9e=20par?= =?UTF-8?q?=20le=20reverse=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Unexpected token '<', " Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 11 +- src/app/(dashboard)/produits/results.tsx | 116 +++++++++++-- src/app/api/produits/search/route.ts | 69 +++++--- src/features/produits/api/search-produits.ts | 165 ++++++++++++++++--- 4 files changed, 295 insertions(+), 66 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2f202e3..2becb43 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -36,8 +36,17 @@ ceux-là qu'on veut voir. 3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy. Un code absent = produit réseau que nous ne référençons pas (`?cc=` sur la fiche). +L'API est **asynchrone** : `POST /api/produits/search?q=…` démarre un job et rend +la main tout de suite, `GET` renvoie l'avancement puis le résultat (polling client +toutes les 2 s). Une requête HTTP maintenue pendant toute l'extraction se faisait +couper par le reverse proxy, qui répond une page HTML — le client échouait sur +« Unexpected token '<' … is not valid JSON ». Même schéma que `POST /api/qlik/sync`. +Deux recherches simultanées au maximum (le serveur Qlik sature vite). + Repli : si Qlik est injoignable, la recherche retombe sur `pgSearchProduits()` et -la réponse le signale (`source: "db"`). Résultats mis en cache mémoire 10 min. +la réponse le signale (`source: "db"`). Un résultat Qlik exploitable est mis en +cache mémoire 10 min ; un repli ne l'est pas, sinon une panne passagère resterait +figée. Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`, le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index 686c1e2..1190f3e 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -15,12 +15,55 @@ const fmtEur = (v: number) => const fmtEur2 = (v: number) => new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v); +/** Intervalle d'interrogation de l'état du job de recherche. */ +const POLL_MS = 2000; + +/** Réponse du job de recherche, telle que la renvoie l'API. */ +interface JobRecherche { + status: "idle" | "running" | "success" | "error"; + etape?: string; + error?: string; + result?: ProduitRechercheResultat; +} + +/** + * Lit une réponse HTTP en **ne présumant pas** qu'elle est du JSON. + * + * Un reverse proxy qui coupe la requête répond une page HTML : `res.json()` + * échouait alors sur « Unexpected token '<' … is not valid JSON », message + * inexploitable pour l'utilisateur. On traduit ici le statut HTTP. + */ +async function lireJson(res: Response): Promise> { + const texte = await res.text(); + let data: Record | null = null; + try { + data = texte ? (JSON.parse(texte) as Record) : null; + } catch { + data = null; + } + if (data == null) { + if (res.status === 504 || res.status === 408) { + throw new Error("Le serveur a mis trop de temps à répondre (délai dépassé côté proxy). Réessayez."); + } + if (res.status === 502 || res.status === 503) { + throw new Error("Application indisponible ou en cours de redémarrage. Réessayez dans un instant."); + } + if (res.status === 401 || res.status === 403) { + throw new Error("Session expirée. Rechargez la page pour vous reconnecter."); + } + throw new Error(`Réponse inattendue du serveur (HTTP ${res.status}).`); + } + if (!res.ok) throw new Error(String(data.error ?? `HTTP ${res.status}`)); + return data; +} + /** * Résultats de recherche produit. * - * La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs - * secondes, parfois plus. On la lance donc côté client avec un état de - * chargement explicite, plutôt que de bloquer le rendu serveur de la page. + * La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est + * lancée par un `POST` qui rend la main tout de suite, puis on interroge son + * avancement toutes les 2 s. Une requête HTTP maintenue pendant toute + * l'extraction se faisait couper par le reverse proxy. */ export function ProduitResults({ query }: { query: string }) { // `relance` mémorise la dernière demande explicite de rafraîchissement, pour @@ -30,6 +73,7 @@ export function ProduitResults({ query }: { query: string }) { cle: string; data: ProduitRechercheResultat | null; error: string | null; + etape: string | null; } | null>(null); const force = relance.n > 0 && relance.q === query; @@ -39,28 +83,64 @@ export function ProduitResults({ query }: { query: string }) { // L'état de chargement est **dérivé** (résultat pas encore aligné sur la // requête courante) plutôt que posé en début d'effet : appeler setState // synchronement dans un effet provoque un rendu en cascade. - const loading = !tropCourt && resultat?.cle !== cle; + const enAttente = resultat?.cle !== cle || (!resultat?.data && !resultat?.error); + const loading = !tropCourt && enAttente; useEffect(() => { if (tropCourt) return; let annule = false; - const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`; - fetch(url, { cache: "no-store" }) - .then(async (res) => { - const json = await res.json(); - if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`); - return json as ProduitRechercheResultat; - }) - .then((data) => { if (!annule) setResultat({ cle, data, error: null }); }) - .catch((e) => { - if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) }); - }); - return () => { annule = true; }; + let timer: ReturnType | undefined; + + const url = (methode: "POST" | "GET") => + `/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`; + + const appliquer = (job: JobRecherche): boolean => { + if (annule) return true; + if (job.status === "success" && job.result) { + setResultat({ cle, data: job.result, error: null, etape: null }); + return true; + } + if (job.status === "error") { + setResultat({ cle, data: null, error: job.error ?? "La recherche a échoué.", etape: null }); + return true; + } + // `idle` = le process a redémarré et a perdu le job : on relance. + setResultat({ cle, data: null, error: null, etape: job.etape ?? null }); + return false; + }; + + const poll = async () => { + try { + const job = (await lireJson(await fetch(url("GET"), { cache: "no-store" }))) as unknown as JobRecherche; + if (job.status === "idle") { + await demarrer(); + return; + } + if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS); + } catch (e) { + if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null }); + } + }; + + const demarrer = async () => { + try { + const job = (await lireJson( + await fetch(url("POST"), { method: "POST", cache: "no-store" }), + )) as unknown as JobRecherche; + if (!appliquer(job) && !annule) timer = setTimeout(() => void poll(), POLL_MS); + } catch (e) { + if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e), etape: null }); + } + }; + + void demarrer(); + return () => { annule = true; if (timer) clearTimeout(timer); }; }, [cle, query, force, tropCourt]); const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query })); const data = resultat?.cle === cle ? resultat.data : null; const error = resultat?.cle === cle ? resultat.error : null; + const etape = resultat?.cle === cle ? resultat.etape : null; if (!query) { return ( @@ -89,11 +169,11 @@ export function ProduitResults({ query }: { query: string }) { >

- Interrogation de Qlik Sense… + {etape ?? "Interrogation de Qlik Sense…"}

Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants. - Cela peut prendre jusqu'à une minute. + Cela peut prendre plus d'une minute — vous pouvez laisser la page ouverte.

); diff --git a/src/app/api/produits/search/route.ts b/src/app/api/produits/search/route.ts index 06c7c45..dae34e0 100644 --- a/src/app/api/produits/search/route.ts +++ b/src/app/api/produits/search/route.ts @@ -1,43 +1,70 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { rechercherProduits } from "@/features/produits/api/search-produits"; +import { demarrerRecherche, etatRecherche } from "@/features/produits/api/search-produits"; -// L'extraction Qlik (Playwright + Engine) prend plusieurs secondes, parfois -// plus d'une minute quand le serveur Qlik est chargé. -export const maxDuration = 300; -// Playwright/Chromium exige le runtime Node.js (pas edge). +// 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"; /** - * GET /api/produits/search?q=[&force=1] + * Recherche produit **Qlik d'abord** — API asynchrone. * - * Recherche produit **Qlik d'abord** : les articles du réseau qui correspondent - * au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement - * avec le catalogue FF Nancy. + * `POST /api/produits/search?q=[&force=1]` démarre la recherche et rend la + * main tout de suite. `GET /api/produits/search?q=` renvoie l'avancement, + * puis le résultat. * - * `force=1` ignore le cache mémoire de 10 min (bouton « relancer »). + * 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. */ -export async function GET(req: NextRequest) { + +async function requireSession(): Promise { const session = await auth(); - if (!session) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + 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(); - if (q.length < 3) { - return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 }); - } - const force = req.nextUrl.searchParams.get("force") === "1"; + 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 result = await rechercherProduits(q, { force }); - return NextResponse.json({ success: true, ...result }); + 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]", message.slice(0, 300)); + 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 }); } } diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index e29f1fb..cea0ca7 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -28,6 +28,17 @@ import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/p /** Durée de validité d'un résultat de recherche en cache mémoire. */ const CACHE_TTL_MS = 10 * 60 * 1000; +/** Durée de conservation d'un job terminé (le client a le temps de le relire). */ +const JOB_TTL_MS = 5 * 60 * 1000; + +/** + * Nombre maximum de recherches Qlik simultanées. + * + * Le serveur Qlik sature vite (« Out of memory », « Request aborted ») : on + * plafonne plutôt que de lui envoyer N extractions en parallèle. + */ +const MAX_RECHERCHES_SIMULTANEES = 2; + /** * Cache mémoire des recherches. Une recherche coûte deux allers-retours Qlik * (plusieurs secondes chacun) : sans ce cache, un retour arrière du navigateur @@ -35,58 +46,157 @@ const CACHE_TTL_MS = 10 * 60 * 1000; */ const cache = new Map(); -/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */ -const enCours = new Map>(); +/** + * Jobs de recherche, indexés par terme normalisé. + * + * Une recherche dure de quelques secondes à plusieurs minutes (deux allers-retours + * Qlik dont une extraction mensuelle). Une requête HTTP maintenue aussi longtemps + * se fait couper par le reverse proxy, qui répond une page d'erreur HTML — le + * client recevait alors « Unexpected token '<' … is not valid JSON ». Le travail + * tourne donc en tâche de fond et le client interroge l'état. + */ +const jobs = new Map(); + +export interface RechercheJob { + jobId: string; + terme: string; + status: "running" | "success" | "error"; + /** Étape en cours, affichée pendant l'attente. */ + etape: string; + startedAt: string; + finishedAt?: string; + error?: string; + result?: ProduitRechercheResultat; +} + +/** État renvoyé au client : le job, ou `idle` si aucune recherche connue. */ +export type RechercheEtat = RechercheJob | { status: "idle"; terme: string }; function cacheKey(term: string): string { return term.trim().toLowerCase().replace(/\s+/g, " "); } /** Purge les entrées expirées (le volume reste faible, un balayage suffit). */ -function purgeCache(): void { +function purge(): void { const now = Date.now(); for (const [k, v] of cache) { if (now - v.at > CACHE_TTL_MS) cache.delete(k); } + for (const [k, j] of jobs) { + if (j.status !== "running" && j.finishedAt && now - Date.parse(j.finishedAt) > JOB_TTL_MS) jobs.delete(k); + } } -export async function rechercherProduits( +function jobsEnCours(): number { + let n = 0; + for (const j of jobs.values()) if (j.status === "running") n++; + return n; +} + +function jobTermine(terme: string, result: ProduitRechercheResultat): RechercheJob { + return { + jobId: `cache_${cacheKey(terme)}`, + terme, + status: "success", + etape: "Terminé", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + result, + }; +} + +/** + * Démarre (ou réutilise) une recherche et rend la main **immédiatement**. + * + * - résultat déjà en cache et pas de `force` → job `success` directement ; + * - recherche déjà en cours sur le même terme → on renvoie ce job ; + * - sinon on lance le travail en tâche de fond. + */ +export function demarrerRecherche( term: string, options: { force?: boolean } = {}, -): Promise { +): RechercheJob { const cleaned = term.trim(); - if (cleaned.length < 3) { - return { - rows: [], source: "qlik", qlikError: null, tronque: false, - champUtilise: null, locauxHorsReseau: [], dureeMs: 0, - }; - } - const key = cacheKey(cleaned); - purgeCache(); + purge(); + if (!options.force) { const hit = cache.get(key); if (hit) { console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`); - return hit.result; + return jobTermine(cleaned, hit.result); } - const running = enCours.get(key); - if (running) return running; } - const p = executerRecherche(cleaned).finally(() => enCours.delete(key)); - enCours.set(key, p); - const result = await p; - // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un repli - // catalogue (panne Qlik passagère) le figerait pour 10 minutes alors qu'un - // simple « Relancer » aurait suffi. - if (result.source === "qlik" && !result.qlikError) { - cache.set(key, { at: Date.now(), result }); + const existant = jobs.get(key); + if (existant && existant.status === "running") return existant; + if (existant && !options.force && existant.status === "success" && existant.result) return existant; + + if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) { + return { + jobId: `refus_${Date.now().toString(36)}`, + terme: cleaned, + status: "error", + etape: "Refusé", + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + error: "Trop de recherches Qlik en cours. Réessayez dans quelques instants.", + }; } - return result; + + const job: RechercheJob = { + jobId: `rech_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, + terme: cleaned, + status: "running", + etape: "Recherche des articles dans Qlik…", + startedAt: new Date().toISOString(), + }; + jobs.set(key, job); + console.log(`[produits/search] job=${job.jobId} démarré pour "${cleaned}"`); + + // Fire-and-forget : le client suivra l'avancement via `etatRecherche`. + void executerRecherche(cleaned, (etape) => { job.etape = etape; }) + .then((result) => { + job.result = result; + job.status = "success"; + job.etape = "Terminé"; + job.finishedAt = new Date().toISOString(); + // On ne met en cache qu'un résultat Qlik exploitable : mémoriser un + // repli catalogue (panne Qlik passagère) le figerait 10 minutes + // alors qu'un simple « Relancer » aurait suffi. + if (result.source === "qlik" && !result.qlikError) { + cache.set(key, { at: Date.now(), result }); + } + console.log(`[produits/search] job=${job.jobId} terminé — ${result.rows.length} produit(s) en ${result.dureeMs} ms`); + }) + .catch((e) => { + const message = e instanceof Error ? e.message : String(e); + job.status = "error"; + job.etape = "Échec"; + job.error = messageQlikLisible(message); + job.finishedAt = new Date().toISOString(); + console.error(`[produits/search] job=${job.jobId} échec :`, message.slice(0, 300)); + }); + + return job; } -async function executerRecherche(term: string): Promise { +/** État courant de la recherche pour ce terme (pour le polling client). */ +export function etatRecherche(term: string): RechercheEtat { + const cleaned = term.trim(); + const key = cacheKey(cleaned); + purge(); + const job = jobs.get(key); + if (job) return job; + const hit = cache.get(key); + if (hit) return jobTermine(cleaned, hit.result); + return { status: "idle", terme: cleaned }; +} + +async function executerRecherche( + term: string, + onEtape: (etape: string) => void = () => {}, +): Promise { const started = Date.now(); // La recherche catalogue local tourne en parallèle : elle sert à la fois de @@ -99,6 +209,7 @@ async function executerRecherche(term: string): Promise(); let metricsError: string | null = null; try { + onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`); metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); // Le libellé / fournisseur ne sortent que de la recherche : on les // attache ici pour qu'ils soient persistés avec les mesures. @@ -163,6 +275,7 @@ async function executerRecherche(term: string): Promise