From a1a95fa2904278d6ba9f3f95f9208a85e70a8b2b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:14:56 +0000 Subject: [PATCH] =?UTF-8?q?fix(qlik-search):=20la=20s=C3=A9lection=20ne=20?= =?UTF-8?q?s'appliquait=20pas,=20les=20r=C3=A9sultats=20ne=20correspondaie?= =?UTF-8?q?nt=20pas=20au=20terme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptôme : « tapis anti » renvoyait 40 articles sans rapport (mugs, miroirs, couvertures) avec des codes centraux contigus — c'est-à-dire le début du catalogue Qlik, pas un résultat de recherche. Cause : SearchResults + SelectValues. On ré-injectait les qText renvoyés par la recherche globale dans un SelectValues sur le champ, sans vérifier le retour et sans laisser à l'Engine le temps d'évaluer la sélection. Quand la sélection n'est pas appliquée, le cube renvoie simplement les premières lignes de la dimension « Article Code » — soit exactement les 40 lignes demandées. Correctifs : - Sélection via list object : SearchListObjectFor + AcceptListObjectSearch, c'est-à-dire le mécanisme d'un volet de filtre Qlik. Qlik applique sa propre sémantique de recherche et sélectionne lui-même les valeurs, sans réinjection de valeurs texte. Libellé d'abord, code centrale en repli. - Délai d'évaluation (QLIK_SETTLE_MS, défaut 250 ms) après ClearAll, après la recherche et après l'acceptation, comme le fait déjà l'extraction. - Garde-fou côté client : chaque ligne du cube est revérifiée contre le terme (tous les mots présents dans le libellé, insensible casse/accents, ou code correspondant). Une liste sans rapport avec la recherche est pire que zéro résultat. - Dédoublonnage par code centrale : un article référencé chez plusieurs fournisseurs produisait autant de lignes (vu sur « Couverture de Pique-nique », code 10636135, listé deux fois). - Diagnostic : inventaire des champs, nombre de valeurs trouvées, sélections actives et compteur de lignes écartées sont tracés dans les logs ; si tout est écarté, l'UI dit que le filtre n'a pas été appliqué au lieu d'afficher un « aucun résultat » trompeur. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- src/app/(dashboard)/produits/results.tsx | 9 +- src/features/produits/api/search-produits.ts | 5 +- src/lib/qlik-search.ts | 227 ++++++++++++++----- 3 files changed, 177 insertions(+), 64 deletions(-) diff --git a/src/app/(dashboard)/produits/results.tsx b/src/app/(dashboard)/produits/results.tsx index c9b1ec1..686c1e2 100644 --- a/src/app/(dashboard)/produits/results.tsx +++ b/src/app/(dashboard)/produits/results.tsx @@ -159,10 +159,15 @@ export function ProduitResults({ query }: { query: string }) { {source === "qlik" && qlikError && (
- Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError} + + + {rows.length === 0 + ? qlikError + : `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`} +
)} diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index e81ccae..4d564fb 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -116,7 +116,10 @@ async function executerRecherche(term: string): Promise | null = null; async function getBrowser(): Promise { @@ -76,6 +84,10 @@ interface InPageSearchResult { rows?: Array<[string, string, string]>; champUtilise?: string | null; total?: number; + /** Lignes brutes lues dans le cube (avant garde-fou). */ + lues?: number; + /** Lignes écartées parce qu'elles ne correspondaient pas au terme. */ + rejetees?: number; /** Journal compact des étapes, remonté dans les logs serveur. */ trace?: string[]; } @@ -94,11 +106,15 @@ export async function searchQlikArticles( cfg: QlikConfig = getQlikConfig(), ): Promise { const cleaned = term.trim(); - if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false }; + if (cleaned.length < 3) return { matches: [], champUtilise: null, tronque: false, avertissement: null }; if (!cfg.appNetwork) throw new Error("[qlik-search] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-search] identifiants Qlik manquants"); const started = Date.now(); + // Latence laissée à l'Engine après chaque mutation de sélection. Même réglage + // que l'extraction (`QLIK_SETTLE_MS`) : sans elle, l'objet créé juste après + // est calculé sur l'état de sélection précédent. + const settleMs = Math.min(2000, Math.max(0, Number(process.env.QLIK_SETTLE_MS ?? "250") || 250)); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("="); const cookieValue = rest.join("="); @@ -128,8 +144,8 @@ export async function searchQlikArticles( if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)"); const result = (await page.evaluate( - ({ app, dim, token, terme, limite, maxFieldMatches, champLibelleForce, champFournisseurForce }: { - app: string; dim: string; token: string; terme: string; limite: number; maxFieldMatches: number; + ({ app, dim, token, terme, limite, settleMs, champLibelleForce, champFournisseurForce }: { + app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { @@ -199,56 +215,89 @@ export async function searchQlikArticles( trace.push("champs disponibles=" + JSON.stringify(champs)); trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); - // ─── 2. Recherche globale Qlik ─────────────────────────── - // On restreint aux champs article : sans restriction, un terme - // courant ("noir") matcherait des dizaines de champs sans - // rapport (couleur magasin, région…) et la sélection deviendrait - // absurde. - const champsRecherche = [champCode, ...(champLibelle ? [champLibelle] : [])]; - const sr = await rpc("SearchResults", { - qOptions: { qSearchFields: champsRecherche, qContext: "Cleared" }, - qTerms: [terme], - qPage: { qOffset: 0, qCount: champsRecherche.length, qMaxNbrFieldMatches: maxFieldMatches }, - }, doc); + // ─── 2. Sélection par recherche de champ ───────────────── + // + // On passe par un **list object** + `SearchListObjectFor` / + // `AcceptListObjectSearch` : c'est exactement ce que fait un + // volet de filtre Qlik quand on tape dans sa zone de recherche. + // Qlik applique lui-même sa sémantique (mots multiples = tous + // présents, insensible casse/accents) et sélectionne les valeurs + // trouvées — sans qu'on ait à ré-injecter des `qText` à + // l'identique, ce qui échouait silencieusement et laissait le + // cube renvoyer le début du catalogue. + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - type Match = { qText?: string }; - type Item = { qIdentifier?: string; qItemMatches?: Match[]; qTotalNumberOfMatches?: number }; - type Group = { qItems?: Item[] }; - const groupes = (((sr.qResult as { qSearchGroupArray?: Group[] })?.qSearchGroupArray) ?? []); - const parChamp = new Map(); - for (const g of groupes) { - for (const it of g.qItems ?? []) { - const champ = it.qIdentifier ?? ""; - if (!champ) continue; - const vals = (it.qItemMatches ?? []).map((m) => String(m.qText ?? "")).filter(Boolean); - if (vals.length === 0) continue; - parChamp.set(champ, [...(parChamp.get(champ) ?? []), ...vals]); + const chercherEtSelectionner = async (champ: string): Promise => { + const lo = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-lb" }, + qListObjectDef: { + qDef: { qFieldDefs: [champ] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + }, + }, + }, doc); + const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const loId = String(ret.qGenericId ?? ret.qId ?? ""); + try { + await rpc("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: terme }, ret.qHandle); + await sleep(settleMs); + const lay = await rpc("GetLayout", {}, ret.qHandle); + const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; + trace.push("recherche " + champ + " → " + nb + " valeur(s)"); + if (nb > 0) { + await rpc("AcceptListObjectSearch", { + qPath: "/qListObjectDef", + qToggleMode: false, + qSoftLock: true, + }, ret.qHandle); + // L'Engine doit finir d'évaluer la sélection avant que + // le cube suivant soit calculé, sinon il est construit + // sur l'état précédent (= catalogue entier). + await sleep(settleMs); + } + return nb; + } finally { + if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } } - } - trace.push("groupes=" + JSON.stringify([...parChamp].map(([c, v]) => c + ":" + v.length))); + }; - // Priorité au champ libellé (une recherche texte), sinon le code. - const champChoisi = (champLibelle && parChamp.has(champLibelle)) ? champLibelle - : parChamp.has(champCode) ? champCode - : [...parChamp.keys()][0] ?? null; + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + + // Le libellé d'abord (cas courant), le code centrale ensuite. + let champChoisi: string | null = null; + if (champLibelle && (await chercherEtSelectionner(champLibelle)) > 0) { + champChoisi = champLibelle; + } else if ((await chercherEtSelectionner(champCode)) > 0) { + champChoisi = champCode; + } if (!champChoisi) { ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, trace }); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, trace }); } - const valeurs = [...new Set(parChamp.get(champChoisi) ?? [])].slice(0, maxFieldMatches); - // ─── 3. Sélection des valeurs trouvées ─────────────────── - const gf = await rpc("GetField", { qFieldName: champChoisi }, doc); - const fh = (gf.qReturn as { qHandle: number }).qHandle; - await rpc("Clear", {}, fh); - await rpc("SelectValues", { - qFieldValues: valeurs.map((v) => ({ qText: v })), - qToggleMode: false, - qSoftLock: true, - }, fh); - trace.push("selection " + champChoisi + " = " + valeurs.length + " valeurs"); + // Trace des sélections réellement actives : si elle est vide, la + // sélection n'a pas pris côté Engine et le cube ci-dessous + // renverra tout le catalogue (le garde-fou de l'étape 4 le + // rattrape, mais c'est ici qu'on voit pourquoi). + try { + const cs = await rpc("CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-search-sel" }, + qSelectionObjectDef: {}, + }, + }, doc); + const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle); + const sel = (((csl.qLayout as { qSelectionObject?: { qSelections?: Array<{ qField?: string; qSelectedCount?: number; qTotal?: number }> } }) + .qSelectionObject?.qSelections) ?? []) + .map((s) => `${s.qField}:${s.qSelectedCount}/${s.qTotal}`); + trace.push("sélections actives=" + JSON.stringify(sel)); + } catch (e) { + trace.push("sélections actives: lecture impossible (" + String((e as Error)?.message || e) + ")"); + } - // ─── 4. Liste des articles correspondants ──────────────── + // ─── 3. Liste des articles correspondants ──────────────── // Cube sans mesure : uniquement l'identité de l'article. Les // métriques réseau sont extraites juste après par // fetchNetworkMetricsPlaywright (qui produit aussi le mensuel). @@ -256,7 +305,10 @@ export async function searchQlikArticles( if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } }); if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); const largeur = dims.length; - const hauteur = Math.min(limite, Math.floor(9000 / Math.max(largeur, 1))); + // On lit large puis on dédoublonne : un article référencé chez + // plusieurs fournisseurs produit autant de lignes que de + // fournisseurs, il ne doit pas consommer plusieurs places. + const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1))); const cube = await rpc("CreateSessionObject", { qProp: { @@ -278,19 +330,60 @@ export async function searchQlikArticles( }; }).qHyperCube; const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; - const rows: Array<[string, string, string]> = []; + + // ─── 4. Garde-fou : le résultat DOIT correspondre au terme ── + // Si la sélection Qlik n'a pas pris (droits, modèle de données, + // évaluation en retard), le cube renvoie le début du catalogue. + // On revérifie donc chaque ligne côté client : mots tous présents + // dans le libellé, ou code correspondant au terme. Mieux vaut + // zéro résultat qu'une liste sans rapport avec la recherche. + const normaliser = (s: string) => s + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); + const mots = normaliser(terme).split(" ").filter(Boolean); + const termeCode = terme.trim().toLowerCase(); + const correspond = (code: string, libelle: string): boolean => { + if (mots.length === 0) return true; + const c = code.toLowerCase(); + if (c === termeCode || c.startsWith(termeCode)) return true; + if (!libelle) return champChoisi === champCode; + const l = normaliser(libelle); + return mots.every((m) => l.includes(m)); + }; + + const parCode = new Map(); + let rejetees = 0; for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelle = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; + const libelleBrut = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; const idxFou = champLibelle ? 2 : 1; - const fournisseur = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; - rows.push([code, libelle === "-" ? "" : libelle, fournisseur === "-" ? "" : fournisseur]); + const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : ""; + const libelle = libelleBrut === "-" ? "" : libelleBrut; + const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut; + if (!correspond(code, libelle)) { rejetees++; continue; } + const existant = parCode.get(code); + if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; } + // Complète les champs manquants d'une ligne déjà vue. + if (!existant[1] && libelle) existant[1] = libelle; + if (!existant[2] && fournisseur) existant[2] = fournisseur; } - trace.push("articles=" + rows.length + "/" + hc.qSize.qcy); + const uniques = [...parCode.values()]; + const rows = uniques.slice(0, limite); + trace.push( + "cube=" + hc.qSize.qcy + " lignes lues=" + matrix.length + + " rejetées(hors terme)=" + rejetees + + " articles uniques=" + uniques.length + " retenus=" + rows.length, + ); ws.close(); - resolve({ ok: true, rows, champUtilise: champChoisi, total: hc.qSize.qcy, trace }); + resolve({ + ok: true, rows, champUtilise: champChoisi, total: uniques.length, + lues: matrix.length, rejetees, trace, + }); } catch (e) { try { ws.close(); } catch { /* noop */ } resolve({ ok: false, error: String((e as Error)?.message || e), trace }); @@ -303,7 +396,7 @@ export async function searchQlikArticles( token: csrfToken, terme: cleaned, limite: limit, - maxFieldMatches: MAX_FIELD_MATCHES, + settleMs, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), }, @@ -318,10 +411,22 @@ export async function searchQlikArticles( console.log( `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, ); + // Toutes les lignes écartées = Qlik a bien répondu mais sans appliquer la + // sélection. On le dit au lieu d'afficher un « aucun résultat » trompeur. + const filtreNonApplique = matches.length === 0 && (result.rejetees ?? 0) > 0; + if (filtreNonApplique) { + console.warn( + `[qlik-search] filtre non appliqué : ${result.rejetees} ligne(s) lues sans rapport avec "${cleaned}" — vérifier le champ ${result.champUtilise} et les traces ci-dessus`, + ); + } + return { matches, champUtilise: result.champUtilise ?? null, tronque: (result.total ?? 0) > matches.length, + avertissement: filtreNonApplique + ? "Qlik a répondu sans appliquer le filtre de recherche : les articles renvoyés ne correspondaient pas au terme et ont été écartés. Voir les logs [qlik-search]." + : null, }; } finally { await ctx.close();