mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
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:
4 files changed
+295
-66
No files matched your search
@@ -36,8 +36,17 @@ ceux-là qu'on veut voir.
|
|||||||
3. `pgGetProduitsByCodeCentrale()` — rapprochement avec le catalogue Nancy.
|
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).
|
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
|
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`,
|
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
|
le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket
|
||||||
|
|||||||
@@ -15,12 +15,55 @@ const fmtEur = (v: number) =>
|
|||||||
const fmtEur2 = (v: number) =>
|
const fmtEur2 = (v: number) =>
|
||||||
new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 2 }).format(v);
|
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.
|
* Résultats de recherche produit.
|
||||||
*
|
*
|
||||||
* La recherche interroge **Qlik d'abord** (`/api/produits/search`) : plusieurs
|
* La recherche interroge **Qlik d'abord** et peut dépasser la minute : elle est
|
||||||
* secondes, parfois plus. On la lance donc côté client avec un état de
|
* lancée par un `POST` qui rend la main tout de suite, puis on interroge son
|
||||||
* chargement explicite, plutôt que de bloquer le rendu serveur de la page.
|
* 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 }) {
|
export function ProduitResults({ query }: { query: string }) {
|
||||||
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
|
// `relance` mémorise la dernière demande explicite de rafraîchissement, pour
|
||||||
@@ -30,6 +73,7 @@ export function ProduitResults({ query }: { query: string }) {
|
|||||||
cle: string;
|
cle: string;
|
||||||
data: ProduitRechercheResultat | null;
|
data: ProduitRechercheResultat | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
etape: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const force = relance.n > 0 && relance.q === query;
|
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
|
// 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
|
// requête courante) plutôt que posé en début d'effet : appeler setState
|
||||||
// synchronement dans un effet provoque un rendu en cascade.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (tropCourt) return;
|
if (tropCourt) return;
|
||||||
let annule = false;
|
let annule = false;
|
||||||
const url = `/api/produits/search?q=${encodeURIComponent(query)}${force ? "&force=1" : ""}`;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
fetch(url, { cache: "no-store" })
|
|
||||||
.then(async (res) => {
|
const url = (methode: "POST" | "GET") =>
|
||||||
const json = await res.json();
|
`/api/produits/search?q=${encodeURIComponent(query)}${methode === "POST" && force ? "&force=1" : ""}`;
|
||||||
if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
|
|
||||||
return json as ProduitRechercheResultat;
|
const appliquer = (job: JobRecherche): boolean => {
|
||||||
})
|
if (annule) return true;
|
||||||
.then((data) => { if (!annule) setResultat({ cle, data, error: null }); })
|
if (job.status === "success" && job.result) {
|
||||||
.catch((e) => {
|
setResultat({ cle, data: job.result, error: null, etape: null });
|
||||||
if (!annule) setResultat({ cle, data: null, error: e instanceof Error ? e.message : String(e) });
|
return true;
|
||||||
});
|
}
|
||||||
return () => { annule = 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]);
|
}, [cle, query, force, tropCourt]);
|
||||||
|
|
||||||
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
|
const relancer = () => setRelance((r) => ({ n: r.n + 1, q: query }));
|
||||||
const data = resultat?.cle === cle ? resultat.data : null;
|
const data = resultat?.cle === cle ? resultat.data : null;
|
||||||
const error = resultat?.cle === cle ? resultat.error : null;
|
const error = resultat?.cle === cle ? resultat.error : null;
|
||||||
|
const etape = resultat?.cle === cle ? resultat.etape : null;
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return (
|
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)" }} />
|
<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)" }}>
|
<p className="text-[13px] font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||||
Interrogation de Qlik Sense…
|
{etape ?? "Interrogation de Qlik Sense…"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] mt-1" style={{ color: "var(--text-muted)" }}>
|
<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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,70 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
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
|
// Les réponses sont immédiates (le travail Qlik tourne en tâche de fond), mais on
|
||||||
// plus d'une minute quand le serveur Qlik est chargé.
|
// garde le runtime Node.js : Playwright/Chromium ne tourne pas en edge, et l'état
|
||||||
export const maxDuration = 300;
|
// des jobs vit dans la mémoire du process.
|
||||||
// Playwright/Chromium exige le runtime Node.js (pas edge).
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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
|
* `POST /api/produits/search?q=<terme>[&force=1]` démarre la recherche et rend la
|
||||||
* au terme, leurs métriques réseau sur 12 mois glissants, puis le rapprochement
|
* main tout de suite. `GET /api/produits/search?q=<terme>` renvoie l'avancement,
|
||||||
* avec le catalogue FF Nancy.
|
* 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.
|
* 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();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lireTerme(req: NextRequest): string | null {
|
||||||
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
const q = (req.nextUrl.searchParams.get("q") ?? "").trim();
|
||||||
if (q.length < 3) {
|
return q.length >= 3 ? q : null;
|
||||||
return NextResponse.json({ error: "Saisissez au moins 3 caractères" }, { status: 400 });
|
}
|
||||||
}
|
|
||||||
const force = req.nextUrl.searchParams.get("force") === "1";
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
const result = await rechercherProduits(q, { force });
|
const job = demarrerRecherche(q, { force });
|
||||||
return NextResponse.json({ success: true, ...result });
|
return NextResponse.json({ success: true, ...job });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = (e as Error).message ?? String(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 });
|
return NextResponse.json({ error: message.slice(0, 300) }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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. */
|
/** Durée de validité d'un résultat de recherche en cache mémoire. */
|
||||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
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
|
* 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
|
* (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 }>();
|
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 {
|
function cacheKey(term: string): string {
|
||||||
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
return term.trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
/** Purge les entrées expirées (le volume reste faible, un balayage suffit). */
|
||||||
function purgeCache(): void {
|
function purge(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [k, v] of cache) {
|
for (const [k, v] of cache) {
|
||||||
if (now - v.at > CACHE_TTL_MS) cache.delete(k);
|
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,
|
term: string,
|
||||||
options: { force?: boolean } = {},
|
options: { force?: boolean } = {},
|
||||||
): Promise<ProduitRechercheResultat> {
|
): RechercheJob {
|
||||||
const cleaned = term.trim();
|
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);
|
const key = cacheKey(cleaned);
|
||||||
purgeCache();
|
purge();
|
||||||
|
|
||||||
if (!options.force) {
|
if (!options.force) {
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
console.log(`[produits/search] "${cleaned}" — servi depuis le cache mémoire`);
|
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));
|
const existant = jobs.get(key);
|
||||||
enCours.set(key, p);
|
if (existant && existant.status === "running") return existant;
|
||||||
const result = await p;
|
if (existant && !options.force && existant.status === "success" && existant.result) return existant;
|
||||||
// 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
|
if (jobsEnCours() >= MAX_RECHERCHES_SIMULTANEES) {
|
||||||
// simple « Relancer » aurait suffi.
|
return {
|
||||||
if (result.source === "qlik" && !result.qlikError) {
|
jobId: `refus_${Date.now().toString(36)}`,
|
||||||
cache.set(key, { at: Date.now(), result });
|
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();
|
const started = Date.now();
|
||||||
|
|
||||||
// La recherche catalogue local tourne en parallèle : elle sert à la fois de
|
// 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;
|
let qlik;
|
||||||
try {
|
try {
|
||||||
|
onEtape("Recherche des articles dans Qlik…");
|
||||||
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
qlik = await searchQlikArticles(term, QLIK_SEARCH_MAX_RESULTS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(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 metrics = new Map<string, NetworkMetric>();
|
||||||
let metricsError: string | null = null;
|
let metricsError: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
onEtape(`Extraction des ventes réseau de ${codes.length} article${codes.length > 1 ? "s" : ""} sur 12 mois…`);
|
||||||
metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter);
|
||||||
// Le libellé / fournisseur ne sortent que de la recherche : on les
|
// Le libellé / fournisseur ne sortent que de la recherche : on les
|
||||||
// attache ici pour qu'ils soient persistés avec les mesures.
|
// 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);
|
console.error("[produits/search] extraction des métriques réseau échouée:", metricsError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEtape("Rapprochement avec notre catalogue…");
|
||||||
const catalogue = await pgGetProduitsByCodeCentrale(codes);
|
const catalogue = await pgGetProduitsByCodeCentrale(codes);
|
||||||
const locaux = await localPromise;
|
const locaux = await localPromise;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user