mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 10:48:09 +02:00
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
110 lines
5.4 KiB
Rust
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()]);
|
|
}
|
|
}
|