diff --git a/scripts/db-init.js b/scripts/db-init.js index 1d845fb..9c2cecc 100644 --- a/scripts/db-init.js +++ b/scripts/db-init.js @@ -112,7 +112,8 @@ async function main() { await tempPool.query(` ALTER TABLE "qlik_network_metrics" ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), - ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4); + ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), + ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb; ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; `); diff --git a/src/db/schema.ts b/src/db/schema.ts index d65951e..745a320 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -123,6 +123,8 @@ export const qlikNetworkMetrics = pgTable("qlik_network_metrics", { margePctReseau: numeric("marge_pct_reseau", { precision: 8, scale: 4 }), /** Période couverte (libre, ex "12m" ou "2025") */ periode: varchar("periode", { length: 20 }), + /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (12 derniers mois). */ + qteByMonth: jsonb("qte_by_month"), /** Dernière synchro depuis Qlik */ fetchedAt: timestamp("fetched_at").defaultNow(), }); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index 376232c..1a8b6ca 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -514,6 +514,7 @@ async function enrichWithNetworkMetrics(productMap: Map): Pr product.caParMagasinReseau = m.caParMagasinReseau; product.margePctReseau = m.margePctReseau; product.tauxPresenceReseau = m.nbMagasinsReseau / NB_MAGASINS_RESEAU; + product.qteReseauByMonth = m.qteByMonth ?? null; product.networkFetchedAt = m.fetchedAt ?? undefined; } } diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index eab6461..a503d26 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -13,7 +13,7 @@ import { RowSelectionState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle } from "lucide-react"; +import { ChevronUp, ChevronDown, ChevronsUpDown, Copy, Check, Store, SlidersHorizontal, ShoppingCart, PackageOpen, Warehouse, AlertTriangle, TrendingUp, TrendingDown, Minus } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -96,7 +96,7 @@ const QLIK_NETWORK_COLUMN_IDS = new Set([ "caReseau", "qteReseau", "nbMagasinsReseau", - "tauxPresenceReseau", + "tendanceReseau", "caParMagasinReseau", "margePctReseau", ]); @@ -238,6 +238,129 @@ interface CellDetailData { receptions: number | null; } +// ─── Tendance réseau (à partir de qteReseauByMonth) ─────────────────────────── +type NetworkTrend = { + values: number[]; // quantités mensuelles, ordre chronologique + labels: string[]; // "YYYY-MM" + direction: "up" | "down" | "flat"; + pct: number | null; // variation moyenne 4 derniers mois vs 4 précédents + hasData: boolean; +}; + +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 + if (labels.length === 0) return empty; + 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) + } + } + return { values, labels, direction, pct, hasData: true }; +} + +const TREND_COLOR: Record = { + up: "#22c55e", + down: "#ef4444", + flat: "#94a3b8", +}; + +function fmtMonthYYYYMM(lab: string): string { + const [y, m] = lab.split("-"); + const names = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."]; + const idx = Number(m) - 1; + return `${names[idx] ?? m} ${(y ?? "").slice(2)}`; +} + +/** Sparkline compacte 12 mois + flèche, teintée selon la tendance. */ +function TrendSparkline({ trend }: { trend: NetworkTrend }) { + if (!trend.hasData) return
-
; + const { values, direction, pct } = trend; + const color = TREND_COLOR[direction]; + const W = 46, H = 18, n = values.length; + const max = Math.max(...values, 1), min = Math.min(...values, 0); + const range = max - min || 1; + const pts = values.map((v, i) => { + const x = n > 1 ? (i / (n - 1)) * (W - 2) + 1 : W / 2; + const y = H - 1 - ((v - min) / range) * (H - 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + const Arrow = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; + return ( +
= 0 ? "+" : ""}${(pct * 100).toFixed(0)}% (4 derniers mois vs 4 précédents)` : "tendance réseau"}> +
+ + {n > 1 && } + {n === 1 && } + + +
+ {pct != null && ( + + {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}% + + )} +
+ ); +} + +/** Modal : quantités vendues réseau sur les 12 derniers mois, mois par mois. */ +function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => void }) { + const trend = computeNetworkTrend(row.qteReseauByMonth); + const { values, labels, direction, pct } = trend; + const max = Math.max(...values, 1); + const color = TREND_COLOR[direction]; + return ( + + + + {row.libelle1} + +

+ Ventes réseau · 12 derniers mois + {row.nbMagasinsReseau != null && <> · {row.nbMagasinsReseau} magasins} + {pct != null && <> · {pct >= 0 ? "+" : ""}{Math.round(pct * 100)}%} +

+
+ {!trend.hasData ? ( +
+ Pas encore de détail mensuel pour ce produit.
Relancez un Sync Qlik pour le remplir. +
+ ) : ( +
+ {labels.map((lab, i) => { + const v = values[i]; + const w = Math.max(2, (v / max) * 100); + return ( +
+ {fmtMonthYYYYMM(lab)} +
+
+
+ + {Math.round(v).toLocaleString("fr-FR")} + +
+ ); + })} +
+ )} + + ); +} + function CellDetailModal({ d, activeMagasin, onClose }: { d: CellDetailData; activeMagasin: string; onClose: () => void }) { const hasReceptions = (d.receptions ?? 0) > 0; const storeLabel = activeMagasin === "TOTAL" ? "Total réseau" : (SITE_LABELS[activeMagasin]?.nom ?? activeMagasin); @@ -328,6 +451,7 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { // État pour le détail d'une cellule mensuelle (modal) const [cellDetail, setCellDetail] = useState(null); + const [networkModal, setNetworkModal] = useState(null); const tableContainerRef = useRef(null); // Calculer les mois dynamiquement pour éviter le mismatch entre serveur et client @@ -561,29 +685,30 @@ 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 : "-"} -
+ ); }, }, { - id: "tauxPresenceReseau", - accessorFn: (row) => getQlikNetworkSortValue(row.tauxPresenceReseau, "tauxPresenceReseau", sorting), - header: () =>
% Prés.
Réseau
, - size: 70, - cell: ({ row }) => { - const val = row.original.tauxPresenceReseau; - if (val == null) return
-
; - const pct = Math.round(val * 100); - const color = pct >= 66 ? "text-emerald-500" : pct >= 33 ? "text-amber-500" : "text-rose-500"; - return ( -
- {pct}% -
- ); + id: "tendanceReseau", + accessorFn: (row) => { + const t = computeNetworkTrend(row.qteReseauByMonth); + return t.hasData && t.pct != null ? t.pct : Number.NEGATIVE_INFINITY; }, + header: () =>
Tendance
Réseau
, + size: 78, + cell: ({ row }) => , }, { id: "caParMagasinReseau", @@ -903,6 +1028,9 @@ export function HeatmapGrid({ onSelectionChange, isAdmin }: HeatmapGridProps) { { if (!open) setCellDetail(null); }}> {cellDetail !== null && setCellDetail(null)} />} + { if (!open) setNetworkModal(null); }}> + {networkModal !== null && setNetworkModal(null)} />} + ); } diff --git a/src/lib/qlik-client.ts b/src/lib/qlik-client.ts index 0feca91..49214f4 100644 --- a/src/lib/qlik-client.ts +++ b/src/lib/qlik-client.ts @@ -89,6 +89,8 @@ export interface NetworkMetric { /** Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%) */ margePctReseau: number; periode?: string; + /** Quantité vendue réseau par mois : { "YYYY-MM": qté }. Rempli si sync avec filtre Date. */ + qteByMonth?: Record; } function makeXrfkey(): string { diff --git a/src/lib/qlik-network-cache.ts b/src/lib/qlik-network-cache.ts index e9b10a1..3127d98 100644 --- a/src/lib/qlik-network-cache.ts +++ b/src/lib/qlik-network-cache.ts @@ -17,6 +17,8 @@ export interface NetworkMetricCached { nbMagasinsReseau: number; caParMagasinReseau: number; margePctReseau: number; + /** Quantité réseau par mois : { "YYYY-MM": qté } — null si non synchronisé avec dates. */ + qteByMonth: Record | null; fetchedAt: string | null; } @@ -40,6 +42,7 @@ export async function getNetworkMetricsByCodeCentrale( nbMagasinsReseau: Number(r.nbMagasinsReseau ?? 0) || 0, caParMagasinReseau: Number(r.caParMagasinReseau ?? 0) || 0, margePctReseau: Number(r.margePctReseau ?? 0) || 0, + qteByMonth: (r.qteByMonth as Record | null) ?? null, fetchedAt: r.fetchedAt ? new Date(r.fetchedAt).toISOString() : null, }); } @@ -58,6 +61,7 @@ export async function upsertNetworkMetrics(metrics: NetworkMetric[]): Promise { interface InPageResult { ok: boolean; rows?: Array>; + /** Quantité réseau par code central et par mois : { code: { "YYYY-MM": qté } }. */ + monthly?: Record>; size?: number; error?: string; diag?: Record; @@ -253,6 +255,18 @@ export async function fetchNetworkMetricsPlaywright( // causé par la réutilisation du même objet sur des centaines de recalculs. const out: Array> = []; + // 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> = {}; + const accMonthly = (batchRows: Array>, 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] ??= {}); + monthlyByCode[c][monthLabel] = (monthlyByCode[c][monthLabel] ?? 0) + q; + } + }; // Compteurs agrégés pour le résumé timing final (loggué côté Node après la sync). let aggMonths = 0; let aggBatches = 0; @@ -366,6 +380,7 @@ export async function fetchNetworkMetricsPlaywright( const batchRows: Array> = []; 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)); @@ -381,6 +396,7 @@ export async function fetchNetworkMetricsPlaywright( 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); @@ -469,6 +485,7 @@ export async function fetchNetworkMetricsPlaywright( const batchRows: Array> = []; 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)); @@ -508,7 +525,7 @@ export async function fetchNetworkMetricsPlaywright( } ws.close(); - resolve({ ok: true, rows: out, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); + resolve({ ok: true, rows: out, monthly: monthlyByCode, size: out.length, months: aggMonths, batches: aggBatches, code15Retries: aggCode15Retries, fallbackCount: aggFallbackCount }); } catch (e) { try { ws.close(); } catch { /* noop */ } fail(String((e as Error)?.message || e)); } })(); }), @@ -577,6 +594,12 @@ export async function fetchNetworkMetricsPlaywright( prev.caParMagasinReseau = prev.nbMagasinsReseau > 0 ? prev.caReseau / prev.nbMagasinsReseau : 0; margeWeight.set(code, nextWeight); } + // Rattache la quantité mensuelle (par code central) au métrique agrégé. + const monthly = result.monthly ?? {}; + for (const [code, metric] of out) { + const mm = monthly[code]; + if (mm && Object.keys(mm).length) metric.qteByMonth = mm; + } 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) => ({ diff --git a/src/types/grid.ts b/src/types/grid.ts index fe48b4f..6b6336a 100644 --- a/src/types/grid.ts +++ b/src/types/grid.ts @@ -119,6 +119,8 @@ export interface ProductRow { margePctReseau?: number; /** Taux de présence réseau = nbMagasinsReseau / 270 */ tauxPresenceReseau?: number; + /** Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal). */ + qteReseauByMonth?: Record | null; /** Fraîcheur des données réseau (ISO) */ networkFetchedAt?: string; }