mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
fix(qlik-search): OU implicite entre les mots, cube abandonné, mauvais champs
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
3 files changed
+216
-114
No files matched your search
@@ -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
|
le réseau référence bien plus de produits que Nancy, et ce sont précisément
|
||||||
ceux-là qu'on veut voir.
|
ceux-là qu'on veut voir.
|
||||||
|
|
||||||
1. `searchQlikArticles()` — recherche globale Engine (`SearchResults`) restreinte
|
1. `searchQlikArticles()` — recherche de list object sur le champ libellé
|
||||||
aux champs article (code + libellé, détectés dynamiquement via `FieldList`).
|
article. `SearchListObjectFor` applique un **OU** entre les mots (« tapis
|
||||||
Les valeurs trouvées sont sélectionnées dans leur champ, ce qui restreint la
|
anti » ramenait 17 696 libellés sur l'app FF, et sélectionner ces 17 696
|
||||||
dimension « Article Code » aux articles correspondants.
|
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
|
2. `fetchNetworkMetricsPlaywright()` — mesures réseau + détail mensuel sur la
|
||||||
fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()`
|
fenêtre 12 mois glissants (mois en cours exclu), puis `upsertNetworkMetrics()`
|
||||||
pour que la fiche produit soit immédiatement servie depuis le cache.
|
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
|
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"`). Résultats mis en cache mémoire 10 min.
|
||||||
|
|
||||||
Les noms des champs libellé / fournisseur varient d'une app Qlik à l'autre : ils
|
Champs de l'app FF (« Magasins Vision Consolidée ») : le code est `Article Code`,
|
||||||
sont détectés par heuristique, et l'inventaire complet des champs est loggué
|
le libellé `Article` (repli `article_libelle_ticket`, qui est le libellé ticket
|
||||||
(`[qlik-search] champs disponibles=…`). Si la détection tombe à côté, forcer :
|
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=<nom exact du champ libellé>
|
QLIK_FIELD_ARTICLE_LIBELLE=<nom exact du champ libellé>
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ export async function rechercherProduits(
|
|||||||
const p = executerRecherche(cleaned).finally(() => enCours.delete(key));
|
const p = executerRecherche(cleaned).finally(() => enCours.delete(key));
|
||||||
enCours.set(key, p);
|
enCours.set(key, p);
|
||||||
const result = await 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;
|
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')) {
|
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.";
|
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")) {
|
if (lower.includes("identifiants qlik manquants") || lower.includes("qlik_user")) {
|
||||||
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
return "Identifiants Qlik non configurés (Paramètres → Qlik).";
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-106
@@ -15,16 +15,23 @@
|
|||||||
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
|
* websocket Engine in-page avec le qlik-csrf-token), car le proxy Qlik refuse
|
||||||
* un websocket « raw » côté serveur.
|
* un websocket « raw » côté serveur.
|
||||||
*
|
*
|
||||||
* L'API Engine utilisée est la recherche de **list object**
|
* ## Comment la sélection est construite
|
||||||
* (`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
|
* La recherche d'un list object Qlik (`SearchListObjectFor`) applique un **OU**
|
||||||
* sélection ne prend pas, le cube renvoie le début du catalogue, et une liste
|
* entre les mots : « tapis anti » a ramené 17 696 libellés sur l'app FF, dont
|
||||||
* sans rapport avec la recherche est pire que pas de résultat du tout.
|
* 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";
|
import "server-only";
|
||||||
@@ -49,8 +56,8 @@ export interface QlikSearchOutcome {
|
|||||||
tronque: boolean;
|
tronque: boolean;
|
||||||
/**
|
/**
|
||||||
* Message de diagnostic quand la recherche aboutit à zéro article alors que
|
* 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é
|
* Qlik a bien répondu (filtre non appliqué, terme trop large…). `null` en
|
||||||
* Engine et le garde-fou a tout écarté. `null` en fonctionnement nominal.
|
* fonctionnement nominal.
|
||||||
*/
|
*/
|
||||||
avertissement: string | null;
|
avertissement: string | null;
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,18 @@ export interface QlikSearchOutcome {
|
|||||||
*/
|
*/
|
||||||
export const QLIK_SEARCH_MAX_RESULTS = 40;
|
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<Browser> | null = null;
|
let browserPromise: Promise<Browser> | null = null;
|
||||||
|
|
||||||
async function getBrowser(): Promise<Browser> {
|
async function getBrowser(): Promise<Browser> {
|
||||||
@@ -83,11 +102,14 @@ interface InPageSearchResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
rows?: Array<[string, string, string]>;
|
rows?: Array<[string, string, string]>;
|
||||||
champUtilise?: string | null;
|
champUtilise?: string | null;
|
||||||
|
/** Nombre d'articles uniques correspondant au terme (avant plafonnement). */
|
||||||
total?: number;
|
total?: number;
|
||||||
/** Lignes brutes lues dans le cube (avant garde-fou). */
|
/** Lignes brutes lues dans le cube (avant garde-fou). */
|
||||||
lues?: number;
|
lues?: number;
|
||||||
/** Lignes écartées parce qu'elles ne correspondaient pas au terme. */
|
/** Lignes écartées parce qu'elles ne correspondaient pas au terme. */
|
||||||
rejetees?: number;
|
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. */
|
/** Journal compact des étapes, remonté dans les logs serveur. */
|
||||||
trace?: string[];
|
trace?: string[];
|
||||||
}
|
}
|
||||||
@@ -128,7 +150,12 @@ export async function searchQlikArticles(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await ctx.newPage();
|
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 csrfToken: string | null = null;
|
||||||
let resolveToken: (() => void) | 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é ?)");
|
if (!csrfToken) throw new Error("[qlik-search] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||||
|
|
||||||
const result = (await page.evaluate(
|
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;
|
app: string; dim: string; token: string; terme: string; limite: number; settleMs: number;
|
||||||
|
champsLibellePreferes: string[]; champsFournisseurPreferes: string[];
|
||||||
champLibelleForce: string; champFournisseurForce: string;
|
champLibelleForce: string; champFournisseurForce: string;
|
||||||
}) =>
|
}) =>
|
||||||
new Promise<InPageSearchResult>((resolve) => {
|
new Promise<InPageSearchResult>((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 loc = (window as unknown as { location: Location }).location;
|
||||||
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
const url = `wss://${loc.host}/app/${app}?reloadUri=${encodeURIComponent(loc.href)}&qlik-csrf-token=${token}`;
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
@@ -174,16 +208,39 @@ export async function searchQlikArticles(
|
|||||||
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise<void>((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 () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
await ready;
|
await ready;
|
||||||
const open = await rpc("OpenDoc", { qDocName: app });
|
const open = await rpc("OpenDoc", { qDocName: app });
|
||||||
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
||||||
trace.push("OpenDoc ok");
|
|
||||||
|
|
||||||
// ─── 1. Inventaire des champs ────────────────────────────
|
// ─── 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", {
|
const fl = await rpc("CreateSessionObject", {
|
||||||
qProp: {
|
qProp: {
|
||||||
qInfo: { qType: "FieldList" },
|
qInfo: { qType: "FieldList" },
|
||||||
@@ -193,70 +250,105 @@ export async function searchQlikArticles(
|
|||||||
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
|
const fll = await rpc("GetLayout", {}, (fl.qReturn as { qHandle: number }).qHandle);
|
||||||
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
|
const champs = ((((fll.qLayout as { qFieldList?: { qItems?: Array<{ qName: string }> } }).qFieldList?.qItems) ?? [])
|
||||||
.map((f) => f.qName));
|
.map((f) => f.qName));
|
||||||
trace.push("champs=" + champs.length);
|
|
||||||
|
|
||||||
const has = (nom: string, re: RegExp) => re.test(nom);
|
const has = (nom: string, re: RegExp) => re.test(nom);
|
||||||
const champCode = champs.find((c) => c === "Article Code")
|
const champCode = champs.find((c) => c === "Article Code")
|
||||||
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
|
?? champs.find((c) => has(c, /article/i) && has(c, /code/i))
|
||||||
?? "Article Code";
|
?? "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` /
|
// `SearchListObjectFor` applique un OU entre les mots. On
|
||||||
// `AcceptListObjectSearch` : c'est exactement ce que fait un
|
// cherche donc mot par mot pour retenir le plus sélectif,
|
||||||
// volet de filtre Qlik quand on tape dans sa zone de recherche.
|
// puis on filtre en ET côté client avant de sélectionner.
|
||||||
// Qlik applique lui-même sa sémantique (mots multiples = tous
|
const chercherEtSelectionner = async (champ: string): Promise<{ retenues: number; brut: number; tropLarge: boolean }> => {
|
||||||
// présents, insensible casse/accents) et sélectionne les valeurs
|
const lo = await rpcRetry("CreateSessionObject", {
|
||||||
// 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));
|
|
||||||
|
|
||||||
const chercherEtSelectionner = async (champ: string): Promise<number> => {
|
|
||||||
const lo = await rpc("CreateSessionObject", {
|
|
||||||
qProp: {
|
qProp: {
|
||||||
qInfo: { qType: "cf-search-lb" },
|
qInfo: { qType: "cf-search-lb" },
|
||||||
qListObjectDef: {
|
qListObjectDef: {
|
||||||
qDef: { qFieldDefs: [champ] },
|
qDef: { qFieldDefs: [champ] },
|
||||||
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }],
|
qInitialDataFetch: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, doc);
|
}, doc);
|
||||||
const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
const ret = lo.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
|
||||||
const loId = String(ret.qGenericId ?? ret.qId ?? "");
|
const loId = String(ret.qGenericId ?? ret.qId ?? "");
|
||||||
|
const lh = ret.qHandle;
|
||||||
try {
|
try {
|
||||||
await rpc("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: terme }, ret.qHandle);
|
// 2a. Mot le plus sélectif (une recherche par mot, on ne lit que le compteur).
|
||||||
await sleep(settleMs);
|
let meilleurMot = terme;
|
||||||
const lay = await rpc("GetLayout", {}, ret.qHandle);
|
let meilleurNb = Number.POSITIVE_INFINITY;
|
||||||
const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0;
|
for (const mot of (mots.length > 1 ? mots : [terme])) {
|
||||||
trace.push("recherche " + champ + " → " + nb + " valeur(s)");
|
await rpcRetry("SearchListObjectFor", { qPath: "/qListObjectDef", qMatch: mot }, lh);
|
||||||
if (nb > 0) {
|
const lay = await rpcRetry("GetLayout", {}, lh);
|
||||||
await rpc("AcceptListObjectSearch", {
|
const nb = ((lay.qLayout as { qListObject?: { qSize?: { qcy?: number } } }).qListObject?.qSize?.qcy) ?? 0;
|
||||||
qPath: "/qListObjectDef",
|
trace.push(" « " + mot + " » dans " + champ + " → " + nb + " valeur(s)");
|
||||||
qToggleMode: false,
|
if (nb > 0 && nb < meilleurNb) { meilleurNb = nb; meilleurMot = mot; }
|
||||||
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;
|
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<Array<{ qText?: string; qElemNumber?: number }>> }>)?.[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 {
|
} finally {
|
||||||
if (loId) { try { await rpc("DestroySessionObject", { qId: loId }, doc); } catch { /* noop */ } }
|
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 rpc("ClearAll", { qLockedAlso: false }, doc);
|
||||||
await sleep(settleMs);
|
await sleep(settleMs);
|
||||||
|
|
||||||
// Le libellé d'abord (cas courant), le code centrale ensuite.
|
|
||||||
let champChoisi: string | null = null;
|
let champChoisi: string | null = null;
|
||||||
if (champLibelle && (await chercherEtSelectionner(champLibelle)) > 0) {
|
let tropLarge = false;
|
||||||
champChoisi = champLibelle;
|
for (const candidat of candidatsLibelle) {
|
||||||
} else if ((await chercherEtSelectionner(champCode)) > 0) {
|
const r = await chercherEtSelectionner(candidat);
|
||||||
champChoisi = champCode;
|
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) {
|
if (!champChoisi) {
|
||||||
ws.close();
|
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
|
// Trace des sélections réellement actives : si elle est vide,
|
||||||
// sélection n'a pas pris côté Engine et le cube ci-dessous
|
// la sélection n'a pas pris côté Engine.
|
||||||
// renverra tout le catalogue (le garde-fou de l'étape 4 le
|
|
||||||
// rattrape, mais c'est ici qu'on voit pourquoi).
|
|
||||||
try {
|
try {
|
||||||
const cs = await rpc("CreateSessionObject", {
|
const cs = await rpc("CreateSessionObject", {
|
||||||
qProp: {
|
qProp: { qInfo: { qType: "cf-search-sel" }, qSelectionObjectDef: {} },
|
||||||
qInfo: { qType: "cf-search-sel" },
|
|
||||||
qSelectionObjectDef: {},
|
|
||||||
},
|
|
||||||
}, doc);
|
}, doc);
|
||||||
const csl = await rpc("GetLayout", {}, (cs.qReturn as { qHandle: number }).qHandle);
|
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 }> } })
|
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
|
// Cube sans mesure : uniquement l'identité de l'article. Les
|
||||||
// métriques réseau sont extraites juste après par
|
// métriques réseau sont extraites juste après par
|
||||||
// fetchNetworkMetricsPlaywright (qui produit aussi le mensuel).
|
// fetchNetworkMetricsPlaywright (qui produit aussi le mensuel).
|
||||||
|
const champLibelleAffiche = champChoisi === champCode ? candidatsLibelle[0] ?? null : champChoisi;
|
||||||
const dims: Array<{ qLibraryId?: string; qDef?: { qFieldDefs: string[] } }> = [{ qLibraryId: dim }];
|
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] } });
|
if (champFournisseur) dims.push({ qDef: { qFieldDefs: [champFournisseur] } });
|
||||||
const largeur = dims.length;
|
const largeur = dims.length;
|
||||||
// On lit large puis on dédoublonne : un article référencé chez
|
// 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.
|
// fournisseurs, il ne doit pas consommer plusieurs places.
|
||||||
const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1)));
|
const hauteur = Math.min(1000, Math.floor(9000 / Math.max(largeur, 1)));
|
||||||
|
|
||||||
const cube = await rpc("CreateSessionObject", {
|
const cube = await rpcRetry("CreateSessionObject", {
|
||||||
qProp: {
|
qProp: {
|
||||||
qInfo: { qType: "cf-search" },
|
qInfo: { qType: "cf-search" },
|
||||||
qHyperCubeDef: {
|
qHyperCubeDef: {
|
||||||
@@ -322,7 +419,7 @@ export async function searchQlikArticles(
|
|||||||
},
|
},
|
||||||
}, doc);
|
}, doc);
|
||||||
const ch = (cube.qReturn as { qHandle: number }).qHandle;
|
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 {
|
const hc = (layout.qLayout as {
|
||||||
qHyperCube: {
|
qHyperCube: {
|
||||||
qSize: { qcy: number };
|
qSize: { qcy: number };
|
||||||
@@ -332,18 +429,6 @@ export async function searchQlikArticles(
|
|||||||
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
|
const matrix = hc.qDataPages?.[0]?.qMatrix ?? [];
|
||||||
|
|
||||||
// ─── 4. Garde-fou : le résultat DOIT correspondre au terme ──
|
// ─── 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 termeCode = terme.trim().toLowerCase();
|
||||||
const correspond = (code: string, libelle: string): boolean => {
|
const correspond = (code: string, libelle: string): boolean => {
|
||||||
if (mots.length === 0) return true;
|
if (mots.length === 0) return true;
|
||||||
@@ -359,15 +444,14 @@ export async function searchQlikArticles(
|
|||||||
for (const r of matrix) {
|
for (const r of matrix) {
|
||||||
const code = String(r[0]?.qText ?? "").trim();
|
const code = String(r[0]?.qText ?? "").trim();
|
||||||
if (!code || code === "-") continue;
|
if (!code || code === "-") continue;
|
||||||
const libelleBrut = champLibelle ? String(r[1]?.qText ?? "").trim() : "";
|
const libelleBrut = champLibelleAffiche ? String(r[1]?.qText ?? "").trim() : "";
|
||||||
const idxFou = champLibelle ? 2 : 1;
|
const idxFou = champLibelleAffiche ? 2 : 1;
|
||||||
const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : "";
|
const fournisseurBrut = champFournisseur ? String(r[idxFou]?.qText ?? "").trim() : "";
|
||||||
const libelle = libelleBrut === "-" ? "" : libelleBrut;
|
const libelle = libelleBrut === "-" ? "" : libelleBrut;
|
||||||
const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut;
|
const fournisseur = fournisseurBrut === "-" ? "" : fournisseurBrut;
|
||||||
if (!correspond(code, libelle)) { rejetees++; continue; }
|
if (!correspond(code, libelle)) { rejetees++; continue; }
|
||||||
const existant = parCode.get(code);
|
const existant = parCode.get(code);
|
||||||
if (!existant) { parCode.set(code, [code, libelle, fournisseur]); continue; }
|
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[1] && libelle) existant[1] = libelle;
|
||||||
if (!existant[2] && fournisseur) existant[2] = fournisseur;
|
if (!existant[2] && fournisseur) existant[2] = fournisseur;
|
||||||
}
|
}
|
||||||
@@ -382,7 +466,7 @@ export async function searchQlikArticles(
|
|||||||
ws.close();
|
ws.close();
|
||||||
resolve({
|
resolve({
|
||||||
ok: true, rows, champUtilise: champChoisi, total: uniques.length,
|
ok: true, rows, champUtilise: champChoisi, total: uniques.length,
|
||||||
lues: matrix.length, rejetees, trace,
|
lues: matrix.length, rejetees, tropLarge, trace,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { ws.close(); } catch { /* noop */ }
|
try { ws.close(); } catch { /* noop */ }
|
||||||
@@ -397,6 +481,8 @@ export async function searchQlikArticles(
|
|||||||
terme: cleaned,
|
terme: cleaned,
|
||||||
limite: limit,
|
limite: limit,
|
||||||
settleMs,
|
settleMs,
|
||||||
|
champsLibellePreferes: CHAMPS_LIBELLE_PREFERES,
|
||||||
|
champsFournisseurPreferes: CHAMPS_FOURNISSEUR_PREFERES,
|
||||||
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
|
champLibelleForce: (process.env.QLIK_FIELD_ARTICLE_LIBELLE ?? "").trim(),
|
||||||
champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(),
|
champFournisseurForce: (process.env.QLIK_FIELD_FOURNISSEUR ?? "").trim(),
|
||||||
},
|
},
|
||||||
@@ -409,24 +495,25 @@ export async function searchQlikArticles(
|
|||||||
codeCentrale, libelle, fournisseur,
|
codeCentrale, libelle, fournisseur,
|
||||||
}));
|
}));
|
||||||
console.log(
|
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.
|
// Zéro article alors que Qlik a répondu : on explique pourquoi plutôt que
|
||||||
const filtreNonApplique = matches.length === 0 && (result.rejetees ?? 0) > 0;
|
// d'afficher un « aucun résultat » qui laisserait croire à une absence
|
||||||
if (filtreNonApplique) {
|
// réelle dans le réseau.
|
||||||
console.warn(
|
let avertissement: string | null = null;
|
||||||
`[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`,
|
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 {
|
return {
|
||||||
matches,
|
matches,
|
||||||
champUtilise: result.champUtilise ?? null,
|
champUtilise: result.champUtilise ?? null,
|
||||||
tronque: (result.total ?? 0) > matches.length,
|
tronque: (result.total ?? 0) > matches.length,
|
||||||
avertissement: filtreNonApplique
|
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]."
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
|
|||||||
Reference in new issue
Block a user