diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index a503d26..594f281 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -247,30 +247,44 @@ type NetworkTrend = { hasData: boolean; }; +/** + * Tendance réseau par RÉGRESSION LINÉAIRE (moindres carrés) sur les 12 derniers mois. + * Utilise tous les points (robuste au bruit d'un mois isolé). L'indicateur `pct` est la + * variation modélisée sur la période (pente × durée) rapportée à la moyenne. + * Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%. + */ function computeNetworkTrend(qteByMonth?: Record | null): NetworkTrend { const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; if (!qteByMonth) return empty; - const labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement + let labels = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement if (labels.length === 0) return empty; + labels = labels.slice(-12); // 12 derniers mois const values = labels.map((l) => Number(qteByMonth[l]) || 0); const n = values.length; let pct: number | null = null; let direction: NetworkTrend["direction"] = "flat"; - if (n >= 2) { - const w = Math.max(1, Math.min(4, Math.floor(n / 2))); - const mean = (a: number[]) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0); - const lastM = mean(values.slice(n - w)); - const prevM = mean(values.slice(n - 2 * w, n - w)); - if (prevM > 0) { - pct = (lastM - prevM) / prevM; - direction = pct > 0.1 ? "up" : pct < -0.1 ? "down" : "flat"; - } else if (lastM > 0) { - direction = "up"; // apparition (rien avant, ventes maintenant) + if (n >= 3) { + const mx = (n - 1) / 2; + const my = values.reduce((s, v) => s + v, 0) / n; + let num = 0, den = 0; + for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); } + const slope = den ? num / den : 0; + if (my > 0) { + pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne + direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; + } else if (slope > 0) { + direction = "up"; } + } else if (n === 2 && values[0] > 0) { + pct = (values[1] - values[0]) / values[0]; + direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat"; } return { values, labels, direction, pct, hasData: true }; } +/** Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI. */ +const TREND_STRONG = 0.25; + const TREND_COLOR: Record = { up: "#22c55e", down: "#ef4444", @@ -331,7 +345,12 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>

Ventes réseau · 12 derniers mois {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} - {pct != null && <> · {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%} + {pct != null && ( + <> · + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% ·{" "} + {pct > TREND_STRONG ? "Forte hausse" : pct > 0.08 ? "Hausse" : pct < -TREND_STRONG ? "Forte baisse" : pct < -0.08 ? "Baisse" : "Stable"} + + )}

{!trend.hasData ? ( @@ -685,18 +704,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { size: 80, cell: ({ row }) => { const val = row.original.nbMagasinsReseau; - if (val == null) return
-
; - // Cliquable → modal des ventes réseau par mois (12 derniers mois). return ( - +
+ {val != null ? val : "-"} +
); }, }, @@ -706,9 +717,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { const t = computeNetworkTrend(row.qteReseauByMonth); return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY; }, - header: () =>
Tendance
Réseau
, + header: () =>
Tendance
Réseau · 12 m
, size: 78, - cell: ({ row }) => , + cell: ({ row }) => { + const trend = computeNetworkTrend(row.original.qteReseauByMonth); + if (!trend.hasData) return
-
; + // Cliquable → modal des ventes réseau mois par mois (12 derniers mois). + return ( + + ); + }, }, { id: "caParMagasinReseau", diff --git a/src/lib/qlik-playwright.ts b/src/lib/qlik-playwright.ts index 1ea20fc..7d5f09e 100644 --- a/src/lib/qlik-playwright.ts +++ b/src/lib/qlik-playwright.ts @@ -108,6 +108,10 @@ export async function fetchNetworkMetricsPlaywright( // 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) : complète les mois de l'année précédente pour + // obtenir 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(); + const yearN = new Date().getFullYear(); console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`); const browser = await getBrowser(); @@ -149,7 +153,7 @@ export async function fetchNetworkMetricsPlaywright( })); const result = (await page.evaluate( - ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId }: { + ({ app, dim, mca, mqte, mnb, mcamag, mmarge, codes, token, monthlyPayload, noDateMode, qlikSettleMs, qlikCode15MaxAttempts, qlikArticleBatchFallbacks, selectAllCodes, monthDim, moisDimId, mqteComp, yearN }: { 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[] }>; @@ -160,6 +164,8 @@ export async function fetchNetworkMetricsPlaywright( selectAllCodes: boolean; monthDim: boolean; moisDimId: string; + mqteComp: string; + yearN: number; }) => new Promise((resolve) => { const loc = (window as unknown as { location: Location }).location; @@ -361,16 +367,16 @@ export async function fetchNetworkMetricsPlaywright( // 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 = 1400; + 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) => void, + onRow: (code: string, mois: string, ca: number, qte: number, nbMag: number, caMag: number, marge: number, qteComp: number) => void, timings?: Record, onCode15Retry?: () => void, ): Promise => { const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], - qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], - qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: PAGEM }], + qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { 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 ?? "") }; @@ -380,7 +386,7 @@ export async function fetchNetworkMetricsPlaywright( 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[5]?.qNum) || 0, Number(r[6]?.qNum) || 0, Number(r[7]?.qNum) || 0, ); } return matrix.length; @@ -393,7 +399,7 @@ export async function fetchNetworkMetricsPlaywright( 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: 7, qHeight: PAGEM }] }, oh, onCode15Retry), timings); + 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> }>)?.[0]?.qMatrix ?? []; if (!matrix.length) break; got += process(matrix); @@ -432,12 +438,17 @@ export async function fetchNetworkMetricsPlaywright( 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((code, mois, ca, qte, nbMag, caMag, marge) => { + const got = await timed("cube", "batch", () => fetchMonthCube((code, mois, ca, qte, nbMag, caMag, marge, qteComp) => { if (!code || code === "-") return; + // Total réseau = année N (mesures "N" : CA/Qté/Marge). Non gonflé. out.push([code, ca, qte, nbMag, caMag, marge]); + // Détail mensuel 12 mois glissants : mois de l'année N → Quantité N, + // mois de l'année N-1 → Quantité COMP. const mm = normMois(mois); + const y = parseInt(mm.slice(0, 4), 10); + const qtyMonth = y === yearN ? qte : qteComp; (monthlyByCode[code] ??= {}); - monthlyByCode[code][mm] = (monthlyByCode[code][mm] ?? 0) + qte; + monthlyByCode[code][mm] = (monthlyByCode[code][mm] ?? 0) + qtyMonth; }, timings, () => { code15Retries++; }), timings); aggBatches++; aggCode15Retries += code15Retries; @@ -703,6 +714,8 @@ export async function fetchNetworkMetricsPlaywright( selectAllCodes: qlikSelectAllCodes, monthDim: qlikMonthDim, moisDimId: qlikMoisDimId, + mqteComp: qlikMeasQteCompId, + yearN, }, )) as InPageResult;