diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index bc12504..5b9b779 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -152,6 +152,14 @@ Un seul cube `[Article Code, Mois]` couvre les 12 mois : ni passe N-1 ni rattrapage — c'est exactement ce qu'ils compensaient. Les totaux deviennent la **somme des 12 mois de la fenêtre**. +Sur l'app de production, les champs `Date`, `Date calendrier` et `Date_Key` +existent mais leur sélection ne réduit pas `Sum(quantite)` : ils restent à +100 % du total historique. L'extracteur essaie d'abord ces champs puis utilise +la dimension maître `Mois` comme chemin fiable. Il crée un list object, retrouve +les 12 valeurs `YYYY-MM`, les sélectionne par `qElemNumber`, puis exécute le +cube mensuel. Cette méthode ne dépend ni du champ sous-jacent ni de son format +dual et conserve le chemin rapide (une sélection articles + un cube paginé). + Garde-fous : « Quantité N » est incluse dans le cube pour **calibrage**, et le log compare les deux sur les mois de l'année en cours, seul périmètre où la master measure est juste : @@ -161,8 +169,20 @@ master measure est juste : ``` Un calibrage faible signale une expression à ajuster (filtre `flag_type_mvt`, -`ca_ttc` plutôt que `ca_ht`…). Quantité totale nulle ou erreur Engine → repli -automatique sur l'ancien chemin. `QLIK_USE_EXPR=0` le force. +`ca_ttc` plutôt que `ca_ht`…). Il n'existe plus de repli automatique vers les +master measures annuelles : quantité totale nulle, champ Date invalide, erreur +Engine ou somme incohérente font échouer la synchronisation et laissent le cache +précédent intact. + +Après sélection de la fenêtre, l'extracteur vérifie deux invariants avant tout +upsert : + +1. la somme de toutes les lignes `[Article Code, Mois]` est égale au total Qlik + sans dimension calculé avec les mêmes sélections ; +2. pour chaque article, la quantité réseau totale est exactement la somme de ses + 12 mois. Les mois sans ligne de faits sont alors seulement matérialisés à `0`. + +Une extraction interrompue ou partielle n'est jamais publiée. ### Valider les expressions sur le vrai serveur @@ -203,8 +223,10 @@ au total sans filtre — 0 % signifie que la sélection ne matche rien, 100 % qu n'a aucun effet. Candidats essayés dans l'ordre : `QLIK_DATE_FIELD` (si défini), `Date`, `Date calendrier`, `Date_Key` (celui-ci sélectionné au format `AAAAMMJJ`). -Si aucun ne filtre, l'extraction le dit et repart sur l'ancien chemin plutôt que -de produire une fenêtre fausse. +Si aucun ne filtre, l'extraction échoue explicitement plutôt que de produire une +fenêtre fausse. Chaque sélection d'un candidat rejeté est effacée avant l'essai +suivant ; autrement un premier candidat à zéro contaminait tous les contrôles +suivants. Avant cet échec, la dimension maître `Mois` est testée elle aussi. ## La passe de rattrapage a été supprimée @@ -217,7 +239,7 @@ qu'elle recopiait dans chaque mois manquant : d'où les plateaux identiques d'ao Un mois qu'on ne sait pas extraire doit rester **absent**, jamais rempli d'une valeur plausible. -## Mois vides de la fenêtre glissante (chemin de repli) +## Mois vides de la fenêtre glissante Les mesures « N » de l'app sont bornées à une année civile. Quand la fenêtre 12 mois glissants chevauche deux années (le cas 11 mois sur 12), le chemin @@ -227,22 +249,15 @@ que sur une seule année : les mois de l'année précédente ressortent vides. cours — d'où, en juillet 2026, un trou observé d'août à décembre 2025 sur **tous** les articles. -Trois garde-fous, dans `qlik-playwright.ts` : +Le chemin daté repose uniquement sur les expressions de faits et un cube unique +`[Article Code, Mois]`. Après validation de la fenêtre et du total, chaque +article reçoit exactement les 12 clés attendues. Une clé absente du cube signifie +alors réellement « aucun fait sur ce mois » et vaut `0`; avant cette validation, +aucun zéro n'est inventé. -1. La passe principale n'écrit plus de `0` pour un mois hors année N, et - « Quantité COMP » n'écrit rien quand elle est vide. Un mois **absent** peut - être rattrapé ; un mois **à 0** se lit comme « pas de vente » et masque le trou. -2. `rattraperMoisVides()` : après les passes N et N-1, tout mois vide pour - **tous** les articles est ré-extrait en ne sélectionnant **que ses dates** — - la mesure se résout alors sur la bonne année, quelle que soit l'écriture de - son set analysis. -3. Les expressions des master measures sont dumpées au log - (`[qlik-pw][dump] expression « Quantité N » = …`) : c'est la seule façon de - savoir comment la mesure est bornée. - -Contournement immédiat sans redéploiement : `QLIK_MONTH_DIM=0` repasse sur -l'itération mois par mois (une sélection de dates par mois, donc pas de trou), -au prix d'une extraction plus lente. +`QLIK_MONTH_DIM=0` ou `QLIK_USE_EXPR=0` désactive désormais un prérequis et fait +échouer la synchronisation datée : ces options ne peuvent plus réactiver un +chemin connu comme incorrect. ⚠️ Les données déjà en cache gardent leurs zéros : il faut relancer la sync Qlik pour les corriger. @@ -263,21 +278,19 @@ 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. -### Résultat partiel plutôt que rien +### Aucun résultat partiel dans le cache -Le script in-page pousse un point de contrôle (`cfCheckpoint`) après chaque passe. -Si la session meurt en cours de route, l'extraction repart de ce point au lieu de -tout perdre : les codes déjà traités ont des données **complètes** (la dimension -Mois livre tous leurs mois d'un coup), seul le reliquat manque. Le log signale -alors `⚠ résultat PARTIEL` — relancer la sync complète le reste. +Le script in-page conserve des points de contrôle à des fins de diagnostic, mais +une synchronisation datée interrompue est refusée intégralement. Le cache garde +sa dernière version complète jusqu'à la réussite d'une nouvelle extraction. ## Tendance réseau = 12 mois glissants stricts `computeNetworkTrend()` reconstruit sa fenêtre à partir de la date du jour : 12 mois complets, **mois en cours exclu** (partiel, il tirait la pente vers le bas). -Les mois de la fenêtre absents du cache valent 0 (produit non vendu) ; ceux -antérieurs au premier mois réellement extrait sont écartés au lieu d'être -inventés à 0 (extraction plus courte que 12 mois). +La tendance n'est affichée que si les 12 clés sont explicitement présentes dans +le cache. Les anciennes séries partielles de deux ou trois mois sont donc +refusées au lieu d'être présentées comme une tendance. Retirés : ranking (champs/query/colonnes), analyse IA (routes `/api/ai/*`, `bulk-ai-analyzer`, dossier `ai-copilot`), score (`score-engine.ts`, colonne score). diff --git a/src/app/api/qlik/sync/route.ts b/src/app/api/qlik/sync/route.ts index 3a075b3..33e217e 100644 --- a/src/app/api/qlik/sync/route.ts +++ b/src/app/api/qlik/sync/route.ts @@ -5,10 +5,7 @@ import { upsertNetworkMetrics } from "@/lib/qlik-network-cache"; import { pgGetArticlesByFournisseur } from "@/lib/pg-ff-client"; import { buildGridNetworkQlikDateFilter, - envMonthsBack, QLIK_MONTHS_BACK_DEFAULT, - QLIK_MONTHS_BACK_MAX, - QLIK_MONTHS_BACK_MIN, } from "@/lib/qlik-date-range"; // Tâche d'extraction Qlik potentiellement très longue (hypercube paginé). @@ -235,16 +232,14 @@ async function runJob(job: QlikSyncJob): Promise { return; } - // Fenêtre temporelle alignée sur la grille, éventuellement raccourcie via - // QLIK_SYNC_MONTHS_BACK (1..12, défaut 12). On logue la valeur effective - // pour audit. - const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + // Contrat métier strict : toujours 12 mois complets glissants. + // Une variable d'environnement ne doit pas pouvoir raccourcir la série. + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); job.periode = dateFilter.label; job.dateDebut = dateFilter.dateDebut; job.dateFin = dateFilter.dateFin; console.log( - `[api/qlik/sync] job=${job.jobId} → extraction Qlik pour ${codes.length} codes — fenêtre ${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, QLIK_SYNC_MONTHS_BACK=${monthsBack}, bornes ${QLIK_MONTHS_BACK_MIN}..${QLIK_MONTHS_BACK_MAX})…`, + `[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); job.fetched = metrics.size; @@ -289,8 +284,7 @@ async function runProductJob(job: QlikSyncJob): Promise { const code = job.codeCentrale!; job.requested = 1; - const monthsBack = envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT); - const dateFilter = buildGridNetworkQlikDateFilter(new Date(), monthsBack); + const dateFilter = buildGridNetworkQlikDateFilter(new Date(), QLIK_MONTHS_BACK_DEFAULT); job.periode = dateFilter.label; job.dateDebut = dateFilter.dateDebut; job.dateFin = dateFilter.dateFin; diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index cede299..1bf3497 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -60,14 +60,15 @@ export const TREND_COLOR: Record = { * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un * mois tronqué. * - * Un mois **absent** du cache n'est PAS supposé nul : l'extraction ne couvre pas - * toujours les 12 mois (les mesures Qlik « N » ne savent pas remonter au-delà de - * l'année en cours). La sync écrit un zéro **explicite** sur chaque mois qu'elle - * a réellement couvert sans vente ; la tendance ne retient donc que les mois - * présents comme clés, et ignore purement et simplement les autres. + * Une série n'est affichée que si les **12 clés sont explicitement présentes**. + * L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé + * la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle + * ne peut donc plus être présentée comme une tendance réelle. * - * Sans cette distinction, un article vendu seulement depuis mai voyait sa - * tendance calculée sur deux points — « +4 100 % · Forte hausse ». + * Cette exigence du « tout ou rien » évite deux écueils observés en production : + * une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un + * article vendu depuis mai), et des mois non extraits comptés comme des mois + * sans vente. * * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la * variation modélisée sur la période (pente × durée) rapportée à la moyenne. @@ -79,16 +80,14 @@ export function computeNetworkTrend( ): NetworkTrend { const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; if (!qteByMonth) return empty; - const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement - if (presents.length === 0) return empty; + const labels = buildRolling12QlikMonths(now); + const complet = labels.every((label) => + Object.prototype.hasOwnProperty.call(qteByMonth, label) && + Number.isFinite(Number(qteByMonth[label])), + ); + if (!complet) return empty; - const fenetre = buildRolling12QlikMonths(now); - // Seuls les mois RÉELLEMENT extraits comptent : ceux que la sync n'a pas - // couverts sont absents des clés et sont écartés, plutôt que comptés à zéro. - const labels = fenetre.filter((m) => qteByMonth[m] != null); - if (labels.length === 0) return empty; - - const values = labels.map((l) => Number(qteByMonth[l]) || 0); + const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; let pct: number | null = null; let direction: NetworkTrend["direction"] = "flat"; diff --git a/src/features/produits/api/search-produits.ts b/src/features/produits/api/search-produits.ts index 32c4e70..9e3f3c0 100644 --- a/src/features/produits/api/search-produits.ts +++ b/src/features/produits/api/search-produits.ts @@ -8,7 +8,7 @@ import { type NetworkMetricCached, } from "@/lib/qlik-network-cache"; import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client"; -import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; +import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend"; import { normalizeMargePct } from "@/features/produits/lib/compare-reseau"; import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types"; @@ -37,6 +37,20 @@ const CACHE_TTL_MS = 10 * 60 * 1000; /** Durée de conservation d'un job terminé (le client a le temps de le relire). */ const JOB_TTL_MS = 5 * 60 * 1000; +/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */ +function cacheReseauComplet( + cache: NetworkMetricCached | undefined, + periode: string | null, + now: Date = new Date(), +): cache is NetworkMetricCached { + const parMois = cache?.qteByMonth; + if (!parMois || !periode || cache.periode !== periode) return false; + return buildRolling12QlikMonths(now).every((mois) => + Object.prototype.hasOwnProperty.call(parMois, mois) && + Number.isFinite(Number(parMois[mois])), + ); +} + /** * Nombre maximum de recherches Qlik simultanées. * @@ -271,21 +285,29 @@ async function executerRecherche( localPromise, ]); - // On persiste les mesures fraîches : la fiche produit ouverte depuis un - // résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est - // pas fourni ici et n'est donc pas écrasé (upsert en COALESCE). + // Le cube de recherche utilise encore les master measures annuelles pour + // classer rapidement les résultats. Il ne doit jamais écraser une extraction + // 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec + // leur mensuel. Sans cache complet, les valeurs de découverte restent + // temporaires jusqu'au bouton « Actualiser depuis Qlik ». try { - const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({ - codeCentrale: m.codeCentrale, - caReseau: m.caReseau, - qteReseau: m.qteReseau, - nbMagasinsReseau: m.nbMagasinsReseau, - caParMagasinReseau: m.caParMagasinReseau, - margePctReseau: m.margePctReseau, - periode: qlik.periode ?? undefined, - libelleReseau: m.libelle || undefined, - fournisseurReseau: m.fournisseur || undefined, - })); + const aPersister: NetworkMetric[] = qlik.matches.map((m) => { + const cache = cacheReseau.get(m.codeCentrale); + const fiable = cacheReseauComplet(cache, qlik.periode); + return { + codeCentrale: m.codeCentrale, + caReseau: fiable ? cache.caReseau : m.caReseau, + qteReseau: fiable ? cache.qteReseau : m.qteReseau, + nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau, + caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau, + margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau, + periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined, + qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined, + metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined, + libelleReseau: m.libelle || undefined, + fournisseurReseau: m.fournisseur || undefined, + }; + }); const n = await upsertNetworkMetrics(aPersister); console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`); } catch (e) { @@ -295,7 +317,12 @@ async function executerRecherche( const rows: ProduitRechercheRow[] = qlik.matches.map((m) => { const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined); const cache = cacheReseau.get(m.codeCentrale); - const nbMag = m.nbMagasinsReseau; + const fiable = cacheReseauComplet(cache, qlik.periode); + const qteReseau = fiable ? cache.qteReseau : m.qteReseau; + const caReseau = fiable ? cache.caReseau : m.caReseau; + const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau; + const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau; + const margePct = fiable ? cache.margePctReseau : m.margePctReseau; return { codeCentrale: m.codeCentrale, libelle: local?.libelle1 || m.libelle || m.codeCentrale, @@ -304,16 +331,16 @@ async function executerRecherche( codein: local?.codein ?? null, nomenclature: local?.nomenclature ?? "", stockLocal: local ? Number(local.stock_total) || 0 : null, - qteReseau: m.qteReseau, - caReseau: m.caReseau, + qteReseau, + caReseau, nbMagasinsReseau: nbMag, tauxPresence: nbMag / NB_MAGASINS_RESEAU, - qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0, - caParMagasinReseau: m.caParMagasinReseau, - prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null, - margePctReseau: normalizeMargePct(m.margePctReseau), + qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0, + caParMagasinReseau: caParMagasin, + prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null, + margePctReseau: normalizeMargePct(margePct), // Le mensuel ne vient que du cache : le cube de recherche est agrégé. - qteByMonth: cache?.qteByMonth ?? null, + qteByMonth: fiable ? cache.qteByMonth : null, periode: qlik.periode, }; }); diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 5506448..b6d2ee1 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -94,11 +94,9 @@ export interface NetworkMetric { /** * Détail mensuel complet : { "YYYY-MM": { qte, ca, nbMag, caMag, margePct } }. * - * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois) — - * les conserver ne coûte aucune requête supplémentaire. Seule `qte` est - * garantie : les mois N-1 dérivés de « Quantité COMP » n'ont que la quantité - * tant que la passe complémentaire N-1 ne les a pas complétés (elle est - * sautée si le champ Qlik `Année` est absent). + * Le cube mensuel Qlik renvoie déjà ces 5 mesures par (Article Code, Mois). + * Lors d'une synchronisation datée validée, les 12 mois complets sont + * toujours présents ; un mois sans fait est explicitement représenté à 0. */ metricsByMonth?: Record; /** diff --git a/src/lib/qlik-date-range.ts b/src/lib/qlik-date-range.ts index e1eff67..1de772c 100644 --- a/src/lib/qlik-date-range.ts +++ b/src/lib/qlik-date-range.ts @@ -144,8 +144,14 @@ export function buildGridNetworkQlikDateFilter( // Début : `months` mois avant le mois courant (1er du mois) const startMonth = new Date(now.getFullYear(), now.getMonth() - months, 1); - const dateDebut = startMonth.toISOString().slice(0, 10); - const dateFin = endMonth.toISOString().slice(0, 10); + // Ne jamais utiliser `toISOString()` ici : minuit local en Europe/Paris + // devient la veille en UTC et transformait par exemple 2025-07-01 en + // 2025-06-30, soit une extraction de 13 mois. Les bornes sont des dates + // calendaires Qlik, on les formate donc avec les composantes locales. + const localIso = (d: Date): string => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + const dateDebut = localIso(startMonth); + const dateFin = localIso(endMonth); const qStart = isoDateToQlikSerial(dateDebut); const qEnd = isoDateToQlikSerial(dateFin); diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 3555e61..904d9f0 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -125,11 +125,12 @@ export async function fetchNetworkMetricsPlaywright( // Agrégations directes des champs de faits. Les master measures « N » ignorent // la sélection Date (mesures « cumul année en cours ») : elles ne peuvent pas // produire une fenêtre 12 mois glissants. Surchargeables si le modèle change. - // Désactivé par défaut : `Sum(quantite)` ignore `Type_Cal` et additionne donc - // les lignes de la période courante ET celles de la période de comparaison — - // il double compte. Les master measures, elles, portent le bon filtre ; il - // suffit de caler `Période` (cf. `choisirPeriode`). Réactivable pour essai. - const qlikUseExpr = ["1", "true", "yes", "on"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + // Agrégation directe des faits : chemin de SECOURS, utilisé quand aucune valeur + // de `Période` ne couvre les 12 mois. Activé par défaut, mais second choix : + // `Sum(quantite)` ignore `Type_Cal` et additionne les lignes de la période + // courante ET de la période de comparaison, alors que les master measures + // portent le bon filtre dès que `Période` est bien calé (cf. `choisirPeriode`). + const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); const qlikExprQte = (process.env.QLIK_EXPR_QTE ?? "Sum(quantite)").trim(); const qlikExprCa = (process.env.QLIK_EXPR_CA ?? "Sum(ca_ht)").trim(); const qlikExprNbMag = (process.env.QLIK_EXPR_NBMAG ?? "Count(DISTINCT [Magasin Code])").trim(); @@ -612,14 +613,22 @@ export async function fetchNetworkMetricsPlaywright( } }; - /** Sélectionne la fenêtre sur un champ date candidat. Renvoie false si le champ n'existe pas. */ - const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { + /** + * Sélectionne la fenêtre sur un champ date candidat. + * + * Renvoie le handle pour que l'appelant puisse impérativement + * effacer une sélection rejetée avant d'essayer le candidat + * suivant. Sans ce Clear, un premier champ qui ne matche aucune + * date maintient l'ensemble courant à zéro et condamne tous les + * essais suivants à renvoyer zéro eux aussi. + */ + const selectionnerFenetreSur = async (champ: string, valeurs: number[]): Promise => { let h: number; try { const gf = await rpc("GetField", { qFieldName: champ }, doc); h = (gf.qReturn as { qHandle: number }).qHandle; - if (typeof h !== "number" || h < 0) return false; - } catch { return false; } + if (typeof h !== "number" || h < 0) return null; + } catch { return null; } await rpc("Clear", {}, h); await sleep(qlikSettleMs); for (let i = 0; i < valeurs.length; i += 500) { @@ -630,7 +639,102 @@ export async function fetchNetworkMetricsPlaywright( }, h); } await sleep(qlikSettleMs); - return true; + return h; + }; + + /** + * Sélectionne les 12 mois via la dimension maître « Mois ». + * + * Dans l'app Magasins Vision Consolidée, les champs Date exposés + * existent mais sont dissociés des faits de vente : leur sélection + * laisse Sum(quantite) à 100 %. La dimension maître utilisée par le + * cube, elle, porte les vraies valeurs associées. Un list object + * permet de sélectionner ses qElemNumber sans connaître le nom du + * champ sous-jacent ni le format dual exact. + */ + const selectionnerFenetreViaDimensionMois = async ( + moisAttendus: Set, + ): Promise<{ total: number; trouves: string[] } | null> => { + let objet: { handle: number; id: string } | null = null; + try { + const cree = await rpcWithRetry("createMonthList", "CreateSessionObject", { + qProp: { + qInfo: { qType: "cf-month-list" }, + qListObjectDef: { + qLibraryId: moisDimId, + qShowAlternatives: true, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 100 }], + }, + }, + }, doc); + const ret = cree.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + objet = { + handle: ret.qHandle, + id: String(ret.qGenericId ?? ret.qId ?? ""), + }; + + const layout = await rpcWithRetry("layoutMonthList", "GetLayout", {}, objet.handle); + const lo = (layout.qLayout as { + qListObject?: { + qSize?: { qcy?: number }; + qDataPages?: Array<{ + qArea?: { qTop?: number }; + qMatrix?: Array>; + }>; + }; + }).qListObject; + const size = Number(lo?.qSize?.qcy) || 0; + const cellules: Array<{ qText?: string; qElemNumber?: number }> = []; + for (const page of lo?.qDataPages ?? []) { + for (const row of page.qMatrix ?? []) if (row[0]) cellules.push(row[0]); + } + for (let top = cellules.length; top < size; top += 500) { + const data = await rpcWithRetry("monthListData", "GetListObjectData", { + qPath: "/qListObjectDef", + qPages: [{ qTop: top, qLeft: 0, qWidth: 1, qHeight: Math.min(500, size - top) }], + }, objet.handle); + const matrix = (data.qDataPages as Array<{ + qMatrix?: Array>; + }>)?.[0]?.qMatrix ?? []; + for (const row of matrix) if (row[0]) cellules.push(row[0]); + if (matrix.length === 0) break; + } + + const parMois = new Map(); + for (const cellule of cellules) { + const mois = normMois(String(cellule.qText ?? "").trim()); + const elem = Number(cellule.qElemNumber); + if (moisAttendus.has(mois) && Number.isInteger(elem) && elem >= 0) { + parMois.set(mois, elem); + } + } + const trouves = [...parMois.keys()].sort(); + console.log( + "[qlik-pw][expr] dimension Mois : " + trouves.length + "/" + moisAttendus.size + + " valeurs trouvées " + JSON.stringify(trouves), + ); + if (trouves.length !== moisAttendus.size) return null; + + const selection = await rpcWithRetry("selectMonthList", "SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [...parMois.values()], + qToggleMode: false, + qSoftLock: true, + }, objet.handle); + console.log("[qlik-pw][expr] sélection dimension Mois → " + JSON.stringify(selection.qReturn)); + await sleep(qlikSettleMs); + const total = await totalDeControle(); + return { total, trouves }; + } catch (e) { + console.log("[qlik-pw][expr] sélection dimension Mois impossible : " + String((e as Error)?.message || e)); + return null; + } finally { + if (objet?.id) { + try { + await rpc("DestroySessionObject", { qId: objet.id }, doc); + } catch { /* objet limité à la session */ } + } + } }; /** @@ -664,7 +768,7 @@ export async function fetchNetworkMetricsPlaywright( * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée * qui décide. */ - const choisirPeriode = async (): Promise => { + const choisirPeriode = async (): Promise => { const fenetre = new Set(monthlyPayload.map((m) => m.label)); let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; try { @@ -683,7 +787,7 @@ export async function fetchNetworkMetricsPlaywright( .map((r) => ({ texte: String(r[0]?.qText ?? ""), elem: r[0]?.qElemNumber ?? -1 })) .filter((v) => v.texte && v.elem >= 0); diag("valeurs de « Période » : " + JSON.stringify(valeurs.map((v) => v.texte))); - if (valeurs.length === 0) return null; + if (valeurs.length === 0) return 0; let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; for (const v of valeurs) { @@ -727,7 +831,7 @@ export async function fetchNetworkMetricsPlaywright( if (!meilleure || meilleure.couverts.length === 0) { diag("aucune valeur de « Période » ne couvre la fenêtre — extraction sur la période par défaut"); - return null; + return 0; } await rpc("SelectListObjectValues", { qPath: "/qListObjectDef", @@ -743,10 +847,10 @@ export async function fetchNetworkMetricsPlaywright( "Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.", ); } - return meilleure.texte; + return meilleure.couverts.length; } catch (e) { diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); - return null; + return 0; } finally { // Le list object est conservé jusqu'au bout : le détruire // annulerait la sélection qu'il porte. @@ -765,11 +869,11 @@ export async function fetchNetworkMetricsPlaywright( if (codes.length && fh !== -1) { await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings); await sleep(qlikSettleMs); - await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", { + await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true, - }, fh), timings); + }, fh, () => { aggCode15Retries++; }), timings); await sleep(qlikSettleMs); } @@ -788,23 +892,46 @@ export async function fetchNetworkMetricsPlaywright( const allSerials = monthlyPayload.flatMap((m) => m.serials); const allYmd = monthlyPayload.flatMap((m) => m.ymd); + const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); let champDate: string | null = null; + let totalFenetre = 0; for (const cand of champsDateCandidats) { const valeurs = /key/i.test(cand) ? allYmd : allSerials; - if (!(await selectionnerFenetreSur(cand, valeurs))) { + const handleCand = await selectionnerFenetreSur(cand, valeurs); + if (handleCand == null) { diag(" champ « " + cand + " » : inexistant"); continue; } const filtre = await totalDeControle(); const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); - if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } + if (filtre > 0 && ratio < 0.995) { + champDate = cand; + totalFenetre = filtre; + break; + } + // Un candidat rejeté ne doit jamais polluer l'essai + // suivant (cas observé : Date=0 puis tous les autres=0). + await rpc("Clear", {}, handleCand); + await sleep(qlikSettleMs); + } + if (!champDate) { + // Les champs date exposés sont dissociés des faits : la + // dimension maître « Mois », elle, porte les vraies valeurs. + const selectionMois = await selectionnerFenetreViaDimensionMois(moisFenetre); + const ratio = selectionMois && sansFiltre > 0 ? selectionMois.total / sansFiltre : 0; + if (selectionMois) { + diag(" dimension « Mois » → " + Math.round(selectionMois.total) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); + } + if (selectionMois && selectionMois.total > 0 && ratio < 0.995) { + champDate = "dimension Mois"; + totalFenetre = selectionMois.total; + } } if (!champDate) { diag( - "ÉCHEC : aucun champ date ne filtre les faits (essayés : " + - JSON.stringify(champsDateCandidats) + "). 0% = la sélection ne matche rien, " + - "100% = elle n'a aucun effet. Forcer le bon champ avec QLIK_DATE_FIELD.", + "ÉCHEC : ni les champs date ni la dimension Mois ne filtrent les faits " + + "(champs essayés : " + JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois.", ); return false; } @@ -812,7 +939,6 @@ export async function fetchNetworkMetricsPlaywright( // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. - const moisFenetre = new Set(monthlyPayload.map((m) => m.label)); let totalQte = 0; let calibrees = 0, calibrOk = 0; let horsFenetre = 0; @@ -842,10 +968,19 @@ export async function fetchNetworkMetricsPlaywright( // Totaux = somme des mois de la fenêtre (et non un cumul année). out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]); (monthlyByCode[code] ??= {}); + // Agrège au lieu d'écraser : protège contre deux valeurs + // brutes de la dimension Mois qui se normaliseraient vers + // la même clé YYYY-MM. + const precedent = monthlyByCode[code][mm]; + const qteCumulee = (precedent?.qte ?? 0) + qte; + const caCumule = (precedent?.ca ?? 0) + ca; + const margeMontant = ((precedent?.margePct ?? 0) * (precedent?.ca ?? 0)) + marge; monthlyByCode[code][mm] = { - qte, ca, nbMag, + qte: qteCumulee, + ca: caCumule, + nbMag: Math.max(precedent?.nbMag ?? 0, nbMag), caMag: nbMag > 0 ? ca / nbMag : 0, - margePct: ca > 0 ? marge / ca : 0, + margePct: caCumule > 0 ? margeMontant / caCumule : 0, }; }, timings), timings); @@ -867,6 +1002,51 @@ export async function fetchNetworkMetricsPlaywright( for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; return false; } + // Contrôle global indépendant : le cube sans dimension, + // calculé sur exactement les mêmes sélections, doit être + // égal à la somme de toutes les lignes mensuelles paginées. + const ecartGlobal = Math.abs(totalQte - totalFenetre); + const toleranceGlobale = Math.max(0.001, Math.abs(totalFenetre) * 1e-9); + if (ecartGlobal > toleranceGlobale) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ REFUSÉE : somme mensuelle=" + totalQte + + " mais total Qlik fenêtre=" + totalFenetre + " (écart=" + ecartGlobal + ")", + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + + // Contrôle et normalisation PAR ARTICLE. Chaque article + // retourné possède ensuite exactement les 12 clés de la + // fenêtre ; une clé absente du cube Qlik signifie qu'aucun + // fait n'existe pour ce mois, donc une vraie valeur 0. + const totauxParCode: Record = {}; + for (let i = marqueOut; i < out.length; i++) { + const ligne = out[i]; + const code = String(ligne[0] ?? "").trim(); + if (code) totauxParCode[code] = (totauxParCode[code] ?? 0) + (Number(ligne[2]) || 0); + } + for (const [code, mois] of Object.entries(monthlyByCode)) { + for (const label of moisFenetre) { + mois[label] ??= { qte: 0, ca: 0, nbMag: 0, caMag: 0, margePct: 0 }; + } + // Ne conserve aucune clé hors de la fenêtre glissante. + for (const label of Object.keys(mois)) { + if (!moisFenetre.has(label)) delete mois[label]; + } + const somme12 = Array.from(moisFenetre).reduce((s, label) => s + (Number(mois[label]?.qte) || 0), 0); + const totalArticle = totauxParCode[code] ?? 0; + if (Math.abs(somme12 - totalArticle) > Math.max(0.001, Math.abs(totalArticle) * 1e-9)) { + console.error( + "[qlik-pw][expr] INTÉGRITÉ ARTICLE REFUSÉE code=" + code + + " somme12=" + somme12 + " total=" + totalArticle, + ); + out.length = marqueOut; + for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; + return false; + } + } if (calibrees > 20 && calibrOk / calibrees < 0.5) { console.warn( "[qlik-pw][expr] ⚠ calibrage faible (" + calibrOk + "/" + calibrees + ") : " + exprQte + @@ -1123,42 +1303,57 @@ export async function fetchNetworkMetricsPlaywright( } }; - if (!noDateMode && monthDim) { - // ÉTAPE 1, avant toute extraction : caler le champ `Période` du - // modèle sur la fenêtre. Toutes les mesures de l'app étant bornées - // par `Type_Cal` (lui-même piloté par `Période`), c'est cette - // sélection — et elle seule — qui détermine les mois accessibles. - await choisirPeriode(); - } + if (!noDateMode) { + // É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` + // (`Sum({} quantite)`), lui-même piloté par + // `Période`. C'est cette sélection — et elle seule — qui détermine + // quels mois sont atteignables. Sous la période par défaut, + // « Quantité N » ne couvre que l'année en cours : d'où les mois + // d'août à décembre vides quelle que soit la sélection de dates. + const couvertureMaster = await choisirPeriode(); - if (!noDateMode && monthDim && useExpr && (await moisParExpressions())) { - // Chemin alternatif : agrégation directe des faits. Conservé - // derrière QLIK_USE_EXPR mais désormais secondaire — `Sum(quantite)` - // mélange les lignes N et COMP du pont de périodes et double compte. - await pousserCheckpoint("expressions"); - } else if (!noDateMode && monthDim) { - // Repli : master measures « année en cours » + passe N-1 + rattrapage. - console.log("[qlik-pw] extraction via dimension Mois (" + monthlyPayload.length + " mois attendus, moisDim=" + moisDimId + ")"); - await monthDimPath(null); - await pousserCheckpoint("passe N"); - // Passe complémentaire : mois de l'année N-1 absents de l'année N - // (ex. août→déc), impossibles à obtenir via COMP. On sélectionne - // l'année N-1 pour que les mesures "N" portent sur cette année. - if (yfh !== -1) { - const before = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0); - try { - await monthDimPath(yearN - 1); - const after = Object.values(monthlyByCode).reduce((s, m) => s + Object.keys(m).length, 0); - console.log("[qlik-pw] (mois) passe N-1 terminée : " + before + " → " + after + " points mensuels"); - } catch (e2) { - console.log("[qlik-pw] (mois) passe N-1 ignorée: " + String((e2 as Error)?.message || e2)); + // ÉTAPE 2 — extraire, puis REFUSER tout résultat incomplet. + // + // Un cache partiel est pire que pas de cache : il se lit comme des + // ventes nulles. Les deux chemins sont donc validés sur le même + // critère — les 12 mois de la fenêtre, ou rien. + const attendus = monthlyPayload.map((m) => m.label); + const couvre12 = () => { + const vus = new Set(); + for (const parMois of Object.values(monthlyByCode)) { + for (const [mois, v] of Object.entries(parMois)) { + if ((v?.qte ?? 0) > 0) vus.add(mois); + } } - try { await rpc("Clear", {}, yfh); } catch { /* noop */ } - await pousserCheckpoint("passe N-1"); + return attendus.every((m) => vus.has(m)); + }; + + if (couvertureMaster >= attendus.length && monthDim) { + // Une valeur de `Période` couvre les 12 mois : les master + // measures sont alors exactes sur toute la fenêtre. + diag("extraction par master measures (Période couvrant " + couvertureMaster + " mois)"); + await monthDimPath(null); + await pousserCheckpoint("master measures"); + } else if (monthDim && useExpr && (await moisParExpressions())) { + // Aucune période ne couvre 12 mois : on retombe sur + // l'agrégation directe des faits. + await pousserCheckpoint("expressions vérifiées"); } else { - console.log("[qlik-pw] (mois) champ Année introuvable → pas de passe N-1"); + throw new Error( + "extraction 12 mois refusée : aucune valeur de « Période » ne couvre la fenêtre " + + "(meilleure = " + couvertureMaster + "/" + attendus.length + " mois) et l'agrégation " + + "directe des faits n'a pas validé ses contrôles", + ); + } + + if (!couvre12()) { + throw new Error( + "extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) + + " n'est pas entièrement couverte — cache laissé inchangé", + ); } - await pousserCheckpoint("passes master"); } else if (noDateMode) { // Aucun filtre Date : extraction en un seul appel (tous les codes d'un coup). // On sélectionne uniquement Article Code ; Date reste libre. @@ -1326,6 +1521,12 @@ export async function fetchNetworkMetricsPlaywright( result = (await evaluer()) as InPageResult; } catch (e) { const msg = String((e as Error)?.message || e); + // Une fenêtre 12 mois n'est publiable que si l'extraction est + // complète et a passé tous les contrôles. Conserver un checkpoint + // partiel fabriquerait précisément des graphiques à 2 ou 3 mois. + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois interrompue, cache inchangé : ${msg}`); + } const secours = checkpoint as { rows?: Array>; monthly?: InPageResult["monthly"] } | null; if (secours?.rows?.length) { console.error(`[qlik-pw] extraction interrompue (${msg.slice(0, 200)}) — reprise du dernier point de contrôle : ${secours.rows.length} lignes`); @@ -1336,6 +1537,9 @@ export async function fetchNetworkMetricsPlaywright( } if (!result.ok) { + if (dateFilter) { + throw new Error(`[qlik-pw] extraction 12 mois refusée, cache inchangé : ${result.error}`); + } // Même logique qu'un crash : un résultat partiel vaut mieux que rien. if (result.rows?.length) { console.error(`[qlik-pw] extraction en échec (${String(result.error).slice(0, 200)}) — ${result.rows.length} lignes déjà extraites conservées`); @@ -1345,6 +1549,9 @@ export async function fetchNetworkMetricsPlaywright( } } if (result.partiel) { + if (dateFilter) { + throw new Error("[qlik-pw] extraction 12 mois partielle refusée, cache inchangé"); + } console.warn("[qlik-pw] ⚠ résultat PARTIEL : certains codes n'ont pas été extraits, relancer la sync pour compléter"); } diff --git a/src/lib/qlik-search.ts b/src/lib/qlik-search.ts index a63a4de..0161cb5 100644 --- a/src/lib/qlik-search.ts +++ b/src/lib/qlik-search.ts @@ -37,7 +37,7 @@ import "server-only"; import { chromium, type Browser } from "playwright-core"; import { getQlikConfig, qlikNtlmSession, type QlikConfig } from "@/lib/qlik-client"; -import { buildGridNetworkQlikDateFilter, envMonthsBack, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; +import { buildGridNetworkQlikDateFilter, QLIK_MONTHS_BACK_DEFAULT } from "@/lib/qlik-date-range"; /** * Un article trouvé côté Qlik, **avec ses mesures réseau sur 12 mois glissants**. @@ -170,7 +170,7 @@ export async function searchQlikArticles( // Même fenêtre que la Grille : 12 mois complets glissants, mois courant exclu. const dateFilter = buildGridNetworkQlikDateFilter( new Date(), - envMonthsBack("QLIK_SYNC_MONTHS_BACK", QLIK_MONTHS_BACK_DEFAULT), + QLIK_MONTHS_BACK_DEFAULT, ); const sess = await qlikNtlmSession(cfg); const [cookieName, ...rest] = sess.cookie.split("=");