mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-12 01:36:43 +02:00
fix(qlik-search): la sélection ne s'appliquait pas, les résultats ne correspondaient pas au terme
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
3 files changed
+177
-64
No files matched your search
@@ -159,10 +159,15 @@ export function ProduitResults({ query }: { query: string }) {
|
||||
|
||||
{source === "qlik" && qlikError && (
|
||||
<div
|
||||
className="rounded-xl p-3 text-[12px]"
|
||||
className="rounded-xl p-3 text-[12px] flex items-start gap-2"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
Articles trouvés, mais l'extraction des mesures réseau a échoué : {qlikError}
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" style={{ color: "var(--accent-error)" }} />
|
||||
<span>
|
||||
{rows.length === 0
|
||||
? qlikError
|
||||
: `Articles trouvés, mais l'extraction des mesures réseau a échoué : ${qlikError}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -116,7 +116,10 @@ async function executerRecherche(term: string): Promise<ProduitRechercheResultat
|
||||
return {
|
||||
rows: [],
|
||||
source: "qlik",
|
||||
qlikError: null,
|
||||
// `avertissement` n'est renseigné que si Qlik a répondu sans appliquer
|
||||
// le filtre : l'utilisateur doit savoir que ce n'est pas « pas de
|
||||
// produit » mais « recherche non appliquée ».
|
||||
qlikError: qlik.avertissement,
|
||||
tronque: false,
|
||||
champUtilise: qlik.champUtilise,
|
||||
locauxHorsReseau: locaux,
|
||||
|
||||
+166
-61
@@ -15,11 +15,16 @@
|
||||
* 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 globale (`SearchResults`) : elle
|
||||
* renvoie, pour chaque champ, les valeurs qui matchent le terme. On sélectionne
|
||||
* ensuite ces valeurs dans leur champ, ce qui restreint la dimension
|
||||
* « Article Code » aux articles correspondants — sans avoir à connaître à
|
||||
* l'avance le nom exact du champ libellé, qui varie selon les apps.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import "server-only";
|
||||
@@ -42,6 +47,12 @@ export interface QlikSearchOutcome {
|
||||
champUtilise: string | null;
|
||||
/** true si Qlik a renvoyé plus de résultats que la limite demandée. */
|
||||
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.
|
||||
*/
|
||||
avertissement: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +64,6 @@ export interface QlikSearchOutcome {
|
||||
*/
|
||||
export const QLIK_SEARCH_MAX_RESULTS = 40;
|
||||
|
||||
/** Nombre maximum de valeurs de champ sélectionnées suite à la recherche. */
|
||||
const MAX_FIELD_MATCHES = 400;
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
async function getBrowser(): Promise<Browser> {
|
||||
@@ -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<QlikSearchOutcome> {
|
||||
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<InPageSearchResult>((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<void>((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<string, string[]>();
|
||||
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<number> => {
|
||||
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<string, [string, string, string]>();
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user