Files
CollectFlow/server/src/grid/network_trend.rs
T
Claude 22a6c59488 Serveur Rust : module grid, phase 1 (instantané, snapshots, gammes, exports, API)
Portage de tout ce qui, dans la Grille, ne dépend ni de la base FF ni de Qlik :
- types.rs : ProductRow et types associés, JSON identique à JSON.stringify
  (camelCase, champs facultatifs omis, entiers sans décimale, NaN → null) et
  relecture sans perte des payloads grid_rows écrits par la version Node ;
- store.rs : grid_rows (upsert par lots de 200 + purge, instantané, report des
  gammes, requêtes filtrées/triées/paginées via QueryBuilder et liste blanche
  de tri, fiche, fournisseurs, fraîcheur, nomenclatures) ;
- rows_cache.rs : cache mémoire 10 min des lignes calculées (clé, LRU,
  patchGridRowsCache, invalidation, calculs en cours, compléments par magasin) ;
- network_trend.rs, months.rs, store_patch.rs : logique pure testée ;
- gammes_a_valider.rs, enregistrer.rs, snapshots.rs : actions getSnapshots,
  deleteSnapshot, saveSnapshot, saveDraftChanges, getGammesAValider,
  rejeterGammesAValider (validation zod reproduite, mêmes messages) ;
- exports.rs : /api/export/excel et /api/export/modified-gammes
  (rust_xlsxwriter, mêmes feuilles, styles, largeurs et formats) ;
- api_schemas.rs, zod.rs : schémas /api/v1, issues zod 4 à l'identique ;
- api_v1.rs, openapi.rs : /api/v1/nomenclatures et /api/v1/openapi.json
  (document identique octet pour octet à celui de la version Node).

Écart volontaire : listGridNomenclatures groupe par position (GROUP BY 1) ; la
requête drizzle répétait left(code3, $n) avec un autre paramètre et échouait
systématiquement sous PostgreSQL.

Test d'intégration (grid_rows, snapshots, gammes à valider) activé par
GRID_TEST_DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:33:53 +00:00

351 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! CollectFlow — Tendance des ventes réseau Qlik sur 12 mois
//! (port de `src/features/grid/lib/network-trend.ts`).
//!
//! Partagé entre la Grille, la fiche produit et l'API `/api/v1` (champ `trend`).
//! Aucun changement de comportement.
use super::js;
use chrono::{Datelike, NaiveDate};
use serde::Serialize;
use std::collections::BTreeMap;
/// Nombre de magasins du réseau La Foir'Fouille servant de dénominateur au taux
/// de présence. Constante métier : source unique pour la Grille et la fiche produit.
pub const NB_MAGASINS_RESEAU: f64 = 270.0;
/// Nombre de mois comparés à chaque bout de la fenêtre.
///
/// Quatre, et pas un : comparer le dernier mois au premier ferait dépendre toute
/// la tendance de deux points, donc du hasard d'un réassort ou d'une opération.
pub const TREND_WINDOW: usize = 4;
/// Seuil de "forte" variation (±25%) pour distinguer hausse/forte hausse dans l'UI.
pub const TREND_STRONG: f64 = 0.25;
/// Seuil au-delà duquel on ne considère plus la tendance comme stable (±8%).
pub const TREND_FLAT: f64 = 0.08;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Direction {
Up,
Down,
Flat,
}
impl Direction {
pub fn as_str(self) -> &'static str {
match self {
Direction::Up => "up",
Direction::Down => "down",
Direction::Flat => "flat",
}
}
}
/// Couleur de chaque direction (`TREND_COLOR`).
pub fn trend_color(d: Direction) -> &'static str {
match d {
Direction::Up => "#22c55e",
Direction::Down => "#ef4444",
Direction::Flat => "#94a3b8",
}
}
struct Nums<'a>(&'a [f64]);
impl Serialize for Nums<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_seq(self.0.iter().map(|v| js::JsNum(*v)))
}
}
fn ser_nums<S: serde::Serializer>(v: &[f64], s: S) -> Result<S::Ok, S::Error> {
Nums(v).serialize(s)
}
fn ser_opt_nums<S: serde::Serializer>(v: &Option<Vec<f64>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(v) => Nums(v).serialize(s),
None => s.serialize_none(),
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NetworkTrend {
/// quantités mensuelles, ordre chronologique
#[serde(serialize_with = "ser_nums")]
pub values: Vec<f64>,
/// "YYYY-MM"
pub labels: Vec<String>,
/// Quantité moyenne par magasin vendeur, mois par mois. `None` si le nombre
/// de magasins n'est pas connu sur les 12 mois.
#[serde(serialize_with = "ser_opt_nums")]
pub per_store: Option<Vec<f64>>,
/// `true` si la tendance porte sur la qté/magasin, `false` si elle a dû se
/// rabattre sur les quantités brutes faute de nombre de magasins.
pub sur_qte_par_magasin: bool,
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers. `None` si aucune base.
#[serde(serialize_with = "js::opt_num::serialize")]
pub pct: Option<f64>,
/// Rien sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// `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.
pub en_retard: bool,
pub has_data: bool,
}
/// Premier jour du mois décalé de `delta` mois (`new Date(y, m + delta, 1)`).
fn mois_decale(now: NaiveDate, delta: i32) -> (i32, u32) {
let total = now.year() * 12 + now.month0() as i32 + delta;
(total.div_euclid(12), total.rem_euclid(12) as u32 + 1)
}
/// Les 12 mois complets glissants au format Qlik `"YYYY-MM"`, du plus ancien au
/// plus récent. **Le mois en cours est exclu** : il est partiel, l'intégrer
/// écraserait systématiquement la tendance vers le bas.
///
/// Même fenêtre que `getLast12Months()` (clés FF `"YYYYMM"`) et que
/// `buildGridNetworkQlikDateFilter()` (extraction Qlik) — les trois doivent
/// rester alignés.
pub fn build_rolling12_qlik_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i| {
let (y, m) = mois_decale(now, -i);
format!("{y}-{m:02}")
})
.collect()
}
/// Les 12 clés de la fenêtre sont-elles explicitement présentes et chiffrées ?
fn fenetre_complete(serie: &BTreeMap<String, f64>, labels: &[String]) -> bool {
labels.iter().all(|l| serie.get(l).map(|v| v.is_finite()).unwrap_or(false))
}
/// 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. `None` si aucune des deux
/// fenêtres n'est complète.
pub fn fenetre_disponible(serie: Option<&BTreeMap<String, f64>>, now: NaiveDate) -> Option<(Vec<String>, bool)> {
let serie = serie?;
let labels = build_rolling12_qlik_months(now);
if fenetre_complete(serie, &labels) {
return Some((labels, false));
}
let (y, m) = mois_decale(now, -1);
let precedente = build_rolling12_qlik_months(NaiveDate::from_ymd_opt(y, m, 1)?);
if fenetre_complete(serie, &precedente) {
return Some((precedente, true));
}
None
}
/// 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.
///
/// Une série n'est retenue que si les **12 clés sont explicitement présentes**
/// (tout ou rien) ; la tendance porte sur la **quantité par magasin vendeur**
/// quand le nombre de magasins est connu sur les 12 mois, sinon sur les
/// quantités brutes (`surQteParMagasin = false`). Sans base de comparaison (rien
/// vendu sur les 4 premiers mois), aucun pourcentage n'est inventé : le produit
/// est signalé « nouveau ».
///
/// Direction : forte hausse >+25%, hausse >+8%, stable, baisse <−8%, forte baisse <−25%.
pub fn compute_network_trend(
qte_by_month: Option<&BTreeMap<String, f64>>,
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
now: NaiveDate,
) -> NetworkTrend {
let empty = NetworkTrend {
values: vec![],
labels: vec![],
per_store: None,
sur_qte_par_magasin: false,
direction: Direction::Flat,
pct: None,
nouveau: false,
en_retard: false,
has_data: false,
};
let (Some(qte), Some((labels, en_retard))) = (qte_by_month, fenetre_disponible(qte_by_month, now)) else {
return empty;
};
let values: Vec<f64> = labels.iter().map(|l| qte.get(l).copied().unwrap_or(f64::NAN)).collect();
let n = values.len();
// 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.
let magasins = compute_stores_series(nb_mag_by_month, Some(&labels));
let per_store: Option<Vec<f64>> =
magasins.map(|(mags, _)| values.iter().zip(mags.iter()).map(|(v, m)| if *m > 0.0 { v / m } else { 0.0 }).collect());
let serie: &[f64] = per_store.as_deref().unwrap_or(&values);
let taille = TREND_WINDOW.min(n / 2);
let moyenne = |xs: &[f64]| if xs.is_empty() { 0.0 } else { xs.iter().sum::<f64>() / xs.len() as f64 };
let debut = moyenne(&serie[..taille]);
let fin = moyenne(&serie[n - taille..]);
let mut pct = None;
let mut nouveau = false;
let mut direction = Direction::Flat;
if debut > 0.0 {
// Plancher naturel à −100 % : `fin` ne peut pas descendre sous zéro sans
// retours massifs, et la division par une base positive garde le sens.
let p = (fin - debut) / debut;
direction = if p > TREND_FLAT {
Direction::Up
} else if p < -TREND_FLAT {
Direction::Down
} else {
Direction::Flat
};
pct = Some(p);
} else if fin > 0.0 {
// Rien au départ, des ventes à l'arrivée : la variation relative n'existe
// pas (division par zéro). On le dit, au lieu d'afficher un nombre inventé.
nouveau = true;
direction = Direction::Up;
}
let sur_qte_par_magasin = per_store.is_some();
NetworkTrend { values, labels, per_store, sur_qte_par_magasin, direction, pct, nouveau, en_retard, has_data: true }
}
/// Série « nombre de magasins vendeurs » sur la MÊME fenêtre que
/// [`compute_network_trend`], pour la superposer à la courbe des quantités.
///
/// Même règle du tout ou rien que la courbe des quantités : les 12 clés doivent
/// être explicitement présentes, sinon `None` — un mois non extrait ne doit pas
/// se lire comme « zéro magasin », ce qui simulerait un arrêt de diffusion.
/// Renvoie `(values, labels)`.
pub fn compute_stores_series(
nb_mag_by_month: Option<&BTreeMap<String, f64>>,
labels: Option<&[String]>,
) -> Option<(Vec<f64>, Vec<String>)> {
let serie = nb_mag_by_month?;
let labels = labels.filter(|l| !l.is_empty())?;
if !fenetre_complete(serie, labels) {
return None;
}
Some((labels.iter().map(|l| serie[l]).collect(), labels.to_vec()))
}
/// Libellé français de la tendance : « Forte hausse », « Baisse », « Stable »…
pub fn trend_label(pct: Option<f64>, nouveau: bool) -> &'static str {
// Un produit sans base de comparaison n'est pas « stable » : il est nouveau.
if nouveau {
return "Nouveau";
}
let Some(p) = pct else { return "Stable" };
if p > TREND_STRONG {
"Forte hausse"
} else if p > TREND_FLAT {
"Hausse"
} else if p < -TREND_STRONG {
"Forte baisse"
} else if p < -TREND_FLAT {
"Baisse"
} else {
"Stable"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, j: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, j).unwrap()
}
fn serie(labels: &[String], f: impl Fn(usize) -> f64) -> BTreeMap<String, f64> {
labels.iter().enumerate().map(|(i, l)| (l.clone(), f(i))).collect()
}
#[test]
fn fenetre_glissante_hors_mois_en_cours() {
let m = build_rolling12_qlik_months(d(2026, 1, 15));
assert_eq!(m.len(), 12);
assert_eq!(m.first().unwrap(), "2025-01");
assert_eq!(m.last().unwrap(), "2025-12");
let m = build_rolling12_qlik_months(d(2026, 10, 1));
assert_eq!(m.first().unwrap(), "2025-10");
assert_eq!(m.last().unwrap(), "2026-09");
}
#[test]
fn tendance_hausse_par_magasin() {
let now = d(2026, 10, 12);
let labels = build_rolling12_qlik_months(now);
let qte = serie(&labels, |i| {
if i < 4 {
10.0
} else if i >= 8 {
20.0
} else {
15.0
}
});
let nb = serie(&labels, |_| 5.0);
let t = compute_network_trend(Some(&qte), Some(&nb), now);
assert!(t.has_data && t.sur_qte_par_magasin && !t.en_retard);
assert_eq!(t.direction, Direction::Up);
assert!((t.pct.unwrap() - 1.0).abs() < 1e-12);
assert_eq!(trend_label(t.pct, t.nouveau), "Forte hausse");
// Même quantité, mais diffusée dans deux fois plus de magasins : stable.
let nb2 = serie(&labels, |i| {
if i < 4 {
5.0
} else if i >= 8 {
10.0
} else {
7.5
}
});
let t2 = compute_network_trend(Some(&qte), Some(&nb2), now);
assert_eq!(t2.direction, Direction::Flat);
assert_eq!(trend_label(t2.pct, t2.nouveau), "Stable");
}
#[test]
fn tendance_nouveau_retard_et_incomplet() {
let now = d(2026, 10, 2);
// Cache pas encore resynchronisé : fenêtre d'un mois plus tôt.
let anciens = build_rolling12_qlik_months(d(2026, 9, 1));
let qte = serie(&anciens, |i| if i < 4 { 0.0 } else { 3.0 });
let t = compute_network_trend(Some(&qte), None, now);
assert!(t.has_data && t.en_retard && t.nouveau && !t.sur_qte_par_magasin);
assert_eq!(t.pct, None);
assert_eq!(t.direction, Direction::Up);
assert_eq!(trend_label(t.pct, t.nouveau), "Nouveau");
// Un mois manquant : aucune tendance.
let mut trou = qte.clone();
trou.remove(&anciens[5]);
let t = compute_network_trend(Some(&trou), None, now);
assert!(!t.has_data);
assert_eq!(
serde_json::to_string(&t).unwrap(),
r#"{"values":[],"labels":[],"perStore":null,"surQteParMagasin":false,"direction":"flat","pct":null,"nouveau":false,"enRetard":false,"hasData":false}"#
);
}
#[test]
fn libelles() {
assert_eq!(trend_label(None, false), "Stable");
assert_eq!(trend_label(Some(0.1), false), "Hausse");
assert_eq!(trend_label(Some(-0.1), false), "Baisse");
assert_eq!(trend_label(Some(-0.3), false), "Forte baisse");
assert_eq!(trend_label(Some(0.08), false), "Stable");
}
}