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 }; }