From 48860271a5e4e56bc4bd61782152e286234036f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 07:29:29 +0000 Subject: [PATCH] =?UTF-8?q?Tendance=20r=C3=A9seau=20:=20de=20retour=20dans?= =?UTF-8?q?=20la=20Grille=20apr=C3=A8s=20le=20changement=20de=20mois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grille servie depuis l'instantané (lot 4) : les colonnes réseau sont relues dans le cache Qlik, comme le calcul en direct. Elles restaient figées à la date du calcul de l'instantané. - Tendance : si la fenêtre du jour est incomplète mais que les 12 mois précédents le sont (cache Qlik pas encore resynchronisé depuis le changement de mois), la tendance porte sur ces 12 mois au lieu de disparaître. Le modal le signale. - Courbe des magasins vendeurs alignée sur les mois de la tendance (modal Grille et fiche produit). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MouZgPPAuEZjHXXifW7bm --- src/app/(dashboard)/produits/fiche.tsx | 2 +- src/features/grid/api/get-product-rows.ts | 14 ++-- src/features/grid/components/heatmap-grid.tsx | 4 +- src/features/grid/lib/network-trend.ts | 67 +++++++++++++------ 4 files changed, 62 insertions(+), 25 deletions(-) diff --git a/src/app/(dashboard)/produits/fiche.tsx b/src/app/(dashboard)/produits/fiche.tsx index 70695fb..dc3107a 100644 --- a/src/app/(dashboard)/produits/fiche.tsx +++ b/src/app/(dashboard)/produits/fiche.tsx @@ -227,7 +227,7 @@ export function ProduitFicheView({ fiche, backQuery }: { fiche: ProduitFiche; ba () => computeNetworkTrend(reseau?.qteByMonth ?? null, nbMagByMonth), [reseau, nbMagByMonth], ); - const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth), [nbMagByMonth]); + const magasinsParMois = useMemo(() => computeStoresSeries(nbMagByMonth, trend.labels), [nbMagByMonth, trend.labels]); const comparatif = useMemo(() => computeComparatif(fiche), [fiche]); const verdict = indiceVerdict(comparatif.indiceQte); diff --git a/src/features/grid/api/get-product-rows.ts b/src/features/grid/api/get-product-rows.ts index f609208..c9f4a6f 100644 --- a/src/features/grid/api/get-product-rows.ts +++ b/src/features/grid/api/get-product-rows.ts @@ -217,11 +217,17 @@ async function serveFromSnapshot(input: GetProductRowsInput, cacheKey: string): if (!snapshot || snapshot.rows.length === 0) return null; const { rows } = snapshot; - const [changes] = await Promise.all([ + // Les colonnes réseau de l'instantané datent de son calcul : on les relit dans + // le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite + // depuis (au changement de mois notamment) restait invisible jusqu'au + // prochain recalcul, et la tendance réseau disparaissait. + const [changes, network] = await Promise.all([ loadLatestSnapshotChanges(codeFournisseur), + fetchNetworkMetrics(rows.map((r) => r.codeCentrale ?? "")), refreshGammeInit(rows, codeFournisseur), ]); applySnapshotChanges(rows, changes); + enrichWithNetworkMetrics(rows, network); if (magasin !== "TOTAL") { const { dateDebut, dateFin } = buildLast12MonthsRange(); @@ -545,7 +551,7 @@ async function buildProductRows(input: GetProductRowsInput): Promise, + products: Iterable, metrics: Map | null, ): void { if (!metrics || metrics.size === 0) return; - for (const product of productMap.values()) { + for (const product of products) { const m = product.codeCentrale ? metrics.get(product.codeCentrale) : undefined; if (!m) continue; product.caReseau = m.caReseau; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index de71071..e87a76e 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -36,6 +36,7 @@ import { getMonthsFromRows, formatMonthLabel, formatDate, + fmtMonthShort, SITE_LABELS, getStoreConfig, } from "@/features/grid/lib/months"; @@ -566,7 +567,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () => const trend = tendanceDe(row); const { values, labels, direction, pct } = trend; const color = TREND_COLOR[direction]; - const magasins = computeStoresSeries(row.nbMagReseauByMonth); + const magasins = computeStoresSeries(row.nbMagReseauByMonth, labels); const Fleche = direction === "up" ? TrendingUp : direction === "down" ? TrendingDown : Minus; const dernier = values.length - 1; @@ -585,6 +586,7 @@ function NetworkMonthlyModal({ row, onClose }: { row: ProductRow; onClose: () =>

Réseau · 12 mois glissants, mois en cours exclu + {trend.enRetard && labels.length > 0 && ` · jusqu'à ${fmtMonthShort(labels[labels.length - 1])}, le dernier mois n'est pas encore synchronisé`}

diff --git a/src/features/grid/lib/network-trend.ts b/src/features/grid/lib/network-trend.ts index 88f2a7e..4433a97 100644 --- a/src/features/grid/lib/network-trend.ts +++ b/src/features/grid/lib/network-trend.ts @@ -30,6 +30,11 @@ export type NetworkTrend = { pct: number | null; /** Rien sur les 4 premiers mois, des ventes sur les 4 derniers. */ nouveau: boolean; + /** + * `true` si la fenêtre s'arrête un mois plus tôt que prévu : le cache Qlik + * n'a pas encore été resynchronisé depuis le changement de mois. + */ + enRetard: boolean; hasData: boolean; }; @@ -71,6 +76,37 @@ export const TREND_COLOR: Record = { flat: "#94a3b8", }; +/** Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ? */ +function fenetreComplete(serie: Record, labels: string[]): boolean { + return labels.every((label) => + Object.prototype.hasOwnProperty.call(serie, label) && + Number.isFinite(Number(serie[label])), + ); +} + +/** + * Les 12 mois réellement disponibles dans une série Qlik : la fenêtre du jour, + * sinon celle qui s'arrête un mois plus tôt. + * + * Le repli couvre les jours qui suivent un changement de mois : la fenêtre a + * glissé, mais le cache Qlik n'est resynchronisé que fournisseur par + * fournisseur, la nuit. Sans lui, toutes les tendances disparaissaient d'un + * coup le 1er du mois. Ce sont toujours 12 mois complets et extraits — aucun + * mois n'est inventé — simplement décalés d'un cran. `null` si aucune des deux + * fenêtres n'est complète. + */ +export function fenetreDisponible( + serie: Record | null | undefined, + now: Date = new Date(), +): { labels: string[]; enRetard: boolean } | null { + if (!serie) return null; + const labels = buildRolling12QlikMonths(now); + if (fenetreComplete(serie, labels)) return { labels, enRetard: false }; + const precedente = buildRolling12QlikMonths(new Date(now.getFullYear(), now.getMonth() - 1, 1)); + if (fenetreComplete(serie, precedente)) return { labels: precedente, enRetard: true }; + return null; +} + /** * Tendance réseau : variation entre les **4 premiers** et les **4 derniers** * mois de la fenêtre de 12 mois glissants, mois en cours exclu. @@ -79,7 +115,8 @@ export const TREND_COLOR: Record = { * présentes dans `qteByMonth`) : c'est le seul moyen de garantir une tendance * réellement glissante. Un mois en cours (partiel), ou plus vieux que 12 mois, * présent dans le cache est donc **ignoré** — sinon la pente est faussée par un - * mois tronqué. + * mois tronqué. Seule tolérance : un cache pas encore resynchronisé depuis le + * changement de mois, lu sur ses 12 mois complets (cf. `fenetreDisponible`). * * Une série n'est affichée que si les **12 clés sont explicitement présentes**. * L'extracteur Qlik écrit lui-même les mois sans faits à 0 après avoir validé @@ -129,15 +166,11 @@ export function computeNetworkTrend( ): NetworkTrend { const empty: NetworkTrend = { values: [], labels: [], perStore: null, surQteParMagasin: false, - direction: "flat", pct: null, nouveau: false, hasData: false, + direction: "flat", pct: null, nouveau: false, enRetard: false, hasData: false, }; - if (!qteByMonth) return empty; - const labels = buildRolling12QlikMonths(now); - const complet = labels.every((label) => - Object.prototype.hasOwnProperty.call(qteByMonth, label) && - Number.isFinite(Number(qteByMonth[label])), - ); - if (!complet) return empty; + const fenetre = fenetreDisponible(qteByMonth, now); + if (!qteByMonth || !fenetre) return empty; + const { labels, enRetard } = fenetre; const values = labels.map((l) => Number(qteByMonth[l])); const n = values.length; @@ -145,7 +178,7 @@ export function computeNetworkTrend( // Quantité par magasin vendeur : l'indicateur qui isole la performance du // produit de sa diffusion. Un mois sans magasin vendeur vaut 0 — c'est le // seul choix cohérent avec une quantité elle-même nulle. - const magasins = computeStoresSeries(nbMagByMonth, now); + const magasins = computeStoresSeries(nbMagByMonth, labels); const perStore = magasins ? values.map((v, i) => (magasins.values[i] > 0 ? v / magasins.values[i] : 0)) : null; @@ -172,7 +205,7 @@ export function computeNetworkTrend( direction = "up"; } - return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, hasData: true }; + return { values, labels, perStore, surQteParMagasin: perStore != null, direction, pct, nouveau, enRetard, hasData: true }; } /** @@ -190,15 +223,11 @@ export function computeNetworkTrend( */ export function computeStoresSeries( nbMagByMonth?: Record | null, - now: Date = new Date(), + /** Mois de la courbe des quantités (`trend.labels`), pour rester alignés. */ + labels?: string[], ): { values: number[]; labels: string[] } | null { - if (!nbMagByMonth) return null; - const labels = buildRolling12QlikMonths(now); - const complet = labels.every((label) => - Object.prototype.hasOwnProperty.call(nbMagByMonth, label) && - Number.isFinite(Number(nbMagByMonth[label])), - ); - if (!complet) return null; + if (!nbMagByMonth || !labels || labels.length === 0) return null; + if (!fenetreComplete(nbMagByMonth, labels)) return null; return { values: labels.map((l) => Number(nbMagByMonth[l])), labels }; }