Merge into main: modal Tendance + régression 12 mois + Quantité COMP

This commit is contained in:
Claude committed 2026-07-23 19:53:22 +00:00
commit dec99ce3c1
2 files changed
+72 -34

No files matched your search

+50 -25
View File
@@ -247,30 +247,44 @@ type NetworkTrend = {
hasData: boolean; 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<string, number> | null): NetworkTrend { function computeNetworkTrend(qteByMonth?: Record<string, number> | null): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false }; const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty; 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; if (labels.length === 0) return empty;
labels = labels.slice(-12); // 12 derniers mois
const values = labels.map((l) => Number(qteByMonth[l]) || 0); const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const n = values.length; const n = values.length;
let pct: number | null = null; let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat"; let direction: NetworkTrend["direction"] = "flat";
if (n >= 2) { if (n >= 3) {
const w = Math.max(1, Math.min(4, Math.floor(n / 2))); const mx = (n - 1) / 2;
const mean = (a: number[]) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0); const my = values.reduce((s, v) => s + v, 0) / n;
const lastM = mean(values.slice(n - w)); let num = 0, den = 0;
const prevM = mean(values.slice(n - 2 * w, n - w)); for (let i = 0; i < n; i++) { num += (i - mx) * (values[i] - my); den += (i - mx) * (i - mx); }
if (prevM > 0) { const slope = den ? num / den : 0;
pct = (lastM - prevM) / prevM; if (my > 0) {
direction = pct > 0.1 ? "up" : pct < -0.1 ? "down" : "flat"; pct = (slope * (n - 1)) / my; // variation modélisée sur toute la période / moyenne
} else if (lastM > 0) { direction = pct > 0.08 ? "up" : pct < -0.08 ? "down" : "flat";
direction = "up"; // apparition (rien avant, ventes maintenant) } 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 }; 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<NetworkTrend["direction"], string> = { const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
up: "#22c55e", up: "#22c55e",
down: "#ef4444", down: "#ef4444",
@@ -331,7 +345,12 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>
<p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}> <p className="text-[12px] mt-0.5" style={{ color: "var(--text-muted)" }}>
Ventes réseau · 12 derniers mois Ventes réseau · 12 derniers mois
{row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>} {row.nbMagasinsReseau != null && <> · <span className="font-semibold" style={{ color: "var(--text-secondary)" }}>{row.nbMagasinsReseau} magasins</span></>}
{pct != null && <> · <span className="font-bold" style={{ color }}>{pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%</span></>} {pct != null && (
<> · <span className="font-bold" style={{ color }}>
{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"}
</span></>
)}
</p> </p>
</DialogHeader> </DialogHeader>
{!trend.hasData ? ( {!trend.hasData ? (
@@ -685,18 +704,10 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
size: 80, size: 80,
cell: ({ row }) => { cell: ({ row }) => {
const val = row.original.nbMagasinsReseau; const val = row.original.nbMagasinsReseau;
if (val == null) return <div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau par mois (12 derniers mois).
return ( return (
<button <div className="text-center tabular-nums text-[12px] font-bold" style={{ color: "var(--text-secondary)" }}>
type="button" {val != null ? val : "-"}
onClick={() => setNetworkModal(row.original)} </div>
title="Voir les ventes réseau mois par mois"
className="w-full text-center tabular-nums text-[12px] font-bold underline decoration-dotted decoration-1 underline-offset-2 hover:opacity-80 transition-opacity cursor-pointer"
style={{ color: "var(--accent)" }}
>
{val}
</button>
); );
}, },
}, },
@@ -706,9 +717,23 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) {
const t = computeNetworkTrend(row.qteReseauByMonth); const t = computeNetworkTrend(row.qteReseauByMonth);
return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY; return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY;
}, },
header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau</span></div>, header: () => <div className="text-center w-full">Tendance<br/><span className="text-[9px] opacity-60">Réseau · 12 m</span></div>,
size: 78, size: 78,
cell: ({ row }) => <TrendSparkline trend={computeNetworkTrend(row.original.qteReseauByMonth)} />, cell: ({ row }) => {
const trend = computeNetworkTrend(row.original.qteReseauByMonth);
if (!trend.hasData) return <div className="text-center text-[12px]" style={{ color: "var(--text-secondary)" }}>-</div>;
// Cliquable → modal des ventes réseau mois par mois (12 derniers mois).
return (
<button
type="button"
onClick={() => setNetworkModal(row.original)}
title="Voir les ventes réseau mois par mois (12 derniers mois)"
className="w-full cursor-pointer rounded hover:bg-[var(--bg-elevated)] transition-colors py-0.5"
>
<TrendSparkline trend={trend} />
</button>
);
},
}, },
{ {
id: "caParMagasinReseau", id: "caParMagasinReseau",
+22 -9
View File
@@ -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). // 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 qlikMonthDim = !["0", "false", "no", "off"].includes((process.env.QLIK_MONTH_DIM ?? "").trim().toLowerCase());
const qlikMoisDimId = (process.env.QLIK_MOIS_DIM_ID ?? "pfGAwTs").trim(); 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}`); console.log(`[qlik-pw] tuning settle=${qlikSettleMs}ms code15Attempts=${qlikCode15MaxAttempts} batchFallbacks=${qlikArticleBatchFallbacks.join(">")} selectAllCodes=${qlikSelectAllCodes} monthDim=${qlikMonthDim}`);
const browser = await getBrowser(); const browser = await getBrowser();
@@ -149,7 +153,7 @@ export async function fetchNetworkMetricsPlaywright(
})); }));
const result = (await page.evaluate( 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; app: string; dim: string; mca: string; mqte: string; mnb: string; mcamag: string; mmarge: string;
codes: string[]; token: string; codes: string[]; token: string;
monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>; monthlyPayload: Array<{ label: string; dateDebut: string; dateFin: string; serials: number[] }>;
@@ -160,6 +164,8 @@ export async function fetchNetworkMetricsPlaywright(
selectAllCodes: boolean; selectAllCodes: boolean;
monthDim: boolean; monthDim: boolean;
moisDimId: string; moisDimId: string;
mqteComp: string;
yearN: number;
}) => }) =>
new Promise<InPageResult>((resolve) => { new Promise<InPageResult>((resolve) => {
const loc = (window as unknown as { location: Location }).location; 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, // Cube [Article Code, Mois] × 5 mesures. Chaque ligne = (code, mois, ca,
// qte, nbMag, caMag, marge). Le "Mois" est au format "YYYYMM" → "YYYY-MM". // 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 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 ( 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<string, number>, timings?: Record<string, number>,
onCode15Retry?: () => void, onCode15Retry?: () => void,
): Promise<number> => { ): Promise<number> => {
const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: { const { value: obj } = await timed("createCube", "CreateSessionObject", () => rpcWithRetry("createCube", "CreateSessionObject", { qProp: { qInfo: { qType: "cf-net-mois" }, qHyperCubeDef: {
qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }], qDimensions: [{ qLibraryId: dim }, { qLibraryId: moisDimId }],
qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rMarge }], qMeasures: [{ qLibraryId: mca }, { qLibraryId: mqte }, { qLibraryId: mnb }, { qLibraryId: rCamag }, { qLibraryId: rMarge }, { qLibraryId: mqteComp }],
qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 7, qHeight: PAGEM }], qInitialDataFetch: [{ qTop: 0, qLeft: 0, qWidth: 8, qHeight: PAGEM }],
} } }, doc, onCode15Retry), timings); } } }, doc, onCode15Retry), timings);
const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string }; const qReturn = obj.qReturn as { qHandle: number; qGenericId?: string; qId?: string };
const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") }; const cube = { handle: qReturn.qHandle, id: String(qReturn.qGenericId ?? qReturn.qId ?? "") };
@@ -380,7 +386,7 @@ export async function fetchNetworkMetricsPlaywright(
onRow( onRow(
String(r[0]?.qText ?? ""), String(r[1]?.qText ?? ""), 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[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; return matrix.length;
@@ -393,7 +399,7 @@ export async function fetchNetworkMetricsPlaywright(
const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0); const firstPage = hc.qDataPages?.find((p) => (p.qArea?.qTop ?? 0) === 0);
if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; } if (firstPage?.qMatrix?.length) { got += process(firstPage.qMatrix); top = PAGEM; }
for (; top < size; 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<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? []; const matrix = (d.qDataPages as Array<{ qMatrix?: Array<Array<{ qText?: string; qNum?: number }>> }>)?.[0]?.qMatrix ?? [];
if (!matrix.length) break; if (!matrix.length) break;
got += process(matrix); 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 timed("selectCode", "SelectValues", () => rpc("SelectValues", { qFieldValues: batch.map((c) => ({ qText: c })), qToggleMode: false, qSoftLock: true }, fh), timings);
await sleep(qlikSettleMs); 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; 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]); 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 mm = normMois(mois);
const y = parseInt(mm.slice(0, 4), 10);
const qtyMonth = y === yearN ? qte : qteComp;
(monthlyByCode[code] ??= {}); (monthlyByCode[code] ??= {});
monthlyByCode[code][mm] = (monthlyByCode[code][mm] ?? 0) + qte; monthlyByCode[code][mm] = (monthlyByCode[code][mm] ?? 0) + qtyMonth;
}, timings, () => { code15Retries++; }), timings); }, timings, () => { code15Retries++; }), timings);
aggBatches++; aggBatches++;
aggCode15Retries += code15Retries; aggCode15Retries += code15Retries;
@@ -703,6 +714,8 @@ export async function fetchNetworkMetricsPlaywright(
selectAllCodes: qlikSelectAllCodes, selectAllCodes: qlikSelectAllCodes,
monthDim: qlikMonthDim, monthDim: qlikMonthDim,
moisDimId: qlikMoisDimId, moisDimId: qlikMoisDimId,
mqteComp: qlikMeasQteCompId,
yearN,
}, },
)) as InPageResult; )) as InPageResult;