From 466cb53da2100b9a2e9040cf32e843d2b465ae90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 13:41:53 +0000 Subject: [PATCH] =?UTF-8?q?fix(qlik-search):=20OU=20implicite=20entre=20le?= =?UTF-8?q?s=20mots,=20cube=20abandonn=C3=A9,=20mauvais=20champs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les logs de production ont tranché les trois causes. 1. La recherche de list object Qlik applique un OU entre les mots : « tapis anti » ramenait 17 696 libellés sur 1 070 645 — presque tous ne contenant que « anti ». On cherche désormais mot par mot pour retenir le plus sélectif, on filtre en ET côté client (tous les mots présents, insensible casse/accents), et on ne sélectionne que les valeurs retenues. 2. Sélectionner ces 17 694 valeurs faisait abandonner le cube suivant (code 15 « Request aborted »), d'où le repli systématique sur le catalogue local. La sélection est maintenant plafonnée à 1 000 valeurs, faite par numéro d'élément (SelectListObjectValues) plutôt que par texte, et les appels Engine retentent trois fois sur code 15. 3. Les champs détectés étaient les mauvais : article_libelle_ticket (libellé ticket tronqué) au lieu d'Article, et code_fournisseur (le code, d'où les « F005 » affichés) au lieu de Fournisseur. Une liste de préférences précède désormais l'heuristique, avec repli automatique sur le candidat suivant si un champ ne ramène rien. Aussi : - Un repli catalogue n'est plus mis en cache 10 min : une panne Qlik passagère se corrigeait uniquement avec « Relancer ». - Le code 15 est traduit en message actionnable, et un terme trop large pour le moteur est signalé au lieu d'un « aucun résultat » trompeur. - Les logs de la page ne relaient plus le bruit du client Qlik (thèmes, extensions). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 21 +- src/features/produits/api/search-produits.ts | 10 +- src/lib/qlik-search.ts | 299 ++++++++++++------- 3 files changed, 216 insertions(+), 114 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index c418c25..2f202e3 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -22,10 +22,14 @@ La page `/produits` ne cherche **plus** dans le catalogue FF Nancy en premier : le réseau référence bien plus de produits que Nancy, et ce sont précisément ceux-là qu'on veut voir. -1. `searchQlikArticles()` — recherche globale Engine (`SearchResults`) restreinte - aux champs article (code + libellé, détectés dynamiquement via `FieldList`). - Les valeurs trouvées sont sélectionnées dans leur champ, ce qui restreint la - dimension « Article Code » aux articles correspondants. +1. `searchQlikArticles()` — recherche de list object sur le champ libellé + article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis + anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696 + valeurs faisait abandonner le cube en `code 15 — Request aborted`). On + cherche donc mot par mot pour retenir le plus sélectif, on filtre en **ET** + côté client, puis on sélectionne les valeurs retenues par numéro d'élément + (`SelectListObjectValues`). La sélection restreint la dimension « Article + Code » aux articles correspondants. 2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()` pour que la fiche produit soit immédiatement servie depuis le cache. @@ -35,9 +39,12 @@ ceux-là qu'on veut voir. 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. -Les noms des champs libellé / fournisseur varient d'une app Qlik à l'autre : ils -sont détectés par heuristique, et l'inventaire complet des champs est loggué -(`[qlik-search] champs disponibles=…`). Si la détection tombe à côté, forcer : +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 +tronqué), le fournisseur `Fournisseur` (repli `code_fournisseur`, qui ne porte +que le code). Ces préférences sont dans `CHAMPS_LIBELLE_PREFERES` / +`CHAMPS_FOURNISSEUR_PREFERES`, complétées par une heuristique pour les autres +apps. Si la détection tombe à côté, forcer : ``` QLIK_FIELD_ARTICLE_LIBELLE= diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 4d564fb..e29f1fb 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -77,7 +77,12 @@ export async function rechercherProduits( const p = executerRecherche(cleaned).finally(() => enCours.delete(key)); enCours.set(key, p); const result = await p; - cache.set(key, { at: Date.now(), result }); + // 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 }); + } return result; } @@ -240,6 +245,9 @@ function messageQlikLisible(message: string): string { if (lower.includes("out of memory") || lower.includes("not enough memory") || lower.includes('"code":6') || lower.includes('"code":3002')) { return "Serveur Qlik saturé : mémoire insuffisante pour charger l'application. Réessayez plus tard."; } + if (/"code"\s*:\s*15\b/.test(message) || lower.includes("request aborted")) { + return "Qlik a interrompu la requête (recherche trop large pour le moteur). Ajoutez un mot plus précis, puis relancez."; + } if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) { return "Identifiants Qlik non configurés (Paramètres → Qlik)."; } diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index ca57b12..7de7dd7 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -15,16 +15,23 @@ * websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse * un websocket « raw » côté serveur. * - * L'API Engine utilisée est la recherche de **list object** - * (`SearchListObjectFor` + `AcceptListObjectSearch`), c'est-à-dire exactement ce - * que fait un volet de filtre Qlik quand on tape dans sa zone de recherche : - * Qlik applique sa propre sémantique (tous les mots présents, insensible à la - * casse et aux accents) puis sélectionne les valeurs trouvées. La sélection - * restreint alors la dimension « Article Code » aux articles correspondants. + * ## Comment la sélection est construite * - * Le résultat est **revérifié côté client** avant d'être renvoyé : si la - * sélection ne prend pas, le cube renvoie le début du catalogue, et une liste - * sans rapport avec la recherche est pire que pas de résultat du tout. + * La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU** + * entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont + * l'immense majorité ne contient que « anti ». Sélectionner ces 17 696 valeurs + * faisait ensuite exploser le cube (`code 15 — Request aborted`). + * + * On procède donc en trois temps : + * 1. une recherche par mot, pour retenir le **plus sélectif** (celui qui + * ramène le moins de valeurs) ; + * 2. lecture de ces valeurs et filtrage **ET** côté client (tous les mots + * présents, insensible casse/accents) ; + * 3. sélection des seules valeurs retenues, **par numéro d'élément** + * (`SelectListObjectValues`) — exact, sans ré-appariement de texte. + * + * Le résultat final est revérifié une dernière fois avant d'être renvoyé : une + * liste sans rapport avec la recherche est pire que pas de résultat du tout. */ import "server-only"; @@ -49,8 +56,8 @@ export interface QlikSearchOutcome { tronque: boolean; /** * Message de diagnostic quand la recherche aboutit à zéro article alors que - * Qlik a bien renvoyé des lignes : le filtre n'a pas été appliqué côté - * Engine et le garde-fou a tout écarté. `null` en fonctionnement nominal. + * Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en + * fonctionnement nominal. */ avertissement: string | null; } @@ -64,6 +71,18 @@ export interface QlikSearchOutcome { */ export const QLIK_SEARCH_MAX_RESULTS = 40; +/** + * Champs candidats pour le libellé article, par ordre de préférence. + * + * Sur l'app FF, `Article` est le libellé métier et `article_libelle_ticket` le + * libellé (tronqué) imprimé sur le ticket. On essaie le premier qui donne des + * résultats : si un candidat ne matche rien, on passe au suivant. + */ +const CHAMPS_LIBELLE_PREFERES = ["Article", "article_libelle_ticket"]; + +/** Champs candidats pour le fournisseur, par ordre de préférence (nom avant code). */ +const CHAMPS_FOURNISSEUR_PREFERES = ["Fournisseur", "code_fournisseur"]; + let browserPromise: Promise | null = null; async function getBrowser(): Promise { @@ -83,11 +102,14 @@ interface InPageSearchResult { error?: string; rows?: Array<[string, string, string]>; champUtilise?: string | null; + /** Nombre d'articles uniques correspondant au terme (avant plafonnement). */ 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; + /** true si le terme ramenait trop de valeurs pour être traité intégralement. */ + tropLarge?: boolean; /** Journal compact des étapes, remonté dans les logs serveur. */ trace?: string[]; } @@ -128,7 +150,12 @@ export async function searchQlikArticles( try { const page = await ctx.newPage(); - page.on("console", (msg) => console.log(`[qlik-search][page] ${msg.text()}`)); + page.on("console", (msg) => { + // Le client Qlik est très bavard au chargement (thèmes, extensions) : + // on ne relaie que ce qui vient de notre script. + const t = msg.text(); + if (t.startsWith("[cf]")) console.log(`[qlik-search][page] ${t}`); + }); let csrfToken: string | null = null; let resolveToken: (() => void) | null = null; @@ -144,11 +171,18 @@ 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, settleMs, champLibelleForce, champFournisseurForce }: { + ({ app, dim, token, terme, limite, settleMs, champsLibellePreferes, champsFournisseurPreferes, champLibelleForce, champFournisseurForce }: { app: string; dim: string; token: string; terme: string; limite: number; settleMs: number; + champsLibellePreferes: string[]; champsFournisseurPreferes: string[]; champLibelleForce: string; champFournisseurForce: string; }) => new Promise((resolve) => { + /** Valeurs lues au maximum dans le champ recherché (2 pages Engine). */ + const MAX_VALEURS_LUES = 10000; + /** Valeurs sélectionnées au maximum — au-delà, le cube abandonne (code 15). */ + const MAX_VALEURS_SELECTION = 1000; + const PAGE_VALEURS = 5000; + const loc = (window as unknown as { location: Location }).location; const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`; const ws = new WebSocket(url); @@ -174,16 +208,39 @@ export async function searchQlikArticles( setTimeout(() => rej(new Error("ws timeout")), 30000); }); + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + /** Qlik Engine code 15 = « Request aborted » : requête entrée en collision avec une évaluation. */ + const estCode15 = (e: unknown) => /"code"\s*:\s*15\b/.test(String((e as Error)?.message ?? e)); + const rpcRetry = async (method: string, params: unknown, handle: number, tentatives = 3) => { + let derniere: unknown; + for (let i = 1; i <= tentatives; i++) { + try { + return await rpc(method, params, handle); + } catch (e) { + derniere = e; + if (!estCode15(e) || i === tentatives) throw e; + trace.push("code15 sur " + method + ", tentative " + i + "/" + tentatives); + await sleep(settleMs * (i + 1)); + } + } + throw derniere; + }; + + 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); + (async () => { try { await ready; const open = await rpc("OpenDoc", { qDocName: app }); const doc = (open.qReturn as { qHandle: number }).qHandle; - trace.push("OpenDoc ok"); // ─── 1. Inventaire des champs ──────────────────────────── - // Sert à repérer, sans connaître l'app par cœur, le champ - // libellé article et le champ fournisseur. const fl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "FieldList" }, @@ -193,70 +250,105 @@ export async function searchQlikArticles( const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle); const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? []) .map((f) => f.qName)); - trace.push("champs=" + champs.length); const has = (nom: string, re: RegExp) => re.test(nom); const champCode = champs.find((c) => c === "Article Code") ?? champs.find((c) => has(c, /article/i) && has(c, /code/i)) ?? "Article Code"; - // Les noms exacts varient selon l'app Qlik : détection par - // heuristique, surchargeable par variable d'environnement si - // l'app expose un intitulé inattendu (cf. les logs [qlik-search]). - const champLibelle = (champLibelleForce && champs.includes(champLibelleForce) ? champLibelleForce : null) - ?? champs.find((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)) - ?? champs.find((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)) - ?? null; - const champFournisseur = (champFournisseurForce && champs.includes(champFournisseurForce) ? champFournisseurForce : null) - ?? champs.find((c) => has(c, /fournisseur/i)) - ?? champs.find((c) => has(c, /(fabricant|supplier)/i)) - ?? null; - // Inventaire complet en trace : sans accès Qlik depuis le poste - // de dev, c'est le seul moyen de régler les surcharges ci-dessus. - trace.push("champs disponibles=" + JSON.stringify(champs)); - trace.push("code=" + champCode + " libelle=" + champLibelle + " fournisseur=" + champFournisseur); - // ─── 2. Sélection par recherche de champ ───────────────── + // Candidats libellé : surcharge d'env, puis noms connus de + // l'app FF, puis heuristique. On essaiera chacun dans l'ordre + // jusqu'à en trouver un qui ramène des valeurs. + const candidatsLibelle = [ + champLibelleForce, + ...champsLibellePreferes, + ...champs.filter((c) => has(c, /article/i) && has(c, /(libell|designation|désignation|denomination|dénomination)/i)), + ...champs.filter((c) => has(c, /(libell|designation|désignation)/i) && !has(c, /(magasin|region|région|famille|rayon|fournisseur)/i)), + ].filter((c, i, arr) => c && champs.includes(c) && arr.indexOf(c) === i); + + const champFournisseur = [ + champFournisseurForce, + ...champsFournisseurPreferes, + ...champs.filter((c) => has(c, /fournisseur/i)), + ...champs.filter((c) => has(c, /(fabricant|supplier)/i)), + ].find((c) => c && champs.includes(c)) ?? null; + + trace.push("code=" + champCode + " libellé candidats=" + JSON.stringify(candidatsLibelle) + " fournisseur=" + champFournisseur); + + // ─── 2. Recherche + sélection sur le champ le plus adapté ─ // - // 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)); - - const chercherEtSelectionner = async (champ: string): Promise => { - const lo = await rpc("CreateSessionObject", { + // `SearchListObjectFor` applique un OU entre les mots. On + // cherche donc mot par mot pour retenir le plus sélectif, + // puis on filtre en ET côté client avant de sélectionner. + const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => { + const lo = await rpcRetry("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search-lb" }, qListObjectDef: { qDef: { qFieldDefs: [champ] }, - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }], + qInitialDataFetch: [], }, }, }, doc); const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; const loId = String(ret.qGenericId ?? ret.qId ?? ""); + const lh = ret.qHandle; 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); + // 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur). + let meilleurMot = terme; + let meilleurNb = Number.POSITIVE_INFINITY; + for (const mot of (mots.length > 1 ? mots : [terme])) { + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh); + const lay = await rpcRetry("GetLayout", {}, lh); + const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0; + trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)"); + if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; } } - return nb; + if (!Number.isFinite(meilleurNb) || meilleurNb === 0) return { retenues: 0, brut: 0, tropLarge: false }; + + // 2b. Relecture des valeurs du mot retenu. On relance la + // recherche systématiquement : la boucle ci-dessus a pu + // laisser le list object sur un autre mot. + await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: meilleurMot }, lh); + const aLire = Math.min(meilleurNb, MAX_VALEURS_LUES); + const elems: number[] = []; + let lues = 0; + for (let top = 0; top < aLire; top += PAGE_VALEURS) { + const d = await rpcRetry("GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(PAGE_VALEURS, aLire - top) }], + }, lh); + const matrix = ((d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix) ?? []; + if (matrix.length === 0) break; + lues += matrix.length; + for (const ligne of matrix) { + const cell = ligne[0]; + const texte = String(cell?.qText ?? ""); + const elem = cell?.qElemNumber; + if (elem == null || elem < 0) continue; + // Filtre ET : tous les mots du terme présents. + const n = normaliser(texte); + if (!mots.every((m) => n.includes(m))) continue; + elems.push(elem); + if (elems.length >= MAX_VALEURS_SELECTION) break; + } + if (elems.length >= MAX_VALEURS_SELECTION) break; + } + trace.push(" mot retenu « " + meilleurMot + " » : " + lues + " valeur(s) lues → " + elems.length + " après filtre ET"); + if (elems.length === 0) { + return { retenues: 0, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; + } + + // 2c. Sélection par numéro d'élément : exact, aucun + // ré-appariement de texte possible. + await rpcRetry("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: elems, + qToggleMode: false, + qSoftLock: true, + }, lh); + await sleep(settleMs); + return { retenues: elems.length, brut: meilleurNb, tropLarge: meilleurNb > MAX_VALEURS_LUES }; } finally { if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } } } @@ -265,28 +357,32 @@ export async function searchQlikArticles( 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; + let tropLarge = false; + for (const candidat of candidatsLibelle) { + const r = await chercherEtSelectionner(candidat); + tropLarge = tropLarge || r.tropLarge; + if (r.retenues > 0) { champChoisi = candidat; break; } + await rpc("ClearAll", { qLockedAlso: false }, doc); + await sleep(settleMs); + } + if (!champChoisi) { + // Repli code centrale (terme saisi = un code). + const r = await chercherEtSelectionner(champCode); + if (r.retenues > 0) champChoisi = champCode; + tropLarge = tropLarge || r.tropLarge; } if (!champChoisi) { ws.close(); - return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, trace }); + return resolve({ ok: true, rows: [], champUtilise: null, total: 0, lues: 0, rejetees: 0, tropLarge, trace }); } + trace.push("champ retenu = " + champChoisi); - // 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). + // Trace des sélections réellement actives : si elle est vide, + // la sélection n'a pas pris côté Engine. try { const cs = await rpc("CreateSessionObject", { - qProp: { - qInfo: { qType: "cf-search-sel" }, - qSelectionObjectDef: {}, - }, + 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 }> } }) @@ -301,8 +397,9 @@ export async function searchQlikArticles( // 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). + const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi; const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }]; - if (champLibelle) dims.push({ qDef: { qFieldDefs: [champLibelle] } }); + if (champLibelleAffiche) dims.push({ qDef: { qFieldDefs: [champLibelleAffiche] } }); if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } }); const largeur = dims.length; // On lit large puis on dédoublonne : un article référencé chez @@ -310,7 +407,7 @@ export async function searchQlikArticles( // 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", { + const cube = await rpcRetry("CreateSessionObject", { qProp: { qInfo: { qType: "cf-search" }, qHyperCubeDef: { @@ -322,7 +419,7 @@ export async function searchQlikArticles( }, }, doc); const ch = (cube.qReturn as { qHandle: number }).qHandle; - const layout = await rpc("GetLayout", {}, ch); + const layout = await rpcRetry("GetLayout", {}, ch); const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; @@ -332,18 +429,6 @@ export async function searchQlikArticles( const matrix = hc.qDataPages?.[0]?.qMatrix ?? []; // ─── 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; @@ -359,15 +444,14 @@ export async function searchQlikArticles( for (const r of matrix) { const code = String(r[0]?.qText ?? "").trim(); if (!code || code === "-") continue; - const libelleBrut = champLibelle ? String(r[1]?.qText ?? "").trim() : ""; - const idxFou = champLibelle ? 2 : 1; + const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : ""; + const idxFou = champLibelleAffiche ? 2 : 1; 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; } @@ -382,7 +466,7 @@ export async function searchQlikArticles( ws.close(); resolve({ ok: true, rows, champUtilise: champChoisi, total: uniques.length, - lues: matrix.length, rejetees, trace, + lues: matrix.length, rejetees, tropLarge, trace, }); } catch (e) { try { ws.close(); } catch { /* noop */ } @@ -397,6 +481,8 @@ export async function searchQlikArticles( terme: cleaned, limite: limit, settleMs, + champsLibellePreferes: CHAMPS_LIBELLE_PREFERES, + champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES, champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(), champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(), }, @@ -409,24 +495,25 @@ export async function searchQlikArticles( codeCentrale, libelle, fournisseur, })); console.log( - `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total Qlik ${result.total ?? 0}, champ=${result.champUtilise}) en ${Date.now() - started} ms`, + `[qlik-search] "${cleaned}" → ${matches.length} article(s) (total ${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`, - ); + + // Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que + // d'afficher un « aucun résultat » qui laisserait croire à une absence + // réelle dans le réseau. + let avertissement: string | null = null; + if (matches.length === 0 && (result.rejetees ?? 0) > 0) { + avertissement = "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]."; + } else if (matches.length === 0 && result.tropLarge) { + avertissement = "Le terme est trop large pour Qlik (plus de 10 000 libellés correspondants). Ajoutez un mot plus précis."; } + if (avertissement) console.warn(`[qlik-search] ${avertissement}`); 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, + avertissement, }; } finally { await ctx.close();