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

110 lines
5.4 KiB
Rust

//! Complément des chiffres d'un magasin par l'API FF (ventes mensuelles) — partie
//! pure de `src/features/grid/lib/store-patch.ts`.
//!
//! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
//! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
//! pour ces articles seulement, les séries et totaux du magasin.
//!
//! `buildStorePatchEntries` (qui lit les entrées mensuelles de l'API FF) viendra
//! avec le calcul de la grille (phase 2).
use super::types::{ProductRow, StorePatchEntry};
use std::collections::HashMap;
/// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin.
pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> {
rows.iter()
.filter(|row| {
row.noid.map(|n| n != 0.0 && !n.is_nan()).unwrap_or(false)
&& row.quantite_by_store.as_ref().and_then(|m| m.get(magasin)).copied().unwrap_or(0.0) == 0.0
})
.collect()
}
/// Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le
/// magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne.
/// Renvoie le nombre de lignes corrigées.
pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, periods: &[String], entries: &[StorePatchEntry]) -> usize {
let par_code: HashMap<&str, &StorePatchEntry> = entries.iter().map(|e| (e.codein.as_str(), e)).collect();
let mut corrigees = 0;
for row in rows.iter_mut() {
let Some(e) = par_code.get(row.codein.as_str()) else { continue };
let sales = row.sales12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
let mut deltas_ventes = Vec::with_capacity(periods.len());
for period in periods {
let current = sales.get(period).copied().unwrap_or(0.0);
let next = e.sales.get(period).copied().unwrap_or(0.0);
sales.insert(period.clone(), next);
deltas_ventes.push((period, next - current));
}
let receptions = row.receptions12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
receptions.insert(period.clone(), e.receptions.get(period).copied().unwrap_or(0.0));
}
let stock = row.stock12m_by_store.get_or_insert_with(Default::default).entry(magasin.to_string()).or_default();
for period in periods {
stock.insert(period.clone(), e.stock.get(period).copied().unwrap_or(0.0));
}
for (period, delta) in deltas_ventes {
let v = row.sales12m.get(period).copied().unwrap_or(0.0);
row.sales12m.insert(period.clone(), v + delta);
}
let ca_by_store = row.ca_by_store.get_or_insert_with(Default::default);
let quantite_by_store = row.quantite_by_store.get_or_insert_with(Default::default);
let marge_by_store = row.marge_by_store.get_or_insert_with(Default::default);
let delta_qty = e.quantite - quantite_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_ca = e.ca - ca_by_store.get(magasin).copied().unwrap_or(0.0);
let delta_marge = e.marge - marge_by_store.get(magasin).copied().unwrap_or(0.0);
quantite_by_store.insert(magasin.to_string(), e.quantite);
ca_by_store.insert(magasin.to_string(), e.ca);
marge_by_store.insert(magasin.to_string(), e.marge);
row.total_quantite += delta_qty;
row.total_ca += delta_ca;
row.total_marge += delta_marge;
row.taux_marge = if row.total_ca > 0.0 { (row.total_marge / row.total_ca) * 100.0 } else { 0.0 };
if !row.working_stores.iter().any(|s| s == magasin) {
row.working_stores.push(magasin.to_string());
}
corrigees += 1;
}
corrigees
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn complement_reporte_sur_les_totaux() {
let periods = vec!["202601".to_string(), "202602".to_string()];
let mut row = ProductRow { codein: "A1".into(), noid: Some(7.0), ..Default::default() };
row.sales12m.insert("202601".into(), 4.0);
row.total_quantite = 4.0;
row.total_ca = 40.0;
row.total_marge = 10.0;
row.working_stores = vec!["292".into()];
let mut q = std::collections::BTreeMap::new();
q.insert("292".to_string(), 4.0);
row.quantite_by_store = Some(q);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "579").len(), 1);
assert_eq!(store_patch_candidates(std::slice::from_ref(&row), "292").len(), 0);
let mut e = StorePatchEntry { codein: "A1".into(), quantite: 3.0, ca: 30.0, marge: 6.0, ..Default::default() };
e.sales.insert("202602".into(), 3.0);
e.stock.insert("202601".into(), 5.0);
let mut rows = vec![row];
assert_eq!(apply_store_patch_in_place(&mut rows, "579", &periods, &[e]), 1);
let r = &rows[0];
assert_eq!(r.total_quantite, 7.0);
assert_eq!(r.total_ca, 70.0);
assert!((r.taux_marge - 16.0 / 70.0 * 100.0).abs() < 1e-9);
assert_eq!(r.sales12m["202602"], 3.0);
assert_eq!(r.sales12m_by_store.as_ref().unwrap()["579"]["202601"], 0.0);
assert_eq!(r.stock12m_by_store.as_ref().unwrap()["579"]["202601"], 5.0);
assert_eq!(r.working_stores, vec!["292".to_string(), "579".to_string()]);
}
}