merge: piloter Période + garde-fou d'intégrité + sélection par dimension Mois

Fusion de deux lignes de travail parallèles sur main, complémentaires et non
concurrentes :

  cd2e77e  selectionnerFenetreViaDimensionMois() — sélectionne les 12 mois par
           qElemNumber sur la dimension maître Mois. C'est ce qui marche : les
           champs Date exposés sont dissociés des faits (100 % du total avant
           comme après), la dimension maître porte les vraies valeurs.
  4ef0e9f  garde-fou d'intégrité — refuse d'écrire le cache si la fenêtre n'est
           pas entièrement couverte. C'est pourquoi le cache est resté inchangé
           au lieu d'être corrompu.
  726fb84  choisirPeriode() — le maillon manquant.

Le dump des expressions donne la cause de fond :

  « Quantité N »    = Sum({<Type_Cal={'N'}>} quantite)
  « Quantité COMP » = Sum({<Type_Cal={'$(vPeriod_comp)'}…>} quantite)

Un pont de périodes duplique les lignes de faits ; Type_Cal marque la période
analysée (N) ou de comparaison (COMP), et le champ Période (4 valeurs) décide
de quelle période il s'agit. Sélectionner correctement les mois ne suffit donc
pas : sous la période par défaut, « Quantité N » rend 0 sur tout mois hors
période courante, même parfaitement sélectionné.

Orchestration fusionnée :
  1. choisirPeriode() essaie chaque valeur de Période et MESURE, sur un cube
     [Mois] × Quantité N, combien de mois de la fenêtre elle rend disponibles.
     Aucun libellé n'est deviné.
  2. Si une période couvre les 12 mois → master measures (exactes sur toute la
     fenêtre). Sinon → agrégation directe des faits, en secours.
  3. Dans les deux cas, la couverture des 12 mois est revérifiée avant
     persistance ; sinon l'extraction est refusée et le cache laissé intact.

La tendance conserve la règle stricte de 4ef0e9f (les 12 clés explicitement
présentes, ou pas de tendance), qui écarte aussi le « +4100 % » calculé sur deux
points pour un article vendu depuis mai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfqUihgixw4K1AmJhizWiu
This commit is contained in:
Claude committed 2026-07-31 11:27:51 +00:00
commit 5f3760f03e
8 files changed
+389 -145

No files matched your search

+15 -16
View File
@@ -60,14 +60,15 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* présent dans le cache est donc **ignoré** — sinon la pente est faussée par un
* mois tronqué.
*
* Un mois **absent** du cache n'est PAS supposé nul : l'extraction ne couvre pas
* toujours les 12 mois (les mesures Qlik « N » ne savent pas remonter au-delà de
* l'année en cours). La sync écrit un zéro **explicite** sur chaque mois qu'elle
* a réellement couvert sans vente ; la tendance ne retient donc que les mois
* présents comme clés, et ignore purement et simplement les autres.
* 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é
* la fenêtre et l'égalité avec le total Qlik. Une ancienne extraction partielle
* ne peut donc plus être présentée comme une tendance réelle.
*
* Sans cette distinction, un article vendu seulement depuis mai voyait sa
* tendance calculée sur deux points — « +4 100 % · Forte hausse ».
* Cette exigence du « tout ou rien » évite deux écueils observés en production :
* une tendance calculée sur deux points (« +4 100 % · Forte hausse » pour un
* article vendu depuis mai), et des mois non extraits comptés comme des mois
* sans vente.
*
* 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.
@@ -79,16 +80,14 @@ export function computeNetworkTrend(
): NetworkTrend {
const empty: NetworkTrend = { values: [], labels: [], direction: "flat", pct: null, hasData: false };
if (!qteByMonth) return empty;
const presents = Object.keys(qteByMonth).sort(); // "YYYY-MM" trie chronologiquement
if (presents.length === 0) 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 = buildRolling12QlikMonths(now);
// Seuls les mois RÉELLEMENT extraits comptent : ceux que la sync n'a pas
// couverts sont absents des clés et sont écartés, plutôt que comptés à zéro.
const labels = fenetre.filter((m) => qteByMonth[m] != null);
if (labels.length === 0) return empty;
const values = labels.map((l) => Number(qteByMonth[l]) || 0);
const values = labels.map((l) => Number(qteByMonth[l]));
const n = values.length;
let pct: number | null = null;
let direction: NetworkTrend["direction"] = "flat";
+50 -23
View File
@@ -8,7 +8,7 @@ import {
type NetworkMetricCached,
} from "@/lib/qlik-network-cache";
import { pgGetProduitsByCodeCentrale, pgSearchProduits, type PgProduitSearchRow } from "@/lib/pg-ff-client";
import { NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { buildRolling12QlikMonths, NB_MAGASINS_RESEAU } from "@/features/grid/lib/network-trend";
import { normalizeMargePct } from "@/features/produits/lib/compare-reseau";
import type { ProduitRechercheResultat, ProduitRechercheRow } from "@/features/produits/types";
@@ -37,6 +37,20 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
/** Durée de conservation d'un job terminé (le client a le temps de le relire). */
const JOB_TTL_MS = 5 * 60 * 1000;
/** Un cache réseau n'est fiable que s'il porte explicitement les 12 mois attendus. */
function cacheReseauComplet(
cache: NetworkMetricCached | undefined,
periode: string | null,
now: Date = new Date(),
): cache is NetworkMetricCached {
const parMois = cache?.qteByMonth;
if (!parMois || !periode || cache.periode !== periode) return false;
return buildRolling12QlikMonths(now).every((mois) =>
Object.prototype.hasOwnProperty.call(parMois, mois) &&
Number.isFinite(Number(parMois[mois])),
);
}
/**
* Nombre maximum de recherches Qlik simultanées.
*
@@ -271,21 +285,29 @@ async function executerRecherche(
localPromise,
]);
// On persiste les mesures fraîches : la fiche produit ouverte depuis un
// résultat les affiche aussitôt, sans re-extraction. Le détail mensuel n'est
// pas fourni ici et n'est donc pas écrasé (upsert en COALESCE).
// Le cube de recherche utilise encore les master measures annuelles pour
// classer rapidement les résultats. Il ne doit jamais écraser une extraction
// 12 mois déjà vérifiée : dans ce cas on republie les valeurs du cache avec
// leur mensuel. Sans cache complet, les valeurs de découverte restent
// temporaires jusqu'au bouton « Actualiser depuis Qlik ».
try {
const aPersister: NetworkMetric[] = qlik.matches.map((m) => ({
codeCentrale: m.codeCentrale,
caReseau: m.caReseau,
qteReseau: m.qteReseau,
nbMagasinsReseau: m.nbMagasinsReseau,
caParMagasinReseau: m.caParMagasinReseau,
margePctReseau: m.margePctReseau,
periode: qlik.periode ?? undefined,
libelleReseau: m.libelle || undefined,
fournisseurReseau: m.fournisseur || undefined,
}));
const aPersister: NetworkMetric[] = qlik.matches.map((m) => {
const cache = cacheReseau.get(m.codeCentrale);
const fiable = cacheReseauComplet(cache, qlik.periode);
return {
codeCentrale: m.codeCentrale,
caReseau: fiable ? cache.caReseau : m.caReseau,
qteReseau: fiable ? cache.qteReseau : m.qteReseau,
nbMagasinsReseau: fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau,
caParMagasinReseau: fiable ? cache.caParMagasinReseau : m.caParMagasinReseau,
margePctReseau: fiable ? cache.margePctReseau : m.margePctReseau,
periode: fiable ? cache.periode ?? undefined : qlik.periode ?? undefined,
qteByMonth: fiable ? cache.qteByMonth ?? undefined : undefined,
metricsByMonth: fiable ? cache.metricsByMonth ?? undefined : undefined,
libelleReseau: m.libelle || undefined,
fournisseurReseau: m.fournisseur || undefined,
};
});
const n = await upsertNetworkMetrics(aPersister);
console.log(`[produits/search] ${n} ligne(s) réseau mises en cache`);
} catch (e) {
@@ -295,7 +317,12 @@ async function executerRecherche(
const rows: ProduitRechercheRow[] = qlik.matches.map((m) => {
const local = catalogue.get(m.codeCentrale) ?? (m.codeCentraleAlt ? catalogue.get(m.codeCentraleAlt) : undefined);
const cache = cacheReseau.get(m.codeCentrale);
const nbMag = m.nbMagasinsReseau;
const fiable = cacheReseauComplet(cache, qlik.periode);
const qteReseau = fiable ? cache.qteReseau : m.qteReseau;
const caReseau = fiable ? cache.caReseau : m.caReseau;
const nbMag = fiable ? cache.nbMagasinsReseau : m.nbMagasinsReseau;
const caParMagasin = fiable ? cache.caParMagasinReseau : m.caParMagasinReseau;
const margePct = fiable ? cache.margePctReseau : m.margePctReseau;
return {
codeCentrale: m.codeCentrale,
libelle: local?.libelle1 || m.libelle || m.codeCentrale,
@@ -304,16 +331,16 @@ async function executerRecherche(
codein: local?.codein ?? null,
nomenclature: local?.nomenclature ?? "",
stockLocal: local ? Number(local.stock_total) || 0 : null,
qteReseau: m.qteReseau,
caReseau: m.caReseau,
qteReseau,
caReseau,
nbMagasinsReseau: nbMag,
tauxPresence: nbMag / NB_MAGASINS_RESEAU,
qteParMagasinReseau: nbMag > 0 ? m.qteReseau / nbMag : 0,
caParMagasinReseau: m.caParMagasinReseau,
prixMoyenReseau: m.qteReseau > 0 ? m.caReseau / m.qteReseau : null,
margePctReseau: normalizeMargePct(m.margePctReseau),
qteParMagasinReseau: nbMag > 0 ? qteReseau / nbMag : 0,
caParMagasinReseau: caParMagasin,
prixMoyenReseau: qteReseau > 0 ? caReseau / qteReseau : null,
margePctReseau: normalizeMargePct(margePct),
// Le mensuel ne vient que du cache : le cube de recherche est agrégé.
qteByMonth: cache?.qteByMonth ?? null,
qteByMonth: fiable ? cache.qteByMonth : null,
periode: qlik.periode,
};
});