fix(produits): recherche asynchrone — la requête était coupée par le reverse proxy

« Unexpected token '<', "<html> <h"... is not valid JSON » : ce n'était pas du
JSON parce que ce n'était pas l'application qui répondait. La recherche enchaîne
deux allers-retours Qlik (dont une extraction mensuelle) et dépassait le délai du
proxy, qui renvoyait sa page d'erreur HTML.

- L'API passe en asynchrone, sur le modèle déjà éprouvé de POST /api/qlik/sync :
  POST démarre un job et rend la main immédiatement, GET renvoie l'avancement
  puis le résultat. Le client interroge toutes les 2 s et affiche l'étape en
  cours (recherche des articles / extraction des ventes / rapprochement
  catalogue). Deux recherches simultanées au maximum.
- Le client ne présume plus que la réponse est du JSON : les statuts 504, 502,
  503, 401 et 403 sont traduits en message actionnable au lieu d'une erreur de
  parsing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-30 04:57:17 +00:00
1 parent 466cb53da2
commit c9ecb47cb1
4 files changed
+295 -66

No files matched your search

+98 -18
View File
@@ -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<Record<string, unknown>> {
const texte = await res.text();
let data: Record<string, unknown> | null = null;
try {
data = texte ? (JSON.parse(texte) as Record<string, unknown>) : 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<typeof setTimeout> | 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 }) {
>
<Loader2 className="w-6 h-6 mx-auto mb-3 animate-spin" style={{ color: "var(--accent)" }} />
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
Interrogation de Qlik Sense…
{etape ?? "Interrogation de Qlik Sense…"}
</p>
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
Recherche des articles du réseau puis extraction de leurs ventes sur 12 mois glissants.
Cela peut prendre jusqu&apos;à une minute.
Cela peut prendre plus d&apos;une minute — vous pouvez laisser la page ouverte.
</p>
</div>
);
+48 -21
View File
@@ -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=<terme>[&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=<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.
*
* `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<NextResponse | null> {
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 });
}
}
+139 -26
View File
@@ -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<string, { at: number; result: ProduitRechercheResultat }>();
/** Recherches en cours, pour ne pas lancer deux extractions identiques en parallèle. */
const enCours = new Map<string, Promise<ProduitRechercheResultat>>();
/**
* 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<string, RechercheJob>();
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<ProduitRechercheResultat> {
): 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<ProduitRechercheResultat> {
/** É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<ProduitRechercheResultat> {
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<ProduitRechercheResultat
let qlik;
try {
onEtape("Recherche des articles dans Qlik…");
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
@@ -140,6 +251,7 @@ async function executerRecherche(term: string): Promise<ProduitRechercheResultat
let metrics = new Map<string, NetworkMetric>();
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<ProduitRechercheResultat
console.error("[produits/search] extraction des métriques réseau échouée:", metricsError);
}
onEtape("Rapprochement avec notre catalogue…");
const catalogue = await pgGetProduitsByCodeCentrale(codes);
const locaux = await localPromise;