fix Qlik rolling 12-month integrity

This commit is contained in:
Michael committed 2026-07-31 10:28:29 +02:00
1 parent db886cd8d9
commit 4ef0e9fdeb
8 files changed
+221 -100

No files matched your search

+11 -14
View File
@@ -62,10 +62,10 @@ export const TREND_COLOR: Record<NetworkTrend["direction"], string> = {
* - un mois de la fenêtre **absent** du cache vaut 0 : côté Qlik, un produit
* non vendu sur un mois ne produit tout simplement pas de ligne.
*
* Seule exception au remplissage par zéro : les mois **antérieurs au premier
* mois réellement extrait**. Ils traduisent une extraction plus courte que 12
* mois (`QLIK_SYNC_MONTHS_BACK`), pas une absence de ventes — les inventer à 0
* simulerait une croissance qui n'existe pas.
* 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.
*
* 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.
@@ -77,17 +77,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);
// Aucun mois extrait ne tombe dans la fenêtre glissante (cache périmé) :
// rien de fiable à tracer.
const premierMoisExtrait = presents[0];
const labels = fenetre.filter((m) => m >= premierMoisExtrait);
if (labels.length === 0 || !labels.some((m) => qteByMonth[m] != null)) 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";
+49 -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,19 @@ 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 {
if (!cache?.qteByMonth || !periode || cache.periode !== periode) return false;
return buildRolling12QlikMonths(now).every((mois) =>
Object.prototype.hasOwnProperty.call(cache.qteByMonth, mois) &&
Number.isFinite(Number(cache.qteByMonth[mois])),
);
}
/**
* Nombre maximum de recherches Qlik simultanées.
*
@@ -271,21 +284,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 +316,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 +330,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,
};
});