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
This commit is contained in:
Claude committed 2026-10-12 19:33:53 +00:00
1 parent 559e6ed714
commit 22a6c59488
19 files changed
+6139 -2

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
//! 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");
}
}