From fa1b64cfa6b15043486b30930b5a3056bd6cfa70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 15:44:22 +0000 Subject: [PATCH] Qlik: selection par code fournisseur au lieu de 41 569 codes articles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le code fournisseur FF de la base SQL/API existe aussi dans Qlik : une seule valeur y designe le meme perimetre que les dizaines de milliers de codes articles. Comme SelectValues sur « Article Code » coute 8 a 100 s par appel et doit etre refait a chaque passe annuelle, la bascule supprime la partie la plus couteuse de la sync. - ETAPE 0 : selectionnerParFournisseur() avant choisirPeriode(). - Essaie QLIK_FIELD_CODE_FOURNISSEUR puis code_fournisseur, Fournisseur, fournisseur_code_centrale. - Verifie la couverture reelle (Article Code visibles vs codes demandes) et n'accepte la bascule qu'au-dela de QLIK_SUPPLIER_MIN_COVERAGE (0.99 par defaut) ; sinon annule la selection et revient aux codes. - Quand la bascule est retenue, plus aucun SelectValues sur Article Code (passes annuelles et agregation par expressions). - La sync fournisseur transmet job.fournisseur ; la sync produit (un seul code centrale) reste inchangee. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 35 +++++++ src/app/api/qlik/sync/route.ts | 6 +- src/lib/qlik-playwright.ts | 181 ++++++++++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 14 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index 2f7fc6a..f05a8a4 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -330,6 +330,38 @@ sélection de codes, puis seule la fenêtre de dates change d'un mois à l'autre La sonde de diagnostic de fin de sync a été retirée : elle refaisait une sélection complète des codes et des 365 jours pour rien. +### Sélection PAR FOURNISSEUR (ÉTAPE 0) + +Le code fournisseur FF présent en base SQL / API (ex. `J009`) existe aussi dans +Qlik. Une valeur y désigne le même périmètre que les dizaines de milliers de codes +articles — pour un fournisseur de 41 569 références, c'est **un** `SelectValues` +trivial au lieu d'un appel de 8 à 100 s **répété à chaque passe annuelle**. + +`selectionnerParFournisseur()` (in-page, avant `choisirPeriode()`) : + +1. essaie chaque champ candidat — `QLIK_FIELD_CODE_FOURNISSEUR`, puis + `code_fournisseur`, `Fournisseur`, `fournisseur_code_centrale` ; +2. `SelectValues` le code fournisseur (soft lock) ; une valeur inconnue passe au + champ suivant ; +3. **vérifie la couverture** : lit les Article Code visibles sous cette sélection + (cube paginé) et compare aux codes demandés ; +4. ne retient la bascule que si la couverture atteint `QLIK_SUPPLIER_MIN_COVERAGE` + (défaut **0.99**, borné à [0.5, 1]). Sinon la sélection est annulée et on + revient à la sélection par codes. + +Ce contrôle est indispensable : un article rattaché à plusieurs fournisseurs, ou +un référencement Qlik différent du référencement FF, doit faire **revenir aux +codes** plutôt que perdre silencieusement des articles. + +Quand la bascule est retenue (`parFournisseur = true`), plus **aucun** +`SelectValues` sur « Article Code » n'est émis — ni par les passes annuelles, ni +par l'agrégation directe par expressions. Le périmètre fournisseur survit aux +sélections `Année` et `Date`, qui portent sur d'autres champs. Le repli par lots +de codes reste disponible si l'Engine refuse un cube. + +En mode produit (sync d'un seul code centrale), aucun fournisseur n'est transmis : +la sélection reste par code. + ### Aucun résultat partiel dans le cache Le script in-page conserve des points de contrôle à des fins de diagnostic, mais @@ -396,6 +428,9 @@ QLIK_DIM_CODE_ARTICLE_ID=fcd239e5-288b-4830-a047-0e3d7665d971 QLIK_MEAS_CA_ID=43a76088-86fa-402e-a80e-0efd7701b3e1 QLIK_MEAS_QTE_ID=7b40caf1-be4b-4811-8d45-50acde33e715 QLIK_MEAS_NBMAG_ID=8b63fae5-db2f-4e4c-8618-f3e9d60b6b3b +# Sélection par fournisseur (facultatif — auto-détection sinon) : +QLIK_FIELD_CODE_FOURNISSEUR= # champ Qlik portant le code fournisseur FF +QLIK_SUPPLIER_MIN_COVERAGE=0.99 # couverture minimale exigée pour basculer ``` ## RESTE À FAIRE diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 33e217e..5614041 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -241,7 +241,11 @@ async function runJob(job: QlikSyncJob): Promise { console.log( `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — 12 mois complets ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin})…`, ); - const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter); + // Le code fournisseur est transmis en plus des codes : s'il désigne dans + // Qlik le même périmètre (couverture vérifiée côté extracteur), une seule + // sélection remplace les dizaines de milliers de `SelectValues` par code. + // Les codes restent le repli et le contrôle de couverture. + const metrics = await fetchNetworkMetricsPlaywright(codes, undefined, dateFilter, job.fournisseur); job.fetched = metrics.size; console.log(`[api/qlik/sync] job=${job.jobId} ← Qlik a renvoyé ${metrics.size} produits réseau`); const count = await upsertNetworkMetrics([...metrics.values()]); diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1d42fa4..1aa96f6 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -90,6 +90,19 @@ export async function fetchNetworkMetricsPlaywright( codeCentraux?: string[], cfg: QlikConfig = getQlikConfig(), dateFilter: QlikDateFilter | null = buildGridNetworkQlikDateFilter(), + /** + * Code fournisseur FF (ex. `J009`), tel qu'il figure dans la base SQL / l'API. + * + * Quand il est fourni, on sélectionne LE fournisseur côté Qlik au lieu des + * dizaines de milliers de codes articles : `SelectValues` sur « Article Code » + * coûte 8 à 100 s par appel quel que soit le nombre de valeurs (le moteur + * balaie un symbole de plus d'un million d'entrées), et il faut le refaire à + * chaque passe annuelle. + * + * La bascule n'est retenue que si la sélection fournisseur **couvre** les + * codes demandés — sinon on revient à la sélection par codes. + */ + fournisseur?: string, ): Promise> { if (!cfg.appNetwork) throw new Error("[qlik-pw] QLIK_APP_NETWORK manquant"); if (!cfg.user || !cfg.password) throw new Error("[qlik-pw] identifiants Qlik manquants"); @@ -139,6 +152,16 @@ export async function fetchNetworkMetricsPlaywright( // FILTRE réellement les faits (total non nul et strictement inférieur au total // sans filtre) : personne n'avait pu le vérifier tant que seules des mesures // insensibles à la sélection étaient utilisées. + // Champs Qlik susceptibles de porter le CODE fournisseur (et non son libellé) : + // c'est `code_fournisseur` qui remontait des valeurs de la forme « F005 », + // « A0491 » — même format que les codes de la base FF. + const qlikSupplierFields = [ + (process.env.QLIK_FIELD_CODE_FOURNISSEUR ?? "").trim(), + "code_fournisseur", "Fournisseur", "fournisseur_code_centrale", + ].filter((c, i, arr) => c && arr.indexOf(c) === i); + /** Part des codes demandés que la sélection fournisseur doit couvrir pour être retenue. */ + const qlikSupplierMinCoverage = Math.min(1, Math.max(0.5, Number(process.env.QLIK_SUPPLIER_MIN_COVERAGE ?? "0.99") || 0.99)); + const qlikDateFields = [ (process.env.QLIK_DATE_FIELD ?? "").trim(), "Date", "Date calendrier", "Date_Key", @@ -208,7 +231,7 @@ export async function fetchNetworkMetricsPlaywright( }); const evaluer = () => page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN, useExpr, exprQte, exprCa, exprNbMag, exprMarge, champsDateCandidats, fournisseurCode, champsFournisseurCandidats, couvertureFournisseurMin }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; codes: string[]; token: string; monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[]; ymd: number[] }>; @@ -227,6 +250,9 @@ export async function fetchNetworkMetricsPlaywright( exprNbMag: string; exprMarge: string; champsDateCandidats: string[]; + fournisseurCode: string; + champsFournisseurCandidats: string[]; + couvertureFournisseurMin: number; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -772,6 +798,114 @@ export async function fetchNetworkMetricsPlaywright( * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée * qui décide. */ + /** + * Vrai quand le périmètre articles est posé par une sélection + * fournisseur : dans ce cas, plus aucun `SelectValues` sur + * « Article Code » n'est émis (ni par passe annuelle, ni par + * l'agrégation directe) — c'est tout l'intérêt de la bascule. + */ + let parFournisseur = false; + + /** + * Sélectionne LE fournisseur plutôt que ses dizaines de milliers de + * codes articles. + * + * `SelectValues` sur « Article Code » coûte 8 à 100 s **par appel**, + * quasi indépendamment du nombre de valeurs — le moteur balaie un + * symbole de plus d'un million d'entrées — et il faut le refaire à + * chaque passe annuelle. Une valeur de fournisseur produit le même + * périmètre en un appel trivial. + * + * La bascule n'est retenue QUE si la sélection couvre les codes + * demandés : un article rattaché à plusieurs fournisseurs, ou un + * référencement Qlik différent du référencement FF, doit faire + * revenir à la sélection par codes plutôt que perdre des articles. + */ + const selectionnerParFournisseur = async (): Promise => { + if (!fournisseurCode || codes.length === 0) return false; + const attendus = new Set(codes.map((c) => c.trim())); + + /** Codes articles visibles sous la sélection courante. */ + const lireCodesVisibles = async (): Promise> => { + const vus = new Set(); + const o = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-codes-fou" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: dim, qNullSuppression: true }], + qMeasures: [], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 5000 }], + }, + } }, doc); + const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + try { + const lay = await rpc("GetLayout", {}, ret.qHandle); + const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array> }> } }).qHyperCube; + const total = Number(hc?.qSize?.qcy) || 0; + for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + for (let top = vus.size ? 5000 : 0; top < total; top += 5000) { + const d = await rpc("GetHyperCubeData", { + qPath: "/qHyperCubeDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(5000, total - top) }], + }, ret.qHandle); + const matrix = (d.qDataPages as Array<{ qMatrix?: Array> }>)?.[0]?.qMatrix ?? []; + if (matrix.length === 0) break; + for (const r of matrix) { + const c = String(r[0]?.qText ?? "").trim(); + if (c && c !== "-") vus.add(c); + } + } + return vus; + } finally { + const id = String(ret.qGenericId ?? ret.qId ?? ""); + if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } } + } + }; + + for (const champ of champsFournisseurCandidats) { + let h = -1; + try { + const gf = await rpc("GetField", { qFieldName: champ }, doc); + h = (gf.qReturn as { qHandle: number }).qHandle; + if (typeof h !== "number" || h < 0) continue; + } catch { continue; } + + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + const sel = await rpc("SelectValues", { + qFieldValues: [{ qText: fournisseurCode }], + qToggleMode: false, + qSoftLock: true, + }, h); + await sleep(qlikSettleMs); + if (sel.qReturn === false) { + diag(" champ « " + champ + " » : valeur « " + fournisseurCode + " » inconnue"); + await rpc("Clear", {}, h); + continue; + } + + const visibles = await lireCodesVisibles(); + let couverts = 0; + for (const c of attendus) if (visibles.has(c)) couverts++; + const couverture = attendus.size > 0 ? couverts / attendus.size : 0; + diag( + " champ « " + champ + " » = « " + fournisseurCode + " » → " + visibles.size + + " articles Qlik, couvre " + couverts + "/" + attendus.size + + " codes demandés (" + Math.round(couverture * 100) + "%)", + ); + if (couverture >= couvertureFournisseurMin) { + diag("sélection PAR FOURNISSEUR retenue sur « " + champ + " » — " + attendus.size + " SelectValues économisés par passe"); + return true; + } + await rpc("Clear", {}, h); + await sleep(qlikSettleMs); + } + diag("aucun champ fournisseur ne couvre les codes demandés → sélection par codes articles"); + return false; + }; + const choisirPeriode = async (): Promise => { const fenetre = new Set(monthlyPayload.map((m) => m.label)); let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; @@ -947,8 +1081,10 @@ export async function fetchNetworkMetricsPlaywright( if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); } // Les codes d'abord : les totaux de contrôle portent alors sur - // le même périmètre que l'extraction. - if (codes.length && fh !== -1) { + // le même périmètre que l'extraction. Sauf si le fournisseur + // pose déjà ce périmètre — le re-sélectionner par codes ne + // changerait rien au résultat et coûterait une minute. + if (codes.length && fh !== -1 && !parFournisseur) { await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); await sleep(qlikSettleMs); await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { @@ -1232,21 +1368,28 @@ export async function fetchNetworkMetricsPlaywright( // pages, elle, coûte ~50 ms. Repli sur les lots si l'Engine // refuse (cube trop gros, code 15, saturation mémoire). const marqueOut = out.length; - if (codes.length && fh !== -1) { + if ((codes.length || parFournisseur) && fh !== -1) { const timingsAll: Record = {}; try { - await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); - await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { - qFieldValues: codes.map((c) => ({ qText: c })), - qToggleMode: false, - qSoftLock: true, - }, fh), timingsAll); - await sleep(qlikSettleMs); + // Le périmètre fournisseur est posé une fois pour toutes + // avant la première passe : il survit aux sélections + // Année/Date, qui portent sur d'autres champs. + if (!parFournisseur) { + await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), timingsAll); + await sleep(qlikSettleMs); + await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + qFieldValues: codes.map((c) => ({ qText: c })), + qToggleMode: false, + qSoftLock: true, + }, fh), timingsAll); + await sleep(qlikSettleMs); + } const got = await timed("cube", "all", () => fetchMonthCube(onRowMois, timingsAll), timingsAll); aggBatches++; console.log( - "[qlik-pw][timing] (mois) chemin rapide — " + codes.length + " codes en 1 sélection, rows=" + got.value + + "[qlik-pw][timing] (mois) chemin rapide — " + + (parFournisseur ? "périmètre fournisseur (0 sélection code)" : codes.length + " codes en 1 sélection") + + ", rows=" + got.value + " cumul=" + out.length + " timings=" + timingSummary(timingsAll), ); return; @@ -1388,6 +1531,15 @@ export async function fetchNetworkMetricsPlaywright( }; if (!noDateMode) { + // ÉTAPE 0 — poser le périmètre articles, une fois pour toutes. + // + // Le code fournisseur FF (base SQL / API) existe aussi dans Qlik : + // une seule valeur y désigne le même périmètre que les dizaines de + // milliers de codes articles qu'on envoyait jusqu'ici. La bascule + // n'est retenue que si la couverture mesurée le justifie, sinon on + // retombe sur la sélection par codes (cf. selectionnerParFournisseur). + parFournisseur = await selectionnerParFournisseur(); + // ÉTAPE 1 — caler le champ `Période` du modèle sur la fenêtre. // // Toutes les mesures de l'app sont bornées par `Type_Cal` @@ -1624,6 +1776,9 @@ export async function fetchNetworkMetricsPlaywright( exprNbMag: qlikExprNbMag, exprMarge: qlikExprMarge, champsDateCandidats: qlikDateFields, + fournisseurCode: (fournisseur ?? "").trim(), + champsFournisseurCandidats: qlikSupplierFields, + couvertureFournisseurMin: qlikSupplierMinCoverage, }, );