Files
CollectFlow/src/lib/qlik-playwright.ts
T
Claude b6225f1920 Qlik: la sync 12 mois passe — sonde fournisseur bornee, commentaires rectifies
Premier run reussi : 12 mois couverts, 40 411 produits ecrits dans le
cache, statut final=success.

Ce que la sonde paginee montre enfin :

    Periode « (aucune) » → master exposee : 2024-01…2026-07 (31 mois)

Sans aucune Periode selectionnee, « Quantite N » couvre 31 mois, et une
seule passe (Annee=2026) sert les 12 mois de la fenetre. L'etat libre
etait donc le bon depuis le debut ; ce sont deux defauts de l'outil de
MESURE qui le cachaient — la sonde tronquee a 60 lignes, puis le Clear
appele sur le list object. Le commentaire de choisirPeriode affirmait
l'inverse (« sans Periode la master est morte ») : il est rectifie, avec
la mesure a l'appui.

Sonde fournisseur : elle lisait 1 207 205 codes en 240 pages avant de
conclure, trois fois de suite — 87 s pour un rejet joue d'avance. La
lecture s'arrete desormais des le plafond depasse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
2026-08-01 07:24:56 +00:00

2448 lines
178 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CollectFlow — Extraction Qlik via Playwright (Chromium headless).
*
* Le proxy Qlik refuse un websocket "raw" côté serveur (403). On ouvre donc
* l'app dans Chromium avec une session authentifiée (cookie X-Qlik-Session
* injecté via le flux ticket NTLM), on récupère le **qlik-csrf-token** que le
* client Qlik utilise pour SON websocket, puis on ouvre notre propre websocket
* Engine **in-page** avec ce token (le proxy l'accepte).
*
* Efficacité : on filtre d'abord Date par mois (~30 jours), puis on sélectionne
* les Article Code demandés par petits lots. Chaque cube reste borné à
* ~30 jours × batch d'articles, sans lire tout le catalogue Qlik.
*/
import "server-only";
import { chromium, type Browser } from "playwright-core";
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<Browser> | null = null;
async function getBrowser(): Promise<Browser> {
if (!browserPromise) {
const execPath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
browserPromise = chromium.launch({
headless: true,
executablePath: execPath || undefined,
args: ["--no-sandbox", "--disable-dev-shm-usage", "--ignore-certificate-errors"],
}).catch((e) => { browserPromise = null; throw e; });
}
return browserPromise;
}
interface InPageResult {
ok: boolean;
rows?: Array<Array<string | number>>;
/**
* Métriques réseau par code central et par mois : { code: { "YYYY-MM": {…} } }.
*
* Le cube mensuel renvoie déjà les 6 mesures par (Article Code, Mois) ; on les
* conserve toutes. Seule `qte` est systématiquement présente : les mois N-1
* dérivés de « Quantité COMP » n'ont que la quantité (COMP ne porte pas les
* autres mesures), et la passe complémentaire N-1 les complète quand le champ
* `Année` existe.
*/
monthly?: Record<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>>;
size?: number;
error?: string;
// Compteurs agrégés pour le résumé timing final côté Node (cf. qlik-playwright).
months?: number;
batches?: number;
code15Retries?: number;
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 {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return defaultValue;
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return defaultValue;
return Math.min(max, Math.max(min, Math.trunc(parsed)));
}
function envNumberCsv(name: string, min: number, max: number): number[] {
const raw = process.env[name];
if (!raw) return [];
return raw
.split(",")
.map((part) => Number(part.trim()))
.filter((value) => Number.isFinite(value))
.map((value) => Math.min(max, Math.max(min, Math.trunc(value))));
}
function uniqueOrdered(values: number[]): number[] {
const seen = new Set<number>();
const out: number[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
out.push(value);
}
return out;
}
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<Map<string, NetworkMetric>> {
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");
// Démarre l'horloge pour le résumé timing final (cf. bloc [qlik-pw][summary]).
const totalStart = Date.now();
console.log(`[qlik-pw] host=${cfg.host} app=${cfg.appNetwork} — ${(codeCentraux ?? []).length} codes à sélectionner`);
if (dateFilter) {
console.log(`[qlik-pw] filtre Date=${dateFilter.label} (${dateFilter.dateDebut} → ${dateFilter.dateFin}, ${dateFilter.setAnalysis})`);
}
const sess = await qlikNtlmSession(cfg);
console.log(`[qlik-pw] session NTLM OK, cookie=${sess.cookie.split("=")[0]}=…`);
const [cookieName, ...rest] = sess.cookie.split("=");
const cookieValue = rest.join("=");
const qlikSettleMs = envNumber("QLIK_SETTLE_MS", 150, 0, 2000);
const qlikCode15MaxAttempts = envNumber("QLIK_CODE15_MAX_ATTEMPTS", 4, 1, 8);
const qlikArticleBatchSize = envNumber("QLIK_ARTICLE_BATCH_SIZE", 150, 1, 500);
const csvFallbacks = envNumberCsv("QLIK_ARTICLE_BATCH_FALLBACKS", 1, 500);
const qlikArticleBatchFallbacks = uniqueOrdered([qlikArticleBatchSize, ...(csvFallbacks.length ? csvFallbacks : [75, 50]), 75, 50]);
// Chemin rapide : sélectionner TOUS les Article Code une seule fois, puis ne
// changer que la Date par mois (au lieu de re-sélectionner par lots de 150 chaque
// mois). Repli automatique sur le chemin par lots en cas de code 15.
// ACTIVÉ PAR DÉFAUT (validé fiable) — désactivable seulement avec QLIK_SELECT_ALL_CODES=0.
const qlikSelectAllCodes = !["0", "false", "no", "off"].includes((process.env.QLIK_SELECT_ALL_CODES ?? "").trim().toLowerCase());
// Découpage mensuel réel via la dimension "Mois" (cube [Article Code, Mois]) : donne
// les vraies quantités par mois ET le vrai total (au lieu de 12× la valeur annuelle).
// Activé par défaut ; désactivable via QLIK_MONTH_DIM=0 (revient à l'itération par mois).
const qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase());
const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim();
// Mesure "Quantité COMP" (année N-1) pour compléter les mois de l'année précédente
// → 12 mois glissants (Quantité N ne couvre que l'année en cours).
const qlikMeasQteCompId = (process.env.QLIK_MEAS_QTE_COMP_ID ?? "96862880-76cd-4957-bf25-b96901f2ac5f").trim();
// 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.
// 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();
const qlikExprMarge = (process.env.QLIK_EXPR_MARGE ?? "Sum(marge)").trim();
// Champs date candidats, essayés dans l'ordre. Un champ n'est retenu que s'il
// 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",
].filter((c, i, arr) => c && arr.indexOf(c) === i);
const yearN = new Date().getFullYear();
console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim} useExpr=${qlikUseExpr}`);
if (qlikUseExpr) {
console.log(`[qlik-pw] expressions mensuelles: qte="${qlikExprQte}" ca="${qlikExprCa}" nbMag="${qlikExprNbMag}" marge="${qlikExprMarge}"`);
console.log(`[qlik-pw] champs date candidats: ${JSON.stringify(qlikDateFields)}`);
}
const browser = await getBrowser();
console.log(`[qlik-pw] chromium lancé`);
const ctx = await browser.newContext({
httpCredentials: { username: cfg.user, password: cfg.password, origin: `https://${cfg.host}` },
ignoreHTTPSErrors: true,
});
await ctx.addCookies([{ name: cookieName, value: cookieValue, domain: cfg.host, path: "/", secure: true }]);
try {
const page = await ctx.newPage();
page.on("console", (msg) => console.log(`[qlik-pw][page] ${msg.text()}`));
page.on("pageerror", (e) => console.log(`[qlik-pw][pageerror] ${e.message}`));
// Capture le qlik-csrf-token depuis le websocket que le client Qlik ouvre
let csrfToken: string | null = null;
let resolveToken: (() => void) | null = null;
const tokenReady = new Promise<void>((r) => { resolveToken = r; });
page.on("websocket", (ws) => {
const m = ws.url().match(/qlik-csrf-token=([^&]+)/);
if (m && !csrfToken) { csrfToken = decodeURIComponent(m[1]); resolveToken?.(); }
});
await page.goto(`https://${cfg.host}/sense/app/${cfg.appNetwork}`, { waitUntil: "domcontentloaded", timeout: cfg.timeoutMs })
.catch(() => { /* le client ouvre son ws ensuite */ });
console.log(`[qlik-pw] page chargée, attente du qlik-csrf-token…`);
await Promise.race([tokenReady, page.waitForTimeout(15000)]);
if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)");
const jeton: string = csrfToken;
console.log(`[qlik-pw] csrf-token capturé, ouverture du websocket Engine in-page…`);
// Pré-calcul des mois côté Node (pour pouvoir logger X/Y et propager au script in-page).
const monthRanges: QlikDateFilter[] = dateFilter ? getMonthRanges(dateFilter) : [];
const monthlyPayload = monthRanges.map((m) => ({
label: m.label,
dateDebut: m.dateDebut,
dateFin: m.dateFin,
serials: m.dailySerials, // ~30 valeurs → SelectValues en une fois
// Même fenêtre au format entier AAAAMMJJ : certains modèles Qlik
// n'exposent la date que par une clé numérique (`Date_Key`), pas par
// un champ date sérialisé.
ymd: m.dailySerials.map((serial) => {
const d = new Date(Date.UTC(1899, 11, 30) + serial * 86_400_000);
return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate();
}),
}));
// Point de contrôle : le script in-page pousse ses résultats intermédiaires
// à chaque passe. Si la session Qlik meurt en cours de route (« Socket
// closed », « Execution context was destroyed »), on repart de là au lieu
// de tout perdre — les codes déjà traités ont des données complètes, la
// dimension Mois livrant tous leurs mois d'un coup.
let checkpoint: { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] } | null = null;
await page.exposeFunction("cfCheckpoint", (payload: { rows?: Array<Array<string | number>>; monthly?: InPageResult["monthly"] }) => {
checkpoint = payload;
return true;
});
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, 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[] }>;
noDateMode: boolean;
qlikSettleMs: number;
qlikCode15MaxAttempts: number;
qlikArticleBatchFallbacks: number[];
selectAllCodes: boolean;
monthDim: boolean;
moisDimId: string;
mqteComp: string;
yearN: number;
useExpr: boolean;
exprQte: string;
exprCa: string;
exprNbMag: string;
exprMarge: string;
champsDateCandidats: string[];
fournisseurCode: string;
champsFournisseurCandidats: string[];
couvertureFournisseurMin: number;
}) =>
new Promise<InPageResult>((resolve) => {
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 ws = new WebSocket(url);
let id = 0;
const pend = new Map<number, { res: (v: { [k: string]: unknown }) => void; rej: (e: unknown) => void }>();
const rpc = (method: string, params: unknown, handle = -1) => new Promise<{ [k: string]: unknown }>((res, rej) => {
const i = ++id; pend.set(i, { res, rej });
ws.send(JSON.stringify({ jsonrpc: "2.0", id: i, handle, method, params }));
});
const ready = new Promise<void>((res, rej) => {
ws.onmessage = (ev: MessageEvent) => {
const m = JSON.parse(ev.data as string);
if (m.method === "OnConnected") return res();
if (m.id && pend.has(m.id)) {
const x = pend.get(m.id)!;
pend.delete(m.id);
if (m.error) x.rej(new Error(JSON.stringify(m.error)));
else x.res(m.result);
}
};
ws.onerror = () => rej(new Error("ws error (403 ?)"));
setTimeout(() => rej(new Error("ws timeout")), 30000);
});
// Qlik Engine code 15 = "Request aborted" : déclenché quand une requête
// (GetLayout / GetHyperCubeData / CreateSessionObject) entre en collision
// avec une évaluation de sélection encore en cours côté Engine. On retente
// quelques fois avec un court backoff et un log explicite de l'étape.
const isEngineCode15 = (e: unknown): boolean => {
const msg = String((e as Error)?.message ?? e);
return /"code"\s*:\s*15\b/.test(msg);
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const nowMs = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
const timed = async <T>(etape: string, method: string, fn: () => Promise<T>, timings?: Record<string, number>): Promise<{ value: T; ms: number }> => {
const start = nowMs();
const value = await fn();
const ms = Math.round(nowMs() - start);
const label = `${etape}:${method}`;
if (timings) timings[label] = (timings[label] ?? 0) + ms;
console.log(`[qlik-pw][timing] ${label} ms=${ms}`);
return { value, ms };
};
const timingSummary = (timings: Record<string, number>) => JSON.stringify(timings);
const rpcWithRetry = async (
etape: string,
method: string,
params: unknown,
handle: number,
onCode15Retry?: () => void,
): Promise<{ [k: string]: unknown }> => {
let lastErr: unknown;
for (let attempt = 1; attempt <= qlikCode15MaxAttempts; attempt++) {
try {
return await rpc(method, params, handle);
} catch (e) {
lastErr = e;
if (!isEngineCode15(e) || attempt === qlikCode15MaxAttempts) throw e;
onCode15Retry?.();
const backoff = qlikSettleMs * attempt;
console.log(`[qlik-pw] retry code15 étape=${etape} méthode=${method} tentative=${attempt}/${qlikCode15MaxAttempts} backoff=${backoff}ms`);
await sleep(backoff);
}
}
throw lastErr;
};
// Accumulateurs déclarés au niveau de l'IIFE (et non dans le `try`) :
// le `catch` doit pouvoir remonter ce qui a déjà été extrait.
const out: Array<Array<string | number>> = [];
// 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<string, Record<string, { qte: number; ca?: number; nbMag?: number; caMag?: number; margePct?: number }>> = {};
// 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 {
await ready;
console.log("ws connecté (OnConnected)");
const open = await rpc("OpenDoc", { qDocName: app });
const doc = (open.qReturn as { qHandle: number }).qHandle;
console.log("OpenDoc OK handle=" + doc);
// Handles des champs Date / Article Code.
let dfh = -1;
let fh = -1;
let yfh = -1;
/**
* Handle du CHAMP « Période ».
*
* ⚠️ `Clear` et `SelectValues` sont des méthodes de **champ**. Les
* appeler sur un objet de session renvoie
* `{"code":-32601,"parameter":"Clear","message":"Method not found"}`,
* ce qui a fait avorter en production la cartographie des périodes ET
* le chemin d'extraction par date brute. Le list object ne sert donc
* plus qu'à ÉNUMÉRER les valeurs ; les sélections passent par ici.
*/
let pfh = -1;
const df = await rpc("GetField", { qFieldName: "Date" }, doc);
dfh = (df.qReturn as { qHandle: number }).qHandle;
try {
const yf = await rpc("GetField", { qFieldName: "Année" }, doc);
yfh = (yf.qReturn as { qHandle: number }).qHandle;
} catch { yfh = -1; }
try {
const pf = await rpc("GetField", { qFieldName: "Période" }, doc);
pfh = (pf.qReturn as { qHandle: number }).qHandle;
} catch { pfh = -1; }
if (codes.length) {
const gf = await rpc("GetField", { qFieldName: "Article Code" }, doc);
fh = (gf.qReturn as { qHandle: number }).qHandle;
}
// Résoudre les master measures par TITRE (le qLibraryId fiable = qInfo.qId, ≠ GUID QRS).
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
const ml = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "MeasureList" }, qMeasureListDef: { qType: "measure", qData: { title: "/qMetaDef/title", expr: "/qMeasure/qDef" } } } }, doc);
const mll = await rpc("GetLayout", {}, (ml.qReturn as { qHandle: number }).qHandle);
const items = (((mll.qLayout as { qMeasureList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string; expr?: string } }> } }).qMeasureList?.qItems) ?? []);
const byTitle = new Map<string, string>();
for (const it of items) { const t = it.qMeta?.title ?? it.qData?.title ?? ""; if (t) byTitle.set(norm(t), it.qInfo.qId); }
const pickMeasure = (title: string, fallback: string) => byTitle.get(norm(title)) ?? fallback;
const rCamag = pickMeasure("CA par Magasin N", mcamag);
const rMarge = pickMeasure("Marge % N", mmarge);
// Résolution par TITRE aussi pour CA / Quantité / Nb magasins : évite tout
// décalage si un qId d'env ne pointe pas sur la mesure attendue.
const rCa = pickMeasure("CA N", mca);
const rQte = pickMeasure("Quantité N", mqte);
const rNbMag = pickMeasure("Magasin Ventes Nb N", mnb);
console.log("[qlik-pw] mesures utilisées: " + JSON.stringify({
"CA N": rCa, "Quantité N": rQte, "Magasin Ventes Nb N": rNbMag,
"CA par Magasin N": rCamag, "Marge % N": rMarge,
env: { mca, mqte, mnb },
}));
console.log("measures résolues par titre: " + JSON.stringify({ rCamag, rMarge }) + " (sur " + items.length + " mesures)");
// DUMP diagnostic : inventaire des mesures + dimensions (titre + id) pour
// identifier une mesure "Quantité" NON annuelle et/ou une dimension "Mois/Période"
// permettant un vrai découpage mensuel du réseau.
// L'EXPRESSION est indispensable au diagnostic : c'est elle qui dit
// si la mesure est bornée à une année (et comment), donc pourquoi des
// mois de la fenêtre glissante peuvent ressortir vides.
console.log("[qlik-pw][dump] MESURES (" + items.length + "): " + JSON.stringify(items.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
for (const it of items) {
const titre = it.qMeta?.title ?? it.qData?.title ?? "";
if (/^(CA N|Quantité N|Magasin Ventes Nb N|CA par Magasin N|Marge % N|Quantité COMP)$/i.test(titre.trim())) {
console.log("[qlik-pw][dump] expression « " + titre + " » = " + (it.qData?.expr ?? "?"));
}
}
try {
const dl = await rpc("CreateSessionObject", { qProp: { qInfo: { qType: "DimensionList" }, qDimensionListDef: { qType: "dimension", qData: { title: "/qMetaDef/title" } } } }, doc);
const dll = await rpc("GetLayout", {}, (dl.qReturn as { qHandle: number }).qHandle);
const ditems = (((dll.qLayout as { qDimensionList?: { qItems?: Array<{ qInfo: { qId: string }; qMeta?: { title?: string }; qData?: { title?: string } }> } }).qDimensionList?.qItems) ?? []);
console.log("[qlik-pw][dump] DIMENSIONS (" + ditems.length + "): " + JSON.stringify(ditems.map((it) => ({ t: it.qMeta?.title ?? it.qData?.title ?? "", id: it.qInfo.qId }))));
} catch (e) { console.log("[qlik-pw][dump] DimensionList error: " + String((e as Error)?.message || e)); }
// L'hypercube session object est désormais créé JETABLE dans fetchCubeForSelection
// (un objet par lot) puis DestroySessionObject — corrige "Request aborted" (code 15)
// causé par la réutilisation du même objet sur des centaines de recalculs.
// (déclaré au niveau de l'IIFE — voir plus haut)
// (monthlyByCode est déclaré au niveau de l'IIFE — voir plus haut)
const accMonthly = (batchRows: Array<Array<string | number>>, monthLabel: string) => {
for (const r of batchRows) {
const c = String(r[0] ?? "").trim();
if (!c || c === "-") continue;
const q = Number(r[2]) || 0;
(monthlyByCode[c] ??= {});
const cur = monthlyByCode[c][monthLabel];
monthlyByCode[c][monthLabel] = {
qte: (cur?.qte ?? 0) + q,
ca: (cur?.ca ?? 0) + (Number(r[1]) || 0),
nbMag: Math.max(cur?.nbMag ?? 0, Number(r[3]) || 0),
caMag: Number(r[4]) || 0,
margePct: Number(r[5]) || 0,
};
}
};
// Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync).
let aggMonths = 0;
let aggBatches = 0;
let aggCode15Retries = 0;
let aggFallbackCount = 0;
// Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes
// brutes lues (avant dédoublonnage côté Node).
//
// IMPORTANT : crée un hypercube session object JETABLE par lot (Date + Article Code
// déjà sélectionnés) puis le détruit. Réutiliser le même objet sur des centaines de
// lots force Qlik à recalculer en boucle et finit par "Request aborted" (code 15).
const PAGE = 1400;
// Petite latence après chaque mutation de sélection : laisser l'Engine
// finaliser l'évaluation de la sélection avant la prochaine requête layout/cube.
const createCubeForBatch = async (timings?: Record<string, number>, onCode15Retry?: () => void): Promise<{ handle: number; id: string }> => {
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }],
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 6, qHeight: PAGE }],
} } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
return { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
};
const destroyCube = async (cube: { handle: number; id: string }, timings?: Record<string, number>) => {
if (!cube.id) return;
try {
await timed("destroyCube", "DestroySessionObject", () => rpcWithRetry("destroyCube", "DestroySessionObject", { qId: cube.id }, doc), timings);
} catch (e) {
// Non fatal : l'objet est de toute façon scopé à la session websocket.
// Un code 15 ici signifie que l'Engine l'a déjà libéré après un abort.
console.log("[qlik-pw] DestroySessionObject ignoré: " + String((e as Error)?.message || e));
}
};
const appendMatrix = (matrix: Array<Array<{ qText?: string; qNum?: number }>>, sink: Array<Array<string | number>>): number => {
for (const r of matrix) sink.push([
String(r[0]?.qText ?? ""),
Number(r[1]?.qNum) || 0,
Number(r[2]?.qNum) || 0,
Number(r[3]?.qNum) || 0,
Number(r[4]?.qNum) || 0,
Number(r[5]?.qNum) || 0,
]);
return matrix.length;
};
const fetchCubeForSelection = async (sink: Array<Array<string | number>>, timings?: Record<string, number>, onCode15Retry?: () => void): Promise<number> => {
const cube = await createCubeForBatch(timings, onCode15Retry);
const oh = cube.handle;
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
const hyperCube = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hyperCube.qSize.qcy;
const firstPage = hyperCube.qDataPages?.find((page) => (page.qArea?.qTop ?? 0) === 0);
let got = 0;
let top = 0;
if (firstPage?.qMatrix?.length) {
got += appendMatrix(firstPage.qMatrix, sink);
top = PAGE;
}
for (; top < size; top += PAGE) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh, onCode15Retry), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += appendMatrix(matrix, sink);
if (matrix.length < PAGE) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
// ─── Découpage mensuel réel via dimension "Mois" ────────────────────
// Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca,
// qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM".
const normMois = (m: string): string => (/^\d{6}$/.test(m) ? m.slice(0, 4) + "-" + m.slice(4) : m);
const PAGEM = 1200; // 8 colonnes × 1200 = 9600 cellules < 10000 (limite Qlik)
const fetchMonthCube = async (
onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void,
timings?: Record<string, number>,
onCode15Retry?: () => void,
): Promise<number> => {
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
qMeasures: [{ qLibraryId: rCa }, { qLibraryId: rQte }, { qLibraryId: rNbMag }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }],
} } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
const oh = cube.handle;
const process = (matrix: Array<Array<{ qText?: string; qNum?: number }>>): number => {
for (const r of matrix) {
onRow(
String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""),
Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0,
Number(r[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0,
);
}
return matrix.length;
};
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hc.qSize.qcy;
let got = 0, top = 0;
const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; }
for (; top < size; top += PAGEM) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 8, qHeight: PAGEM }] }, oh, onCode15Retry), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += process(matrix);
if (matrix.length < PAGEM) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
// ─── Cube mensuel par EXPRESSIONS (respecte la sélection Date) ─────
//
// Constat de production : les master measures « CA N » / « Quantité N »
// **ignorent la sélection Date**. Vérifié arithmétiquement : avec août
// 2025 seul sélectionné, le cube renvoyait 164 unités / 2 220,75 € —
// soit exactement la somme de janvier à juillet 2026. Ce sont des
// mesures « cumul année en cours », pas des mesures de période.
//
// Conséquences : les totaux réseau étaient un cumul année en cours
// (mois courant partiel inclus) et non 12 mois glissants, les mois de
// l'année précédente non couverts par « Quantité COMP » restaient
// vides, et la passe de rattrapage y recopiait le total de période.
//
// Aucune sélection ne peut corriger cela : on agrège donc directement
// les champs de faits, qui eux respectent les sélections.
const PAGEX = 1200; // 7 colonnes × 1200 = 8400 cellules < 10000
const fetchMonthCubeExpr = async (
eCa: string,
eQte: string,
eNbMag: string,
eMarge: string,
onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, marge: number, qteMaster: number) => void,
timings?: Record<string, number>,
onCode15Retry?: () => void,
/**
* Inclure la master « Quantité N » (colonne de calibrage) ?
*
* ⚠️ Elle ne coûte rien quand elle vit, mais **annule tout le cube**
* quand elle est morte : mesuré en production, 4 expressions valides
* à 6 mois chacune et le cube complet à **0 ligne** après 25 s de
* calcul. L'appelant ne l'ajoute donc qu'après l'avoir sondée.
*/
avecMaster = true,
): Promise<number> => {
const largeur = avecMaster ? 7 : 6;
const { value: obj } = await timed("createCubeExpr", "CreateSessionObject", () => rpcWithRetry("createCubeExpr", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois-expr" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
qMeasures: [
{ qDef: { qDef: eCa } },
{ qDef: { qDef: eQte } },
{ qDef: { qDef: eNbMag } },
{ qDef: { qDef: eMarge } },
// Master « Quantité N », uniquement pour le calibrage : sur les
// mois de l'année en cours les deux doivent coïncider.
...(avecMaster ? [{ qLibraryId: rQte }] : []),
],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: largeur, qHeight: PAGEX }],
} } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
const oh = cube.handle;
const process = (matrix: Array<Array<{ qText?: string; qNum?: number }>>): number => {
for (const r of matrix) {
onRow(
String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""),
Number(r[2]?.qNum) || 0, Number(r[3]?.qNum) || 0,
Number(r[4]?.qNum) || 0, Number(r[5]?.qNum) || 0,
avecMaster ? Number(r[6]?.qNum) || 0 : 0,
);
}
return matrix.length;
};
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, oh, onCode15Retry), timings);
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hc.qSize.qcy;
let got = 0, top = 0;
const firstPage = hc.qDataPages?.find((pg) => (pg.qArea?.qTop ?? 0) === 0);
if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEX; }
for (; top < size; top += PAGEX) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: largeur, qHeight: PAGEX }] }, oh, onCode15Retry), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += process(matrix);
if (matrix.length < PAGEX) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
/**
* Passe unique par expressions : sélectionne la fenêtre 12 mois et tous
* les codes, puis lit un seul cube [Article Code, Mois].
*
* Renvoie false si le résultat est inexploitable (expression invalide,
* champ absent) — l'appelant repart alors sur les master measures.
*/
/**
* Total de contrôle : un cube 1 cellule, sans dimension, sur
* l'expression quantité. Sert à savoir si une sélection de dates
* FILTRE réellement les faits — ce que les master measures, qui
* l'ignorent, ne permettaient pas de vérifier.
*/
const totalDeControle = async (): Promise<number> => {
const o = await rpc("CreateSessionObject", { qProp: {
qInfo: { qType: "cf-ctrl" },
qHyperCubeDef: {
qDimensions: [],
qMeasures: [{ qDef: { qDef: exprQte } }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 1, qHeight: 1 }],
},
} }, doc);
const ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
try {
const lay = await rpc("GetLayout", {}, ret.qHandle);
const cell = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qNum?: number; qText?: string }>> }> } })
.qHyperCube?.qDataPages?.[0]?.qMatrix?.[0]?.[0];
return Number(cell?.qNum) || 0;
} finally {
const id = String(ret.qGenericId ?? ret.qId ?? "");
if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } }
}
};
/**
* 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<number | null> => {
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 null;
} catch { return null; }
await rpc("Clear", {}, h);
await sleep(qlikSettleMs);
for (let i = 0; i < valeurs.length; i += 500) {
await rpc("SelectValues", {
qFieldValues: valeurs.slice(i, i + 500).map((v) => ({ qNum: v, qText: String(v) })),
qToggleMode: i > 0,
qSoftLock: true,
}, h);
}
await sleep(qlikSettleMs);
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<string>,
): 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<Array<{ qText?: string; qElemNumber?: number }>>;
}>;
};
}).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<Array<{ qText?: string; qElemNumber?: number }>>;
}>)?.[0]?.qMatrix ?? [];
for (const row of matrix) if (row[0]) cellules.push(row[0]);
if (matrix.length === 0) break;
}
const parMois = new Map<string, number>();
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 */ }
}
}
};
/**
* 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({<Type_Cal={'N'}>} quantite)
* « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} 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.
*/
/**
* 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<boolean> => {
if (!fournisseurCode || codes.length === 0) return false;
const attendus = new Set(codes.map((c) => c.trim()));
/**
* Au-delà de ce nombre de codes visibles, la sélection fournisseur
* ne restreint rien (elle laisse tout le catalogue) : inutile de
* finir la lecture, la réponse est déjà connue.
*/
const plafond = attendus.size * 3;
/** Codes articles visibles sous la sélection courante. */
const lireCodesVisibles = async (): Promise<Set<string>> => {
const vus = new Set<string>();
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<Array<{ qText?: string }>> }> } }).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<Array<{ qText?: string }>> }>)?.[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);
}
// Inutile de lire tout le catalogue pour conclure : au-delà
// du plafond, la sélection ne restreint manifestement rien.
// Mesuré : 1 207 205 codes lus en 240 pages, ×3 champs =
// 87 s perdues avant un rejet joué d'avance.
if (vus.size > plafond) break;
}
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();
// Un vrai périmètre fournisseur est de l'ordre de grandeur des
// codes demandés. Mesuré en production : « J009 » laissait
// 1 207 205 articles visibles, soit tout le catalogue — la
// sélection n'avait rien restreint, et la « couverture » de 97 %
// ne faisait que constater que le catalogue contient nos codes.
if (visibles.size > plafond) {
diag(
" champ « " + champ + " » = « " + fournisseurCode + " » → plus de " + plafond +
" articles visibles pour " + attendus.size + " demandés : la sélection ne restreint rien, ignorée",
);
await rpc("Clear", {}, h);
await sleep(qlikSettleMs);
continue;
}
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;
};
/** Valeur de `Période` à poser avant la passe d'une année donnée. */
const periodeParAnnee = new Map<number, { texte: string | null }>();
/**
* Pose (ou efface, si `texte` est nul) la sélection `Période`.
*
* Passe par le HANDLE DU CHAMP, comme pour `Année` et `Date` : c'est la
* seule API qui expose `Clear` et `SelectValues`. Ne propage jamais
* d'erreur — une `Période` qu'on n'arrive pas à poser doit dégrader le
* résultat, pas interrompre l'extraction (leçon de production : un
* `Clear` en échec a fait avorter deux chemins entiers).
*/
const appliquerPeriode = async (texte: string | null): Promise<boolean> => {
if (pfh === -1) return texte == null;
try {
await rpc("Clear", {}, pfh);
await sleep(qlikSettleMs);
if (texte == null) return true;
const sel = await rpc("SelectValues", {
qFieldValues: [{ qText: texte }],
qToggleMode: false,
qSoftLock: true,
}, pfh);
await sleep(qlikSettleMs);
return sel.qReturn !== false;
} catch (e) {
diag(" sélection Période « " + (texte ?? "(aucune)") + " » impossible : " + String((e as Error)?.message || e));
return false;
}
};
/**
* Choisit la valeur de `Période` sous laquelle l'extraction aura lieu.
*
* CE QUE DIT LA MESURE (production, 2026-08-01, sonde paginée) :
*
* Période « (aucune) » → master exposée : 2024-01…2026-07 (31 mois)
*
* Sans aucune `Période` sélectionnée, « Quantité N » couvre **31 mois**,
* et une seule passe (`Année=2026`) sert les 12 mois de la fenêtre.
* L'état libre est donc le bon — encore fallait-il pouvoir le voir.
*
* Deux erreurs successives l'avaient masqué, toutes deux dans l'outil
* de mesure et non dans le modèle Qlik :
* 1. la sonde ne lisait que les 60 premières lignes du cube `[Mois]`,
* donc jamais les mois récents → « 0/12 » pour toute valeur ;
* 2. `Clear` était appelée sur le list object au lieu du champ, ce qui
* faisait avorter la cartographie entière avant son premier essai.
*
* D'où le principe retenu ici : **ne rien présumer, tout mesurer**. La
* couverture est évaluée par année et dans le contexte exact où elle
* sera exploitée (`monthDimPath` pose la même valeur), l'état sans
* `Période` étant un candidat comme un autre — et, en l'occurrence, le
* gagnant.
*/
const choisirPeriode = async (annees: number[]): Promise<number> => {
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<Array<{ qText?: string; qElemNumber?: number }>> }> } })
.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 0;
/**
* Couverture réelle : quels mois « Quantité N » rend-elle vivants ?
*
* ⚠️ Le cube est PAGINÉ intégralement. La version précédente ne
* lisait que les 60 premières lignes : la dimension Mois porte
* tout l'historique de l'app, les mois récents tombaient hors
* de la première page, et la sonde annonçait 0/12 quelle que
* soit la `Période` — alors que la master vivait (mesuré :
* total 9 721 153). C'est ce faux zéro qui faisait effacer
* `Période` et condamnait toute l'extraction.
*
* Renvoie les mois vivants DE LA FENÊTRE, et journalise aussi
* ce que la dimension expose hors fenêtre : sans cela on ne
* distingue pas « le modèle ne donne rien » de « le modèle
* donne autre chose que ce qu'on attend ».
*/
const mesurerCouverture = async (): Promise<{ fenetre: string[]; tous: string[] }> => {
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: 500 }],
},
} }, doc);
const sRet = sonde.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
try {
const sl = await rpc("GetLayout", {}, sRet.qHandle);
const hc = (sl.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const lignes: Array<Array<{ qText?: string; qNum?: number }>> = [...(hc?.qDataPages?.[0]?.qMatrix ?? [])];
const total = Number(hc?.qSize?.qcy) || 0;
for (let top = lignes.length; top < total; top += 500) {
const d = await rpc("GetHyperCubeData", {
qPath: "/qHyperCubeDef",
qPages: [{ qTop: top, qLeft: 0, qWidth: 2, qHeight: Math.min(500, total - top) }],
}, sRet.qHandle);
const m = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (m.length === 0) break;
lignes.push(...m);
}
const vivants = lignes
.filter((r) => (Number(r[1]?.qNum) || 0) > 0)
.map((r) => normMois(String(r[0]?.qText ?? "")))
.sort();
return { fenetre: vivants.filter((m) => fenetre.has(m)), tous: vivants };
} finally {
const sid = String(sRet.qGenericId ?? sRet.qId ?? "");
if (sid) { try { await rpc("DestroySessionObject", { qId: sid }, doc); } catch { /* noop */ } }
}
};
/** Résumé compact d'une liste de mois : « 2025-01…2025-12 (12) ». */
const resume = (mois: string[]) =>
mois.length === 0 ? "aucun" : mois[0] + "…" + mois[mois.length - 1] + " (" + mois.length + ")";
/**
* Couverture d'une `Période` telle qu'elle sera RÉELLEMENT
* exploitée : une passe par année, comme `monthDimPath`, puis
* union. C'est le seul chiffre qui a un sens ici — mesurer
* sans année revient à juger « Année à date » sur la seule
* année en cours (6/12) et à la rejeter à tort.
*/
// ─── Une `Période` PAR ANNÉE ─────────────────────────────
//
// C'est le point que la version globale manquait : la bonne
// valeur dépend de l'année analysée. « Année à date » convient
// à 2026 (janvier → mois courant) mais pas à 2025, dont on veut
// les 12 mois. On cartographie donc, pour chaque année de la
// fenêtre, la valeur qui sert le PLUS de mois attendus de cette
// année-là — l'état sans `Période` étant un candidat comme un
// autre.
const couvertsGlobal = new Set<string>();
for (const annee of annees) {
const attendusAnnee = [...fenetre].filter((m) => m.startsWith(String(annee) + "-"));
diag(" année " + annee + " — mois attendus " + JSON.stringify(attendusAnnee.sort()));
if (yfh !== -1) {
await rpc("Clear", {}, yfh);
await sleep(qlikSettleMs);
await rpc("SelectValues", {
qFieldValues: [{ qNum: annee, qText: String(annee) }],
qToggleMode: false,
qSoftLock: true,
}, yfh);
await sleep(qlikSettleMs);
}
// `null` = état sans `Période`, candidat comme un autre.
const candidats: Array<string | null> = [null, ...valeurs.map((v) => v.texte)];
let meilleure: { texte: string | null; servis: string[] } | null = null;
for (const c of candidats) {
const libelle = c ?? "(aucune)";
// Un candidat qui échoue ne doit pas emporter toute la
// cartographie : on le note et on passe au suivant.
let servis: string[] = [];
try {
const pose = await appliquerPeriode(c);
if (!pose && c !== null) {
diag(" Période « " + libelle + " » → non sélectionnable, ignorée");
continue;
}
const mois = await mesurerCouverture();
servis = mois.fenetre.filter((m) => attendusAnnee.includes(m));
diag(
" Période « " + libelle + " » → sert " + servis.length + "/" + attendusAnnee.length +
" mois attendus " + JSON.stringify(servis) + " ; master exposée : " + resume(mois.tous),
);
} catch (e) {
diag(" Période « " + libelle + " » → erreur " + String((e as Error)?.message || e));
continue;
}
if (!meilleure || servis.length > meilleure.servis.length) {
meilleure = { texte: c, servis };
}
if (servis.length >= attendusAnnee.length) break; // année complète
}
if (meilleure) {
periodeParAnnee.set(annee, { texte: meilleure.texte });
for (const m of meilleure.servis) couvertsGlobal.add(m);
diag(
" → année " + annee + " sera extraite sous Période « " + (meilleure.texte ?? "(aucune)") + " » (" +
meilleure.servis.length + "/" + attendusAnnee.length + " mois)",
);
}
}
if (yfh !== -1) {
// Chaque passe pose sa propre année.
await rpc("Clear", {}, yfh);
await sleep(qlikSettleMs);
}
return couvertsGlobal.size;
} catch (e) {
diag("choix de « Période » impossible : " + String((e as Error)?.message || e));
return 0;
} finally {
// Le list object est conservé jusqu'au bout : le détruire
// annulerait la sélection qu'il porte.
void lo;
}
};
/**
* Teste UNE expression isolément sur un petit cube `[Mois]`.
*
* Une seule mesure invalide suffit à faire renvoyer **zéro ligne** à
* tout un hypercube — observé en production : le cube à 5 mesures
* calculait 26 s puis rendait 0 ligne, alors que `Sum(quantite)` seul
* donnait 35 424 324. Impossible de savoir laquelle sans les isoler.
*
* Renvoie l'expression si elle produit des lignes, sinon `"0"` — une
* colonne neutre qui préserve les indices sans casser le cube.
*/
const validerExpression = async (nom: string, expr: string): Promise<string> => {
let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null;
try {
const o = await rpc("CreateSessionObject", { qProp: {
qInfo: { qType: "cf-test-expr" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: moisDimId }],
qMeasures: [{ qDef: { qDef: expr } }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }],
},
} }, doc);
ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const lay = await rpc("GetLayout", {}, ret.qHandle);
const hc = (lay.qLayout as { qHyperCube?: { qSize?: { qcy?: number }; qError?: unknown; qDataPages?: Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const matrix = hc?.qDataPages?.[0]?.qMatrix ?? [];
const lignes = Number(hc?.qSize?.qcy) || 0;
let total = 0;
let texteErreur = "";
for (const r of matrix) {
total += Number(r[1]?.qNum) || 0;
const t = String(r[1]?.qText ?? "");
if (!texteErreur && /error|erreur|garbage|^-$/i.test(t)) texteErreur = t;
}
if (hc?.qError) texteErreur = texteErreur || JSON.stringify(hc.qError);
if (lignes === 0 || texteErreur) {
diag(" mesure « " + nom + " » = " + expr + " → INVALIDE (" + (texteErreur || "cube vide") + ")");
return "0";
}
diag(" mesure « " + nom + " » = " + expr + " → " + lignes + " mois, total=" + Math.round(total));
return expr;
} catch (e) {
diag(" mesure « " + nom + " » = " + expr + " → ERREUR " + String((e as Error)?.message || e));
return "0";
} finally {
const id = String(ret?.qGenericId ?? ret?.qId ?? "");
if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } }
}
};
/**
* La master « Quantité N » produit-elle la moindre ligne sur `[Mois]`
* dans l'état de sélection courant ? Même sonde que
* `validerExpression`, mais par `qLibraryId`.
*/
const masterQteVivante = async (): Promise<boolean> => {
let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null;
try {
const o = await rpc("CreateSessionObject", { qProp: {
qInfo: { qType: "cf-test-master" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: moisDimId }],
qMeasures: [{ qLibraryId: rQte }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 30 }],
},
} }, doc);
ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const lay = await rpc("GetLayout", {}, ret.qHandle);
const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qNum?: number }>> }> } }).qHyperCube;
let total = 0;
for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0;
diag(" master « Quantité N » (calibrage) → total=" + Math.round(total));
return total > 0;
} catch (e) {
diag(" master « Quantité N » → ERREUR " + String((e as Error)?.message || e));
return false;
} finally {
const id = String(ret?.qGenericId ?? ret?.qId ?? "");
if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } }
}
};
/**
* Somme de `expr` VENTILÉE par la dimension Mois.
*
* Comparée au total sans dimension, elle dit si la dimension Mois
* démultiplie les faits à travers le pont de périodes — auquel cas
* l'agrégation directe est inexploitable.
*/
const totalVentileParMois = async (expr: string): Promise<number> => {
let ret: { qHandle: number; qGenericId?: string; qId?: string } | null = null;
try {
const o = await rpc("CreateSessionObject", { qProp: {
qInfo: { qType: "cf-ventil-mois" },
qHyperCubeDef: {
qDimensions: [{ qLibraryId: moisDimId }],
qMeasures: [{ qDef: { qDef: expr } }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 2, qHeight: 200 }],
},
} }, doc);
ret = o.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const lay = await rpc("GetLayout", {}, ret.qHandle);
const hc = (lay.qLayout as { qHyperCube?: { qDataPages?: Array<{ qMatrix?: Array<Array<{ qNum?: number }>> }> } }).qHyperCube;
let total = 0;
for (const r of hc?.qDataPages?.[0]?.qMatrix ?? []) total += Number(r[1]?.qNum) || 0;
return total;
} catch {
return 0;
} finally {
const id = String(ret?.qGenericId ?? ret?.qId ?? "");
if (id) { try { await rpc("DestroySessionObject", { qId: id }, doc); } catch { /* noop */ } }
}
};
/**
* Cube `[Article Code] × 4 expressions`, SANS dimension Mois.
*
* C'est tout l'intérêt : la dimension Mois passe par le pont de
* périodes et rattache une même ligne de fait à plusieurs contextes
* (mesuré : ×5,3). Sans elle, `Sum(quantite)` porte une fois et une
* seule sur les faits visibles — donc sur le mois sélectionné.
*/
const fetchCodeCubeExpr = async (
eCa: string, eQte: string, eNbMag: string, eMarge: string,
onRow: (code: string, ca: number, qte: number, nbMag: number, marge: number) => void,
timings?: Record<string, number>,
): Promise<number> => {
const LARGEUR = 5;
const PAGE = 1800; // 5 × 1800 = 9000 cellules < 10000
const { value: obj } = await timed("createCubeCode", "CreateSessionObject", () => rpcWithRetry("createCubeCode", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-code-expr" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }],
qMeasures: [
{ qDef: { qDef: eCa } },
{ qDef: { qDef: eQte } },
{ qDef: { qDef: eNbMag } },
{ qDef: { qDef: eMarge } },
],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }],
} } }, doc), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
const traiter = (matrix: Array<Array<{ qText?: string; qNum?: number }>>): number => {
for (const r of matrix) {
onRow(
String(r[0]?.qText ?? ""),
Number(r[1]?.qNum) || 0, Number(r[2]?.qNum) || 0,
Number(r[3]?.qNum) || 0, Number(r[4]?.qNum) || 0,
);
}
return matrix.length;
};
try {
const { value: layout } = await timed("layout", "GetLayout", () => rpcWithRetry("layout", "GetLayout", {}, cube.handle), timings);
const hc = (layout.qLayout as { qHyperCube: { qSize: { qcy: number }; qDataPages?: Array<{ qArea?: { qTop?: number }; qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }> } }).qHyperCube;
const size = hc.qSize.qcy;
let got = 0, top = 0;
const p0 = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
if (p0?.qMatrix?.length) { got += traiter(p0.qMatrix); top = PAGE; }
for (; top < size; top += PAGE) {
const { value: d } = await timed("getCubeData", "GetHyperCubeData", () => rpcWithRetry("getCubeData", "GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: LARGEUR, qHeight: PAGE }] }, cube.handle), timings);
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break;
got += traiter(matrix);
if (matrix.length < PAGE) break;
}
return got;
} finally {
await destroyCube(cube, timings);
}
};
/**
* EXTRACTION MOIS PAR MOIS SUR LE CHAMP DATE BRUT.
*
* Le chemin que rien n'avait encore essayé, et le seul qui n'emprunte
* ni le pont de périodes ni les master measures :
*
* - on sélectionne les jours d'UN mois sur le champ date ;
* - on lit un cube `[Article Code]` sans dimension Mois, donc sans
* démultiplication ;
* - `Type_Cal`, `Période` et `Année` sont effacés : les expressions
* brutes n'ont pas besoin du pont, elles agrègent les faits visibles.
*
* Il n'y a donc plus de « mois inatteignable » : chaque mois est un
* filtre de dates, pas un contexte de calendrier.
*
* CONTRÔLE D'INTÉGRITÉ. La somme des 12 mois ne doit pas dépasser le
* total sans filtre de date : si la dimension ou le champ démultipliait
* les faits, elle exploserait (le pont donne ×5,3). Et chaque mois doit
* être servi. Sinon rien n'est écrit.
*/
const moisParDateBrute = async (): Promise<boolean> => {
const timings: Record<string, number> = {};
// Aucun contexte de calendrier : on veut les faits nus.
if (yfh !== -1) { await rpc("Clear", {}, yfh); await sleep(qlikSettleMs); }
await appliquerPeriode(null);
if (dfh !== -1) { await rpc("Clear", {}, dfh); await sleep(qlikSettleMs); }
if (codes.length && fh !== -1 && !parFournisseur) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
await sleep(qlikSettleMs);
await timed("selectCodeAll", "SelectValues", () => rpcWithRetry("selectCodeAll", "SelectValues", {
qFieldValues: codes.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh, () => { aggCode15Retries++; }), timings);
await sleep(qlikSettleMs);
}
const sansFiltre = await totalDeControle();
diag("[date brute] contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre));
if (sansFiltre <= 0) {
diag("[date brute] ÉCHEC : l'expression quantité ne renvoie rien (corriger QLIK_EXPR_QTE)");
return false;
}
// ─── Quel champ date filtre les faits ? ──────────────────
//
// ⚠️ On teste avec UN SEUL MOIS, pas avec la fenêtre entière.
// Toute la table de faits tient dans les 12 mois (mesuré : 100 %
// du total) : sélectionner la fenêtre ne réduit donc RIEN, et
// l'ancien test « le total doit baisser » rejetait tous les
// champs, y compris les bons.
const moisTest = monthlyPayload[Math.floor(monthlyPayload.length / 2)];
let champDate: string | null = null;
let handleDate = -1;
for (const cand of champsDateCandidats) {
const valeurs = /key/i.test(cand) ? moisTest.ymd : moisTest.serials;
const h = await selectionnerFenetreSur(cand, valeurs);
if (h == null) { diag("[date brute] champ « " + cand + " » : inexistant"); continue; }
const t = await totalDeControle();
const part = sansFiltre > 0 ? t / sansFiltre : 0;
diag(
"[date brute] champ « " + cand + " » sur le seul mois " + moisTest.label + " → " +
Math.round(t) + " (" + (part * 100).toFixed(1) + "% du total)",
);
// Un mois isolé doit peser une fraction du total : ni zéro
// (le champ ne touche pas les faits) ni la totalité (il ne
// filtre pas).
if (t > 0 && part < 0.5) { champDate = cand; handleDate = h; break; }
await rpc("Clear", {}, h);
await sleep(qlikSettleMs);
}
if (!champDate || handleDate === -1) {
diag(
"[date brute] ÉCHEC : aucun champ date ne filtre les faits mois par mois " +
"(essayés : " + JSON.stringify(champsDateCandidats) + ")",
);
return false;
}
diag("[date brute] champ date retenu : « " + champDate + " »");
// À partir d'ici la série sera reconstruite entièrement : les
// passes annuelles (master measures) et la série brute n'ont pas
// la même sémantique, on ne les mélange pas.
out.length = 0;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
const totauxMois: Record<string, number> = {};
let sommeMois = 0;
for (let i = 0; i < monthlyPayload.length; i++) {
const m = monthlyPayload[i];
const valeurs = /key/i.test(champDate) ? m.ymd : m.serials;
await rpc("Clear", {}, handleDate);
await sleep(qlikSettleMs);
await rpcWithRetry("selectDate", "SelectValues", {
qFieldValues: valeurs.map((v) => ({ qNum: v, qText: String(v) })),
qToggleMode: false,
qSoftLock: true,
}, handleDate, () => { aggCode15Retries++; });
await sleep(qlikSettleMs);
let totalDuMois = 0;
const lignes = await fetchCodeCubeExpr(exprCa, exprQte, exprNbMag, exprMarge, (code, ca, qte, nbMag, marge) => {
if (!code || code === "-") return;
if (qte === 0 && ca === 0) return;
totalDuMois += qte;
out.push([code, ca, qte, nbMag, nbMag > 0 ? ca / nbMag : 0, ca > 0 ? marge / ca : 0]);
(monthlyByCode[code] ??= {});
monthlyByCode[code][m.label] = {
qte, ca, nbMag,
caMag: nbMag > 0 ? ca / nbMag : 0,
margePct: ca > 0 ? marge / ca : 0,
};
}, timings);
totauxMois[m.label] = totalDuMois;
sommeMois += totalDuMois;
aggBatches++;
console.log(
"[qlik-pw][date brute] mois " + (i + 1) + "/" + monthlyPayload.length + " " + m.label +
" → " + lignes + " articles, quantité=" + Math.round(totalDuMois),
);
await pousserCheckpoint("date brute " + m.label);
}
aggMonths = monthlyPayload.length;
diag("[date brute] quantités par mois : " + JSON.stringify(
Object.fromEntries(Object.entries(totauxMois).map(([k, v]) => [k, Math.round(v)])),
));
// ─── Intégrité ───────────────────────────────────────────
const vides = Object.entries(totauxMois).filter(([, v]) => v <= 0).map(([k]) => k);
if (vides.length) {
diag("[date brute] ÉCHEC : mois sans aucune vente " + JSON.stringify(vides));
return false;
}
if (sommeMois > sansFiltre * 1.02) {
diag(
"[date brute] ÉCHEC : la somme des 12 mois (" + Math.round(sommeMois) +
") dépasse le total sans filtre (" + Math.round(sansFiltre) + ") — les faits sont " +
"démultipliés, les ventes réseau seraient fausses",
);
return false;
}
diag(
"[date brute] OK — somme des 12 mois = " + Math.round(sommeMois) + " pour un total sans filtre de " +
Math.round(sansFiltre) + " (" + Math.round((sommeMois / sansFiltre) * 100) + "%), " +
Object.keys(monthlyByCode).length + " articles",
);
console.log("[qlik-pw][date brute] timings=" + timingSummary(timings));
return true;
};
const moisParExpressions = async (): Promise<boolean> => {
const timings: Record<string, number> = {};
const marqueOut = out.length;
try {
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. 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", {
qFieldValues: codes.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh, () => { aggCode15Retries++; }), timings);
await sleep(qlikSettleMs);
}
// ─── Quel champ date filtre RÉELLEMENT les faits ? ───────
//
// Les master measures ignorant la sélection Date, personne
// n'avait jamais pu vérifier que « Date » filtrait quoi que
// ce soit. Un champ valide doit donner un total non nul ET
// strictement inférieur au total sans filtre.
const sansFiltre = await totalDeControle();
diag("contrôle « " + exprQte + " » sans filtre date = " + Math.round(sansFiltre));
if (sansFiltre <= 0) {
diag("ÉCHEC : « " + exprQte + " » renvoie 0 même sans filtre → expression ou champ invalide (corriger QLIK_EXPR_QTE)");
return false;
}
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;
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)");
// ⚠️ 100 % ne veut PAS dire « ce champ ne filtre rien » : toute
// la table de faits tient dans les 12 mois (mesuré), donc
// sélectionner la fenêtre entière ne retire rien. Exiger une
// baisse rejetait donc les champs valides. Ici la sélection
// ne sert qu'à borner la fenêtre : un total non nul suffit,
// et on préfère simplement un champ qui filtre s'il existe.
if (filtre > 0) {
champDate = cand;
totalFenetre = filtre;
if (ratio < 0.995) break;
diag(" (la fenêtre couvre 100 % des faits — normal si l'app ne porte que ces 12 mois)");
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 : 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;
}
diag("champ date retenu : « " + champDate + " »");
// Chaque expression est validée SÉPARÉMENT : une seule mesure
// invalide fait rendre zéro ligne à tout l'hypercube, sans
// qu'aucun message ne dise laquelle.
diag("validation des expressions, une par une :");
const vQte = await validerExpression("quantité", exprQte);
if (vQte === "0") {
diag("ÉCHEC : l'expression quantité est inutilisable, rien à extraire");
return false;
}
const vCa = await validerExpression("CA", exprCa);
const vNbMag = await validerExpression("magasins", exprNbMag);
const vMarge = await validerExpression("marge", exprMarge);
// La master « Quantité N » n'est là que pour le calibrage, mais
// une master morte suffit à vider tout l'hypercube. On la sonde
// donc comme les autres, et on l'omet plutôt que de perdre
// l'extraction entière pour une colonne de contrôle.
const avecMaster = await masterQteVivante();
if (!avecMaster) {
diag(" master « Quantité N » morte sous cette sélection → colonne de calibrage retirée du cube");
}
// ─── La dimension Mois démultiplie-t-elle les faits ? ────
//
// Le pont de périodes rattache une même ligne de fait à
// plusieurs contextes. Ventilé par `Mois`, `Sum(quantite)` peut
// donc dépasser le total sans dimension — mesuré : 179 574 616
// contre 33 945 285, soit ×5,3. Écrire cela dans le cache
// donnerait des ventes réseau cinq fois trop hautes : on refuse.
const totalVentile = await totalVentileParMois(vQte);
if (totalVentile > totalFenetre * 1.05) {
diag(
"ÉCHEC : ventilée par Mois, « " + vQte + " » totalise " + Math.round(totalVentile) +
" contre " + Math.round(totalFenetre) + " sans dimension (×" +
(totalFenetre > 0 ? (totalVentile / totalFenetre).toFixed(1) : "?") +
"). La dimension Mois passe par le pont de périodes et démultiplie les faits — " +
"l'agrégation directe donnerait des ventes réseau fausses, elle est refusée.",
);
return false;
}
// Seuls les mois de la fenêtre nous intéressent ; la dimension
// Mois peut en exposer d'autres si une sélection déborde.
let totalQte = 0;
let calibrees = 0, calibrOk = 0;
let horsFenetre = 0;
let echantillon = false;
const got = await timed("cube", "expr", () => fetchMonthCubeExpr(vCa, vQte, vNbMag, vMarge, (code, mois, ca, qte, nbMag, marge, qteMaster) => {
if (!code || code === "-") return;
const mm = normMois(mois);
if (!echantillon) {
echantillon = true;
// É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.
diag("échantillon brut: " + JSON.stringify({
code, moisBrut: mois, moisNormalise: mm, dansLaFenetre: moisFenetre.has(mm),
ca, qte, nbMag, marge, "Quantité N (master)": qteMaster,
}));
}
if (!moisFenetre.has(mm)) { horsFenetre++; return; }
// Calibrage sur l'année en cours, seul périmètre où la master
// measure est censée être juste.
if (avecMaster && parseInt(mm.slice(0, 4), 10) === yearN && (qte > 0 || qteMaster > 0)) {
calibrees++;
if (Math.abs(qte - qteMaster) <= Math.max(1, qteMaster * 0.02)) calibrOk++;
}
totalQte += qte;
// 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: qteCumulee,
ca: caCumule,
nbMag: Math.max(precedent?.nbMag ?? 0, nbMag),
caMag: nbMag > 0 ? ca / nbMag : 0,
margePct: caCumule > 0 ? margeMontant / caCumule : 0,
};
}, timings, undefined, avecMaster), timings);
aggMonths = monthlyPayload.length;
aggBatches++;
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) {
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.",
);
out.length = marqueOut;
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<string, number> = {};
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 +
" diverge de « Quantité N ». Vérifier l'expression (QLIK_EXPR_QTE).",
);
}
return true;
} catch (e) {
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;
}
};
// Sélectionne toute la fenêtre de dates une fois, puis parcourt les codes par
// lots ; un seul cube [Article Code, Mois] par lot donne TOUS les mois d'un coup.
// selectYear = null → passe principale (année N + N-1 dérivée via COMP).
// selectYear = 2025 → passe complémentaire : on sélectionne l'année pour
// récupérer directement ses mois (seul moyen d'obtenir août→déc N-1, que
// COMP ne peut pas fournir puisque l'année N ne va pas jusque-là).
let loggedSampleRow = false;
const monthDimPath = async (selectYear: number): Promise<void> => {
const moisFenetre = new Set(monthlyPayload.map((m) => m.label));
/**
* Traitement d'une ligne du cube [Article Code, Mois].
*
* Une passe = UNE année explicitement sélectionnée, exactement ce
* que fait un utilisateur dans l'app (« je sélectionne 2026 et
* j'obtiens N + la comparaison N-1 »). Chaque passe n'écrit que
* les mois DE LA FENÊTRE, et les totaux s'additionnent sur ces
* mois-là : deux années différentes ne peuvent donc pas se
* doubler, puisqu'elles ne partagent aucun mois.
*/
const onRowMois = (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => {
if (!code || code === "-") return;
const mm = normMois(mois);
if (!moisFenetre.has(mm)) return;
out.push([code, ca, qte, nbMag, caMag, marge]);
const y = parseInt(mm.slice(0, 4), 10);
if (!loggedSampleRow) {
loggedSampleRow = true;
console.log("[qlik-pw][mesures] échantillon ligne mensuelle: " + JSON.stringify({
code, mois: mm, "CA N": ca, "Quantité N (utilisée pour la courbe)": qte,
"Magasin Ventes Nb N": nbMag, "CA par Magasin N": caMag, "Marge % N": marge, "Quantité COMP": qteComp,
}));
}
(monthlyByCode[code] ??= {});
// Les 5 mesures du mois courant de la ligne. Le cube les renvoie
// déjà toutes : les conserver ne coûte aucune requête Qlik en plus.
const full = { qte, ca, nbMag, caMag, margePct: marge };
// « Quantité COMP » n'est plus nécessaire : chaque année de la
// fenêtre est extraite directement par sa propre passe, ce qui
// donne les 5 mesures et non la seule quantité.
void y; void qteComp;
// Une passe ultérieure ne doit JAMAIS écraser un mois servi par
// un zéro : hors de son année, la passe voit la combinaison
// (article, mois) mais toutes ses mesures sont nulles.
const precedent = monthlyByCode[code][mm];
if (precedent && (precedent.qte ?? 0) > 0 && qte <= 0) return;
monthlyByCode[code][mm] = full;
};
if (yfh === -1) throw new Error("champ « Année » introuvable : impossible d'extraire année par année");
// La `Période` qui sert cette année-là — pas la même pour toutes :
// « Année à date » convient à l'année en cours, une autre valeur à
// une année révolue (cf. choisirPeriode).
const periodeAnnee = periodeParAnnee.get(selectYear);
if (periodeAnnee) {
console.log("[qlik-pw] (mois) Période « " + (periodeAnnee.texte ?? "(aucune)") + " » pour l'année " + selectYear);
await appliquerPeriode(periodeAnnee.texte);
}
await rpc("Clear", {}, yfh);
await sleep(qlikSettleMs);
const ysel = await rpc("SelectValues", { qFieldValues: [{ qNum: selectYear, qText: String(selectYear) }], qToggleMode: false, qSoftLock: true }, yfh);
console.log("[qlik-pw] (mois) sélection Année=" + selectYear + " → " + JSON.stringify(ysel.qReturn));
await sleep(qlikSettleMs);
const allSerials = monthlyPayload.flatMap((m) => m.serials);
if (dfh !== -1 && allSerials.length) {
await rpc("Clear", {}, dfh);
await sleep(qlikSettleMs);
await rpc("SelectValues", { qFieldValues: allSerials.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true }, dfh);
await sleep(qlikSettleMs);
}
aggMonths = monthlyPayload.length;
// ─── Chemin rapide : UNE seule sélection Article Code ────
//
// Mesuré en production : `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). Avec 7 200 codes en lots de 300, cela
// faisait 24 appels, ~15 min de sync, et la session Qlik
// mourait avant la fin (« Socket closed » puis « Execution
// context was destroyed »).
//
// La dimension Mois donne déjà tous les mois d'un coup : rien
// n'oblige à découper les codes. On tente donc un seul appel
// pour tous les codes et un seul cube paginé — la lecture des
// 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 || parFournisseur) && fh !== -1) {
const timingsAll: Record<string, number> = {};
try {
// 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 — " +
(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;
} catch (e) {
// Les lignes déjà lues seraient recomptées par le repli :
// on les retire (les écritures mensuelles, elles, sont
// des affectations idempotentes).
out.length = marqueOut;
console.log("[qlik-pw] (mois) chemin rapide échoué → repli par lots : " + String((e as Error)?.message || e));
}
}
// Lots plus petits = cubes plus légers (moins de pression mémoire côté
// moteur Qlik, qui a tendance à saturer "Out of memory" sur les gros cubes).
const fallbackSizes = [300, 150, 75];
const totalCodes = codes.length;
let codeIndex = 0, batchNumber = 0, fallbackIndex = 0;
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + fallbackSizes[fallbackIndex], totalCodes)) : [];
const timings: Record<string, number> = {};
let code15Retries = 0;
batchNumber++;
const bp = "[qlik-pw] (mois) lot " + batchNumber + " [" + codeIndex + "," + (codeIndex + batch.length) + ") size=" + batch.length;
try {
if (batch.length && fh !== -1) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
await sleep(qlikSettleMs);
await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
await sleep(qlikSettleMs);
}
const got = await timed("cube", "batch", () => fetchMonthCube(onRowMois, timings, () => { code15Retries++; }), timings);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + bp + " — rows=" + got.value + " cumul=" + out.length + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
if (!codes.length) break;
codeIndex += batch.length;
fallbackIndex = 0;
} catch (batchErr) {
const nextSize = fallbackSizes[fallbackIndex + 1];
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
console.log("[qlik-pw] " + bp + " — fallback code15 " + batch.length + "→" + nextSize);
fallbackIndex += 1;
aggFallbackCount++;
continue;
}
throw new Error(bp + " — " + String((batchErr as Error)?.message || batchErr));
}
}
};
// Chemin rapide (flag QLIK_SELECT_ALL_CODES) : sélectionne TOUS les
// Article Code une seule fois, puis n'itère que la sélection Date par
// mois avec UN cube paginé (au lieu de re-sélectionner les codes par lots
// de 150 à chaque mois — la SelectValues répétée coûte ~22 s × 120).
// Renvoie true si le mois complet a réussi ; false si repli code 15 (les
// résultats partiels et sélections sont réinitialisés avant le chemin par lots).
const trySelectAllCodesPath = async (): Promise<boolean> => {
const savedLen = out.length;
try {
if (codes.length && fh !== -1) {
await timed("clearCodeAll", "Clear", () => rpc("Clear", {}, fh), {});
await sleep(qlikSettleMs);
const { value: sel } = await timed("selectCodeAll", "SelectValues", () => rpc("SelectValues", {
qFieldValues: codes.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh), {});
console.log("[qlik-pw] select-all — SelectValues Article Code (" + codes.length + " codes) → " + JSON.stringify(sel.qReturn));
await sleep(qlikSettleMs);
}
const total = monthlyPayload.length;
console.log("[qlik-pw] chemin rapide select-all — " + total + " mois, 1 sélection Article Code globale");
for (let mi = 0; mi < total; mi++) {
const m = monthlyPayload[mi];
const prefix = "[qlik-pw] (rapide) mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
const monthStart = nowMs();
const monthTimings: Record<string, number> = {};
aggMonths = mi + 1;
let code15Retries = 0;
// Sélection Date du mois uniquement (la sélection Article Code reste active).
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
}, dfh), monthTimings);
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
await sleep(qlikSettleMs);
// Un seul cube paginé pour tous les articles du mois.
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, monthTimings, () => { code15Retries++; }), monthTimings);
out.push(...batchRows);
accMonthly(batchRows, m.label);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + prefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(monthTimings));
// Clear Date uniquement — on garde la sélection Article Code pour le mois suivant.
if (mi < total - 1) {
try { await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings); }
catch (e) { console.log(prefix + " — Clear Date ignoré: " + String((e as Error)?.message || e)); }
}
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " timings=" + timingSummary(monthTimings));
}
return true;
} catch (e) {
if (isEngineCode15(e)) {
console.log("[qlik-pw] chemin rapide échoué (code 15) → repli sur le chemin par lots");
out.length = savedLen;
for (const k of Object.keys(monthlyByCode)) delete monthlyByCode[k];
aggMonths = 0; aggBatches = 0; aggCode15Retries = 0;
try { await rpc("Clear", {}, dfh); if (fh !== -1) await rpc("Clear", {}, fh); } catch { /* noop */ }
await sleep(qlikSettleMs);
return false;
}
throw e;
}
};
// La passe de « rattrapage des mois vides » a été SUPPRIMÉE.
//
// Elle ré-extrayait un mois vide en ne sélectionnant que ses dates.
// Or les master measures ignorent la sélection Date : le cube lui
// renvoyait le total de période, qu'elle recopiait dans chacun des
// mois manquants. D'où les plateaux identiques d'août à décembre
// (87 648 sur douze mois pour un article, etc.) et les tendances
// « -162 % » qu'ils fabriquaient.
//
// Un mois qu'on ne sait pas extraire doit rester ABSENT.
/** Pousse l'état courant vers Node (voir `cfCheckpoint`). */
const pousserCheckpoint = async (etape: string) => {
try {
const fn = (window as unknown as { cfCheckpoint?: (p: unknown) => Promise<unknown> }).cfCheckpoint;
if (!fn) return;
await fn({ rows: out, monthly: monthlyByCode });
console.log("[qlik-pw] point de contrôle « " + etape + " » : " + out.length + " lignes");
} catch (e) {
console.log("[qlik-pw] point de contrôle « " + etape + " » ignoré : " + String((e as Error)?.message || e));
}
};
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();
const attendus = monthlyPayload.map((m) => m.label);
// Années de la fenêtre, la PLUS RÉCENTE D'ABORD — c'est l'usage de
// l'app (« je sélectionne 2026 »), et cela met les mois les plus
// utiles en tête des points de contrôle.
const annees = [...new Set(attendus.map((m) => Number(m.slice(0, 4))))].sort((a, b) => b - a);
// É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({<Type_Cal={'N'}>} 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(annees);
// É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 moisManquants = () => {
const vus = new Set<string>();
for (const parMois of Object.values(monthlyByCode)) {
for (const [mois, v] of Object.entries(parMois)) {
if ((v?.qte ?? 0) > 0) vus.add(mois);
}
}
return attendus.filter((m) => !vus.has(m));
};
const couvre12 = () => moisManquants().length === 0;
// ÉTAPE 2 — extraire ANNÉE PAR ANNÉE, comme dans l'app.
//
// Dans l'interface, on sélectionne « 2026 » et on obtient l'année
// plus sa comparaison N-1. C'est le mécanisme natif du modèle :
// `Type_Cal='N'` désigne l'année sélectionnée. Il suffit donc
// d'enchaîner les années couvertes par la fenêtre — ici 2025 puis
// 2026 — pour reconstituer les 12 mois glissants.
//
// Chaque passe est faite SOUS la `Période` retenue : c'est elle qui
// donne son sens à `Type_Cal='N'`. Avec `Année=2025`, « Année à
// date » vaut l'année 2025 entière (elle est passée) ; avec
// `Année=2026`, elle s'arrête au mois courant. L'union des deux
// passes reconstitue donc bien les 12 mois glissants.
diag("extraction année par année : " + JSON.stringify(annees));
let extraitParAnnee = false;
if (monthDim) {
try {
for (const annee of annees) {
const manquantsAvant = moisManquants();
await monthDimPath(annee);
const manquantsApres = moisManquants();
const gagnes = manquantsAvant.filter((m) => !manquantsApres.includes(m));
diag(
" année " + annee + " → mois de la fenêtre désormais servis : " + JSON.stringify(gagnes) +
" ; encore manquants : " + JSON.stringify(manquantsApres),
);
await pousserCheckpoint("année " + annee);
if (manquantsApres.length === 0) break; // fenêtre complète, inutile de continuer
}
extraitParAnnee = couvre12();
if (!extraitParAnnee) {
diag(
"l'extraction année par année ne couvre pas les 12 mois (manquants : " +
JSON.stringify(moisManquants()) + ") — essai de l'agrégation directe",
);
}
} catch (e) {
diag("extraction année par année interrompue : " + String((e as Error)?.message || e));
}
}
// REPLI 1 — mois par mois sur le champ date brut.
//
// Passe en premier car c'est le seul chemin sans pont de périodes :
// aucun mois n'y est « inatteignable », puisqu'un mois y est un
// filtre de dates et non un contexte de calendrier.
if (!extraitParAnnee && useExpr) {
try {
if (await moisParDateBrute()) {
extraitParAnnee = couvre12();
if (extraitParAnnee) {
diag("extraction par date brute retenue — les " + attendus.length + " mois sont servis");
await pousserCheckpoint("date brute complète");
} else {
diag("extraction par date brute incomplète (manquants : " + JSON.stringify(moisManquants()) + ")");
}
}
} catch (e) {
diag("extraction par date brute interrompue : " + String((e as Error)?.message || e));
}
}
if (!extraitParAnnee) {
if (!(monthDim && useExpr && (await moisParExpressions()))) {
throw new Error(
"extraction 12 mois refusée : ni l'extraction année par année " +
"(couverture master annoncée = " + couvertureMaster + "/" + attendus.length + ") " +
"ni l'agrégation directe des faits n'ont couvert la fenêtre — mois manquants : " +
JSON.stringify(moisManquants()),
);
}
await pousserCheckpoint("expressions vérifiées");
}
if (!couvre12()) {
throw new Error(
"extraction 12 mois refusée : la fenêtre " + JSON.stringify(attendus) +
" n'est pas entièrement couverte (manquants : " + JSON.stringify(moisManquants()) +
") — cache laissé inchangé",
);
}
} 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.
const timings: Record<string, number> = {};
let code15Retries = 0;
console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois");
if (codes.length && fh !== -1) {
const { value: sel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn));
}
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
out.push(...batchRows);
aggMonths = 1;
aggBatches = 1;
aggCode15Retries = code15Retries;
console.log("[qlik-pw][timing] noDate rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
} else if (selectAllCodes && (await trySelectAllCodesPath())) {
// Chemin rapide terminé avec succès (voir trySelectAllCodesPath).
// Si false (repli code 15), on tombe dans le chemin par lots ci-dessous.
} else {
// Itération par mois × lots de codes : pour chaque mois, SelectValues Date
// (~30 serials), puis SelectValues Article Code par petits lots. Le cube
// reste borné à ~30 jours × batch contrôlé, sans lire tout le catalogue Qlik.
const total = monthlyPayload.length;
const fallbackSizes = qlikArticleBatchFallbacks.length ? qlikArticleBatchFallbacks : [150, 75, 50];
const fallbackLabel = fallbackSizes.join(">");
const estimatedBatches = codes.length ? Math.ceil(codes.length / fallbackSizes[0]) : 1;
console.log("itération par mois — " + total + " mois × ~" + estimatedBatches + " lot(s), batchFallbacks=" + fallbackLabel);
for (let mi = 0; mi < total; mi++) {
const m = monthlyPayload[mi];
const prefix = "[qlik-pw] mois " + (mi + 1) + "/" + total + " " + m.label + " (" + m.dateDebut + "→" + m.dateFin + ", " + m.serials.length + "j)";
const monthStart = nowMs();
const monthTimings: Record<string, number> = {};
aggMonths = mi + 1;
try {
// 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs).
const { value: dsel } = await timed("selectDate", "SelectValues", () => rpc("SelectValues", {
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
qToggleMode: false,
qSoftLock: true,
}, dfh), monthTimings);
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
// Laisser l'Engine finaliser l'évaluation de la sélection Date
// avant la première SelectValues Article Code du 1er lot.
await sleep(qlikSettleMs);
// 2) Sélection Article Code par lots contrôlés.
let codeIndex = 0;
let batchNumber = 0;
let fallbackIndex = 0;
const totalCodes = codes.length || 0;
while (codes.length ? codeIndex < totalCodes : batchNumber === 0) {
const batchSize = codes.length ? fallbackSizes[fallbackIndex] : 0;
const batch = codes.length ? codes.slice(codeIndex, Math.min(codeIndex + batchSize, totalCodes)) : [];
const batchStart = codeIndex;
const batchEnd = codeIndex + batch.length;
const timings: Record<string, number> = {};
let code15Retries = 0;
batchNumber++;
const batchPrefix = prefix + " — lot " + batchNumber + " [" + batchStart + "," + batchEnd + ") size=" + batch.length;
try {
if (fh !== -1) {
await timed("clearCode", "Clear", () => rpc("Clear", {}, fh), timings);
// Laisser l'Engine appliquer le Clear avant la nouvelle sélection.
await sleep(qlikSettleMs);
const { value: csel } = await timed("selectCode", "SelectValues", () => rpc("SelectValues", {
qFieldValues: batch.map((c) => ({ qText: c })),
qToggleMode: false,
qSoftLock: true,
}, fh), timings);
console.log(batchPrefix + " — SelectValues Article Code → " + JSON.stringify(csel.qReturn));
// Laisser l'Engine finaliser l'évaluation de la sélection
// avant de créer / interroger le cube (évite code 15 sur le 1er lot).
await sleep(qlikSettleMs);
}
const batchRows: Array<Array<string | number>> = [];
const { value: got, ms } = await timed("cube", "batch", () => fetchCubeForSelection(batchRows, timings, () => { code15Retries++; }), timings);
out.push(...batchRows);
accMonthly(batchRows, m.label);
aggBatches++;
aggCode15Retries += code15Retries;
console.log("[qlik-pw][timing] " + batchPrefix + " — rows=" + got + " cumul=" + out.length + " ms=" + ms + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
if (!codes.length) break;
codeIndex = batchEnd;
fallbackIndex = 0;
} catch (batchErr) {
const msg = String((batchErr as Error)?.message || batchErr);
const nextFallbackIndex = fallbackIndex + 1;
const nextSize = fallbackSizes[nextFallbackIndex];
if (codes.length && isEngineCode15(batchErr) && nextSize && batch.length > nextSize) {
console.log("[qlik-pw][timing] " + batchPrefix + " — fallback code15 " + batch.length + "→" + nextSize + " retries15=" + code15Retries + " timings=" + timingSummary(timings));
fallbackIndex = nextFallbackIndex;
aggFallbackCount++;
continue;
}
console.error(batchPrefix + " — ABORT — " + msg);
throw new Error(batchPrefix + " — " + msg);
}
}
} catch (monthErr) {
const msg = String((monthErr as Error)?.message || monthErr);
console.error(prefix + " — ABORT — " + msg);
throw new Error(prefix + " — " + msg);
}
// 3) Clear Date + Article Code avant le mois suivant (soulage l'Engine).
if (mi < total - 1) {
try {
await timed("clearDate", "Clear", () => rpc("Clear", {}, dfh), monthTimings);
if (fh !== -1) await timed("clearCodeMonth", "Clear", () => rpc("Clear", {}, fh), monthTimings);
} catch (clearErr) {
console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr));
}
}
console.log("[qlik-pw][timing] " + prefix + " — done ms=" + Math.round(nowMs() - monthStart) + " cumul=" + out.length + " timings=" + timingSummary(monthTimings));
}
}
// La sonde de diagnostic du cube [Article Code, Mois] a été retirée :
// elle refaisait une sélection complète des codes et des 365 jours en
// fin de sync (des dizaines de secondes) alors que le découpage
// 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, 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, diagExpr: diagnostics });
}
})();
}),
{
app: cfg.appNetwork,
dim: cfg.dimCodeArticleId,
mca: cfg.measCaId,
mqte: cfg.measQteId,
mnb: cfg.measNbMagId,
mcamag: cfg.measCaMagId,
mmarge: cfg.measMargePctId,
codes: codeCentraux ?? [],
token: jeton,
monthlyPayload,
noDateMode: !dateFilter,
qlikSettleMs,
qlikCode15MaxAttempts,
qlikArticleBatchFallbacks,
selectAllCodes: qlikSelectAllCodes,
monthDim: qlikMonthDim,
moisDimId: qlikMoisDimId,
mqteComp: qlikMeasQteCompId,
yearN,
useExpr: qlikUseExpr,
exprQte: qlikExprQte,
exprCa: qlikExprCa,
exprNbMag: qlikExprNbMag,
exprMarge: qlikExprMarge,
champsDateCandidats: qlikDateFields,
fournisseurCode: (fournisseur ?? "").trim(),
champsFournisseurCandidats: qlikSupplierFields,
couvertureFournisseurMin: qlikSupplierMinCoverage,
},
);
let result: InPageResult;
try {
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<Array<string | number>>; 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`);
result = { ok: true, rows: secours.rows, monthly: secours.monthly, size: secours.rows.length, partiel: true };
} else {
throw e;
}
}
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`);
result = { ...result, ok: true, partiel: true };
} else {
throw new Error(`[qlik-pw] ${result.error}`);
}
}
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");
}
// Résumé timing final : synthèse compacte de la sync pour audit/debug.
// 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}`,
);
const wanted = codeCentraux ? new Set(codeCentraux) : null;
const out = new Map<string, NetworkMetric>();
const margeWeight = new Map<string, number>();
for (const r of result.rows ?? []) {
const code = String(r[0]).trim();
if (!code || code === "-") continue;
if (wanted && !wanted.has(code)) continue;
const ca = Number(r[1]) || 0;
const qte = Number(r[2]) || 0;
const nbMag = Number(r[3]) || 0;
const margePct = Number(r[5]) || 0;
const prev = out.get(code);
if (!prev) {
out.set(code, {
codeCentrale: code,
caReseau: ca,
qteReseau: qte,
nbMagasinsReseau: nbMag,
caParMagasinReseau: nbMag > 0 ? ca / nbMag : 0,
margePctReseau: margePct,
periode: dateFilter?.label,
});
margeWeight.set(code, ca);
continue;
}
prev.caReseau += ca;
prev.qteReseau += qte;
prev.nbMagasinsReseau = Math.max(prev.nbMagasinsReseau, nbMag);
const weight = margeWeight.get(code) ?? 0;
const nextWeight = weight + ca;
prev.margePctReseau = nextWeight > 0 ? ((prev.margePctReseau * weight) + (margePct * ca)) / nextWeight : Math.max(prev.margePctReseau, margePct);
prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0;
margeWeight.set(code, nextWeight);
}
// Rattache le détail mensuel (par code central) au métrique agrégé.
// On produit DEUX représentations :
// - `qteByMonth` : { "YYYY-MM": qté } — format historique, dont dépend la
// 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<string>();
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] ?? {};
const qteByMonth: Record<string, number> = {};
const metricsByMonth: Record<string, QlikMonthMetrics> = {};
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 = metricsByMonth;
}
// Diagnostic CONCLUSIF : combien de codes ont des mois qui VARIENT vs identiques.
// Si presque tous varient → la mesure respecte le mois (feature OK).
// Si tous identiques → la mesure Qlik ignore la sélection Date.
let nVar = 0, nFlat = 0;
let exVar: NetworkMetric | null = null, exFlat: NetworkMetric | null = null;
for (const m of out.values()) {
const bm = m.qteByMonth;
if (!bm) continue;
const vals = Object.values(bm);
if (vals.length < 2) continue;
const allEq = vals.every((v) => v === vals[0]);
if (allEq) { nFlat++; if (!exFlat) exFlat = m; }
else { nVar++; if (!exVar) exVar = m; }
}
console.log(`[qlik-pw] qteByMonth distribution: ${nVar} codes VARIENT / ${nFlat} identiques (total ${nVar + nFlat})`);
if (exVar) console.log(`[qlik-pw] exemple VARIE ${exVar.codeCentrale}:`, JSON.stringify(exVar.qteByMonth));
if (exFlat) console.log(`[qlik-pw] exemple IDENTIQUE ${exFlat.codeCentrale}:`, JSON.stringify(exFlat.qteByMonth));
console.log(`[qlik-pw] ${out.size} produits réseau (cube ${result.size})`);
// Échantillon brut pour vérifier que les 4 nouvelles mesures (cols 4-7) renvoient des valeurs
const sample = (result.rows ?? []).slice(0, 3).map((r) => ({
code: r[0], ca: r[1], qte: r[2], nbMag: r[3], caMag: r[4], margePct: r[5],
}));
console.log(`[qlik-pw] échantillon lignes:`, JSON.stringify(sample));
return out;
} finally {
await ctx.close();
}
}