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

+10 -1
View File
@@ -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
+98 -18
View File
@@ -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&apos;à une minute. Cela peut prendre plus d&apos;une minute — vous pouvez laisser la page ouverte.
</p> </p>
</div> </div>
); );
+48 -21
View File
@@ -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 });
} }
} }
+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. */ /** 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;