mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
fix(qlik): aggregate multi-month extraction correctly
This commit is contained in:
1 parent
6036ea5600
commit
649a1df267
3 files changed
+261
-70
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
qlikSerialToIsoDate,
|
||||
buildGridNetworkQlikDateFilter,
|
||||
chunkSerials,
|
||||
getMonthRanges,
|
||||
QLIK_DATE_ANCHORS,
|
||||
} from "../src/lib/qlik-date-range";
|
||||
|
||||
@@ -107,8 +108,39 @@ eq("chunks[3][64]", chunks[3][64], 46173);
|
||||
console.log("\n[7] Robustesse");
|
||||
throws("format invalide '2025/01/01'", () => isoDateToQlikSerial("2025/01/01"), /YYYY-MM-DD/);
|
||||
throws("format vide", () => isoDateToQlikSerial(""), /YYYY-MM-DD/);
|
||||
truthy("label non vide pour fen\u00eatre valide", f.label && f.label.length > 0);
|
||||
truthy("label non vide pour fenêtre valide", f.label && f.label.length > 0);
|
||||
truthy("setAnalysis commence par 'Date='", f.setAnalysis.startsWith("Date="));
|
||||
|
||||
console.log(`\n=== ${pass} pass\u00e9s, ${fail} \u00e9chou\u00e9s ===`);
|
||||
console.log("\n[8] getMonthRanges — fenêtre 12 mois (now=2026-06-21)");
|
||||
const months = getMonthRanges(f);
|
||||
eq("12 mois exactement", months.length, 12);
|
||||
eq("premier mois label", months[0].label, "2025-06");
|
||||
eq("premier mois dateDebut", months[0].dateDebut, "2025-06-01");
|
||||
eq("premier mois dateFin", months[0].dateFin, "2025-06-30");
|
||||
eq("premier mois serials.length = 30", months[0].dailySerials.length, 30);
|
||||
eq("premier mois qStart", months[0].qStart, 45809);
|
||||
eq("premier mois qEnd", months[0].qEnd, 45838);
|
||||
eq("dernier mois label", months[11].label, "2026-05");
|
||||
eq("dernier mois dateDebut", months[11].dateDebut, "2026-05-01");
|
||||
eq("dernier mois dateFin", months[11].dateFin, "2026-05-31");
|
||||
eq("dernier mois serials.length = 31", months[11].dailySerials.length, 31);
|
||||
// Couvre février d'une année non bissextile (2025) — window now=2025-06-15 → 2024-06..2025-05
|
||||
const fFeb25 = buildGridNetworkQlikDateFilter(new Date("2025-06-15T08:00:00Z"));
|
||||
truthy("contient février 2025 (28j)", getMonthRanges(fFeb25).some((m) => m.label === "2025-02" && m.dailySerials.length === 28));
|
||||
// Couvre février d'une année bissextile (2024)
|
||||
const fBiss = buildGridNetworkQlikDateFilter(new Date("2025-01-05T08:00:00Z"));
|
||||
const monthsBiss = getMonthRanges(fBiss);
|
||||
truthy("contient février 2024 (29j)", monthsBiss.some((m) => m.label === "2024-02" && m.dailySerials.length === 29));
|
||||
// Total jours = 365 (somme des serials)
|
||||
const totalDays = months.reduce((acc, m) => acc + m.dailySerials.length, 0);
|
||||
eq("somme des jours = 365", totalDays, 365);
|
||||
|
||||
console.log("\n[9] getMonthRanges — fenêtre courte partielle (troncature dernier mois)");
|
||||
const fShort = { ...f, dateDebut: "2025-06-01", dateFin: "2025-08-15", qStart: 45809, qEnd: 45884, dailySerials: [], setAnalysis: "" };
|
||||
const monthsShort = getMonthRanges(fShort);
|
||||
eq("3 mois (juin, juillet, août)", monthsShort.length, 3);
|
||||
eq("août tronqué à 15j", monthsShort[2].dailySerials.length, 15);
|
||||
eq("août dateFin = 2025-08-15", monthsShort[2].dateFin, "2025-08-15");
|
||||
|
||||
console.log(`\n=== ${pass} passés, ${fail} échoués ===`);
|
||||
if (fail > 0) process.exit(1);
|
||||
@@ -135,3 +135,99 @@ export function chunkSerials(serials: number[], batchSize = 500): number[][] {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Découpe une fenêtre temporelle en sous-fenêtres mensuelles calendaires
|
||||
* **inclusives et continues**.
|
||||
*
|
||||
* But : éviter qu'un seul hypercube Qlik ne doive charger 365 jours × N
|
||||
* codes d'un coup (provoque `code:15 Request aborted`). Avec cette découpe,
|
||||
* chaque mois ≈ 28–31 jours × N codes → beaucoup plus léger pour l'Engine.
|
||||
*
|
||||
* Règles :
|
||||
* - Le 1er mois commence à `filter.dateDebut` (peut être un jour quelconque,
|
||||
* pas forcément le 1er calendaire).
|
||||
* - Chaque mois intermédiaire est un mois calendaire **complet** (du 1er
|
||||
* au dernier jour du mois).
|
||||
* - Le dernier mois se termine à `filter.dateFin` (tronqué si pas fin de mois).
|
||||
* - Les mois sont contigus : fin d'un mois + 1 = début du suivant.
|
||||
* - Si `filter.dateDebut` et `filter.dateFin` tombent dans le même mois,
|
||||
* on renvoie un seul mois (identique à `filter` mais re-calculé).
|
||||
*
|
||||
* Chaque mois retourné est un `QlikDateFilter` autonome : `qStart`/`qEnd`,
|
||||
* `dailySerials` recomptés, `setAnalysis` et `label` au format `YYYY-MM`.
|
||||
*
|
||||
* Pré-condition : `dateDebut <= dateFin` au sens calendaire (pas vérifié ici,
|
||||
* la validation est faite par `buildGridNetworkQlikDateFilter`).
|
||||
*/
|
||||
export function getMonthRanges(filter: QlikDateFilter): QlikDateFilter[] {
|
||||
const start = filter.dateDebut; // YYYY-MM-DD
|
||||
const end = filter.dateFin; // YYYY-MM-DD
|
||||
|
||||
// [yyyy, mm, dd] depuis YYYY-MM-DD (déjà validé par isoDateToQlikSerial).
|
||||
const sy = Number(start.slice(0, 4));
|
||||
const sm = Number(start.slice(5, 7));
|
||||
// const sd = Number(start.slice(8, 10)); // non utilisé directement
|
||||
const ey = Number(end.slice(0, 4));
|
||||
const em = Number(end.slice(5, 7));
|
||||
const ed = Number(end.slice(8, 10));
|
||||
|
||||
// Dernier jour du mois (0-ième du mois suivant en UTC local Date).
|
||||
const lastDayOfMonth = (y: number, m: number): number => {
|
||||
// m: 1..12 ; on prend le jour 0 du mois suivant, qui donne le dernier jour du mois courant.
|
||||
return new Date(y, m, 0).getDate();
|
||||
};
|
||||
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
|
||||
const out: QlikDateFilter[] = [];
|
||||
|
||||
// Itère mois par mois du mois de début au mois de fin (inclus).
|
||||
let curY = sy;
|
||||
let curM = sm;
|
||||
// Sécurité anti-boucle infinie (au cas où).
|
||||
const MAX_MONTHS = 1200; // 100 ans, large.
|
||||
let safety = 0;
|
||||
while (safety++ < MAX_MONTHS) {
|
||||
const isFirst = curY === sy && curM === sm;
|
||||
const isLast = curY === ey && curM === em;
|
||||
|
||||
// Début du mois courant : si c'est le premier mois, on respecte `dateDebut` ;
|
||||
// sinon on prend le 1er calendaire.
|
||||
const monthStartDay = isFirst ? Number(start.slice(8, 10)) : 1;
|
||||
const monthStartDate = `${curY}-${pad(curM)}-${pad(monthStartDay)}`;
|
||||
|
||||
// Fin du mois courant : si c'est le dernier mois, on respecte `dateFin` ;
|
||||
// sinon on prend le dernier jour calendaire du mois.
|
||||
const monthEndDay = isLast ? ed : lastDayOfMonth(curY, curM);
|
||||
const monthEndDate = `${curY}-${pad(curM)}-${pad(monthEndDay)}`;
|
||||
|
||||
const qStart = isoDateToQlikSerial(monthStartDate);
|
||||
const qEnd = isoDateToQlikSerial(monthEndDate);
|
||||
|
||||
const dailySerials: number[] = [];
|
||||
for (let s = qStart; s <= qEnd; s++) dailySerials.push(s);
|
||||
|
||||
const label = `${curY}-${pad(curM)}`;
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
|
||||
out.push({ dateDebut: monthStartDate, dateFin: monthEndDate, qStart, qEnd, label, dailySerials, setAnalysis });
|
||||
|
||||
if (isLast) break;
|
||||
|
||||
// Avance d'un mois calendaire (gère le passage décembre → janvier).
|
||||
curM += 1;
|
||||
if (curM > 12) { curM = 1; curY += 1; }
|
||||
}
|
||||
|
||||
if (out.length === 0) {
|
||||
// Fenêtre vide : on retourne un seul mois vide aligné sur dateDebut.
|
||||
const qStart = filter.qStart;
|
||||
const qEnd = filter.qEnd;
|
||||
const label = start.slice(0, 7);
|
||||
const setAnalysis = `Date={">=${qStart}<=${qEnd}"}`;
|
||||
out.push({ dateDebut: start, dateFin: end, qStart, qEnd, label, dailySerials: [], setAnalysis });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
+131
-68
@@ -14,7 +14,7 @@
|
||||
import "server-only";
|
||||
import { chromium, type Browser } from "playwright-core";
|
||||
import { getQlikConfig, qlikNtlmSession, type QlikConfig, type NetworkMetric } from "@/lib/qlik-client";
|
||||
import { buildGridNetworkQlikDateFilter, chunkSerials, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
import { buildGridNetworkQlikDateFilter, getMonthRanges, type QlikDateFilter } from "@/lib/qlik-date-range";
|
||||
|
||||
let browserPromise: Promise<Browser> | null = null;
|
||||
|
||||
@@ -79,8 +79,22 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
if (!csrfToken) throw new Error("[qlik-pw] qlik-csrf-token introuvable (client Qlik non chargé ?)");
|
||||
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
|
||||
}));
|
||||
|
||||
const result = (await page.evaluate(
|
||||
({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, dateBatches, dateLabel }: { app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string; codes: string[]; token: string; dateBatches: number[][]; dateLabel: string | null }) =>
|
||||
({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode }: {
|
||||
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[] }>;
|
||||
noDateMode: boolean;
|
||||
}) =>
|
||||
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}`;
|
||||
@@ -101,6 +115,32 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
ws.onerror = () => rej(new Error("ws error (403 ?)"));
|
||||
setTimeout(() => rej(new Error("ws timeout")), 30000);
|
||||
});
|
||||
|
||||
// Sélectionne Date + Article Code puis pagine le cube. Renvoie le nb de lignes
|
||||
// brutes lues (avant dédoublonnage côté Node).
|
||||
const PAGE = 1400;
|
||||
const fetchCubeForSelection = async (oh: number, sink: Array<Array<string | number>>): Promise<number> => {
|
||||
const layout = await rpc("GetLayout", {}, oh);
|
||||
const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy;
|
||||
let got = 0;
|
||||
for (let top = 0; top < size; top += PAGE) {
|
||||
const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh);
|
||||
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||
if (!matrix.length) break;
|
||||
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,
|
||||
]);
|
||||
got += matrix.length;
|
||||
if (matrix.length < PAGE) break;
|
||||
}
|
||||
return got;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await ready;
|
||||
@@ -109,33 +149,16 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
const doc = (open.qReturn as { qHandle: number }).qHandle;
|
||||
console.log("OpenDoc OK handle=" + doc);
|
||||
|
||||
// Aligne Qlik sur la timeline grille : 12 mois complets glissants,
|
||||
// hors mois courant. La sélection du champ Date est héritée par les
|
||||
// master measures du cube, sans dupliquer leurs expressions.
|
||||
if (dateBatches.length) {
|
||||
try {
|
||||
const df = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||
const dfh = (df.qReturn as { qHandle: number }).qHandle;
|
||||
for (let i = 0; i < dateBatches.length; i++) {
|
||||
const batch = dateBatches[i];
|
||||
await rpc(
|
||||
"SelectValues",
|
||||
{ qFieldValues: batch.map((s) => ({ qNum: s, qText: String(s) })), qToggleMode: false, qSoftLock: true },
|
||||
dfh,
|
||||
);
|
||||
console.log("filtre Date " + (i + 1) + "/" + dateBatches.length + " — " + batch.length + " jours — " + dateLabel);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("filtre Date ignoré: " + String((e as Error)?.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle du champ de sélection (réutilisé pour chaque lot).
|
||||
// Handles des champs Date / Article Code.
|
||||
let dfh = -1;
|
||||
let fh = -1;
|
||||
const df = await rpc("GetField", { qFieldName: "Date" }, doc);
|
||||
dfh = (df.qReturn as { qHandle: number }).qHandle;
|
||||
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" } } } }, doc);
|
||||
@@ -153,53 +176,65 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
qInitialDataFetch: [],
|
||||
} } }, doc);
|
||||
const oh = (obj.qReturn as { qHandle: number }).qHandle;
|
||||
|
||||
const out: Array<Array<string | number>> = [];
|
||||
const PAGE = 1400;
|
||||
// Lots de sélection : avec le filtre Date actif (~365 jours), même ~400 codes
|
||||
// forcés d'un coup déclenchent un recalcul de cube trop lourd → l'Engine
|
||||
// annule (code 15 "Request aborted"). On extrait par petits lots, on vide la
|
||||
// sélection "Article Code" entre chaque lot (le filtre Date est conservé car
|
||||
// posé indépendamment), et on accumule les lignes côté client.
|
||||
const BATCH = 100;
|
||||
const batches: Array<string[] | null> = codes.length
|
||||
? Array.from({ length: Math.ceil(codes.length / BATCH) }, (_, i) => codes.slice(i * BATCH, i * BATCH + BATCH))
|
||||
: [null];
|
||||
console.log("codes=" + codes.length + " → " + batches.length + " lot(s) de ≤ " + BATCH);
|
||||
for (let b = 0; b < batches.length; b++) {
|
||||
const batch = batches[b];
|
||||
const sample = batch ? batch.slice(0, 3).join(",") + (batch.length > 3 ? ",…" : "") : "∅";
|
||||
try {
|
||||
if (batch && fh !== -1) {
|
||||
const sel = await rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
|
||||
console.log("lot " + (b + 1) + "/" + batches.length + " — SelectValues " + batch.length + " codes [" + sample + "] → " + JSON.stringify(sel.qReturn));
|
||||
}
|
||||
const layout = await rpc("GetLayout", {}, oh);
|
||||
const size = ((layout.qLayout as { qHyperCube: { qSize: { qcy: number } } }).qHyperCube).qSize.qcy;
|
||||
let got = 0;
|
||||
for (let top = 0; top < size; top += PAGE) {
|
||||
const d = await rpc("GetHyperCubeData", { qPath: "/qHyperCubeDef", qPages: [{ qTop: top, qLeft: 0, qWidth: 6, qHeight: PAGE }] }, oh);
|
||||
const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
|
||||
if (!matrix.length) break;
|
||||
for (const r of matrix) out.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]);
|
||||
got += matrix.length;
|
||||
if (matrix.length < PAGE) break;
|
||||
}
|
||||
console.log("lot " + (b + 1) + "/" + batches.length + " — " + got + " ligne(s) (cube qcy=" + size + "), cumul=" + out.length);
|
||||
} catch (batchErr) {
|
||||
const msg = String((batchErr as Error)?.message || batchErr);
|
||||
console.error("[qlik-pw] lot " + (b + 1) + "/" + batches.length + " abort [" + sample + "] — " + msg);
|
||||
throw new Error("[qlik-pw] abort lot " + (b + 1) + "/" + batches.length + " (" + (batch?.length ?? 0) + " codes) — " + msg);
|
||||
|
||||
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.
|
||||
console.log("mode sans dateFilter — sélection de " + codes.length + " codes en une fois");
|
||||
if (codes.length && fh !== -1) {
|
||||
const sel = await rpc("SelectValues", { qFieldValues: codes.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh);
|
||||
console.log("SelectValues Article Code → " + JSON.stringify(sel.qReturn));
|
||||
}
|
||||
// Vide la sélection Article Code avant le lot suivant : le filtre Date
|
||||
// reste posé sur l'app, on évite l'accumulation côté Engine.
|
||||
if (batch && fh !== -1 && b < batches.length - 1) {
|
||||
const got = await fetchCubeForSelection(oh, out);
|
||||
console.log("extraction sans date — " + got + " ligne(s), cumul=" + out.length);
|
||||
} else {
|
||||
// Itération par mois : pour chaque mois, SelectValues Date (~30 serials) +
|
||||
// SelectValues Article Code (tous les codes d'un coup) → cube mensuel léger.
|
||||
// Clear Date + Clear Article Code avant le mois suivant.
|
||||
const total = monthlyPayload.length;
|
||||
console.log("itération par mois — " + total + " mois, " + codes.length + " codes");
|
||||
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)";
|
||||
try {
|
||||
await rpc("Clear", {}, fh);
|
||||
} catch (clearErr) {
|
||||
console.log("[qlik-pw] Clear Article Code ignoré: " + String((clearErr as Error)?.message || clearErr));
|
||||
// 1) Sélection Date pour le mois (un seul SelectValues, ~30 valeurs).
|
||||
const dsel = await rpc("SelectValues", {
|
||||
qFieldValues: m.serials.map((s) => ({ qNum: s, qText: String(s) })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, dfh);
|
||||
console.log(prefix + " — SelectValues Date " + m.serials.length + " serials → " + JSON.stringify(dsel.qReturn));
|
||||
// 2) Sélection Article Code (tous les codes d'un coup).
|
||||
if (codes.length && fh !== -1) {
|
||||
const csel = await rpc("SelectValues", {
|
||||
qFieldValues: codes.map((c) => ({ qText: c })),
|
||||
qToggleMode: false,
|
||||
qSoftLock: true,
|
||||
}, fh);
|
||||
console.log(prefix + " — SelectValues Article Code " + codes.length + " codes → " + JSON.stringify(csel.qReturn));
|
||||
}
|
||||
// 3) Lecture du cube mensuel.
|
||||
const got = await fetchCubeForSelection(oh, out);
|
||||
console.log(prefix + " — " + got + " ligne(s), cumul=" + out.length);
|
||||
} catch (monthErr) {
|
||||
const msg = String((monthErr as Error)?.message || monthErr);
|
||||
console.error(prefix + " — ABORT — " + msg);
|
||||
throw new Error(prefix + " — " + msg);
|
||||
}
|
||||
// 4) Clear Date + Clear Article Code avant le mois suivant (soulage l'Engine).
|
||||
if (mi < total - 1) {
|
||||
try {
|
||||
await rpc("Clear", {}, dfh);
|
||||
if (fh !== -1) await rpc("Clear", {}, fh);
|
||||
} catch (clearErr) {
|
||||
console.log(prefix + " — Clear ignoré: " + String((clearErr as Error)?.message || clearErr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
resolve({ ok: true, rows: out, size: out.length });
|
||||
} catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); }
|
||||
@@ -215,8 +250,8 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
mmarge: cfg.measMargePctId,
|
||||
codes: codeCentraux ?? [],
|
||||
token: csrfToken,
|
||||
dateBatches: dateFilter ? chunkSerials(dateFilter.dailySerials, 500) : [],
|
||||
dateLabel: dateFilter?.label ?? null,
|
||||
monthlyPayload,
|
||||
noDateMode: !dateFilter,
|
||||
},
|
||||
)) as InPageResult;
|
||||
|
||||
@@ -224,11 +259,39 @@ export async function fetchNetworkMetricsPlaywright(
|
||||
|
||||
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;
|
||||
out.set(code, { codeCentrale: code, caReseau: Number(r[1]) || 0, qteReseau: Number(r[2]) || 0, nbMagasinsReseau: Number(r[3]) || 0, caParMagasinReseau: Number(r[4]) || 0, margePctReseau: Number(r[5]) || 0, periode: dateFilter?.label });
|
||||
|
||||
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);
|
||||
}
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user