Files
CollectFlow/server/src/grid/months.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

98 lines
3.6 KiB
Rust

//! CollectFlow — Helpers de fenêtre mensuelle (port des fonctions de
//! `src/features/grid/lib/months.ts` utilisées côté serveur).
//!
//! ⚠️ Deux conventions de clé de mois cohabitent dans l'application :
//! - séries FF Nancy (ventes / stock / réceptions) : `"YYYYMM"` (sans tiret)
//! - séries réseau Qlik (`qteByMonth`, `metricsByMonth`) : `"YYYY-MM"` (avec tiret)
//!
//! Utiliser [`ff_month_to_qlik`] / [`qlik_month_to_ff`] pour passer de l'une à l'autre.
use super::types::ProductRow;
use chrono::{Datelike, NaiveDate};
/// Date du jour, heure locale du serveur (`new Date()`).
pub fn today() -> NaiveDate {
chrono::Local::now().date_naive()
}
/// Les 12 mois complets glissants, du plus ancien au plus récent, au format
/// `"YYYYMM"`. Le mois en cours est **exclu** (on part de i=12 jusqu'à i=1).
///
/// Même fenêtre que `last12Periods()` dans `get-product-rows.ts`.
pub fn get_last12_months(now: NaiveDate) -> Vec<String> {
(1..=12)
.rev()
.map(|i: i32| {
let total = now.year() * 12 + now.month0() as i32 - i;
format!("{}{:02}", total.div_euclid(12), total.rem_euclid(12) + 1)
})
.collect()
}
/// Fenêtre de 12 mois **telle que le serveur l'a calculée**, lue dans les clés des
/// séries mensuelles des lignes reçues. `None` si aucune ligne ne porte de série
/// complète (l'appelant retombe alors sur [`get_last12_months`]).
pub fn get_months_from_rows(rows: &[ProductRow]) -> Option<Vec<String>> {
for r in rows {
let mut keys: Vec<&String> = r.sales12m.keys().filter(|k| k.len() == 6 && k.bytes().all(|b| b.is_ascii_digit())).collect();
if keys.len() < 12 {
continue;
}
// Toutes les lignes d'un même calcul partagent la même fenêtre : la
// première ligne complète suffit.
keys.sort();
return Some(keys[keys.len() - 12..].iter().map(|k| k.to_string()).collect());
}
None
}
/// `"202601"` → `"Jan 26"`.
pub fn format_month_label(key: &str) -> String {
const NOMS: [&str; 12] = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"];
let m: usize = key.get(4..6).and_then(|s| s.parse().ok()).unwrap_or(0);
let nom = m.checked_sub(1).and_then(|i| NOMS.get(i)).copied().unwrap_or("undefined");
format!("{nom} {}", key.get(2..4).unwrap_or(""))
}
/// Clé de mois FF `"202601"` → clé Qlik `"2026-01"`.
pub fn ff_month_to_qlik(key: &str) -> String {
format!("{}-{}", key.get(..4).unwrap_or(key), key.get(4..6).unwrap_or(""))
}
/// Clé de mois Qlik `"2026-01"` → clé FF `"202601"`.
pub fn qlik_month_to_ff(key: &str) -> String {
key.replacen('-', "", 1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn douze_mois_hors_mois_en_cours() {
let m = get_last12_months(NaiveDate::from_ymd_opt(2026, 1, 31).unwrap());
assert_eq!(m.first().unwrap(), "202501");
assert_eq!(m.last().unwrap(), "202512");
assert_eq!(m.len(), 12);
}
#[test]
fn conversions_de_cles() {
assert_eq!(ff_month_to_qlik("202601"), "2026-01");
assert_eq!(qlik_month_to_ff("2026-01"), "202601");
assert_eq!(format_month_label("202602"), "Fév 26");
}
#[test]
fn fenetre_depuis_les_lignes() {
let mut r = ProductRow::default();
for k in get_last12_months(NaiveDate::from_ymd_opt(2026, 10, 1).unwrap()) {
r.sales12m.insert(k, 1.0);
}
r.sales12m.insert("202409".into(), 0.0);
let m = get_months_from_rows(&[ProductRow::default(), r]).unwrap();
assert_eq!(m.first().unwrap(), "202510");
assert_eq!(m.last().unwrap(), "202609");
}
}