From 726fb8418dcdfe9de941b325b2cb039849762841 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 11:22:56 +0000 Subject: [PATCH] =?UTF-8?q?fix(qlik):=20piloter=20le=20champ=20P=C3=A9riod?= =?UTF-8?q?e=20du=20mod=C3=A8le=20au=20lieu=20de=20lutter=20contre=20lui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dump des expressions donne enfin la cause de fond : « Quantité N » = Sum({} quantite) « Quantité COMP » = Sum({} quantite) « Magasin Ventes Nb N » = Count({} Distinct ventes_code_site) L'app n'est pas un modèle « faits + calendrier ». Un pont de périodes duplique les lignes de faits : Type_Cal='N' marque celles de la période analysée, 'COMP' celles de la période de comparaison, et le champ Période (4 valeurs, une sélectionnée) décide de quelle période il s'agit. Tout en découle : - sélectionner Date ne change rien (mesuré : 100 % du total avant comme après), le périmètre venant du pont de périodes et non du champ date ; - « Quantité N » ne couvre que la période courante, d'où août→décembre vides ; - « Quantité COMP » ne couvre que les mois ayant un comparable, d'où janvier→juillet de l'année précédente ; - Sum(quantite) brut additionne les lignes N ET COMP : il double compte, il ne pouvait pas servir de substitut. choisirPeriode() essaie donc chaque valeur de Période, mesure sur un cube [Mois] × Quantité N combien de mois de la fenêtre elle rend réellement disponibles, et garde la meilleure. Aucun libellé n'est deviné : c'est la couverture mesurée qui décide, et elle est tracée. Si aucune valeur ne couvre les 12 mois, les mois manquants restent absents du cache. Aussi : - Les mois réellement couverts reçoivent un zéro EXPLICITE quand l'article n'a pas vendu ; la tendance ne retient que les mois présents comme clés. Un article vendu depuis mai voyait sa tendance calculée sur deux points (« +4100 % »). - Les diagnostics du chemin d'extraction sont répétés en fin de sync ([qlik-pw][diag]) : ils étaient au début d'un log de plusieurs milliers de lignes. - QLIK_USE_EXPR passe à désactivé par défaut. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu --- docs/qlik-integration.md | 44 +++++ src/features/grid/lib/network-trend.ts | 29 ++-- src/lib/qlik-playwright.ts | 222 +++++++++++++++++++++---- 3 files changed, 249 insertions(+), 46 deletions(-) diff --git a/docs/qlik-integration.md b/docs/qlik-integration.md index d632981..bc12504 100644 --- a/docs/qlik-integration.md +++ b/docs/qlik-integration.md @@ -78,6 +78,50 @@ QLIK_FIELD_FOURNISSEUR= Sans champ libellé exploitable, la recherche fonctionne encore par code centrale. +## Le modèle est piloté par `Période` / `Type_Cal` — pas par `Date` + +**C'est l'explication de fond**, obtenue en dumpant les expressions réelles : + +``` +« Quantité N » = Sum({} quantite) +« CA N » = Sum({} $(vCA_vat)) +« Quantité COMP » = Sum({} quantite) +« Magasin Ventes Nb N » = Count({} Distinct ventes_code_site) +``` + +L'app n'est pas un modèle « faits + calendrier » classique. Un **pont de périodes** +duplique les lignes de faits : `Type_Cal='N'` marque celles de la période +analysée, `'COMP'` celles de la période de comparaison. Et c'est le champ +**`Période`** (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + +Tout ce qu'on observait en découle : + +| Symptôme | Cause | +|---|---| +| Sélectionner `Date` ne change rien (100 % du total avant/après) | Le périmètre vient du pont de périodes, pas du champ date | +| Août→décembre vides sur tous les articles | « Quantité N » ne couvre que la période courante (janvier→juillet) | +| Janvier→juillet de l'année précédente renseignés | Ce sont les mois que « Quantité COMP » sait atteindre | +| `Sum(quantite)` brut inexploitable | Il additionne les lignes N **et** COMP : il double compte | + +La solution n'est donc pas de contourner le modèle mais de **le piloter** : +`choisirPeriode()` essaie chaque valeur de `Période`, mesure sur un cube +`[Mois] × Quantité N` combien de mois de la fenêtre elle rend réellement +disponibles, et garde la meilleure. Aucun libellé n'est deviné — c'est la +couverture mesurée qui décide : + +``` +[qlik-pw][diag] valeurs de « Période » : ["Année en cours","12 mois glissants","Mois","Semaine"] +[qlik-pw][diag] Période « Année en cours » → 7/12 mois de la fenêtre [...] +[qlik-pw][diag] Période « 12 mois glissants » → 12/12 mois de la fenêtre [...] +[qlik-pw][diag] Période retenue : « 12 mois glissants » (12/12 mois) +``` + +Si aucune valeur ne couvre les 12 mois, l'extraction le dit et les mois manquants +restent **absents** du cache — jamais remplis d'une valeur plausible. + +`QLIK_USE_EXPR=1` réactive l'ancien essai par agrégation directe des faits ; il +est désactivé par défaut puisque `Sum(quantite)` double compte. + ## ⚠️ Les master measures « N » ignorent la sélection Date **Constat de production, vérifié arithmétiquement.** Avec août 2025 seul diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index b3d8646..cede299 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -56,16 +56,18 @@ export const TREND_COLOR: Record = { * * La fenêtre est reconstruite à partir de la date du jour (et non des clés * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance - * réellement glissante. Deux conséquences voulues : - * - un mois en cours (partiel) ou un mois plus vieux que 12 mois présent dans - * le cache est **ignoré** — sinon la pente est faussée par un mois tronqué ; - * - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit - * non vendu sur un mois ne produit tout simplement pas de ligne. + * réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois, + * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un + * mois tronqué. * - * Seule exception au remplissage par zéro : les mois **antérieurs au premier - * mois réellement extrait**. Ils traduisent une extraction plus courte que 12 - * mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0 - * simulerait une croissance qui n'existe pas. + * 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. + * + * Sans cette distinction, un article vendu seulement depuis mai voyait sa + * tendance calculée sur deux points — « +4 100 % · Forte hausse ». * * 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. @@ -81,11 +83,10 @@ export function computeNetworkTrend( if (presents.length === 0) return empty; const fenetre = buildRolling12QlikMonths(now); - // Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) : - // rien de fiable à tracer. - const premierMoisExtrait = presents[0]; - const labels = fenetre.filter((m) => m >= premierMoisExtrait); - if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) return empty; + // 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 n = values.length; diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index aaa4038..3555e61 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -14,7 +14,7 @@ import "server-only"; import { chromium, type Browser } from "playwright-core"; -import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client"; +import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric, type QlikMonthMetrics } from "@/lib/qlik-client"; import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range"; let browserPromise: Promise | null = null; @@ -46,7 +46,6 @@ interface InPageResult { monthly?: Record>; size?: number; error?: string; - diag?: Record; // Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright). months?: number; batches?: number; @@ -54,6 +53,8 @@ interface InPageResult { fallbackCount?: number; /** true si le résultat vient d'un point de contrôle (extraction interrompue). */ partiel?: boolean; + /** Diagnostics clés du chemin par expressions, répétés en fin de sync. */ + diagExpr?: string[]; } function envNumber(name: string, defaultValue: number, min: number, max: number): number { @@ -124,7 +125,11 @@ 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. - const qlikUseExpr = !["0", "false", "no", "off"].includes((process.env.QLIK_USE_EXPR ?? "").trim().toLowerCase()); + // 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()); 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(); @@ -295,6 +300,11 @@ export async function fetchNetworkMetricsPlaywright( // Quantité réseau par code central et par mois (label "YYYY-MM"). // Accumulée en parallèle du total, sans toucher l'agrégation existante. const monthlyByCode: Record> = {}; + // Diagnostics clés, RÉPÉTÉS en fin de sync : le log d'une sync fait + // des milliers de lignes de timings, et ce qui explique un échec se + // trouve tout au début — là où personne ne va le chercher. + const diagnostics: string[] = []; + const diag = (msg: string) => { diagnostics.push(msg); console.log("[qlik-pw][expr] " + msg); }; (async () => { try { @@ -623,6 +633,127 @@ export async function fetchNetworkMetricsPlaywright( return true; }; + /** + * Cale le champ `Période` du modèle sur la fenêtre 12 mois glissants. + * + * L'app n'est pas un modèle « faits + calendrier » classique : TOUTES + * ses mesures sont bornées par un type de calendrier — + * + * « Quantité N » = Sum({} quantite) + * « Quantité COMP » = Sum({} quantite) + * + * `Type_Cal` marque les lignes de la période analysée (`N`) et celles + * de la période de comparaison (`COMP`), et c'est le champ `Période` + * (4 valeurs, une sélectionnée) qui décide de quelle période il s'agit. + * + * Cela explique tout ce qu'on observait : + * - sélectionner `Date` ne change RIEN (le périmètre vient du pont de + * périodes, pas du champ date) — mesuré : 100 % du total avant comme + * après ; + * - « Quantité N » ne renvoie que les mois de la période courante + * (janvier→juillet avec « année en cours »), d'où août→décembre + * vides ; + * - « Quantité COMP » ne couvre que les mois ayant un comparable dans + * cette période, d'où janvier→juillet de l'année précédente ; + * - `Sum(quantite)` brut mélange les lignes N et COMP : il double + * compte, et ne peut pas se substituer aux mesures. + * + * La solution n'est donc pas de contourner le modèle mais de le + * piloter : on essaie chaque valeur de `Période`, on mesure combien de + * mois de la fenêtre elle rend réellement disponibles, et on garde la + * meilleure. Aucun libellé n'est deviné — c'est la couverture mesurée + * qui décide. + */ + const choisirPeriode = async (): Promise => { + const fenetre = new Set(monthlyPayload.map((m) => m.label)); + let lo: { qHandle: number; qGenericId?: string; qId?: string } | null = null; + try { + const obj = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periodes" }, + qListObjectDef: { + qDef: { qFieldDefs: ["Période"] }, + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 50 }], + }, + } }, doc); + lo = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + const lay = await rpc("GetLayout", {}, lo.qHandle); + const matrix = ((lay.qLayout as { qListObject?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qListObject?.qDataPages?.[0]?.qMatrix) ?? []; + const valeurs = matrix + .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; + + let meilleure: { texte: string; elem: number; couverts: string[] } | null = null; + for (const v of valeurs) { + await rpc("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [v.elem], + qToggleMode: false, + qSoftLock: true, + }, lo.qHandle); + await sleep(qlikSettleMs); + + // Cube [Mois] × « Quantité N » : quels mois cette période rend-elle vivants ? + const sonde = await rpc("CreateSessionObject", { qProp: { + qInfo: { qType: "cf-periode-sonde" }, + qHyperCubeDef: { + qDimensions: [{ qLibraryId: moisDimId }], + qMeasures: [{ qLibraryId: rQte }], + qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 60 }], + }, + } }, doc); + const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; + let couverts: string[] = []; + try { + const sl = await rpc("GetLayout", {}, sRet.qHandle); + const sm = ((sl.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array> }> } }) + .qHyperCube?.qDataPages?.[0]?.qMatrix) ?? []; + couverts = sm + .filter((r) => (Number(r[1]?.qNum) || 0) > 0) + .map((r) => normMois(String(r[0]?.qText ?? ""))) + .filter((m) => fenetre.has(m)); + } finally { + const sid = String(sRet.qGenericId ?? sRet.qId ?? ""); + if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } } + } + diag(" Période « " + v.texte + " » → " + couverts.length + "/" + fenetre.size + " mois de la fenêtre " + JSON.stringify(couverts.sort())); + if (!meilleure || couverts.length > meilleure.couverts.length) { + meilleure = { texte: v.texte, elem: v.elem, couverts }; + } + if (couverts.length >= fenetre.size) break; // couverture complète, inutile de continuer + } + + 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; + } + await rpc("SelectListObjectValues", { + qPath: "/qListObjectDef", + qValues: [meilleure.elem], + qToggleMode: false, + qSoftLock: true, + }, lo.qHandle); + await sleep(qlikSettleMs); + diag("Période retenue : « " + meilleure.texte + " » (" + meilleure.couverts.length + "/" + fenetre.size + " mois)"); + if (meilleure.couverts.length < fenetre.size) { + diag( + "⚠ couverture incomplète : les mois manquants resteront ABSENTS du cache. " + + "Aucune valeur de « Période » ne porte 12 mois glissants dans cette app.", + ); + } + return meilleure.texte; + } catch (e) { + diag("choix de « Période » impossible : " + String((e as Error)?.message || e)); + return null; + } finally { + // Le list object est conservé jusqu'au bout : le détruire + // annulerait la sélection qu'il porte. + void lo; + } + }; + const moisParExpressions = async (): Promise => { const timings: Record = {}; const marqueOut = out.length; @@ -649,9 +780,9 @@ export async function fetchNetworkMetricsPlaywright( // ce soit. Un champ valide doit donner un total non nul ET // strictement inférieur au total sans filtre. const sansFiltre = await totalDeControle(); - console.log("[qlik-pw][expr] contrôle " + exprQte + " sans filtre date = " + Math.round(sansFiltre)); + diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre)); if (sansFiltre <= 0) { - console.error("[qlik-pw][expr] " + exprQte + " renvoie 0 même sans filtre → expression ou champ invalide (QLIK_EXPR_QTE)"); + diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)"); return false; } @@ -661,26 +792,23 @@ export async function fetchNetworkMetricsPlaywright( for (const cand of champsDateCandidats) { const valeurs = /key/i.test(cand) ? allYmd : allSerials; if (!(await selectionnerFenetreSur(cand, valeurs))) { - console.log("[qlik-pw][expr] champ « " + cand + " » : inexistant"); + diag(" champ « " + cand + " » : inexistant"); continue; } const filtre = await totalDeControle(); const ratio = sansFiltre > 0 ? filtre / sansFiltre : 0; - console.log( - "[qlik-pw][expr] champ « " + cand + " » → " + Math.round(filtre) + - " (" + Math.round(ratio * 100) + "% du total sans filtre)", - ); + diag(" champ « " + cand + " » → " + Math.round(filtre) + " (" + Math.round(ratio * 100) + "% du total sans filtre)"); if (filtre > 0 && ratio < 0.995) { champDate = cand; break; } } if (!champDate) { - console.error( - "[qlik-pw][expr] aucun champ date ne filtre les faits (essayés : " + - JSON.stringify(champsDateCandidats) + ") → impossible de borner la fenêtre 12 mois. " + - "Forcer le bon champ avec QLIK_DATE_FIELD.", + 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.", ); return false; } - console.log("[qlik-pw][expr] champ date retenu : « " + champDate + " »"); + diag("champ date retenu : « " + champDate + " »"); // Seuls les mois de la fenêtre nous intéressent ; la dimension // Mois peut en exposer d'autres si une sélection déborde. @@ -698,7 +826,7 @@ export async function fetchNetworkMetricsPlaywright( // Échantillon AVANT filtrage : si le mois ne tombe pas dans // la fenêtre, c'est le format de la dimension Mois qui est // en cause, pas les mesures. - console.log("[qlik-pw][expr] échantillon brut: " + JSON.stringify({ + diag("échantillon brut: " + JSON.stringify({ code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm), ca, qte, nbMag, marge, "Quantité N (master)": qteMaster, })); @@ -723,15 +851,15 @@ export async function fetchNetworkMetricsPlaywright( aggMonths = monthlyPayload.length; aggBatches++; - console.log( - "[qlik-pw][expr] " + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + - ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »" + - " timings=" + timingSummary(timings), + diag( + got.value + " lignes (" + horsFenetre + " hors fenêtre), quantité totale=" + Math.round(totalQte) + + ", calibrage année " + yearN + " : " + calibrOk + "/" + calibrees + " mois conformes à « Quantité N »", ); + console.log("[qlik-pw][expr] timings=" + timingSummary(timings)); if (totalQte <= 0) { - console.error( - "[qlik-pw][expr] quantité totale nulle sur la fenêtre alors que le contrôle global valait " + + diag( + "ÉCHEC : quantité totale nulle sur la fenêtre alors que le contrôle global valait " + Math.round(sansFiltre) + " — " + horsFenetre + " ligne(s) écartées comme hors fenêtre. " + "Si ce nombre est élevé, c'est le format de la dimension Mois qui ne correspond pas aux libellés attendus.", ); @@ -747,7 +875,7 @@ export async function fetchNetworkMetricsPlaywright( } return true; } catch (e) { - console.error("[qlik-pw][expr] échec : " + String((e as Error)?.message || e) + " → repli sur les master measures"); + diag("ÉCHEC : " + String((e as Error)?.message || e) + " → repli sur les master measures"); out.length = marqueOut; for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k]; return false; @@ -995,10 +1123,18 @@ 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 && monthDim && useExpr && (await moisParExpressions())) { - // Chemin nominal : un seul cube, tous les mois de la fenêtre, des - // mesures qui respectent la sélection Date. Ni passe N-1 ni - // rattrapage nécessaires — c'est justement ce qu'ils compensaient. + // 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. @@ -1147,12 +1283,12 @@ export async function fetchNetworkMetricsPlaywright( // mensuel est validé et en production depuis. ws.close(); - resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount, diagExpr: diagnostics }); } catch (e) { try { ws.close(); } catch { /* noop */ } // On remonte ce qui a été extrait : les codes déjà traités ont // toutes leurs données, seul le reliquat manque. - resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length }); + resolve({ ok: false, error: String((e as Error)?.message || e), rows: out, monthly: monthlyByCode, size: out.length, diagExpr: diagnostics }); } })(); }), @@ -1216,6 +1352,10 @@ export async function fetchNetworkMetricsPlaywright( // Les logs détaillés par étape restent en place (timings batch/mois) ; // ici on consolide les compteurs agrégés et la durée totale. const totalMs = Date.now() - totalStart; + if (result.diagExpr?.length) { + console.log("[qlik-pw][diag] ─── diagnostic du chemin par expressions ───"); + for (const d of result.diagExpr) console.log("[qlik-pw][diag] " + d); + } console.log( `[qlik-pw][summary] rows=${result.size ?? 0} months=${result.months ?? 0} batches=${result.batches ?? 0} code15Retries=${result.code15Retries ?? 0} fallbackCount=${result.fallbackCount ?? 0} totalMs=${totalMs}`, ); @@ -1262,15 +1402,33 @@ export async function fetchNetworkMetricsPlaywright( // colonne « Tendance / Réseau » de la Grille. Inchangé. // - `metricsByMonth` : toutes les mesures du mois, pour la fiche produit. const monthly = result.monthly ?? {}; + + // Mois RÉELLEMENT couverts par l'extraction = union des mois vus sur + // l'ensemble des codes. Distinguer « couvert, zéro vente » de « non + // extrait » est indispensable : sans cela, un article vendu seulement + // depuis mai voyait sa tendance calculée sur deux points (« +4 100 % »), + // et un mois non extrait passait pour une absence de vente. + const couverts = new Set(); + for (const parMois of Object.values(monthly)) { + for (const mois of Object.keys(parMois)) couverts.add(mois); + } + const moisCouverts = [...couverts].sort(); + console.log(`[qlik-pw] mois couverts par l'extraction (${moisCouverts.length}) : ${JSON.stringify(moisCouverts)}`); + for (const [code, metric] of out) { - const mm = monthly[code]; - if (!mm || Object.keys(mm).length === 0) continue; + const mm = monthly[code] ?? {}; const qteByMonth: Record = {}; - for (const [mois, v] of Object.entries(mm)) { + const metricsByMonth: Record = {}; + for (const mois of moisCouverts) { + const v = mm[mois]; + // Zéro EXPLICITE sur un mois couvert sans vente : c'est une + // information, au même titre qu'une vente. Un mois non couvert + // reste absent et sera ignoré par la tendance. qteByMonth[mois] = Number(v?.qte) || 0; + metricsByMonth[mois] = v ?? { qte: 0 }; } metric.qteByMonth = qteByMonth; - metric.metricsByMonth = mm; + metric.metricsByMonth = metricsByMonth; } // Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques. // Si presque tous varient → la mesure respecte le mois (feature OK).