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

+488
View File
@@ -0,0 +1,488 @@
//! CollectFlow — Validation des paramètres de l'API `/api/v1`
//! (port de `src/lib/api-schemas.ts`, schémas zod).
//!
//! Toute entrée utilisateur passe par ici : bornes de pagination, listes blanches
//! de tri, projection de champs. Les erreurs remontent en `400 bad_request` avec
//! le détail des issues zod (`parsed.error.issues`), pour que l'appelant sache
//! quoi corriger — voir [`Issues::to_json`].
//!
//! Les paramètres d'URL arrivent comme `Object.fromEntries(searchParams)` : une
//! table nom → valeur (la dernière occurrence l'emporte), cf. [`query_map`].
use super::gammes::GAMMES;
use super::store::{GridSortKey, GRID_SORT_KEYS};
use super::zod::{self, child, Issues, Seg};
use serde_json::{Map, Value};
use std::collections::HashMap;
/// `Object.fromEntries(req.nextUrl.searchParams)`.
pub fn query_map(pairs: Vec<(String, String)>) -> HashMap<String, String> {
pairs.into_iter().collect()
}
fn p(k: &str) -> Vec<Seg> {
vec![Seg::from(k)]
}
/// `z.string().min(1[, message]).optional()` sur un paramètre d'URL.
fn opt_non_vide(q: &HashMap<String, String>, k: &str, message: Option<&str>, issues: &mut Issues) -> Option<String> {
let v = q.get(k)?;
zod::check_len(v, &p(k), Some((1, message)), None, issues).then(|| v.clone())
}
/// `z.string().min(1, message)` obligatoire sur un paramètre d'URL.
fn requis(q: &HashMap<String, String>, k: &str, message: &str, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => {
issues.invalid_type(&p(k), "string", None);
None
}
Some(v) => zod::check_len(v, &p(k), Some((1, Some(message))), None, issues).then(|| v.clone()),
}
}
/// `z.enum([...]).default(d)` / `.optional()` sur un paramètre d'URL.
fn enum_param(q: &HashMap<String, String>, k: &str, values: &[&str], defaut: Option<&str>, issues: &mut Issues) -> Option<String> {
match q.get(k) {
None => defaut.map(str::to_string),
Some(v) if values.contains(&v.as_str()) => Some(v.clone()),
Some(_) => {
issues.invalid_value(&p(k), values, None);
None
}
}
}
/// `z.coerce.number().int().min(1)` avec défaut éventuel.
fn int_param(q: &HashMap<String, String>, k: &str, defaut: Option<i64>, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
match q.get(k) {
None => defaut,
Some(v) => zod::coerce_int(v, &p(k), 1.0, max, issues),
}
}
/// Message du tri inconnu (liste des clés acceptées).
pub fn message_tri_inconnu() -> String {
format!("Tri inconnu. Valeurs acceptées : {}", GRID_SORT_KEYS.join(", "))
}
/// Paramètres de tri et de projection communs (`sortShape`).
#[derive(Debug, Clone, PartialEq)]
pub struct SortParams {
pub sort: Option<String>,
/// `"asc"` | `"desc"` (défaut `"desc"`).
pub order: String,
/// Liste de champs de ProductRow à conserver, séparés par des virgules.
pub fields: Option<String>,
/// `"1"` (défaut) : relit les métriques réseau Qlik et la gamme serveur au moment
/// de l'appel. `"0"` : sert l'instantané brut, un peu plus rapide.
pub enrich: String,
}
fn sort_shape(q: &HashMap<String, String>, issues: &mut Issues) -> SortParams {
let sort = q.get("sort").and_then(|v| {
if GRID_SORT_KEYS.contains(&v.as_str()) {
Some(v.clone())
} else {
issues.custom(&p("sort"), &message_tri_inconnu());
None
}
});
let order = enum_param(q, "order", &["asc", "desc"], Some("desc"), issues).unwrap_or_default();
let fields = q.get("fields").cloned();
let enrich = enum_param(q, "enrich", &["0", "1"], Some("1"), issues).unwrap_or_default();
SortParams { sort, order, fields, enrich }
}
/// `/api/v1/grid` — le fournisseur est obligatoire (recherche transversale : /products/search).
#[derive(Debug, Clone, PartialEq)]
pub struct GridQueryParams {
pub fournisseur: String,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par
/// /api/v1/nomenclatures. À préférer à code1/code2.
pub nomenclature: Option<String>,
pub search: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande s'il n'existe pas encore ;
/// `"0"` : comportement strict, échoue vite en `not_ready`.
pub compute: String,
pub sort: SortParams,
pub page: i64,
/// **Aucun plafond, et aucune valeur par défaut** : sans `limit`, `/grid`
/// renvoie *toutes* les lignes du fournisseur.
pub limit: Option<i64>,
}
const MESSAGE_NOMENCLATURE: &str = "La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)";
/// `gridQuerySchema.safeParse(...)`.
pub fn parse_grid_query(q: &HashMap<String, String>) -> Result<GridQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let code1 = opt_non_vide(q, "code1", None, &mut issues);
let code2 = opt_non_vide(q, "code2", None, &mut issues);
let code3 = opt_non_vide(q, "code3", None, &mut issues);
let nomenclature = q.get("nomenclature").and_then(|v| {
let chemin = p("nomenclature");
let mut ok = zod::check_len(v, &chemin, Some((1, None)), Some((20, None)), &mut issues);
if v.is_empty() || !v.bytes().all(|b| b.is_ascii_digit()) {
issues.invalid_regex(&chemin, "/^[0-9]+$/", MESSAGE_NOMENCLATURE);
ok = false;
}
ok.then(|| v.clone())
});
let search = opt_non_vide(q, "search", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", None, None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GridQueryParams {
fournisseur: fournisseur.unwrap_or_default(),
gamme,
code1,
code2,
code3,
nomenclature,
search,
compute,
sort,
page: page.unwrap_or(1),
limit,
})
}
/// `/api/v1/products/search` — `fournisseur` facultatif : c'est la recherche transversale.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductSearchParams {
pub q: String,
pub fournisseur: Option<String>,
pub gamme: Option<String>,
pub sort: SortParams,
pub page: i64,
/// Défaut 100, sans plafond.
pub limit: i64,
}
/// `productSearchSchema.safeParse(...)`.
pub fn parse_product_search(q: &HashMap<String, String>) -> Result<ProductSearchParams, Issues> {
let mut issues = Issues::default();
let texte = match q.get("q") {
None => {
issues.invalid_type(&p("q"), "string", None);
None
}
Some(v) => zod::check_len(v, &p("q"), Some((1, Some("Paramètre 'q' requis"))), Some((120, None)), &mut issues).then(|| v.clone()),
};
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let gamme = opt_non_vide(q, "gamme", None, &mut issues);
let sort = sort_shape(q, &mut issues);
let page = int_param(q, "page", Some(1), None, &mut issues);
let limit = int_param(q, "limit", Some(100), None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductSearchParams { q: texte.unwrap_or_default(), fournisseur, gamme, sort, page: page.unwrap_or(1), limit: limit.unwrap_or(100) })
}
#[derive(Debug, Clone, PartialEq)]
pub struct FournisseursQueryParams {
pub search: Option<String>,
/// `"1"` = uniquement les fournisseurs déjà présents dans l'instantané de grille.
pub with_data: Option<String>,
}
/// `fournisseursQuerySchema.safeParse(...)`.
pub fn parse_fournisseurs_query(q: &HashMap<String, String>) -> Result<FournisseursQueryParams, Issues> {
let mut issues = Issues::default();
let search = opt_non_vide(q, "search", None, &mut issues);
let with_data = enum_param(q, "withData", &["0", "1"], None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(FournisseursQueryParams { search, with_data })
}
/// `/api/v1/nomenclatures` — inventaire des postes d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct NomenclaturesQueryParams {
pub fournisseur: String,
/// 1 = univers, 2 = famille, 3 = sous-famille (selon la nomenclature FF).
pub niveau: u8,
/// Restreint à un poste parent (préfixe).
pub parent: Option<String>,
}
/// `nomenclaturesQuerySchema.safeParse(...)`.
pub fn parse_nomenclatures_query(q: &HashMap<String, String>) -> Result<NomenclaturesQueryParams, Issues> {
let mut issues = Issues::default();
let fournisseur = requis(q, "fournisseur", "Paramètre 'fournisseur' requis", &mut issues);
let niveau = int_param(q, "niveau", Some(1), Some(3.0), &mut issues);
let parent = opt_non_vide(q, "parent", None, &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(NomenclaturesQueryParams { fournisseur: fournisseur.unwrap_or_default(), niveau: niveau.unwrap_or(1).clamp(1, 3) as u8, parent })
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProductDetailParams {
pub fournisseur: Option<String>,
/// `"1"` (défaut) : calcule l'instantané à la demande si le produit n'y figure pas
/// encore. Nécessite `fournisseur`, le calcul se faisant par lot fournisseur.
pub compute: String,
}
/// `productDetailSchema.safeParse(...)`.
pub fn parse_product_detail(q: &HashMap<String, String>) -> Result<ProductDetailParams, Issues> {
let mut issues = Issues::default();
let fournisseur = opt_non_vide(q, "fournisseur", None, &mut issues);
let compute = enum_param(q, "compute", &["0", "1"], Some("1"), &mut issues).unwrap_or_default();
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductDetailParams { fournisseur, compute })
}
/// Message des gammes inconnues (`gammeShape`).
pub fn message_gamme_inconnue() -> String {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
format!("Gamme inconnue. Valeurs acceptées : {}", codes.join(", "))
}
fn gamme_shape(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<String> {
let codes: Vec<&str> = GAMMES.iter().map(|g| g.code).collect();
zod::enum_value(v, &codes, chemin, Some(&message_gamme_inconnue()), issues)
}
/// `z.boolean().default(true)`.
fn bool_defaut_vrai(o: &Map<String, Value>, k: &str, issues: &mut Issues) -> Option<bool> {
match o.get(k) {
None => Some(true),
v => zod::boolean(v, &p(k), issues),
}
}
/// `PUT /api/v1/products/:codein/gamme` — affecte ou change la gamme d'un article.
#[derive(Debug, Clone, PartialEq)]
pub struct ProductGammeBody {
pub gamme: String,
/// Obligatoire seulement si l'article est référencé chez plusieurs fournisseurs.
pub fournisseur: Option<String>,
/// `true` (défaut) : calcule l'instantané du fournisseur s'il n'existe pas encore.
pub compute: bool,
}
/// `productGammeBodySchema.safeParse(corps)`.
pub fn parse_product_gamme_body(corps: &Value) -> Result<ProductGammeBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let gamme = gamme_shape(o.get("gamme"), &p("gamme"), &mut issues);
let fournisseur = match o.get("fournisseur") {
None => Some(None),
v => zod::string(v, &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, None)), None, &mut issues).then_some(Some(s))),
};
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(ProductGammeBody { gamme: gamme.unwrap_or_default(), fournisseur: fournisseur.flatten(), compute: compute.unwrap_or(true) })
}
/// Un changement de `POST /api/v1/gammes`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeChange {
pub codein: String,
pub gamme: String,
}
/// `POST /api/v1/gammes` — affecte des gammes à plusieurs articles d'un fournisseur.
#[derive(Debug, Clone, PartialEq)]
pub struct GammesBatchBody {
pub fournisseur: String,
pub changes: Vec<GammeChange>,
pub compute: bool,
}
/// `gammesBatchBodySchema.safeParse(corps)`.
pub fn parse_gammes_batch_body(corps: &Value) -> Result<GammesBatchBody, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(corps), &[], &mut issues) else { return Err(issues) };
let fournisseur = zod::string(o.get("fournisseur"), &p("fournisseur"), &mut issues)
.and_then(|s| zod::check_len(&s, &p("fournisseur"), Some((1, Some("Champ 'fournisseur' requis"))), None, &mut issues).then_some(s));
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues)
.and_then(|s| zod::check_len(&s, &child(&ci, "codein"), Some((1, None)), None, &mut issues).then_some(s));
let gamme = gamme_shape(c.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gamme)) = (codein, gamme) {
changes.push(GammeChange { codein, gamme });
}
}
if a.is_empty() {
issues.too_small(&chemin, "array", 1.0, Some("Au moins un changement attendu"));
}
if a.len() > 10_000 {
issues.too_big(&chemin, "array", 10_000.0, Some("10 000 changements au maximum par appel"));
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let compute = bool_defaut_vrai(o, "compute", &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok(GammesBatchBody { fournisseur: fournisseur.unwrap_or_default(), changes, compute: compute.unwrap_or(true) })
}
/// Projection de champs : `?fields=codein,libelle1,totalCa`.
///
/// Sert à alléger la réponse — un `ProductRow` complet porte les séries mensuelles
/// et les ventilations par magasin, ce qui est volumineux sur plusieurs centaines de
/// lignes. `codein` est toujours conservé pour que la ligne reste identifiable.
pub fn pick_fields(row: Value, fields: Option<&str>) -> Value {
let Some(fields) = fields.filter(|f| !f.is_empty()) else { return row };
let mut wanted: Vec<&str> = Vec::new();
for f in fields.split(',').map(super::js::js_trim).filter(|f| !f.is_empty()) {
if !wanted.contains(&f) {
wanted.push(f);
}
}
if wanted.is_empty() {
return row;
}
if !wanted.contains(&"codein") {
wanted.push("codein");
}
let Value::Object(source) = row else { return row };
let mut out = Map::new();
for key in wanted {
if let Some(v) = source.get(key) {
out.insert(key.to_string(), v.clone());
}
}
Value::Object(out)
}
/// Normalise le tri validé vers la clé attendue par grid-store.
pub fn to_sort_key(sort: Option<&str>) -> Option<GridSortKey> {
sort.and_then(GridSortKey::from_key)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn q(pairs: &[(&str, &str)]) -> HashMap<String, String> {
query_map(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect())
}
fn issues_json(i: Issues) -> String {
serde_json::to_string(&i.to_json()).unwrap()
}
#[test]
fn grid_query_comme_zod() {
let err = parse_grid_query(&q(&[
("fournisseur", "x"),
("nomenclature", ""),
("sort", "bad"),
("order", "up"),
("enrich", "2"),
("compute", "x"),
("page", "0"),
("limit", "-1"),
]))
.unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["nomenclature"],"message":"Too small: expected string to have >=1 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"},{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"code":"custom","path":["sort"],"message":"Tri inconnu. Valeurs acceptées : libelle1, codein, codeFournisseur, codeGamme, totalCa, totalQuantite, totalMarge, tauxMarge, stockActuel, caReseau, qteReseau, nbMagasinsReseau, caParMagasinReseau, computedAt"},{"code":"invalid_value","values":["asc","desc"],"path":["order"],"message":"Invalid option: expected one of \"asc\"|\"desc\""},{"code":"invalid_value","values":["0","1"],"path":["enrich"],"message":"Invalid option: expected one of \"0\"|\"1\""},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["page"],"message":"Too small: expected number to be >=1"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
let err = parse_grid_query(&q(&[("fournisseur", "x"), ("nomenclature", "123456789012345678901a")])).unwrap_err();
assert_eq!(
issues_json(err),
r#"[{"origin":"string","code":"too_big","maximum":20,"inclusive":true,"path":["nomenclature"],"message":"Too big: expected string to have <=20 characters"},{"origin":"string","code":"invalid_format","format":"regex","pattern":"/^[0-9]+$/","path":["nomenclature"],"message":"La nomenclature doit être un préfixe numérique (ex. 32, 3202, 320211)"}]"#
);
let ok = parse_grid_query(&q(&[("fournisseur", "x")])).unwrap();
assert_eq!((ok.compute.as_str(), ok.sort.order.as_str(), ok.sort.enrich.as_str(), ok.page, ok.limit), ("1", "desc", "1", 1, None));
let ok = parse_grid_query(&q(&[("fournisseur", "x"), ("page", "2"), ("limit", "50"), ("sort", "codein"), ("fields", "")])).unwrap();
assert_eq!((ok.page, ok.limit, ok.sort.sort.as_deref(), ok.sort.fields.as_deref()), (2, Some(50), Some("codein"), Some("")));
}
#[test]
fn autres_schemas_comme_zod() {
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[])).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"}]"#
);
assert_eq!(
issues_json(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "4"), ("parent", "")])).unwrap_err()),
r#"[{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["parent"],"message":"Too small: expected string to have >=1 characters"}]"#
);
assert_eq!(parse_nomenclatures_query(&q(&[("fournisseur", "x"), ("niveau", "2.0")])).unwrap().niveau, 2);
assert_eq!(
issues_json(parse_fournisseurs_query(&q(&[("withData", "2"), ("search", "")])).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["search"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["0","1"],"path":["withData"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
assert_eq!(
issues_json(parse_product_search(&q(&[("q", "x"), ("page", "1.5"), ("limit", "0")])).unwrap_err()),
r#"[{"expected":"int","format":"safeint","code":"invalid_type","path":["page"],"message":"Invalid input: expected int, received number"},{"origin":"number","code":"too_small","minimum":1,"inclusive":true,"path":["limit"],"message":"Too small: expected number to be >=1"}]"#
);
assert_eq!(parse_product_search(&q(&[("q", "x")])).unwrap().limit, 100);
assert_eq!(
issues_json(parse_product_detail(&q(&[("compute", "2")])).unwrap_err()),
r#"[{"code":"invalid_value","values":["0","1"],"path":["compute"],"message":"Invalid option: expected one of \"0\"|\"1\""}]"#
);
}
#[test]
fn corps_comme_zod() {
assert_eq!(
issues_json(parse_product_gamme_body(&json!({ "gamme": "Q", "compute": "yes", "fournisseur": 3 })).unwrap_err()),
r#"[{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received number"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received string"}]"#
);
assert_eq!(
issues_json(parse_product_gamme_body(&json!([])).unwrap_err()),
r#"[{"expected":"object","code":"invalid_type","path":[],"message":"Invalid input: expected object, received array"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "", "changes": [] })).unwrap_err()),
r#"[{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["fournisseur"],"message":"Champ 'fournisseur' requis"},{"origin":"array","code":"too_small","minimum":1,"inclusive":true,"path":["changes"],"message":"Au moins un changement attendu"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "changes": [{ "codein": "", "gamme": "Q" }, { "gamme": 1 }, 5] })).unwrap_err()),
r#"[{"expected":"string","code":"invalid_type","path":["fournisseur"],"message":"Invalid input: expected string, received undefined"},{"origin":"string","code":"too_small","minimum":1,"inclusive":true,"path":["changes",0,"codein"],"message":"Too small: expected string to have >=1 characters"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",0,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"string","code":"invalid_type","path":["changes",1,"codein"],"message":"Invalid input: expected string, received undefined"},{"code":"invalid_value","values":["A","B","C","Y","Z"],"path":["changes",1,"gamme"],"message":"Gamme inconnue. Valeurs acceptées : A, B, C, Y, Z"},{"expected":"object","code":"invalid_type","path":["changes",2],"message":"Invalid input: expected object, received number"}]"#
);
assert_eq!(
issues_json(parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": "x", "compute": null })).unwrap_err()),
r#"[{"expected":"array","code":"invalid_type","path":["changes"],"message":"Invalid input: expected array, received string"},{"expected":"boolean","code":"invalid_type","path":["compute"],"message":"Invalid input: expected boolean, received null"}]"#
);
let ok = parse_gammes_batch_body(&json!({ "fournisseur": "f", "changes": [{ "codein": "1", "gamme": "A" }] })).unwrap();
assert!(ok.compute);
assert_eq!(ok.changes, vec![GammeChange { codein: "1".into(), gamme: "A".into() }]);
}
#[test]
fn projection_de_champs() {
let row = json!({ "codein": "1", "libelle1": "L", "totalCa": 3, "gtin": "G" });
assert_eq!(pick_fields(row.clone(), None), row);
assert_eq!(pick_fields(row.clone(), Some(" , ")), row);
let p = pick_fields(row, Some("totalCa, libelle1,absent"));
assert_eq!(serde_json::to_string(&p).unwrap(), r#"{"totalCa":3,"libelle1":"L","codein":"1"}"#);
assert_eq!(to_sort_key(Some("caReseau")), Some(GridSortKey::CaReseau));
assert_eq!(to_sort_key(Some("x")), None);
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Routes `/api/v1` du module qui ne dépendent que de l'instantané `grid_rows`
//! (phase 1) : `GET /api/v1/nomenclatures`.
use super::api_schemas::{parse_nomenclatures_query, query_map};
use super::store::{get_grid_freshness, list_grid_nomenclatures};
use crate::api_v1_response::{fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use axum::extract::Query;
use axum::response::Response;
use serde_json::{json, Map, Value};
/// `GET /api/v1/nomenclatures?fournisseur=…&niveau=1|2|3&parent=…`
///
/// Répartition des articles d'un fournisseur par poste de nomenclature, avec le
/// nombre d'articles et le CA de chacun.
///
/// C'est la porte d'entrée pour traiter un gros fournisseur : certains dépassent
/// 130 000 articles, et tout récupérer d'un coup n'a de sens ni pour une IA ni
/// pour une application. On liste d'abord les postes — quelques dizaines de
/// lignes — puis on interroge `/grid?fournisseur=…&nomenclature=…` poste par poste.
///
/// Les codes sont hiérarchiques par préfixe (6 chiffres, de 31xxxx à 40xxxx) :
/// niveau 1 = « 32 », niveau 2 = « 3202 », niveau 3 = « 320211 ». `parent` accepte
/// un préfixe pour descendre l'arborescence sans tout charger.
pub async fn get_nomenclatures(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let params = query_map(params);
let parsed = match parse_nomenclatures_query(&params) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let fournisseur = parsed.fournisseur;
// Même distinction que /grid : « pas encore calculé » n'est pas « vide ».
let freshness = match get_grid_freshness(&fournisseur).await {
Ok(f) => f,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let Some(freshness) = freshness else {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {fournisseur} ». Appelez /api/v1/grid?fournisseur={fournisseur} une fois pour le calculer."
),
Some(json!({ "fournisseur": fournisseur })),
);
};
let postes = match list_grid_nomenclatures(&fournisseur, parsed.niveau, parsed.parent.as_deref()).await {
Ok(p) => p,
Err(e) => return fail(ApiErrorCode::InternalError, e.message, None),
};
let nb_articles: i64 = postes.iter().map(|p| p.nb_articles).sum();
let mut meta = Map::new();
meta.insert("fournisseur".into(), json!(fournisseur));
meta.insert("niveau".into(), json!(parsed.niveau));
meta.insert("parent".into(), parsed.parent.map(Value::String).unwrap_or(Value::Null));
meta.insert("nbPostes".into(), json!(postes.len()));
// Total couvert par les postes listés — à rapprocher de snapshotRowCount.
meta.insert("nbArticles".into(), json!(nb_articles));
meta.insert("snapshotRowCount".into(), json!(freshness.row_count));
meta.insert("snapshotComputedAt".into(), json!(freshness.computed_at));
// Le paramètre de /grid à utiliser avec les `code` renvoyés ci-dessus.
// `nomenclature` filtre par préfixe et fonctionne aux trois niveaux.
meta.insert("filtreGrid".into(), json!("nomenclature"));
ok(postes, None, Some(meta))
}
+293
View File
@@ -0,0 +1,293 @@
//! Enregistrement des gammes (port de `src/features/grid/api/enregistrer-gammes.ts`,
//! `save-draft-changes.ts` et `gammes-a-valider-actions.ts`).
use super::gammes_a_valider::{lister_gammes_a_valider, supprimer_gammes_a_valider};
use super::session::{final_user_id, verifier_session};
use super::snapshots::date_fr;
use super::zod::{self, child, Issues, Seg};
use super::{rows_cache, store};
use crate::db;
use crate::error::AppResult;
use crate::rpc::Ctx;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangementGamme {
pub codein: String,
/// Gamme avant la modification (état serveur), conservée pour l'historique.
pub code_gamme_before: Option<String>,
pub code_gamme: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnregistrementGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
pub user_id: Option<i64>,
/// Libellé du snapshot, « Draft — date » par défaut.
pub label: Option<String>,
}
/// Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
///
/// Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
/// puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
/// Lève une erreur si l'écriture du snapshot échoue.
pub async fn enregistrer_gammes(input: &EnregistrementGammes) -> AppResult<usize> {
let EnregistrementGammes { code_fournisseur, nom_fournisseur, magasin, changes, user_id, label } = input;
if changes.is_empty() {
return Ok(0);
}
let pool = db::pool()?;
// Charger le dernier snapshot existant pour merger les changements
let existing = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
let prev_changes: Map<String, Value> = match existing.map(|r| r.try_get::<Value, _>("changes")) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
// Merger les nouveaux changements par-dessus l'existant
let mut merged_changes = prev_changes;
for c in changes {
merged_changes.insert(c.codein.clone(), json!({ "before": c.code_gamme_before, "after": c.code_gamme }));
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, null, 'snapshot')",
)
.bind(user_id)
.bind(code_fournisseur)
.bind(nom_fournisseur)
.bind(magasin)
.bind(label.clone().unwrap_or_else(|| format!("Draft — {}", date_fr())))
.bind(Value::Object(merged_changes))
.execute(&pool)
.await?;
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
rows_cache::patch_grid_rows_cache(code_fournisseur, changes.iter().map(|c| (c.codein.as_str(), c.code_gamme.as_str())));
let code = code_fournisseur.clone();
let paires: Vec<(String, String)> = changes.iter().map(|c| (c.codein.clone(), c.code_gamme.clone())).collect();
tokio::spawn(async move {
if let Err(e) = store::update_grid_rows_gamme(&code, paires.iter().map(|(c, g)| (c.as_str(), g.as_str()))).await {
tracing::error!("[enregistrerGammes] mise à jour grid_rows KO: {}", crate::error::short(&e.message, 200));
}
});
Ok(changes.len())
}
// ---------------------------------------------------------------------------
// saveDraftChanges
// ---------------------------------------------------------------------------
/// Données validées de `SaveDraftsSchema`.
#[derive(Debug, Clone)]
pub struct SaveDraftsInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
/// Propositions de l'API examinées dans la Grille : validées (présentes dans
/// `changes`) ou écartées. Elles quittent la file « à valider ».
pub gammes_a_valider_traitees: Vec<String>,
}
/// `z.array(z.string())`.
fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<Vec<String>> {
let Some(Value::Array(a)) = v else {
issues.invalid_type(chemin, "array", v);
return None;
};
let mut out = Vec::with_capacity(a.len());
let mut ok = true;
for (i, x) in a.iter().enumerate() {
match zod::string(Some(x), &child(chemin, i), issues) {
Some(s) => out.push(s),
None => ok = false,
}
}
ok.then_some(out)
}
/// `SaveDraftsSchema.safeParse(raw)`.
pub fn parse_save_drafts(raw: &Value) -> Result<SaveDraftsInput, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues);
let before = zod::nullable_string(c.get("codeGammeBefore"), &child(&ci, "codeGammeBefore"), &mut issues);
let gamme = zod::string(c.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
if let (Some(codein), Some(code_gamme_before), Some(code_gamme)) = (codein, before, gamme) {
changes.push(ChangementGamme { codein, code_gamme_before, code_gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let traitees = match o.get("gammesAValiderTraitees") {
None => Some(Vec::new()),
v => tableau_de_chaines(v, &p("gammesAValiderTraitees"), &mut issues),
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveDraftsInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
changes,
gammes_a_valider_traitees: traitees.unwrap_or_default(),
})
}
/// `saveDraftChanges` une fois la session vérifiée : `{ success, saved, error? }`.
pub async fn enregistrer_brouillon(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_drafts(raw) {
Ok(d) => d,
Err(issues) => return json!({ "success": false, "saved": 0, "error": format!("Validation failed: {}", issues.message()) }),
};
let user_id = final_user_id(user_raw_id).map(|v| v as i64);
let res: AppResult<usize> = async {
let saved = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: d.code_fournisseur.clone(),
nom_fournisseur: d.nom_fournisseur.clone(),
magasin: d.magasin.clone(),
changes: d.changes.clone(),
user_id,
label: None,
})
.await?;
supprimer_gammes_a_valider(&d.code_fournisseur, &d.gammes_a_valider_traitees).await?;
Ok(saved)
}
.await;
match res {
Ok(saved) => json!({ "success": true, "saved": saved }),
Err(e) => json!({ "success": false, "saved": 0, "error": e.message }),
}
}
/// Action `saveDraftChanges(raw)`.
async fn save_draft_changes(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(json!({ "success": false, "saved": 0, "error": message })),
};
Ok(enregistrer_brouillon(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// Gammes à valider (actions)
// ---------------------------------------------------------------------------
/// Action `getGammesAValider(codeFournisseur)` : gammes proposées par l'API pour
/// un fournisseur, en attente de validation.
async fn get_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "propositions": [], "error": message }));
}
let code = super::js::js_string(ctx.args.first());
match lister_gammes_a_valider(&code).await {
Ok(propositions) => Ok(json!({ "success": true, "propositions": propositions })),
Err(e) => Ok(json!({ "success": false, "propositions": [], "error": e.message })),
}
}
/// `RejetSchema.safeParse(raw)` → `(codeFournisseur, codeins)`.
pub fn parse_rejet(raw: &Value) -> Result<(String, Vec<String>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let chemin = vec![Seg::from("codeFournisseur")];
let code = zod::string(o.get("codeFournisseur"), &chemin, &mut issues);
if let Some(c) = &code {
zod::check_len(c, &chemin, Some((1, None)), None, &mut issues);
}
let codeins = tableau_de_chaines(o.get("codeins"), &[Seg::from("codeins")], &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok((code.unwrap_or_default(), codeins.unwrap_or_default()))
}
/// Action `rejeterGammesAValider(raw)` : rejette des propositions de l'API
/// (« Annuler les modifications » dans la Grille).
async fn rejeter_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "error": message }));
}
let (code, codeins) = match parse_rejet(&ctx.raw(0)) {
Ok(v) => v,
Err(issues) => return Ok(json!({ "success": false, "error": format!("Validation failed: {}", issues.message()) })),
};
match supprimer_gammes_a_valider(&code, &codeins).await {
Ok(()) => Ok(json!({ "success": true })),
Err(e) => Ok(json!({ "success": false, "error": e.message })),
}
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("saveDraftChanges", crate::handler!(save_draft_changes)),
("getGammesAValider", crate::handler!(get_gammes_a_valider)),
("rejeterGammesAValider", crate::handler!(rejeter_gammes_a_valider)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_brouillon_comme_zod() {
let raw = json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a" }], "gammesAValiderTraitees": "x" });
let issues = parse_save_drafts(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes",0,"codeGammeBefore"],"message":"Invalid input: expected string, received undefined"},{"expected":"string","code":"invalid_type","path":["changes",0,"codeGamme"],"message":"Invalid input: expected string, received undefined"},{"expected":"array","code":"invalid_type","path":["gammesAValiderTraitees"],"message":"Invalid input: expected array, received string"}]"#
);
let ok = parse_save_drafts(
&json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a", "codeGammeBefore": null, "codeGamme": "B" }] }),
)
.unwrap();
assert_eq!(ok.changes[0].code_gamme, "B");
assert!(ok.gammes_a_valider_traitees.is_empty());
}
#[test]
fn validation_rejet_comme_zod() {
let issues = parse_rejet(&json!({ "codeFournisseur": "", "codeins": [1] })).unwrap_err();
assert_eq!(
issues.message(),
"[\n {\n \"origin\": \"string\",\n \"code\": \"too_small\",\n \"minimum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"codeFournisseur\"\n ],\n \"message\": \"Too small: expected string to have >=1 characters\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"codeins\",\n 0\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]"
);
assert_eq!(parse_rejet(&json!(5)).unwrap_err().0.len(), 1);
}
}
+402
View File
@@ -0,0 +1,402 @@
//! Exports Excel de la Grille (port de `src/app/api/export/excel/route.ts` et
//! `src/app/api/export/modified-gammes/route.ts`, ExcelJS → rust_xlsxwriter).
//!
//! Mêmes feuilles, colonnes, largeurs, styles d'en-tête, formats et noms de
//! fichier. Le découpage du fichier d'import des gammes en lots de 5 000 lignes
//! (`LIGNES_MAX_PAR_FICHIER_GAMMES`) se fait côté interface, qui appelle
//! `/api/export/modified-gammes` une fois par lot : la route ne fait que mettre en
//! forme les lignes reçues.
use super::js;
use super::zod::{self, child, Issues, Seg};
use crate::auth::SessionUser;
use crate::error::{AppError, AppResult};
use axum::body::Bytes;
use axum::http::{header, HeaderValue};
use axum::response::{IntoResponse, Response};
use axum::Json;
use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, FormatPattern, Workbook, Worksheet, XlsxError};
use serde_json::{json, Value};
const CONTENT_TYPE_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/// Largeur ExcelJS (`width: 14`) : valeur écrite telle quelle dans `<col width>`.
/// rust_xlsxwriter ajoute la marge de cellule à une largeur en caractères ; en
/// pixels (7 px par caractère), il écrit exactement `width`.
fn largeur(ws: &mut Worksheet, col: u16, width: u32) -> Result<(), XlsxError> {
ws.set_column_width_pixels(col, width * 7)?;
Ok(())
}
/// Style d'en-tête commun aux deux exports.
fn style_entete() -> Format {
Format::new()
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF))
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(0x0F172A))
.set_align(FormatAlign::Center)
.set_border_bottom(FormatBorder::Thin)
.set_border_bottom_color(Color::RGB(0x334155))
}
fn xlsx_err(e: XlsxError) -> AppError {
AppError::internal(e.to_string())
}
/// `await req.json()` : un corps illisible lève une exception (500 côté Next).
fn lire_json(body: &Bytes) -> AppResult<Value> {
serde_json::from_slice(body).map_err(|e| AppError::internal(format!("Corps JSON illisible : {e}")))
}
fn payload_invalide() -> Response {
(axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid payload" }))).into_response()
}
/// `${prefixe}_${nomFournisseur.replace(/\s+/g, "_")}_${YYYY-MM-DD}.xlsx` (date UTC,
/// comme `toISOString().slice(0, 10)`).
pub fn nom_fichier(prefixe: &str, nom_fournisseur: &str) -> String {
format!("{prefixe}_{}_{}.xlsx", js::replace_whitespace_runs(nom_fournisseur, "_"), chrono::Utc::now().format("%Y-%m-%d"))
}
/// `attachment; filename="…"`. Comme Node, l'en-tête est écrit octet par octet en
/// Latin-1 : un caractère au-delà de U+00FF (ou un caractère de contrôle) est
/// refusé, ce qui faisait échouer la route Next en 500.
pub fn content_disposition(filename: &str) -> AppResult<HeaderValue> {
let mut octets = Vec::with_capacity(filename.len() + 32);
octets.extend_from_slice(b"attachment; filename=\"");
for c in filename.chars() {
let v = c as u32;
if v > 0xFF || (v < 0x20 && v != 0x09) || v == 0x7F {
return Err(AppError::internal(format!("Invalid character in header content [\"Content-Disposition\"] : {c:?}")));
}
octets.push(v as u8);
}
octets.push(b'"');
HeaderValue::from_bytes(&octets).map_err(|e| AppError::internal(e.to_string()))
}
fn reponse_xlsx(buffer: Vec<u8>, filename: &str) -> AppResult<Response> {
let disposition = content_disposition(filename)?;
Ok(([(header::CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_XLSX)), (header::CONTENT_DISPOSITION, disposition)], buffer)
.into_response())
}
// ---------------------------------------------------------------------------
// POST /api/export/excel
// ---------------------------------------------------------------------------
/// Ligne de `ExportSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct ExportRow {
pub codein: String,
pub libelle1: String,
pub code3: String,
pub libelle3: String,
pub code_gamme: Option<String>,
pub total_quantite: f64,
pub total_ca: f64,
pub taux_marge: f64,
/// Before gamme for delta
pub gamme_avant: Option<String>,
}
/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`.
pub fn parse_export(body: &Value) -> Result<(String, Vec<ExportRow>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("rows")];
let mut rows = Vec::new();
match o.get("rows") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let s = |k: &str, issues: &mut Issues| zod::string(r.get(k), &child(&ci, k), issues);
let codein = s("codein", &mut issues);
let libelle1 = s("libelle1", &mut issues);
let code3 = s("code3", &mut issues);
let libelle3 = s("libelle3", &mut issues);
let code_gamme = zod::nullable_string(r.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
let n = |k: &str, issues: &mut Issues| zod::number(r.get(k), &child(&ci, k), issues);
let total_quantite = n("totalQuantite", &mut issues);
let total_ca = n("totalCa", &mut issues);
let taux_marge = n("tauxMarge", &mut issues);
let gamme_avant = match r.get("gammeAvant") {
None => Some(None),
v => zod::nullable_string(v, &child(&ci, "gammeAvant"), &mut issues),
};
if let (
Some(codein),
Some(libelle1),
Some(code3),
Some(libelle3),
Some(code_gamme),
Some(total_quantite),
Some(total_ca),
Some(taux_marge),
Some(gamme_avant),
) = (codein, libelle1, code3, libelle3, code_gamme, total_quantite, total_ca, taux_marge, gamme_avant)
{
rows.push(ExportRow {
codein,
libelle1,
code3,
libelle3,
code_gamme,
total_quantite,
total_ca,
taux_marge,
gamme_avant,
});
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), rows))
}
/// Gamme colours
fn couleur_gamme(code: &str) -> Option<u32> {
match code {
"A" => Some(0x064E3B),
"B" => Some(0x1E3A5F),
"C" => Some(0x78350F),
"Z" => Some(0x4C0519),
_ => None,
}
}
/// Classeur « Assortiment » (tous les produits, gamme avant/après, chiffres 12 mois).
pub fn classeur_assortiment(rows: &[ExportRow]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Assortiment")?;
sheet.set_freeze_panes(1, 0)?;
let entete = style_entete();
let colonnes: [(&str, u32); 9] = [
("Code", 14),
("Désignation", 40),
("Nomenclature", 25),
("Gamme Avant", 14),
("Gamme Après", 14),
("Delta", 10),
("Vol. 12m", 12),
("CA 12m (€)", 14),
("Marge (%)", 12),
];
for (i, (titre, w)) in colonnes.iter().enumerate() {
largeur(sheet, i as u16, *w)?;
sheet.write_string_with_format(0, i as u16, *titre, &entete)?;
}
sheet.set_row_height(0, 22)?;
let fmt_ca = Format::new().set_num_format("#,##0.00 \"€\"");
let fmt_marge = Format::new().set_num_format("0.00\"%\"");
let fmt_delta = Format::new().set_bold().set_font_color(Color::RGB(0xFBBF24));
for (i, row) in rows.iter().enumerate() {
let r = (i + 1) as u32;
let delta = if row.gamme_avant != row.code_gamme {
format!("{} → {}", row.gamme_avant.as_deref().unwrap_or("—"), row.code_gamme.as_deref().unwrap_or("—"))
} else {
String::new()
};
sheet.write_string(r, 0, &row.codein)?;
sheet.write_string(r, 1, &row.libelle1)?;
sheet.write_string(r, 2, &row.libelle3)?;
sheet.write_string(r, 3, row.gamme_avant.as_deref().unwrap_or("—"))?;
// Colour the "Gamme Après" cell
let gamme_apres = row.code_gamme.as_deref().unwrap_or("—");
match row.code_gamme.as_deref().and_then(couleur_gamme) {
Some(c) => {
let f = Format::new()
.set_pattern(FormatPattern::Solid)
.set_background_color(Color::RGB(c))
.set_bold()
.set_font_color(Color::RGB(0xFFFFFF));
sheet.write_string_with_format(r, 4, gamme_apres, &f)?;
}
None => {
sheet.write_string(r, 4, gamme_apres)?;
}
}
// Highlight changed rows
if delta.is_empty() {
sheet.write_string(r, 5, &delta)?;
} else {
sheet.write_string_with_format(r, 5, &delta, &fmt_delta)?;
}
sheet.write_number(r, 6, js::js_round(row.total_quantite))?;
// Format numbers
sheet.write_number_with_format(r, 7, js::js_to_fixed2(row.total_ca), &fmt_ca)?;
sheet.write_number_with_format(r, 8, js::js_to_fixed2(row.taux_marge), &fmt_marge)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/excel`.
pub async fn export_excel(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, rows)) = parse_export(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_assortiment(&rows).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Assortiment", &nom_fournisseur))
}
// ---------------------------------------------------------------------------
// POST /api/export/modified-gammes
// ---------------------------------------------------------------------------
/// Ligne de `ExportModifiedGammesSchema`.
#[derive(Debug, Clone, PartialEq)]
pub struct GammeModifiee {
pub codein: String,
pub gtin: String,
pub code_fournisseur: Option<String>,
pub gamme: String,
}
/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`.
pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec<GammeModifiee>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
let chemin = vec![Seg::from("changes")];
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(r.get("codein"), &child(&ci, "codein"), &mut issues);
let gtin = zod::string(r.get("gtin"), &child(&ci, "gtin"), &mut issues);
let code_fournisseur = zod::opt_string(r.get("codeFournisseur"), &child(&ci, "codeFournisseur"), &mut issues);
let gamme = zod::string(r.get("gamme"), &child(&ci, "gamme"), &mut issues);
if let (Some(codein), Some(gtin), Some(code_fournisseur), Some(gamme)) = (codein, gtin, code_fournisseur, gamme) {
changes.push(GammeModifiee { codein, gtin, code_fournisseur, gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
if !issues.is_empty() {
return Err(issues);
}
Ok((nom.unwrap_or_default(), changes))
}
/// `x || "—"`.
fn ou_tiret(s: &str) -> &str {
if s.is_empty() {
"—"
} else {
s
}
}
/// Classeur « Gammes Modifiées » (CODE FOURNISSEUR, CODEIN, GAMME) : le fichier
/// d'import des gammes.
pub fn classeur_gammes_modifiees(changes: &[GammeModifiee]) -> Result<Vec<u8>, XlsxError> {
let mut workbook = Workbook::new();
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
let sheet = workbook.add_worksheet();
sheet.set_name("Gammes Modifiées")?;
sheet.set_freeze_panes(1, 0)?;
// Format en texte pour eviter la notation scientifique excel ; centrer la
// colonne Gamme. ExcelJS applique ces styles de colonne à toutes les cellules
// déjà écrites, en-tête compris. « @ » = format intégré n° 49, comme ExcelJS.
let texte = Format::new().set_num_format_index(49);
let centre = Format::new().set_align(FormatAlign::Center);
let entete = style_entete();
let entete_texte = style_entete().set_num_format_index(49);
largeur(sheet, 0, 22)?;
largeur(sheet, 1, 18)?;
largeur(sheet, 2, 12)?;
sheet.set_column_format(0, &texte)?;
sheet.set_column_format(1, &texte)?;
sheet.set_column_format(2, &centre)?;
sheet.write_string_with_format(0, 0, "CODE FOURNISSEUR", &entete_texte)?;
sheet.write_string_with_format(0, 1, "CODEIN", &entete_texte)?;
sheet.write_string_with_format(0, 2, "GAMME", &entete)?;
sheet.set_row_height(0, 22)?;
for (i, row) in changes.iter().enumerate() {
let r = (i + 1) as u32;
sheet.write_string_with_format(r, 0, ou_tiret(row.code_fournisseur.as_deref().unwrap_or("")), &texte)?;
sheet.write_string_with_format(r, 1, ou_tiret(&row.codein), &texte)?;
sheet.write_string_with_format(r, 2, ou_tiret(&row.gamme), &centre)?;
}
workbook.save_to_buffer()
}
/// `POST /api/export/modified-gammes`.
pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
let body = lire_json(&body)?;
let Ok((nom_fournisseur, changes)) = parse_modified_gammes(&body) else { return Ok(payload_invalide()) };
let buffer = classeur_gammes_modifiees(&changes).map_err(xlsx_err)?;
reponse_xlsx(buffer, &nom_fichier("Modifications_Gammes", &nom_fournisseur))
}
#[cfg(test)]
mod tests {
use super::*;
/// Écrit les deux classeurs d'exemple dans `GRID_EXPORT_DIR` (pour inspection).
#[test]
fn classeurs_d_exemple() {
let (_, rows) = parse_export(&json!({
"nomFournisseur": "Le Fournisseur",
"rows": [
{ "codein": "123456", "libelle1": "Bougie parfumée", "code3": "320211", "libelle3": "Bougies", "codeGamme": "A", "totalQuantite": 12.5, "totalCa": 1234.125, "tauxMarge": 41.2345, "gammeAvant": "B" },
{ "codein": "000789", "libelle1": "Vase", "code3": "320212", "libelle3": "Vases", "codeGamme": null, "totalQuantite": -2.5, "totalCa": 10, "tauxMarge": 0, "gammeAvant": null },
{ "codein": "42", "libelle1": "", "code3": "", "libelle3": "", "codeGamme": "Y", "totalQuantite": 3, "totalCa": 0.125, "tauxMarge": 1.005, "gammeAvant": "Y" }
]
}))
.unwrap();
let a = classeur_assortiment(&rows).unwrap();
let (_, changes) = parse_modified_gammes(&json!({
"nomFournisseur": "X",
"changes": [
{ "codein": "000123", "gtin": "3700", "codeFournisseur": "F001", "gamme": "A" },
{ "codein": "", "gtin": "", "gamme": "" }
]
}))
.unwrap();
let b = classeur_gammes_modifiees(&changes).unwrap();
assert!(a.starts_with(b"PK") && b.starts_with(b"PK"));
if let Ok(dir) = std::env::var("GRID_EXPORT_DIR") {
std::fs::write(format!("{dir}/rust_excel.xlsx"), a).unwrap();
std::fs::write(format!("{dir}/rust_modified.xlsx"), b).unwrap();
}
}
#[test]
fn validation_et_entetes() {
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [{ "codein": "a" }] })).is_err());
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [] })).is_ok());
assert!(parse_modified_gammes(
&json!({ "nomFournisseur": "x", "changes": [{ "codein": "a", "gtin": "g", "gamme": "A", "codeFournisseur": 3 }] })
)
.is_err());
let nom = nom_fichier("Assortiment", "Le Fournisseur\tÉté");
assert!(nom.starts_with("Assortiment_Le_Fournisseur_Été_") && nom.ends_with(".xlsx"));
let h = content_disposition("Assortiment_Été.xlsx").unwrap();
assert_eq!(h.as_bytes(), b"attachment; filename=\"Assortiment_\xc9t\xe9.xlsx\"");
assert!(content_disposition("Fournisseur_œ.xlsx").is_err());
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Les gammes d'assortiment : code, nom et couleurs — source unique
//! (port de `src/lib/gammes.ts`, utilisé par la validation `/api/v1` et le
//! document OpenAPI).
pub struct Gamme {
pub code: &'static str,
/// Nom court : « Cœur », « Sortie »…
pub nom: &'static str,
/// Phrase d'explication pour les infobulles.
pub description: &'static str,
/// Classes de pastille (fond, bordure, texte) lisibles en clair comme en sombre.
pub classes: &'static str,
}
pub const GAMMES: [Gamme; 5] = [
Gamme {
code: "A",
nom: "Cœur",
description: "Produit permanent, au cœur de l'assortiment.",
classes: "border-emerald-500/40 bg-emerald-500/10 text-emerald-800 dark:text-emerald-300",
},
Gamme {
code: "B",
nom: "Complémentaire",
description: "Produit qui complète l'offre du rayon.",
classes: "border-blue-500/40 bg-blue-500/10 text-blue-800 dark:text-blue-300",
},
Gamme {
code: "C",
nom: "Saisonnier",
description: "Produit présent selon la saison.",
classes: "border-amber-500/40 bg-amber-500/10 text-amber-800 dark:text-amber-300",
},
Gamme {
code: "Y",
nom: "En veille",
description: "Produit mis en attente : il n'est plus commandé pour l'instant.",
classes: "border-violet-500/40 bg-violet-500/10 text-violet-800 dark:text-violet-300",
},
Gamme {
code: "Z",
nom: "Sortie",
description: "Produit à retirer de l'assortiment.",
classes: "border-rose-500/40 bg-rose-500/10 text-rose-800 dark:text-rose-300",
},
];
/// Pastille d'un produit sans gamme.
pub const CLASSES_SANS_GAMME: &str = "border-slate-400/40 bg-slate-500/10 text-slate-700 dark:text-slate-300";
/// Codes de gamme, dans l'ordre de [`GAMMES`].
pub fn codes_gamme() -> Vec<&'static str> {
GAMMES.iter().map(|g| g.code).collect()
}
pub fn trouver_gamme(code: Option<&str>) -> Option<&'static Gamme> {
let code = code.filter(|c| !c.is_empty())?;
let c = super::js::js_trim(code);
GAMMES.iter().find(|g| g.code == c)
}
#[cfg(test)]
mod tests {
#[test]
fn coherent_avec_le_socle() {
assert_eq!(super::codes_gamme(), crate::domain::CODES_GAMME.to_vec());
assert_eq!(super::trouver_gamme(Some(" Z ")).unwrap().nom, "Sortie");
assert!(super::trouver_gamme(None).is_none());
}
}
+129
View File
@@ -0,0 +1,129 @@
//! CollectFlow — Gammes proposées par l'API, en attente de validation
//! (port de `src/lib/gammes-a-valider.ts`).
//!
//! L'API `/api/v1` ne change plus une gamme directement : elle dépose une
//! proposition, que la Grille affiche comme une modification non enregistrée.
//! L'utilisateur voit ainsi quels produits changent de gamme, puis valide
//! (« Enregistrer ») ou rejette (« Annuler les modifications »).
use crate::db;
use crate::error::AppResult;
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PropositionGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeAValider {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de la proposition.
pub gamme_precedente: Option<String>,
pub auteur: Option<String>,
/// Date ISO de la proposition.
pub proposee_le: String,
}
/// Dépose des propositions (la dernière l'emporte pour un même article) et retire
/// celles des articles `annules` — redemander la gamme déjà enregistrée annule la
/// proposition en attente.
pub async fn proposer_gammes(code_fournisseur: &str, propositions: &[PropositionGamme], annules: &[String], auteur: &str) -> AppResult<()> {
let pool = db::pool()?;
let mut tx = pool.begin().await?;
if !annules.is_empty() {
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(annules)
.execute(&mut *tx)
.await?;
}
if !propositions.is_empty() {
let maintenant = chrono::Utc::now().naive_utc();
let auteur: String = auteur.chars().take(100).collect();
let mut qb: QueryBuilder<Postgres> =
QueryBuilder::new("insert into gammes_a_valider (code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at) ");
qb.push_values(propositions, |mut b, p| {
b.push_bind(code_fournisseur.to_string())
.push_bind(p.codein.clone())
.push_bind(p.gamme.clone())
.push_bind(p.gamme_precedente.clone())
.push_bind(auteur.clone())
.push_bind(maintenant);
});
qb.push(
" on conflict (code_fournisseur, codein) do update set gamme = excluded.gamme, \
gamme_precedente = excluded.gamme_precedente, auteur = excluded.auteur, created_at = excluded.created_at",
);
qb.build().execute(&mut *tx).await?;
}
tx.commit().await?;
Ok(())
}
/// Propositions en attente pour un fournisseur.
pub async fn lister_gammes_a_valider(code_fournisseur: &str) -> AppResult<Vec<GammeAValider>> {
let pool = db::pool()?;
let rows = sqlx::query(
"select code_fournisseur, codein, gamme, gamme_precedente, auteur, created_at from gammes_a_valider \
where code_fournisseur = $1",
)
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GammeAValider {
codein: r.try_get("codein").unwrap_or_default(),
gamme: r.try_get("gamme").unwrap_or_default(),
gamme_precedente: r.try_get("gamme_precedente").ok().flatten(),
auteur: r.try_get("auteur").ok().flatten(),
proposee_le: r.try_get::<NaiveDateTime, _>("created_at").map(db::iso).unwrap_or_default(),
})
.collect())
}
/// Propositions en attente pour une liste d'articles, indexées par `fournisseur|codein`.
pub async fn gammes_a_valider_par_article(codeins: &[String]) -> AppResult<HashMap<String, String>> {
if codeins.is_empty() {
return Ok(HashMap::new());
}
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, codein, gamme from gammes_a_valider where codein = any($1)")
.bind(codeins)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| {
let f: String = r.try_get("code_fournisseur").unwrap_or_default();
let c: String = r.try_get("codein").unwrap_or_default();
(format!("{f}|{c}"), r.try_get("gamme").unwrap_or_default())
})
.collect())
}
/// Retire des propositions, une fois validées ou rejetées dans la Grille.
pub async fn supprimer_gammes_a_valider(code_fournisseur: &str, codeins: &[String]) -> AppResult<()> {
if codeins.is_empty() {
return Ok(());
}
let pool = db::pool()?;
sqlx::query("delete from gammes_a_valider where code_fournisseur = $1 and codein = any($2)")
.bind(code_fournisseur)
.bind(codeins)
.execute(&pool)
.await?;
Ok(())
}
+472
View File
@@ -0,0 +1,472 @@
//! Équivalents des conversions JavaScript dont dépend le comportement du code TS
//! d'origine : `Number(…)`, `parseInt(…, 10)`, `String(nombre)`, `Math.round`,
//! `toFixed`, et la sérialisation JSON des nombres (`12` et non `12.0`).
//!
//! Les nombres de `ProductRow` sont des `f64` ; `JSON.stringify` écrit un entier
//! sans décimale et `NaN` en `null`. Les modules de sérialisation ci-dessous
//! reproduisent ces deux règles, et relisent un `null` en `NaN` pour qu'une ligne
//! persistée par la version Node fasse l'aller-retour à l'identique.
use serde::de::Deserializer;
use serde::ser::{SerializeMap, Serializer};
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::BTreeMap;
use std::sync::LazyLock;
/// Espace blanc au sens de JS (`trim`, `\s`).
pub fn is_js_whitespace(c: char) -> bool {
matches!(
c,
'\u{9}'..='\u{d}'
| ' '
| '\u{a0}'
| '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}'
| '\u{2029}'
| '\u{202f}'
| '\u{205f}'
| '\u{3000}'
| '\u{feff}'
)
}
/// `s.trim()`.
pub fn js_trim(s: &str) -> &str {
s.trim_matches(is_js_whitespace)
}
/// `s.replace(/\s+/g, remplacement)`.
pub fn replace_whitespace_runs(s: &str, remplacement: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut dans_blanc = false;
for c in s.chars() {
if is_js_whitespace(c) {
if !dans_blanc {
out.push_str(remplacement);
dans_blanc = true;
}
} else {
dans_blanc = false;
out.push(c);
}
}
out
}
static DECIMAL: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r"^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$").unwrap());
/// `Number(s)` pour une chaîne (coercition de `z.coerce.number()`).
pub fn js_number_from_str(s: &str) -> f64 {
let t = js_trim(s);
if t.is_empty() {
return 0.0;
}
match t {
"Infinity" | "+Infinity" => return f64::INFINITY,
"-Infinity" => return f64::NEG_INFINITY,
_ => {}
}
let base = |chiffres: &str, radix: u32| -> f64 {
if chiffres.is_empty() {
return f64::NAN;
}
let mut v = 0f64;
for c in chiffres.chars() {
match c.to_digit(radix) {
Some(d) => v = v * radix as f64 + d as f64,
None => return f64::NAN,
}
}
v
};
let prefixe = |p: &[&str]| p.iter().find_map(|x| t.strip_prefix(x));
if let Some(r) = prefixe(&["0x", "0X"]) {
return base(r, 16);
}
if let Some(r) = prefixe(&["0o", "0O"]) {
return base(r, 8);
}
if let Some(r) = prefixe(&["0b", "0B"]) {
return base(r, 2);
}
if !DECIMAL.is_match(t) {
return f64::NAN;
}
t.parse::<f64>().unwrap_or(f64::NAN)
}
/// `parseInt(s, 10)` : préfixe numérique, `NaN` s'il n'y en a pas.
pub fn js_parse_int(s: &str) -> f64 {
let t = s.trim_start_matches(is_js_whitespace);
let (negatif, reste) = match t.as_bytes().first() {
Some(b'-') => (true, &t[1..]),
Some(b'+') => (false, &t[1..]),
_ => (false, t),
};
let fin = reste.bytes().take_while(|b| b.is_ascii_digit()).count();
if fin == 0 {
return f64::NAN;
}
let v: f64 = reste[..fin].parse().unwrap_or(f64::NAN);
if negatif {
-v
} else {
v
}
}
/// `String(x)` pour un nombre (`Number.prototype.toString()`).
pub fn js_number_to_string(x: f64) -> String {
if x.is_nan() {
return "NaN".into();
}
if x == 0.0 {
return "0".into();
}
if x.is_infinite() {
return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() };
}
let signe = if x < 0.0 { "-" } else { "" };
// `{:e}` donne les chiffres les plus courts qui relisent la même valeur,
// exactement ceux que retient l'algorithme de JS.
let e = format!("{:e}", x.abs());
let (mantisse, exposant) = e.split_once('e').unwrap_or((&e, "0"));
let chiffres: String = mantisse.chars().filter(|c| *c != '.').collect();
let k = chiffres.len() as i64;
let n = exposant.parse::<i64>().unwrap_or(0) + 1;
let corps = if k <= n && n <= 21 {
format!("{chiffres}{}", "0".repeat((n - k) as usize))
} else if 0 < n && n <= 21 {
format!("{}.{}", &chiffres[..n as usize], &chiffres[n as usize..])
} else if -6 < n && n <= 0 {
format!("0.{}{chiffres}", "0".repeat((-n) as usize))
} else {
let reste = if k > 1 { format!(".{}", &chiffres[1..]) } else { String::new() };
let e = n - 1;
format!("{}{reste}e{}{}", &chiffres[..1], if e < 0 { "-" } else { "+" }, e.abs())
};
format!("{signe}{corps}")
}
/// `String(v)` pour une valeur JSON quelconque (argument d'action non typé).
pub fn js_string(v: Option<&Value>) -> String {
match v {
None => "undefined".into(),
Some(Value::Null) => "null".into(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Number(n)) => js_number_to_string(n.as_f64().unwrap_or(f64::NAN)),
Some(Value::String(s)) => s.clone(),
Some(Value::Array(a)) => a
.iter()
.map(|x| match x {
Value::Null => String::new(),
autre => js_string(Some(autre)),
})
.collect::<Vec<_>>()
.join(","),
Some(Value::Object(_)) => "[object Object]".into(),
}
}
/// Valeur « vraie » au sens de JS.
pub fn js_truthy(v: Option<&Value>) -> bool {
match v {
None | Some(Value::Null) => false,
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(false),
Some(Value::String(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// `Math.round(x)` : demi arrondi vers +∞ (`Math.round(-2.5) === -2`).
pub fn js_round(x: f64) -> f64 {
if !x.is_finite() {
return x;
}
let f = x.floor();
if x - f >= 0.5 {
f + 1.0
} else {
f
}
}
/// `parseFloat(x.toFixed(2))`.
///
/// `toFixed` arrondit la valeur binaire exacte ; en cas d'égalité parfaite
/// (0,125 → « 0.13 »), il retient le plus grand entier en valeur absolue, là où
/// le formatage de Rust arrondit au pair.
pub fn js_to_fixed2(x: f64) -> f64 {
if !x.is_finite() || x.abs() >= 1e21 {
return x;
}
let a = x.abs();
// Une égalité exacte au millième n'est possible que pour un multiple de 1/8.
let huitiemes = a * 8.0;
let r = if huitiemes.fract() == 0.0 && huitiemes < 9.0e15 {
let centiemes = a * 100.0; // exact : multiple de 12,5
let f = centiemes.floor();
let c = if centiemes - f >= 0.5 { f + 1.0 } else { f };
c / 100.0
} else {
format!("{a:.2}").parse::<f64>().unwrap_or(a)
};
if x < 0.0 {
-r
} else {
r
}
}
/// Clé d'objet qui est un indice de tableau pour JS (`"0"`, `"404"`, `"123456"`) :
/// ces clés sont énumérées avant les autres, par ordre croissant.
pub fn js_array_index(k: &str) -> Option<u64> {
if k.is_empty() || k.len() > 10 || (k.len() > 1 && k.starts_with('0')) || !k.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
k.parse::<u64>().ok().filter(|v| *v < u32::MAX as u64)
}
/// Réordonne un objet comme JS énumère ses clés : indices croissants, puis les
/// autres clés dans l'ordre d'insertion.
pub fn js_object_order(m: Map<String, Value>) -> Map<String, Value> {
if !m.keys().any(|k| js_array_index(k).is_some()) {
return m;
}
let mut indices: Vec<(u64, String, Value)> = Vec::new();
let mut autres: Vec<(String, Value)> = Vec::new();
for (k, v) in m {
match js_array_index(&k) {
Some(i) => indices.push((i, k, v)),
None => autres.push((k, v)),
}
}
indices.sort_by_key(|(i, _, _)| *i);
indices.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
/// Texte d'un nombre pour une colonne `numeric` (équivalent de `String(n)`).
pub fn numeric_text(x: f64) -> String {
js_number_to_string(x)
}
// ---------------------------------------------------------------------------
// Sérialisation des nombres façon `JSON.stringify`
// ---------------------------------------------------------------------------
/// Nombre sérialisé comme le ferait `JSON.stringify` : entier sans décimale,
/// `null` pour `NaN`/`Infinity`.
pub struct JsNum(pub f64);
impl serde::Serialize for JsNum {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let v = self.0;
if !v.is_finite() {
s.serialize_unit()
} else if v.fract() == 0.0 && v.abs() < 9.0e15 {
s.serialize_i64(v as i64)
} else {
s.serialize_f64(v)
}
}
}
/// `f64` (un `null` relu vaut `NaN`).
pub mod num {
use super::*;
pub fn serialize<S: Serializer>(v: &f64, s: S) -> Result<S::Ok, S::Error> {
serde::Serialize::serialize(&JsNum(*v), s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
Ok(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN))
}
}
/// `Option<f64>` d'un champ facultatif (présent à `null` → `Some(NaN)`).
pub mod opt_num {
use super::*;
pub fn serialize<S: Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(x) => serde::Serialize::serialize(&JsNum(*x), s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
Ok(Some(Option::<f64>::deserialize(d)?.unwrap_or(f64::NAN)))
}
}
fn ser_map<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &JsNum(*v))?;
}
out.end()
}
fn de_map<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?.unwrap_or_default();
Ok(brut.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())
}
/// `Record<string, number>` obligatoire.
pub mod map {
use super::*;
pub fn serialize<S: Serializer>(m: &BTreeMap<String, f64>, s: S) -> Result<S::Ok, S::Error> {
ser_map(m, s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f64>, D::Error> {
de_map(d)
}
}
/// `Record<string, number>` facultatif.
pub mod opt_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<BTreeMap<String, f64>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => ser_map(m, s),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<BTreeMap<String, f64>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()))
}
}
/// `Record<string, number> | null` facultatif : absent (`None`), `null`
/// (`Some(None)`) ou renseigné.
pub mod opt_null_map {
use super::*;
pub fn serialize<S: Serializer>(m: &Option<Option<BTreeMap<String, f64>>>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(Some(m)) => ser_map(m, s),
_ => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Option<BTreeMap<String, f64>>>, D::Error> {
let brut = Option::<BTreeMap<String, Option<f64>>>::deserialize(d)?;
Ok(Some(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())))
}
}
/// `Record<string, Record<string, number>>` facultatif (ventilations par magasin).
pub mod opt_nested_map {
use super::*;
pub type Nested = BTreeMap<String, BTreeMap<String, f64>>;
struct Interne<'a>(&'a BTreeMap<String, f64>);
impl serde::Serialize for Interne<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
ser_map(self.0, s)
}
}
pub fn serialize<S: Serializer>(m: &Option<Nested>, s: S) -> Result<S::Ok, S::Error> {
match m {
Some(m) => {
let mut out = s.serialize_map(Some(m.len()))?;
for (k, v) in m {
out.serialize_entry(k, &Interne(v))?;
}
out.end()
}
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Nested>, D::Error> {
let brut = Option::<BTreeMap<String, Option<BTreeMap<String, Option<f64>>>>>::deserialize(d)?;
Ok(brut.map(|m| {
m.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|(p, x)| (p, x.unwrap_or(f64::NAN))).collect())).collect()
}))
}
}
/// Chaîne obligatoire : un `null` relu devient `""`.
pub mod string {
use super::*;
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
Ok(Option::<String>::deserialize(d)?.unwrap_or_default())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn number_from_str_comme_js() {
assert_eq!(js_number_from_str(""), 0.0);
assert_eq!(js_number_from_str(" "), 0.0);
assert_eq!(js_number_from_str(" 0x2 "), 2.0);
assert_eq!(js_number_from_str("0b11"), 3.0);
assert_eq!(js_number_from_str("0o2"), 2.0);
assert!(js_number_from_str("-0x2").is_nan());
assert!(js_number_from_str("1_000").is_nan());
assert!(js_number_from_str("abc").is_nan());
assert!(js_number_from_str("inf").is_nan());
assert_eq!(js_number_from_str("+3"), 3.0);
assert_eq!(js_number_from_str(".2e1"), 2.0);
assert_eq!(js_number_from_str("1."), 1.0);
assert_eq!(js_number_from_str("\t2\n"), 2.0);
assert_eq!(js_number_from_str("-Infinity"), f64::NEG_INFINITY);
assert_eq!(js_number_from_str("1e400"), f64::INFINITY);
}
#[test]
fn parse_int_comme_js() {
assert_eq!(js_parse_int("12abc"), 12.0);
assert_eq!(js_parse_int(" -5"), -5.0);
assert_eq!(js_parse_int("0"), 0.0);
assert!(js_parse_int("abc").is_nan());
assert!(js_parse_int("").is_nan());
assert_eq!(js_parse_int("0x10"), 0.0);
}
#[test]
fn number_to_string_comme_js() {
assert_eq!(js_number_to_string(123.0), "123");
assert_eq!(js_number_to_string(-1.5), "-1.5");
assert_eq!(js_number_to_string(0.1 + 0.2), "0.30000000000000004");
assert_eq!(js_number_to_string(1e21), "1e+21");
assert_eq!(js_number_to_string(1e20), "100000000000000000000");
assert_eq!(js_number_to_string(1e-7), "1e-7");
assert_eq!(js_number_to_string(0.000001), "0.000001");
assert_eq!(js_number_to_string(1.5e-10), "1.5e-10");
assert_eq!(js_number_to_string(f64::NAN), "NaN");
}
#[test]
fn arrondis_comme_js() {
assert_eq!(js_round(-2.5), -2.0);
assert_eq!(js_round(2.5), 3.0);
assert_eq!(js_round(0.49999999999999994), 0.0);
assert_eq!(js_to_fixed2(0.125), 0.13);
assert_eq!(js_to_fixed2(-0.125), -0.13);
assert_eq!(js_to_fixed2(1.005), 1.0);
assert_eq!(js_to_fixed2(1234.5678), 1234.57);
}
#[test]
fn nombres_json_comme_js() {
assert_eq!(serde_json::to_string(&JsNum(12.0)).unwrap(), "12");
assert_eq!(serde_json::to_string(&JsNum(-0.0)).unwrap(), "0");
assert_eq!(serde_json::to_string(&JsNum(12.5)).unwrap(), "12.5");
assert_eq!(serde_json::to_string(&JsNum(f64::NAN)).unwrap(), "null");
}
#[test]
fn string_et_truthy() {
assert_eq!(js_string(Some(&serde_json::json!(123))), "123");
assert_eq!(js_string(Some(&serde_json::json!(null))), "null");
assert_eq!(js_string(None), "undefined");
assert!(!js_truthy(Some(&serde_json::json!(""))));
assert!(js_truthy(Some(&serde_json::json!("export"))));
assert_eq!(replace_whitespace_runs("Le Fournisseur\tX", "_"), "Le_Fournisseur_X");
}
}
+40 -2
View File
@@ -1,16 +1,54 @@
//! Module `grid` — à porter.
//! Module `grid` — grille d'assortiment.
//!
//! Phase 1 (ce qui ne dépend ni de la base FF ni de Qlik) :
//! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ;
//! - [`store`] : instantané persisté `grid_rows` (lu par `/api/v1`) ;
//! - [`rows_cache`] : cache mémoire des lignes calculées (10 min) ;
//! - [`network_trend`], [`months`], [`store_patch`] : logique pure ;
//! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ;
//! - [`exports`] : exports Excel ; [`api_schemas`], [`api_v1`], [`openapi`] : API publique.
//!
//! Phase 2 : calcul de la grille (`getProductRows`), enrichissement `/api/v1`
//! (`api-enrich`), routes `/api/v1/grid`, `/products/*`, `/gammes`,
//! `/fournisseurs`, `/network/*` et `/api/grid/rows*`.
pub mod api_schemas;
pub mod api_v1;
pub mod enregistrer;
pub mod exports;
pub mod gammes;
pub mod gammes_a_valider;
pub mod js;
pub mod months;
pub mod network_trend;
pub mod openapi;
pub mod rows_cache;
pub mod session;
pub mod snapshots;
pub mod store;
pub mod store_patch;
pub mod types;
pub mod zod;
#[cfg(test)]
mod tests_db;
use crate::rpc::Handler;
use axum::routing::{get, post};
use axum::Router;
/// Routes HTTP du module.
pub fn router() -> Router {
Router::new()
.route("/api/export/excel", post(exports::export_excel))
.route("/api/export/modified-gammes", post(exports::export_modified_gammes))
.route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures))
.route("/api/v1/openapi.json", get(openapi::get_openapi))
}
/// Actions (`POST /api/actions/{nom}`) du module.
pub fn actions() -> Vec<(&'static str, Handler)> {
vec![]
[snapshots::actions(), enregistrer::actions()].concat()
}
/// Données de pages (`GET /api/pages/{nom}`) du module.
+97
View File
@@ -0,0 +1,97 @@
//! 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");
}
}
+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");
}
}
File diff suppressed because it is too large. Load diff
+273
View File
@@ -0,0 +1,273 @@
//! Cache mémoire des lignes de grille calculées (partie « cache » de
//! `src/features/grid/api/get-product-rows.ts`).
//!
//! Clé : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`. Durée de vie : 10 minutes.
//! Le calcul lui-même (`getProductRows`, `buildProductRows`, `serveFromSnapshot`,
//! `getStorePatch`) viendra en phase 2 et s'appuie sur les briques ci-dessous :
//!
//! - [`get_fresh`] : lecture d'une entrée de moins de 10 min, remontée en tête de
//! l'ordre d'usage (éviction LRU) ; [`peek_fresh`] la lit sans la remonter
//! (`getStorePatch`) ;
//! - [`cache_grid_rows`] : insertion avec éviction (`cacheGridRows`) ;
//! - [`pending_get`] / [`pending_insert`] / [`pending_remove`] : calculs en cours,
//! partagés entre appels simultanés (`gridRowsPending`) ;
//! - [`gamme_save_version`] : compteur d'enregistrements de gammes par fournisseur,
//! qu'un calcul compare avant/après pour relire le dernier snapshot ;
//! - [`invalidate_grid_rows_cache`], [`patch_grid_rows_cache`].
//!
//! Les lignes d'une entrée sont partagées ([`SharedRows`]) et modifiées sur place,
//! comme le tableau JS : `patchGridRowsCache` et `refreshGammeInit` écrivent dans
//! les lignes déjà servies. Les compléments par magasin (`storePatches`, une
//! `WeakMap` indexée par le tableau en TS) sont rattachés au même objet : ils
//! vivent et meurent avec lui.
use super::types::{GammeCode, ProductRow, StorePatch};
use futures::future::{BoxFuture, Shared};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
pub const GRID_ROWS_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
/// Bornes du cache mémoire. La synchro nocturne et le préchauffage passent par
/// `getProductRows()` pour CHAQUE fournisseur : sans plafond, le processus finissait
/// par garder toutes les grilles en mémoire (plusieurs centaines de Mo). Ordre
/// d'insertion = ordre d'usage : on évince les entrées les moins récemment servies.
///
/// `Number(process.env.X ?? défaut)` : une valeur illisible donne `NaN`, et toute
/// comparaison avec `NaN` est fausse — comportement conservé.
fn borne(nom: &str, defaut: f64) -> f64 {
match std::env::var(nom) {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => defaut,
}
}
fn max_entries() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ENTRIES", 30.0)
}
fn max_rows() -> f64 {
borne("GRID_ROWS_CACHE_MAX_ROWS", 150_000.0)
}
/// Complément par magasin en cours de calcul ou calculé (partagé entre appelants).
pub type StorePatchFuture = Shared<BoxFuture<'static, Result<Arc<StorePatch>, String>>>;
/// Lignes d'une entrée du cache, partagées et modifiables sur place.
#[derive(Default)]
pub struct GridRows {
pub rows: RwLock<Vec<ProductRow>>,
/// `storePatches` : magasin → complément, rattaché à ce tableau de lignes.
pub store_patches: Mutex<HashMap<String, StorePatchFuture>>,
}
impl GridRows {
pub fn new(rows: Vec<ProductRow>) -> SharedRows {
Arc::new(GridRows { rows: RwLock::new(rows), store_patches: Mutex::new(HashMap::new()) })
}
pub fn len(&self) -> usize {
self.rows.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Copie des lignes (pour les sérialiser hors verrou).
pub fn snapshot(&self) -> Vec<ProductRow> {
self.rows.read().clone()
}
}
pub type SharedRows = Arc<GridRows>;
/// Calcul en cours, partagé : l'erreur est un message (le calcul TS relance l'erreur
/// à chaque appelant).
pub type RowsFuture = Shared<BoxFuture<'static, Result<SharedRows, String>>>;
struct Entree {
rows: SharedRows,
created_at: Instant,
}
#[derive(Default)]
struct Etat {
/// Entrées dans l'ordre d'usage (la plus ancienne en tête).
cache: Vec<(String, Entree)>,
pending: HashMap<String, RowsFuture>,
/// Nombre d'enregistrements de gammes par fournisseur (cf. [`patch_grid_rows_cache`]).
gamme_save_version: HashMap<String, u64>,
}
static ETAT: LazyLock<Mutex<Etat>> = LazyLock::new(|| Mutex::new(Etat::default()));
/// `gridRowsCacheKey` : `"{codeFournisseur}:{magasin ?? "TOTAL"}"`.
pub fn grid_rows_cache_key(code_fournisseur: &str, magasin: Option<&str>) -> String {
format!("{code_fournisseur}:{}", magasin.unwrap_or("TOTAL"))
}
fn prefixe(code_fournisseur: &str) -> String {
format!("{code_fournisseur}:")
}
/// `cacheGridRows` : (ré)insère l'entrée en fin d'ordre d'usage, puis évince les
/// plus anciennes tant que les bornes sont dépassées (jamais la dernière).
pub fn cache_grid_rows(key: &str, rows: SharedRows) {
let (max_e, max_r) = (max_entries(), max_rows());
let mut etat = ETAT.lock();
etat.cache.retain(|(k, _)| k != key);
etat.cache.push((key.to_string(), Entree { rows, created_at: Instant::now() }));
let mut total_rows: f64 = etat.cache.iter().map(|(_, e)| e.rows.len() as f64).sum();
while etat.cache.len() > 1 {
let taille = etat.cache.len() as f64;
if taille <= max_e && total_rows <= max_r {
break;
}
let (_, e) = etat.cache.remove(0);
total_rows -= e.rows.len() as f64;
}
}
/// Entrée de moins de 10 min, remontée en tête de l'ordre d'usage (hit de
/// `getProductRows`). `None` si absente ou expirée.
pub fn get_fresh(key: &str) -> Option<SharedRows> {
let mut etat = ETAT.lock();
let pos = etat.cache.iter().position(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL)?;
// Remonte l'entrée en tête de l'ordre d'usage (éviction LRU), sans changer
// sa date de création.
let entree = etat.cache.remove(pos);
let rows = entree.1.rows.clone();
etat.cache.push(entree);
Some(rows)
}
/// Entrée de moins de 10 min, sans toucher à l'ordre d'usage (`getStorePatch`).
pub fn peek_fresh(key: &str) -> Option<SharedRows> {
let etat = ETAT.lock();
etat.cache.iter().find(|(k, e)| k == key && e.created_at.elapsed() < GRID_ROWS_CACHE_TTL).map(|(_, e)| e.rows.clone())
}
/// Calcul en cours pour cette clé.
pub fn pending_get(key: &str) -> Option<RowsFuture> {
ETAT.lock().pending.get(key).cloned()
}
pub fn pending_contains(key: &str) -> bool {
ETAT.lock().pending.contains_key(key)
}
pub fn pending_insert(key: &str, fut: RowsFuture) {
ETAT.lock().pending.insert(key.to_string(), fut);
}
pub fn pending_remove(key: &str) {
ETAT.lock().pending.remove(key);
}
/// Compteur d'enregistrements de gammes du fournisseur.
pub fn gamme_save_version(code_fournisseur: &str) -> u64 {
ETAT.lock().gamme_save_version.get(code_fournisseur).copied().unwrap_or(0)
}
/// `invalidateGridRowsCache` : un fournisseur (toutes ses vues magasin), ou tout.
pub fn invalidate_grid_rows_cache(code_fournisseur: Option<&str>) {
let mut etat = ETAT.lock();
match code_fournisseur.filter(|c| !c.is_empty()) {
Some(code) => {
let p = prefixe(code);
etat.cache.retain(|(k, _)| !k.starts_with(&p));
etat.pending.retain(|k, _| !k.starts_with(&p));
}
None => {
etat.cache.clear();
etat.pending.clear();
}
}
}
/// Reporte des gammes enregistrées sur les lignes déjà en cache, au lieu de jeter
/// le cache : l'invalidation forçait un recalcul complet (jusqu'à ~40 s sur un gros
/// fournisseur) à la réouverture suivante. Le résultat est celui qu'aurait donné
/// ce recalcul : la colonne Gamme reprend la valeur du dernier snapshot (Phase 9),
/// la colonne INIT reste l'état serveur.
pub fn patch_grid_rows_cache<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) {
let by_codein: HashMap<&str, GammeCode> = changes.into_iter().map(|(c, g)| (c, g.to_string())).collect();
if by_codein.is_empty() {
return;
}
let entrees: Vec<SharedRows> = {
let mut etat = ETAT.lock();
// Un calcul en cours pour ce fournisseur a lu les gammes avant cet
// enregistrement : le compteur lui signale de les relire avant d'être caché.
*etat.gamme_save_version.entry(code_fournisseur.to_string()).or_insert(0) += 1;
let p = prefixe(code_fournisseur);
etat.cache.iter().filter(|(k, _)| k.starts_with(&p)).map(|(_, e)| e.rows.clone()).collect()
};
for entree in entrees {
for row in entree.rows.write().iter_mut() {
if let Some(g) = by_codein.get(row.codein.as_str()) {
row.code_gamme = Some(g.clone());
}
}
}
}
/// Nombre d'entrées en cache (diagnostic).
pub fn cache_len() -> usize {
ETAT.lock().cache.len()
}
/// Sérialise les tests qui touchent à l'état global du cache.
#[cfg(test)]
pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
#[cfg(test)]
pub(crate) fn reset_for_tests() {
*ETAT.lock() = Etat::default();
}
#[cfg(test)]
mod tests {
use super::*;
fn lignes(n: usize) -> SharedRows {
GridRows::new((0..n).map(|i| ProductRow { codein: format!("C{i}"), ..Default::default() }).collect())
}
/// Un seul test : l'état est global au processus.
#[test]
fn cache_patch_invalidation_eviction() {
let _verrou = TEST_LOCK.lock();
reset_for_tests();
assert_eq!(grid_rows_cache_key("F1", None), "F1:TOTAL");
assert_eq!(grid_rows_cache_key("F1", Some("292")), "F1:292");
let total = lignes(3);
cache_grid_rows("F1:TOTAL", total.clone());
cache_grid_rows("F1:292", lignes(3));
cache_grid_rows("F10:TOTAL", lignes(3));
assert!(get_fresh("F1:TOTAL").is_some());
assert!(peek_fresh("absent").is_none());
// Patch : seules les entrées du fournisseur F1 (pas F10) reçoivent la gamme.
patch_grid_rows_cache("F1", [("C1", "Z")]);
assert_eq!(gamme_save_version("F1"), 1);
assert_eq!(gamme_save_version("F10"), 0);
assert_eq!(total.rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F1:292").unwrap().rows.read()[1].code_gamme.as_deref(), Some("Z"));
assert_eq!(get_fresh("F10:TOTAL").unwrap().rows.read()[1].code_gamme, None);
// Invalidation d'un fournisseur : « F1: » ne touche pas « F10: ».
invalidate_grid_rows_cache(Some("F1"));
assert!(get_fresh("F1:TOTAL").is_none() && get_fresh("F1:292").is_none());
assert!(get_fresh("F10:TOTAL").is_some());
// Éviction LRU par nombre de lignes (borne 150 000 par défaut).
cache_grid_rows("GROS:TOTAL", lignes(150_000));
assert!(get_fresh("F10:TOTAL").is_none(), "l'entrée la moins récente est évincée");
assert!(get_fresh("GROS:TOTAL").is_some());
// Jamais la dernière, même au-delà des bornes.
cache_grid_rows("ENORME:TOTAL", lignes(150_001));
assert_eq!(cache_len(), 1);
invalidate_grid_rows_cache(None);
assert_eq!(cache_len(), 0);
}
}
+95
View File
@@ -0,0 +1,95 @@
//! Accès et identifiant utilisateur, tels que les actions TS les manipulent.
use super::js;
use crate::auth::UtilisateurCourant;
use crate::error::AppResult;
use crate::rpc::Ctx;
use axum::http::StatusCode;
use serde_json::{Map, Value};
/// `verifierSession()` : `Ok(Ok(u))` si connecté, `Ok(Err(message))` pour un
/// refus d'accès (401/403) que l'action renvoie dans `{ success: false, error }`.
/// Toute autre erreur est propagée, comme le `throw e` du TS.
pub async fn verifier_session(ctx: &Ctx) -> AppResult<Result<UtilisateurCourant, String>> {
match ctx.session().await {
Ok(u) => Ok(Ok(u)),
Err(e) if e.status == StatusCode::UNAUTHORIZED || e.status == StatusCode::FORBIDDEN => Ok(Err(e.message)),
Err(e) => Err(e),
}
}
/// `rawUserId ? parseInt(rawUserId, 10) : null` — `None` pour `null`, sinon le
/// nombre (éventuellement `NaN`).
pub fn parse_user_id(raw: &str) -> Option<f64> {
if raw.is_empty() {
None
} else {
Some(js::js_parse_int(raw))
}
}
/// `userId && !isNaN(userId) ? userId : null` : le compte de secours « 0 » et les
/// identifiants non numériques valent `null` (snapshots « anonymes »).
pub fn final_user_id(raw: &str) -> Option<f64> {
parse_user_id(raw).filter(|v| *v != 0.0 && !v.is_nan())
}
/// Représentation de `${userId}` dans les journaux.
pub fn user_id_log(raw: &str) -> String {
match parse_user_id(raw) {
None => "null".into(),
Some(v) => js::js_number_to_string(v),
}
}
/// Valeur liée à une colonne `integer` : hors bornes, PostgreSQL refuse la
/// requête (« value out of range for type integer ») — même issue qu'en TS.
pub fn as_pg_integer(v: f64) -> Result<i32, String> {
if v.fract() == 0.0 && v >= i32::MIN as f64 && v <= i32::MAX as f64 {
Ok(v as i32)
} else {
Err(format!("value \"{}\" is out of range for type integer", js::js_number_to_string(v)))
}
}
/// Clés d'un objet dans l'ordre où JS les énumère : indices entiers croissants
/// d'abord (« 123456 » est un indice), puis les autres dans l'ordre d'insertion.
pub fn js_key_order(m: &Map<String, Value>) -> Vec<(&String, &Value)> {
let indice = js::js_array_index;
let mut entiers: Vec<(u64, &String, &Value)> = Vec::new();
let mut autres = Vec::new();
for (k, v) in m {
match indice(k) {
Some(i) => entiers.push((i, k, v)),
None => autres.push((k, v)),
}
}
entiers.sort_by_key(|(i, _, _)| *i);
entiers.into_iter().map(|(_, k, v)| (k, v)).chain(autres).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiants_comme_en_ts() {
assert_eq!(final_user_id("12"), Some(12.0));
assert_eq!(final_user_id("12abc"), Some(12.0));
assert_eq!(final_user_id("0"), None);
assert_eq!(final_user_id("abc"), None);
assert_eq!(final_user_id(""), None);
assert_eq!(user_id_log("abc"), "NaN");
assert_eq!(user_id_log(""), "null");
assert_eq!(user_id_log("0"), "0");
assert!(as_pg_integer(99_999_999_999.0).is_err());
assert_eq!(as_pg_integer(-5.0), Ok(-5));
}
#[test]
fn ordre_des_cles_js() {
let v: Value = serde_json::from_str(r#"{"b":1,"20":2,"a":3,"3":4,"007":5}"#).unwrap();
let ordre: Vec<&str> = js_key_order(v.as_object().unwrap()).into_iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ordre, vec!["3", "20", "b", "a", "007"]);
}
}
+407
View File
@@ -0,0 +1,407 @@
//! Snapshots de la Grille (port de `src/features/snapshots/api/*.ts`) :
//! actions `getSnapshots`, `deleteSnapshot`, `saveSnapshot`.
//!
//! `session_snapshots.user_id` est un entier : l'identifiant de session est lu
//! comme le faisait le TS (`parseInt`), et le compte de secours « 0 » ou un
//! identifiant non numérique travaillent sur les snapshots « anonymes »
//! (`user_id IS NULL`). Les `revalidatePath` de Next.js n'ont plus d'objet.
use super::session::{as_pg_integer, final_user_id, js_key_order, user_id_log, verifier_session};
use super::zod::{self, child, Issues, Seg};
use crate::db;
use crate::error::{AppError, AppResult};
use crate::rpc::{json, Ctx};
use chrono::NaiveDateTime;
use serde::Serialize;
use serde_json::{json as j, Map, Value};
use sqlx::{PgPool, Row};
/// Ligne de `session_snapshots`, telle que `db.select().from(sessionSnapshots)`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSnapshot {
pub id: i32,
pub user_id: Option<i32>,
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Value,
pub summary_json: Option<Value>,
pub label: Option<String>,
#[serde(rename = "type")]
pub type_: Option<String>,
pub created_at: Option<String>,
}
fn ligne_snapshot(r: &sqlx::postgres::PgRow) -> SessionSnapshot {
SessionSnapshot {
id: r.try_get("id").unwrap_or_default(),
user_id: r.try_get("user_id").ok().flatten(),
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
magasin: r.try_get("magasin").unwrap_or_default(),
changes: r.try_get("changes").unwrap_or(Value::Null),
summary_json: r.try_get("summary_json").ok().flatten(),
label: r.try_get("label").ok().flatten(),
type_: r.try_get("type").ok().flatten(),
created_at: r.try_get::<Option<NaiveDateTime>, _>("created_at").ok().flatten().map(db::iso),
}
}
// ---------------------------------------------------------------------------
// getSnapshots
// ---------------------------------------------------------------------------
/// Snapshots de l'utilisateur connecté (ou anonymes), du plus récent au plus ancien.
pub async fn lister_snapshots(user_raw_id: &str, type_: Option<&str>) -> Result<Vec<SessionSnapshot>, String> {
let pool = db::pool().map_err(|e| e.message)?;
// Si on a un userId, on filtre par celui-ci.
// Sinon, on filtre par userId IS NULL (mode anonyme/local)
let mut sql = String::from(
"select id, user_id, code_fournisseur, nom_fournisseur, magasin, changes, summary_json, label, type, created_at \
from session_snapshots where ",
);
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
sql.push_str(if user_id.is_some() { "user_id = $1" } else { "user_id is null" });
if type_.is_some() {
sql.push_str(if user_id.is_some() { " and type = $2" } else { " and type = $1" });
}
sql.push_str(" order by created_at desc");
let mut q = sqlx::query(&sql);
if let Some(id) = user_id {
q = q.bind(id);
}
if let Some(t) = type_ {
q = q.bind(t);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(ligne_snapshot).collect())
}
/// Action `getSnapshots(type?)`.
async fn get_snapshots(ctx: Ctx) -> AppResult<Value> {
let utilisateur = ctx.session().await?;
let arg = ctx.args.first();
// `if (type)` : seule une valeur « vraie » filtre.
let type_ = super::js::js_truthy(arg).then(|| super::js::js_string(arg));
match lister_snapshots(&utilisateur.id, type_.as_deref()).await {
Ok(rows) => json(rows),
Err(msg) => {
let premiere_ligne = msg.lines().next().unwrap_or_default().to_string();
tracing::error!("[getSnapshots] ERROR (type={}): {premiere_ligne}", type_.as_deref().unwrap_or("undefined"));
// Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot »
// au lieu de signaler que le chargement a échoué.
Err(AppError::internal("Chargement de l'historique impossible."))
}
}
}
// ---------------------------------------------------------------------------
// deleteSnapshot
// ---------------------------------------------------------------------------
/// Supprime un snapshot de l'utilisateur (ou anonyme). Résultat `{ success, error? }`.
pub async fn supprimer_snapshot(user_raw_id: &str, id: &Value) -> Value {
tracing::info!("[deleteSnapshot] Request for ID: {}, Session UserId: {}", super::js::js_string(Some(id)), user_id_log(user_raw_id));
let res: Result<u64, String> = async {
let pool = db::pool().map_err(|e| e.message)?;
// Condition de sécurité : match ID ET match le propriétaire (ou anonyme)
let user_id = match final_user_id(user_raw_id) {
Some(v) => Some(as_pg_integer(v)?),
None => None,
};
let id_sql: Option<i32> = match id {
Value::Null => None,
Value::Number(n) => Some(as_pg_integer(n.as_f64().unwrap_or(f64::NAN))?),
Value::String(s) => Some(s.trim().parse::<i32>().map_err(|_| format!("invalid input syntax for type integer: \"{s}\""))?),
autre => return Err(format!("invalid input syntax for type integer: {autre}")),
};
let sql = if user_id.is_some() {
"delete from session_snapshots where (id = $1 and user_id = $2) returning id"
} else {
"delete from session_snapshots where (id = $1 and user_id is null) returning id"
};
let mut q = sqlx::query(sql).bind(id_sql);
if let Some(u) = user_id {
q = q.bind(u);
}
let rows = q.fetch_all(&pool).await.map_err(|e| e.to_string())?;
Ok(rows.len() as u64)
}
.await;
match res {
Ok(0) => {
tracing::warn!(
"[deleteSnapshot] No rows deleted for ID {}. Snapshot may not exist or belongs to another user.",
super::js::js_string(Some(id))
);
j!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
}
Ok(_) => {
tracing::info!("[deleteSnapshot] Successfully deleted ID: {}", super::js::js_string(Some(id)));
j!({ "success": true })
}
Err(e) => {
// Le détail technique reste dans les journaux du serveur.
tracing::error!("[deleteSnapshot] DB Error: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
/// Action `deleteSnapshot(id)`.
async fn delete_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(supprimer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// saveSnapshot
// ---------------------------------------------------------------------------
/// Données validées de `SaveSnapshotSchema` (clés inconnues retirées, comme zod).
#[derive(Debug, Clone)]
pub struct SaveSnapshotInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub label: Option<String>,
/// codein → `{ before, after }`.
pub changes: Map<String, Value>,
pub type_: Option<String>,
/// `{ totalRows, totalCa, totalMarge, tauxMargeGlobal }`.
pub summary: Option<Value>,
}
/// `SaveSnapshotSchema.safeParse(raw)`.
pub fn parse_save_snapshot(raw: &Value) -> Result<SaveSnapshotInput, Issues> {
let mut issues = Issues::default();
let racine: Vec<Seg> = vec![];
let Some(o) = zod::object(Some(raw), &racine, &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let label = zod::opt_string(o.get("label"), &p("label"), &mut issues);
let mut changes = Map::new();
let chemin_changes = p("changes");
match o.get("changes") {
Some(Value::Object(m)) => {
for (k, v) in js_key_order(m) {
let chemin = child(&chemin_changes, k.as_str());
let Some(entree) = zod::object(Some(v), &chemin, &mut issues) else { continue };
let before = zod::nullable_string(entree.get("before"), &child(&chemin, "before"), &mut issues);
let after = zod::string(entree.get("after"), &child(&chemin, "after"), &mut issues);
if let (Some(before), Some(after)) = (before, after) {
changes.insert(k.clone(), j!({ "before": before, "after": after }));
}
}
}
autre => issues.invalid_type(&chemin_changes, "record", autre),
}
let type_ = match o.get("type") {
None => Some(None),
v => zod::enum_value(v, &["snapshot", "export"], &p("type"), None, &mut issues).map(Some),
};
let summary = match o.get("summary") {
None => Some(None),
v => {
let chemin = p("summary");
match zod::object(v, &chemin, &mut issues) {
Some(s) => {
let mut out = Map::new();
let mut complet = true;
for k in ["totalRows", "totalCa", "totalMarge", "tauxMargeGlobal"] {
match zod::number(s.get(k), &child(&chemin, k), &mut issues) {
Some(_) => {
out.insert(k.to_string(), s[k].clone());
}
None => complet = false,
}
}
complet.then_some(Some(Value::Object(out)))
}
None => None,
}
}
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveSnapshotInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
label: label.flatten(),
changes,
type_: type_.flatten(),
summary: summary.flatten(),
})
}
/// Date du jour façon `toLocaleDateString("fr-FR")`.
pub fn date_fr() -> String {
chrono::Local::now().format("%d/%m/%Y").to_string()
}
async fn inserer_snapshot(pool: &PgPool, user_id: Option<f64>, d: &SaveSnapshotInput) -> Result<i32, sqlx::Error> {
let label = d
.label
.clone()
.unwrap_or_else(|| format!("{} — {}", if d.type_.as_deref() == Some("export") { "Export" } else { "Snapshot" }, date_fr()));
// L'identifiant est lié en entier 64 bits : hors bornes de la colonne
// `integer`, PostgreSQL refuse l'insertion, comme pour la version Node.
let row = sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, $7, $8) returning id",
)
.bind(user_id.map(|v| v as i64))
.bind(&d.code_fournisseur)
.bind(&d.nom_fournisseur)
.bind(&d.magasin)
.bind(label)
.bind(Value::Object(d.changes.clone()))
.bind(d.summary.clone())
.bind(d.type_.clone().unwrap_or_else(|| "snapshot".into()))
.fetch_one(pool)
.await?;
row.try_get("id")
}
/// Enregistre un snapshot pour l'utilisateur. Résultat `{ success, snapshotId?, error? }`.
pub async fn enregistrer_snapshot(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_snapshot(raw) {
Ok(d) => d,
Err(issues) => {
tracing::error!("Snapshot validation failed: {}", serde_json::to_string(&issues.to_json()).unwrap_or_default());
return j!({ "success": false, "error": format!("Validation failed: {}", issues.messages_joined()) });
}
};
tracing::info!(
"[saveSnapshot] User: {} (raw: {user_raw_id}), Supplier: {}, Type: {}",
user_id_log(user_raw_id),
d.code_fournisseur,
d.type_.as_deref().unwrap_or("undefined")
);
let final_id = final_user_id(user_raw_id);
let pool = match db::pool() {
Ok(p) => p,
Err(e) => {
tracing::error!("Snapshot auto-repair/retry failed: {}", e.message);
return j!({ "success": false, "error": "erreur technique sur le serveur" });
}
};
match inserer_snapshot(&pool, final_id, &d).await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(err) => {
tracing::error!("Initial snapshot save failed, attempting auto-repair... {err}");
let reparation = async {
// Tentative systématique de création de table (no-op si déjà là)
sqlx::raw_sql(
"CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
user_id INTEGER,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
magasin VARCHAR(20) NOT NULL,
changes JSONB NOT NULL,
summary_json JSONB,
label TEXT,
type VARCHAR(20) DEFAULT 'snapshot',
created_at TIMESTAMP DEFAULT NOW()
);",
)
.execute(&pool)
.await?;
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
let alter = async {
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'")
.execute(&pool)
.await?;
sqlx::raw_sql("ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER").execute(&pool).await
};
let _ = alter.await; // Ignore
// Deuxième tentative d'insertion
inserer_snapshot(&pool, final_id, &d).await
};
match reparation.await {
Ok(id) => j!({ "success": true, "snapshotId": id }),
Err(e) => {
// Le détail technique (les deux erreurs) reste dans les journaux du serveur.
tracing::error!("Snapshot auto-repair/retry failed: {e}");
j!({ "success": false, "error": "erreur technique sur le serveur" })
}
}
}
}
}
/// Action `saveSnapshot(raw)`.
async fn save_snapshot(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(j!({ "success": false, "error": message })),
};
Ok(enregistrer_snapshot(&utilisateur.id, &ctx.raw(0)).await)
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("getSnapshots", crate::handler!(get_snapshots)),
("deleteSnapshot", crate::handler!(delete_snapshot)),
("saveSnapshot", crate::handler!(save_snapshot)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_comme_zod() {
let raw: Value = serde_json::from_str(
r#"{"codeFournisseur":"1","magasin":"T","changes":{"a":{"before":1},"b":3},"type":"x","summary":{"totalRows":"1"}}"#,
)
.unwrap();
let issues = parse_save_snapshot(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes","a","before"],"message":"Invalid input: expected string, received number"},{"expected":"string","code":"invalid_type","path":["changes","a","after"],"message":"Invalid input: expected string, received undefined"},{"expected":"object","code":"invalid_type","path":["changes","b"],"message":"Invalid input: expected object, received number"},{"code":"invalid_value","values":["snapshot","export"],"path":["type"],"message":"Invalid option: expected one of \"snapshot\"|\"export\""},{"expected":"number","code":"invalid_type","path":["summary","totalRows"],"message":"Invalid input: expected number, received string"},{"expected":"number","code":"invalid_type","path":["summary","totalCa"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","totalMarge"],"message":"Invalid input: expected number, received undefined"},{"expected":"number","code":"invalid_type","path":["summary","tauxMargeGlobal"],"message":"Invalid input: expected number, received undefined"}]"#
);
let vide = parse_save_snapshot(&j!({})).unwrap_err();
assert_eq!(
vide.messages_joined(),
"Invalid input: expected string, received undefined, Invalid input: expected string, received undefined, Invalid input: expected record, received undefined"
);
assert!(parse_save_snapshot(&j!({ "codeFournisseur": "1", "magasin": "T", "changes": [] })).is_err());
}
#[test]
fn donnees_nettoyees() {
let raw = j!({
"codeFournisseur": "F1", "magasin": "TOTAL", "inconnu": true,
"changes": { "200": { "before": null, "after": "A", "x": 1 }, "100": { "before": "B", "after": "Z" } },
"summary": { "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5, "y": 0 }
});
let d = parse_save_snapshot(&raw).unwrap();
assert_eq!(serde_json::to_string(&d.changes).unwrap(), r#"{"100":{"before":"B","after":"Z"},"200":{"before":null,"after":"A"}}"#);
assert_eq!(d.summary.unwrap(), j!({ "totalRows": 2, "totalCa": 10.5, "totalMarge": 3, "tauxMargeGlobal": 28.5 }));
assert_eq!(d.type_, None);
assert_eq!(d.label, None);
}
}
+699
View File
@@ -0,0 +1,699 @@
//! CollectFlow — Instantané persisté de la grille (table `grid_rows`),
//! port de `src/lib/grid-store.ts`.
//!
//! `getProductRows()` reconstruit la grille en direct (6 requêtes SQL sur la base
//! miroir FF + jointure Qlik) et ne la garde qu'en mémoire 10 minutes. L'API `/api/v1`
//! ne doit **jamais** déclencher ce calcul : elle lit cette table, remplie en effet de
//! bord quand quelqu'un ouvre la Grille.
//!
//! On n'introduit donc aucun calcul supplémentaire — on arrête de jeter un résultat
//! qui existe déjà, et il survit désormais aux redémarrages.
use super::js;
use super::types::ProductRow;
use crate::db;
use crate::error::{AppError, AppResult};
use chrono::{Datelike, NaiveDateTime, TimeZone};
use serde::Serialize;
use serde_json::Value;
use sqlx::{Postgres, QueryBuilder, Row};
use std::collections::HashMap;
/// Colonnes autorisées au tri — liste blanche, jamais l'entrée utilisateur brute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GridSortKey {
Libelle1,
Codein,
CodeFournisseur,
CodeGamme,
TotalCa,
TotalQuantite,
TotalMarge,
TauxMarge,
StockActuel,
CaReseau,
QteReseau,
NbMagasinsReseau,
CaParMagasinReseau,
ComputedAt,
}
/// `SORTABLE` : clé exposée → colonne SQL.
const SORTABLE: [(GridSortKey, &str, &str); 14] = [
(GridSortKey::Libelle1, "libelle1", "libelle1"),
(GridSortKey::Codein, "codein", "codein"),
(GridSortKey::CodeFournisseur, "codeFournisseur", "code_fournisseur"),
(GridSortKey::CodeGamme, "codeGamme", "code_gamme"),
(GridSortKey::TotalCa, "totalCa", "total_ca"),
(GridSortKey::TotalQuantite, "totalQuantite", "total_quantite"),
(GridSortKey::TotalMarge, "totalMarge", "total_marge"),
(GridSortKey::TauxMarge, "tauxMarge", "taux_marge"),
(GridSortKey::StockActuel, "stockActuel", "stock_actuel"),
(GridSortKey::CaReseau, "caReseau", "ca_reseau"),
(GridSortKey::QteReseau, "qteReseau", "qte_reseau"),
(GridSortKey::NbMagasinsReseau, "nbMagasinsReseau", "nb_magasins_reseau"),
(GridSortKey::CaParMagasinReseau, "caParMagasinReseau", "ca_par_magasin_reseau"),
(GridSortKey::ComputedAt, "computedAt", "computed_at"),
];
/// `GRID_SORT_KEYS`, dans l'ordre de `SORTABLE`.
pub const GRID_SORT_KEYS: [&str; 14] = [
"libelle1",
"codein",
"codeFournisseur",
"codeGamme",
"totalCa",
"totalQuantite",
"totalMarge",
"tauxMarge",
"stockActuel",
"caReseau",
"qteReseau",
"nbMagasinsReseau",
"caParMagasinReseau",
"computedAt",
];
impl GridSortKey {
/// Clé de tri depuis son nom exposé ; `None` hors liste blanche.
pub fn from_key(key: &str) -> Option<Self> {
SORTABLE.iter().find(|(_, k, _)| *k == key).map(|(s, _, _)| *s)
}
pub fn as_str(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, k, _)| *k).unwrap_or("totalCa")
}
/// Colonne SQL (constante du code, jamais l'entrée utilisateur).
fn column(self) -> &'static str {
SORTABLE.iter().find(|(s, _, _)| *s == self).map(|(_, _, c)| *c).unwrap_or("total_ca")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SortOrder {
Asc,
#[default]
Desc,
}
#[derive(Debug, Clone, Default)]
pub struct GridQuery {
pub code_fournisseur: Option<String>,
pub gamme: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
pub code3: Option<String>,
/// Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3.
pub nomenclature: Option<String>,
/// Recherche libre : libellé, codein, GTIN, référence, code centrale.
pub search: Option<String>,
pub sort: Option<GridSortKey>,
pub order: Option<SortOrder>,
pub page: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridQueryResult {
pub rows: Vec<ProductRow>,
pub total: i64,
/// Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée.
pub computed_at: Option<String>,
}
/// Version du format des lignes persistées. À incrémenter dès qu'un champ de
/// `ProductRow` est ajouté ou change de sens : les instantanés plus anciens ne
/// seront plus servis à la Grille (recalcul), mais restent lisibles par /api/v1.
pub const GRID_PAYLOAD_VERSION: f64 = 1.0;
/// Lots d'écriture : payload jsonb volumineux, lots plus petits que pour des scalaires.
const CHUNK_UPSERT: usize = 200;
/// Bien en deçà des 65 535 paramètres liés de PostgreSQL.
const CHUNK_CODEINS: usize = 5000;
/// Chaîne non vide (`if (q.x)` côté TS).
fn renseigne(s: &Option<String>) -> Option<&str> {
s.as_deref().filter(|v| !v.is_empty())
}
/// Horodatage façon `new Date()` stocké par drizzle (UTC, à la milliseconde).
fn maintenant_utc_ms() -> NaiveDateTime {
use chrono::Timelike;
let now = chrono::Utc::now().naive_utc();
now.with_nanosecond(now.nanosecond() / 1_000_000 * 1_000_000).unwrap_or(now)
}
/// Colonne `numeric` : le TS passe `String(n)` ; on fait de même (texte casté).
fn push_numeric(b: &mut sqlx::query_builder::Separated<'_, '_, Postgres, &'static str>, v: Option<f64>) {
b.push("CAST(");
b.push_bind_unseparated(v.map(js::numeric_text));
b.push_unseparated(" AS numeric)");
}
/// Persiste les lignes calculées pour un fournisseur.
///
/// Stratégie : toutes les lignes du lot portent le même `computedAt`, puis on supprime
/// celles du fournisseur restées sur un `computedAt` antérieur. Cela purge les articles
/// disparus du catalogue sans avoir à passer des centaines de codein en paramètres.
pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> AppResult<usize> {
if code_fournisseur.is_empty() || rows.is_empty() {
return Ok(0);
}
let pool = db::pool()?;
let computed_at = maintenant_utc_ms();
let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect();
if values.is_empty() {
return Ok(0);
}
let mut written = 0usize;
for batch in values.chunks(CHUNK_UPSERT) {
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \
code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \
total_marge, taux_marge, stock_actuel, ca_reseau, qte_reseau, nb_magasins_reseau, \
ca_par_magasin_reseau, marge_pct_reseau, payload, computed_at) ",
);
let mut erreur_payload = None;
qb.push_values(batch.iter(), |mut b, r| {
let mut payload = (*r).clone();
payload.payload_version = Some(GRID_PAYLOAD_VERSION);
let payload = serde_json::to_value(&payload).unwrap_or_else(|e| {
erreur_payload = Some(e.to_string());
Value::Null
});
b.push_bind(code_fournisseur.to_string())
.push_bind(r.codein.clone())
.push_bind(r.nom_fournisseur.clone())
.push_bind(r.libelle1.clone())
.push_bind(r.gtin.clone())
.push_bind(r.reference.clone())
.push_bind(r.code_centrale.clone())
.push_bind(r.code1.clone())
.push_bind(r.code2.clone())
.push_bind(r.code3.clone())
.push_bind(r.code_gamme.clone())
.push_bind(r.code_gamme_init.clone());
push_numeric(&mut b, Some(r.total_ca));
push_numeric(&mut b, Some(r.total_quantite));
push_numeric(&mut b, Some(r.total_marge));
push_numeric(&mut b, Some(r.taux_marge));
push_numeric(&mut b, r.stock_actuel);
push_numeric(&mut b, r.ca_reseau);
push_numeric(&mut b, r.qte_reseau);
// Colonne integer : le TS passe le nombre tel quel.
b.push("CAST(");
b.push_bind_unseparated(r.nb_magasins_reseau.map(js::numeric_text));
b.push_unseparated(" AS integer)");
push_numeric(&mut b, r.ca_par_magasin_reseau);
push_numeric(&mut b, r.marge_pct_reseau);
b.push_bind(payload).push_bind(computed_at);
});
if let Some(e) = erreur_payload {
return Err(AppError::internal(e));
}
qb.push(
" on conflict (code_fournisseur, codein) do update set \
nom_fournisseur = excluded.nom_fournisseur, libelle1 = excluded.libelle1, gtin = excluded.gtin, \
reference = excluded.reference, code_centrale = excluded.code_centrale, code1 = excluded.code1, \
code2 = excluded.code2, code3 = excluded.code3, code_gamme = excluded.code_gamme, \
code_gamme_init = excluded.code_gamme_init, total_ca = excluded.total_ca, \
total_quantite = excluded.total_quantite, total_marge = excluded.total_marge, \
taux_marge = excluded.taux_marge, stock_actuel = excluded.stock_actuel, ca_reseau = excluded.ca_reseau, \
qte_reseau = excluded.qte_reseau, nb_magasins_reseau = excluded.nb_magasins_reseau, \
ca_par_magasin_reseau = excluded.ca_par_magasin_reseau, marge_pct_reseau = excluded.marge_pct_reseau, \
payload = excluded.payload, computed_at = excluded.computed_at",
);
qb.build().execute(&pool).await?;
written += batch.len();
}
// Purge des articles qui ne font plus partie du catalogue du fournisseur.
sqlx::query("delete from grid_rows where code_fournisseur = $1 and computed_at < $2")
.bind(code_fournisseur)
.bind(computed_at)
.execute(&pool)
.await?;
Ok(written)
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
pub struct GridSnapshot {
pub rows: Vec<ProductRow>,
/// Calcul le plus ancien des lignes (UTC).
pub computed_at: NaiveDateTime,
}
/// Instantané complet d'un fournisseur, tel que persisté par le dernier calcul.
///
/// Renvoie `None` (→ recalcul) si rien n'est persisté, si une ligne est d'une
/// version de format antérieure, ou si le calcul date d'un autre mois que le mois
/// en cours : la fenêtre des 12 mois glissants aurait changé entre-temps.
/// Les lignes sont rendues dans l'ordre du calcul en direct (identifiant article).
pub async fn read_grid_snapshot(code_fournisseur: &str) -> AppResult<Option<GridSnapshot>> {
let pool = db::pool()?;
let found = sqlx::query("select payload, computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_all(&pool)
.await?;
if found.is_empty() {
return Ok(None);
}
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
let payload: Value = r.try_get("payload")?;
if payload.get("payloadVersion").and_then(Value::as_f64) != Some(GRID_PAYLOAD_VERSION) {
return Ok(None);
}
let row: ProductRow = match serde_json::from_value(payload) {
Ok(row) => row,
Err(e) => {
tracing::warn!("[grid-store] payload illisible pour {code_fournisseur} : {}", crate::error::short(&e, 200));
return Ok(None);
}
};
let computed_at: NaiveDateTime = r.try_get("computed_at")?;
if oldest.map(|o| computed_at < o).unwrap_or(true) {
oldest = Some(computed_at);
}
rows.push(row);
}
let Some(oldest) = oldest else { return Ok(None) };
// Comparaison en heure locale, comme `getFullYear()` / `getMonth()`.
let local = chrono::Local.from_utc_datetime(&oldest);
let now = chrono::Local::now();
if local.year() != now.year() || local.month() != now.month() {
return Ok(None);
}
rows.sort_by(|a, b| {
let (x, y) = (a.noid.unwrap_or(0.0), b.noid.unwrap_or(0.0));
(x - y).partial_cmp(&0.0).unwrap_or(std::cmp::Ordering::Equal)
});
Ok(Some(GridSnapshot { rows, computed_at: oldest }))
}
/// Reporte des gammes enregistrées dans l'instantané persisté (colonne et payload),
/// pour que /api/v1 les voie sans attendre un recalcul complet de la grille.
pub async fn update_grid_rows_gamme<'a>(code_fournisseur: &str, changes: impl IntoIterator<Item = (&'a str, &'a str)>) -> AppResult<()> {
// Map TS : ordre de première apparition de chaque gamme.
let mut par_gamme: Vec<(String, Vec<String>)> = Vec::new();
for (codein, gamme) in changes {
match par_gamme.iter_mut().find(|(g, _)| g == gamme) {
Some((_, codeins)) => codeins.push(codein.to_string()),
None => par_gamme.push((gamme.to_string(), vec![codein.to_string()])),
}
}
if par_gamme.is_empty() {
return Ok(());
}
let pool = db::pool()?;
for (gamme, codeins) in &par_gamme {
for lot in codeins.chunks(CHUNK_CODEINS) {
sqlx::query(
"update grid_rows set code_gamme = $1, payload = jsonb_set(payload, '{codeGamme}', to_jsonb($2::text)) \
where code_fournisseur = $3 and codein = any($4)",
)
.bind(gamme)
.bind(gamme)
.bind(code_fournisseur)
.bind(lot)
.execute(&pool)
.await?;
}
}
Ok(())
}
/// Construit la clause WHERE commune à la grille et à la recherche transversale.
fn push_where(qb: &mut QueryBuilder<'_, Postgres>, q: &GridQuery) {
let mut premiere = true;
let mut et = |qb: &mut QueryBuilder<'_, Postgres>| {
qb.push(if premiere { " where (" } else { " and " });
premiere = false;
};
if let Some(v) = renseigne(&q.code_fournisseur) {
et(qb);
qb.push("code_fournisseur = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.gamme) {
et(qb);
qb.push("code_gamme = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code1) {
et(qb);
qb.push("code1 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code2) {
et(qb);
qb.push("code2 = ").push_bind(v.to_string());
}
if let Some(v) = renseigne(&q.code3) {
et(qb);
qb.push("code3 = ").push_bind(v.to_string());
}
// Filtre par **préfixe** de nomenclature : « 32 », « 3202 » ou « 320211 ».
// C'est celui à utiliser avec /api/v1/nomenclatures, car il ne dépend pas de
// code1/code2 — parfois vides quand la hiérarchie n'a pas pu être remontée.
if let Some(v) = renseigne(&q.nomenclature) {
et(qb);
qb.push("code3 like ").push_bind(format!("{v}%"));
}
if let Some(search) = q.search.as_deref().map(js::js_trim).filter(|s| !s.is_empty()) {
let pattern = format!("%{search}%");
et(qb);
qb.push("(libelle1 ilike ")
.push_bind(pattern.clone())
.push(" or codein ilike ")
.push_bind(pattern.clone())
.push(" or gtin ilike ")
.push_bind(pattern.clone())
.push(" or reference ilike ")
.push_bind(pattern.clone())
.push(" or code_centrale ilike ")
.push_bind(pattern)
.push(")");
}
if !premiere {
qb.push(")");
}
}
/// Ligne exposée par l'API : la version de format est un détail interne.
fn vers_ligne_api(payload: Value) -> AppResult<ProductRow> {
let mut ligne: ProductRow =
serde_json::from_value(payload).map_err(|e| AppError::internal(format!("payload grid_rows illisible : {e}")))?;
ligne.payload_version = None;
Ok(ligne)
}
/// Lit les lignes de grille persistées : filtre, tri et pagination faits en SQL.
/// Ne déclenche aucun recalcul et ne contacte jamais Qlik.
pub async fn query_grid_rows(q: &GridQuery) -> AppResult<GridQueryResult> {
let pool = db::pool()?;
let page = q.page.unwrap_or(1).max(1);
// `limit` absent = aucune limite : on renvoie toutes les lignes du filtre.
let limit = q.limit.map(|l| l.max(1));
let sort_col = q.sort.unwrap_or(GridSortKey::TotalCa).column();
let sens = if q.order == Some(SortOrder::Asc) { "asc" } else { "desc" };
let mut selection: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows");
push_where(&mut selection, q);
// Mêmes ORDER BY que drizzle (`desc(col)`, `asc(codein)`).
selection.push(format!(" order by {sort_col} {sens}, codein asc"));
if let Some(l) = limit {
selection.push(" limit ").push_bind(l).push(" offset ").push_bind((page - 1) * l);
}
let mut comptage: QueryBuilder<Postgres> = QueryBuilder::new("select count(*)::int as total from grid_rows");
push_where(&mut comptage, q);
// Comptage et page en parallèle : deux connexions du pool, un seul temps d'attente.
let (count_row, found) = tokio::try_join!(comptage.build().fetch_one(&pool), selection.build().fetch_all(&pool))?;
let mut oldest: Option<NaiveDateTime> = None;
let mut rows = Vec::with_capacity(found.len());
for r in &found {
if let Ok(Some(c)) = r.try_get::<Option<NaiveDateTime>, _>("computed_at") {
if oldest.map(|o| c < o).unwrap_or(true) {
oldest = Some(c);
}
}
rows.push(vers_ligne_api(r.try_get("payload")?)?);
}
Ok(GridQueryResult { rows, total: count_row.try_get::<i32, _>("total").map(i64::from).unwrap_or(0), computed_at: oldest.map(db::iso) })
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridRowFound {
pub row: ProductRow,
pub computed_at: Option<String>,
}
/// Fiche complète d'un produit. `code_fournisseur` lève l'ambiguïté d'un article multi-fournisseurs.
pub async fn get_grid_row_by_codein(codein: &str, code_fournisseur: Option<&str>) -> AppResult<Option<GridRowFound>> {
let pool = db::pool()?;
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select payload, computed_at from grid_rows where (codein = ");
qb.push_bind(codein.to_string());
if let Some(f) = code_fournisseur.filter(|f| !f.is_empty()) {
qb.push(" and code_fournisseur = ").push_bind(f.to_string());
}
qb.push(") order by computed_at desc limit 1");
let Some(found) = qb.build().fetch_optional(&pool).await? else {
return Ok(None);
};
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridRowFound { row: vers_ligne_api(found.try_get("payload")?)?, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplierRef {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
}
/// Fournisseurs chez lesquels un article figure dans l'instantané.
pub async fn list_grid_suppliers_for_codein(codein: &str) -> AppResult<Vec<GridSupplierRef>> {
let pool = db::pool()?;
let rows = sqlx::query("select code_fournisseur, nom_fournisseur from grid_rows where codein = $1 order by code_fournisseur asc")
.bind(codein)
.fetch_all(&pool)
.await?;
Ok(rows
.iter()
.map(|r| GridSupplierRef {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
})
.collect())
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GammeInstantane {
/// Gamme courante, modifications enregistrées comprises.
pub code_gamme: Option<String>,
/// Gamme serveur au moment du calcul de la grille.
pub code_gamme_init: Option<String>,
pub nom_fournisseur: Option<String>,
}
/// Gammes d'articles d'un fournisseur dans l'instantané. Un codein absent de la
/// carte n'appartient pas à ce fournisseur (ou n'a pas encore été calculé).
pub async fn get_grid_rows_gammes(code_fournisseur: &str, codeins: &[String]) -> AppResult<HashMap<String, GammeInstantane>> {
let mut out = HashMap::new();
if codeins.is_empty() {
return Ok(out);
}
let pool = db::pool()?;
for lot in codeins.chunks(CHUNK_CODEINS) {
let found = sqlx::query(
"select codein, code_gamme, code_gamme_init, nom_fournisseur from grid_rows \
where code_fournisseur = $1 and codein = any($2)",
)
.bind(code_fournisseur)
.bind(lot)
.fetch_all(&pool)
.await?;
for r in &found {
out.insert(
r.try_get::<String, _>("codein").unwrap_or_default(),
GammeInstantane {
code_gamme: r.try_get("code_gamme").ok().flatten(),
code_gamme_init: r.try_get("code_gamme_init").ok().flatten(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
},
);
}
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridFreshness {
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Indique si un fournisseur a déjà été calculé au moins une fois.
/// Sert à répondre `202 not_ready` plutôt que de déclencher un calcul long.
pub async fn get_grid_freshness(code_fournisseur: &str) -> AppResult<Option<GridFreshness>> {
let pool = db::pool()?;
let found =
sqlx::query("select count(*)::int as row_count, max(computed_at) as computed_at from grid_rows where code_fournisseur = $1")
.bind(code_fournisseur)
.fetch_one(&pool)
.await?;
let row_count = found.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0);
if row_count == 0 {
return Ok(None);
}
let computed_at: Option<NaiveDateTime> = found.try_get("computed_at").ok().flatten();
Ok(Some(GridFreshness { row_count, computed_at: computed_at.map(db::iso) }))
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridSupplier {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub row_count: i64,
pub computed_at: Option<String>,
}
/// Fournisseurs présents dans l'instantané, avec leur fraîcheur.
pub async fn list_grid_suppliers() -> AppResult<Vec<GridSupplier>> {
let pool = db::pool()?;
let found = sqlx::query(
"select code_fournisseur, max(nom_fournisseur) as nom_fournisseur, count(*)::int as row_count, \
max(computed_at) as computed_at from grid_rows group by code_fournisseur order by code_fournisseur asc",
)
.fetch_all(&pool)
.await?;
Ok(found
.iter()
.map(|r| GridSupplier {
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
nom_fournisseur: r.try_get("nom_fournisseur").ok().flatten(),
row_count: r.try_get::<i32, _>("row_count").map(i64::from).unwrap_or(0),
computed_at: r.try_get::<Option<NaiveDateTime>, _>("computed_at").ok().flatten().map(db::iso),
})
.collect())
}
// ---------------------------------------------------------------------------
// Inventaire des nomenclatures d'un fournisseur
// ---------------------------------------------------------------------------
/// Un poste de nomenclature, avec son poids — de quoi décider par où découper.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GridNomenclature {
pub code: String,
/// Libellé du poste, lu dans le payload (il n'a pas de colonne dédiée).
pub libelle: Option<String>,
pub nb_articles: i64,
#[serde(serialize_with = "js::num::serialize")]
pub total_ca: f64,
#[serde(serialize_with = "js::num::serialize")]
pub total_quantite: f64,
}
/// Longueur du code pour chaque niveau : « 32 » → « 3202 » → « 320211 ».
pub const NOMENCLATURE_LONGUEURS: [(u8, i32); 3] = [(1, 2), (2, 4), (3, 6)];
/// Longueur du code d'un niveau (1, 2 ou 3).
pub fn nomenclature_longueur(niveau: u8) -> i32 {
NOMENCLATURE_LONGUEURS.iter().find(|(n, _)| *n == niveau).map(|(_, l)| *l).unwrap_or(2)
}
/// Répartition des articles d'un fournisseur par niveau de nomenclature.
///
/// Sans cela, un appelant qui veut découper un gros fournisseur par nomenclature
/// est coincé : le filtre existe sur `/grid`, mais rien ne lui dit **quelles**
/// valeurs existent — il devrait tout télécharger pour les découvrir, ce qui
/// annule le bénéfice du découpage.
///
/// Les niveaux sont dérivés par **préfixe de `code3`** (codes hiérarchiques à 6
/// chiffres, de 31xxxx à 40xxxx), et non des colonnes `code1`/`code2`. Celles-ci
/// ne sont renseignées que si `pgGetNomenclatureByFournisseur` a su remonter la
/// hiérarchie ; quand elle échoue, seul `code3` est rempli. Le préfixe, lui, est
/// toujours disponible.
///
/// Tout est agrégé en SQL : la réponse fait quelques dizaines de lignes, même pour
/// un fournisseur de 130 000 articles.
///
/// Écart volontaire avec le TS : drizzle répétait `left(code3, $n)` dans le
/// GROUP BY avec un **autre** paramètre, ce que PostgreSQL refuse (« must appear
/// in the GROUP BY clause ») — la requête échouait systématiquement. On groupe
/// ici par position (`group by 1`), même résultat attendu.
pub async fn list_grid_nomenclatures(code_fournisseur: &str, niveau: u8, parent: Option<&str>) -> AppResult<Vec<GridNomenclature>> {
let pool = db::pool()?;
let longueur = nomenclature_longueur(niveau);
// Les libellés n'ont pas de colonne dédiée : on les lit dans le payload.
// Aux niveaux 1 et 2 ils peuvent être absents (même cause que ci-dessus) ;
// le code et les compteurs, eux, restent justes.
let libelle_key = match niveau {
1 => "libelleNiveau1",
2 => "libelleNiveau2",
_ => "libelle3",
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select left(code3, ");
qb.push_bind(longueur)
.push(") as code, max(payload ->> ")
.push_bind(libelle_key)
.push(") as libelle, count(*)::int as nb_articles, coalesce(sum(total_ca), 0)::float as total_ca, ")
.push("coalesce(sum(total_quantite), 0)::float as total_quantite from grid_rows where (code_fournisseur = ")
.push_bind(code_fournisseur.to_string())
.push(" and code3 is not null and length(code3) >= ")
.push_bind(longueur);
// `parent` est lui aussi un préfixe : « 32 » pour descendre au niveau 2.
if let Some(p) = parent.filter(|p| !p.is_empty()) {
qb.push(" and code3 like ").push_bind(format!("{p}%"));
}
qb.push(") group by 1 order by coalesce(sum(total_ca), 0) desc");
let rows = qb.build().fetch_all(&pool).await?;
Ok(rows
.iter()
.map(|r| GridNomenclature {
code: db::string_or_empty(r, "code"),
libelle: r.try_get("libelle").ok().flatten(),
nb_articles: db::f64_or0(r, "nb_articles") as i64,
total_ca: db::f64_or0(r, "total_ca"),
total_quantite: db::f64_or0(r, "total_quantite"),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn liste_blanche_du_tri() {
assert_eq!(GRID_SORT_KEYS.len(), SORTABLE.len());
for (i, (_, k, _)) in SORTABLE.iter().enumerate() {
assert_eq!(GRID_SORT_KEYS[i], *k);
assert_eq!(GridSortKey::from_key(k).unwrap().as_str(), *k);
}
assert_eq!(GridSortKey::from_key("total_ca; drop table x"), None);
assert_eq!(GridSortKey::TotalCa.column(), "total_ca");
}
#[test]
fn clause_where() {
let q = GridQuery {
code_fournisseur: Some("F1".into()),
gamme: Some(String::new()),
nomenclature: Some("32".into()),
search: Some(" bougie ".into()),
..Default::default()
};
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut qb, &q);
assert_eq!(
qb.sql(),
"select 1 from grid_rows where (code_fournisseur = $1 and code3 like $2 and (libelle1 ilike $3 or codein ilike $4 or gtin ilike $5 or reference ilike $6 or code_centrale ilike $7))"
);
let mut vide: QueryBuilder<Postgres> = QueryBuilder::new("select 1 from grid_rows");
push_where(&mut vide, &GridQuery::default());
assert_eq!(vide.sql(), "select 1 from grid_rows");
}
#[test]
fn longueurs_de_nomenclature() {
assert_eq!(nomenclature_longueur(1), 2);
assert_eq!(nomenclature_longueur(2), 4);
assert_eq!(nomenclature_longueur(3), 6);
}
}
+109
View File
@@ -0,0 +1,109 @@
//! 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()]);
}
}
+298
View File
@@ -0,0 +1,298 @@
//! Test d'intégration contre une vraie base PostgreSQL : `grid_rows`,
//! `session_snapshots`, `gammes_a_valider`.
//!
//! Désactivé par défaut. Pour le lancer :
//!
//! ```sh
//! GRID_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/collectflow cargo test grid::tests_db
//! ```
//!
//! Les données de test portent des codes fournisseur `TSTGRID…`, supprimés avant
//! et après le passage.
use super::api_schemas::query_map;
use super::enregistrer::{enregistrer_brouillon, enregistrer_gammes, ChangementGamme, EnregistrementGammes};
use super::gammes_a_valider::*;
use super::rows_cache;
use super::snapshots::{enregistrer_snapshot, lister_snapshots, supprimer_snapshot};
use super::store::*;
use super::types::ProductRow;
use serde_json::{json, Value};
use std::time::Duration;
const F1: &str = "TSTGRID1";
const F2: &str = "TSTGRID2";
fn ligne(codein: &str, noid: f64, libelle: &str, code3: &str, ca: f64) -> ProductRow {
let mut r: ProductRow = serde_json::from_value(json!({
"codein": codein, "codeFournisseur": F1, "nomFournisseur": "FOURNISSEUR TEST",
"libelle1": libelle, "gtin": format!("370{codein}"), "reference": format!("REF-{codein}"),
"codeCentrale": format!("10000{codein}"),
"code1": &code3[..2], "libelleNiveau1": "Univers", "code2": &code3[..4], "libelleNiveau2": "Famille",
"code3": code3, "libelle3": format!("Sous-famille {code3}"),
"codeGamme": "B", "codeGammeInit": "B", "codeGammeDraft": null,
"sales12m": { "202509": 1, "202510": 2 }, "stock12m": { "202509": 5, "202510": 4 },
"receptions12m": { "202509": 0, "202510": 0 },
"totalQuantite": 3, "totalCa": ca, "totalMarge": ca / 4.0, "tauxMarge": 25,
"workingStores": ["292"], "noid": noid, "stockActuel": 4.5, "nbMagasinsReseau": 120,
"qteReseauByMonth": null, "champInconnu": { "garde": true }
}))
.unwrap();
r.libelle1 = libelle.to_string();
r
}
async fn nettoyer(pool: &sqlx::PgPool) {
for t in ["grid_rows", "session_snapshots", "gammes_a_valider"] {
sqlx::query(&format!("delete from {t} where code_fournisseur like 'TSTGRID%'")).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn grid_rows_snapshots_gammes_a_valider() {
let Ok(url) = std::env::var("GRID_TEST_DATABASE_URL") else {
eprintln!("GRID_TEST_DATABASE_URL absent : test d'intégration ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
// La configuration lue par `db::pool()` : pas de fichier de réglages, l'URL de test.
let dir = std::env::temp_dir().join(format!("grid-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
// ── upsert / lecture de l'instantané ────────────────────────────────────
let rows = vec![
ligne("900002", 20.0, "Vase en verre", "320212", 50.0),
ligne("900001", 10.0, "Bougie parfumée", "320211", 150.0),
ProductRow { codein: String::new(), ..Default::default() },
ligne("900003", 5.0, "Cadre photo", "330101", 0.125),
];
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
assert_eq!(upsert_grid_rows("", &rows).await.unwrap(), 0);
let snap = read_grid_snapshot(F1).await.unwrap().expect("instantané");
let codes: Vec<&str> = snap.rows.iter().map(|r| r.codein.as_str()).collect();
assert_eq!(codes, vec!["900003", "900001", "900002"], "tri par noid");
assert_eq!(snap.rows[1].payload_version, Some(GRID_PAYLOAD_VERSION));
let mut relu = serde_json::to_value(&snap.rows[1]).unwrap();
relu.as_object_mut().unwrap().remove("payloadVersion");
assert_eq!(relu, serde_json::to_value(&rows[1]).unwrap(), "aller-retour du payload");
assert!(read_grid_snapshot("TSTGRID-ABSENT").await.unwrap().is_none());
// Colonnes scalaires (numeric arrondi par PostgreSQL).
let (ca, nb): (f64, Option<i32>) =
sqlx::query_as("select total_ca::float8, nb_magasins_reseau from grid_rows where code_fournisseur = $1 and codein = '900003'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!((ca, nb), (0.13, Some(120)));
// Une ligne d'un ancien format n'est pas servie à la Grille.
sqlx::query("update grid_rows set payload = payload - 'payloadVersion' where code_fournisseur = $1 and codein = '900002'")
.bind(F1)
.execute(&pool)
.await
.unwrap();
assert!(read_grid_snapshot(F1).await.unwrap().is_none());
assert_eq!(upsert_grid_rows(F1, &rows).await.unwrap(), 3);
// ── requêtes filtrées / triées / paginées ──────────────────────────────
let tout = query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), ..Default::default() }).await.unwrap();
assert_eq!(tout.total, 3);
assert_eq!(tout.rows.iter().map(|r| r.codein.as_str()).collect::<Vec<_>>(), vec!["900001", "900002", "900003"]);
assert!(tout.rows.iter().all(|r| r.payload_version.is_none()));
assert_eq!(tout.rows[0].extra.get("champInconnu"), Some(&json!({ "garde": true })));
assert!(tout.computed_at.as_deref().unwrap().ends_with('Z'));
let page2 = query_grid_rows(&GridQuery {
code_fournisseur: Some(F1.into()),
sort: Some(GridSortKey::Codein),
order: Some(SortOrder::Asc),
page: Some(2),
limit: Some(1),
..Default::default()
})
.await
.unwrap();
assert_eq!((page2.total, page2.rows.len(), page2.rows[0].codein.as_str()), (3, 1, "900002"));
let recherche =
query_grid_rows(&GridQuery { search: Some(" BOUGIE ".into()), code_fournisseur: Some(F1.into()), ..Default::default() })
.await
.unwrap();
assert_eq!(recherche.total, 1);
let par_centrale = query_grid_rows(&GridQuery { search: Some("10000900003".into()), ..Default::default() }).await.unwrap();
assert_eq!(par_centrale.rows[0].codein, "900003");
let nomenclature =
query_grid_rows(&GridQuery { code_fournisseur: Some(F1.into()), nomenclature: Some("3202".into()), ..Default::default() })
.await
.unwrap();
assert_eq!(nomenclature.total, 2);
// Une valeur hostile reste une valeur liée.
let hostile = query_grid_rows(&GridQuery { code_fournisseur: Some("x' or '1'='1".into()), ..Default::default() }).await.unwrap();
assert_eq!(hostile.total, 0);
// ── gammes, fiches, fournisseurs, fraîcheur, nomenclatures ─────────────
update_grid_rows_gamme(F1, [("900001", "A"), ("900002", "Z"), ("900003", "A")]).await.unwrap();
let gammes = get_grid_rows_gammes(F1, &["900001".into(), "900002".into(), "inconnu".into()]).await.unwrap();
assert_eq!(gammes.len(), 2);
assert_eq!(gammes["900001"].code_gamme.as_deref(), Some("A"));
assert_eq!(gammes["900001"].code_gamme_init.as_deref(), Some("B"));
let fiche = get_grid_row_by_codein("900002", Some(F1)).await.unwrap().unwrap();
assert_eq!(fiche.row.code_gamme.as_deref(), Some("Z"), "payload mis à jour");
assert!(get_grid_row_by_codein("900002", Some("TSTGRID-ABSENT")).await.unwrap().is_none());
let mut chez_f2 = ligne("900001", 1.0, "Bougie parfumée", "320211", 1.0);
chez_f2.code_fournisseur = F2.into();
upsert_grid_rows(F2, &[chez_f2]).await.unwrap();
let fournisseurs: Vec<String> =
list_grid_suppliers_for_codein("900001").await.unwrap().into_iter().map(|f| f.code_fournisseur).collect();
assert!(fournisseurs.contains(&F1.to_string()) && fournisseurs.contains(&F2.to_string()));
let tous = list_grid_suppliers().await.unwrap();
let f1 = tous.iter().find(|s| s.code_fournisseur == F1).unwrap();
assert_eq!((f1.row_count, f1.nom_fournisseur.as_deref()), (3, Some("FOURNISSEUR TEST")));
let fraicheur = get_grid_freshness(F1).await.unwrap().unwrap();
assert_eq!(fraicheur.row_count, 3);
assert!(get_grid_freshness("TSTGRID-ABSENT").await.unwrap().is_none());
let n1 = list_grid_nomenclatures(F1, 1, None).await.unwrap();
assert_eq!(n1.iter().map(|n| (n.code.as_str(), n.nb_articles)).collect::<Vec<_>>(), vec![("32", 2), ("33", 1)]);
assert_eq!(n1[0].libelle.as_deref(), Some("Univers"));
assert_eq!(n1[0].total_ca, 200.0);
let n3 = list_grid_nomenclatures(F1, 3, Some("3202")).await.unwrap();
assert_eq!(n3.iter().map(|n| n.code.as_str()).collect::<Vec<_>>(), vec!["320211", "320212"]);
assert_eq!(n3[0].libelle.as_deref(), Some("Sous-famille 320211"));
// Purge : un nouveau calcul sans l'article 900003 le retire.
tokio::time::sleep(Duration::from_millis(5)).await;
assert_eq!(upsert_grid_rows(F1, &rows[..2]).await.unwrap(), 2);
assert_eq!(get_grid_freshness(F1).await.unwrap().unwrap().row_count, 2);
// ── snapshots (actions) ────────────────────────────────────────────────
let brut = json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": { "900001": { "before": "B", "after": "A" } } });
let anonyme = enregistrer_snapshot("0", &brut).await;
assert_eq!(anonyme["success"], true, "{anonyme}");
let id_anonyme = anonyme["snapshotId"].as_i64().unwrap();
let a_moi = enregistrer_snapshot("987654", &json!({ "codeFournisseur": F1, "magasin": "292", "type": "export", "label": "Mon export", "changes": {}, "summary": { "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 } })).await;
assert_eq!(a_moi["success"], true, "{a_moi}");
let id_a_moi = a_moi["snapshotId"].as_i64().unwrap();
let ids = |v: Vec<super::snapshots::SessionSnapshot>| -> Vec<i64> {
v.into_iter().filter(|s| s.code_fournisseur == F1).map(|s| s.id as i64).collect()
};
assert_eq!(ids(lister_snapshots("0", None).await.unwrap()), vec![id_anonyme]);
assert_eq!(ids(lister_snapshots("abc", None).await.unwrap()), vec![id_anonyme], "identifiant non numérique = anonyme");
assert_eq!(ids(lister_snapshots("987654", Some("export")).await.unwrap()), vec![id_a_moi]);
assert!(ids(lister_snapshots("987654", Some("snapshot")).await.unwrap()).is_empty());
let detail = lister_snapshots("987654", None).await.unwrap().into_iter().find(|s| s.id as i64 == id_a_moi).unwrap();
let detail = serde_json::to_value(detail).unwrap();
assert_eq!(detail["label"], "Mon export");
assert_eq!(detail["userId"], 987654);
assert_eq!(detail["summaryJson"], json!({ "totalRows": 1, "totalCa": 2.5, "totalMarge": 1, "tauxMargeGlobal": 40 }));
assert!(detail["createdAt"].as_str().unwrap().ends_with('Z'));
assert!(lister_snapshots("99999999999", None).await.is_err(), "hors bornes integer : erreur SQL comme en TS");
let label_defaut: (String,) =
sqlx::query_as("select label from session_snapshots where id = $1").bind(id_anonyme as i32).fetch_one(&pool).await.unwrap();
assert!(label_defaut.0.starts_with("Snapshot — "));
let invalide = enregistrer_snapshot("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(
invalide,
json!({ "success": false, "error": "Validation failed: Invalid input: expected string, received undefined, Invalid input: expected record, received undefined" })
);
let trop_grand = enregistrer_snapshot("99999999999", &brut).await;
assert_eq!(trop_grand, json!({ "success": false, "error": "erreur technique sur le serveur" }));
assert_eq!(
supprimer_snapshot("0", &json!(id_a_moi)).await,
json!({ "success": false, "error": "Snapshot non trouvé ou non autorisé" })
);
assert_eq!(supprimer_snapshot("987654", &json!(id_a_moi)).await, json!({ "success": true }));
assert_eq!(supprimer_snapshot("0", &json!("abc")).await, json!({ "success": false, "error": "erreur technique sur le serveur" }));
// ── enregistrerGammes / saveDraftChanges ───────────────────────────────
rows_cache::invalidate_grid_rows_cache(None);
let en_cache = rows_cache::GridRows::new(rows[..2].to_vec());
rows_cache::cache_grid_rows(&rows_cache::grid_rows_cache_key(F1, None), en_cache.clone());
let n = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: F1.into(),
nom_fournisseur: Some("FOURNISSEUR TEST".into()),
magasin: "TOTAL".into(),
changes: vec![ChangementGamme { codein: "900002".into(), code_gamme_before: Some("B".into()), code_gamme: "C".into() }],
user_id: None,
label: None,
})
.await
.unwrap();
assert_eq!(n, 1);
let dernier: (Value, Option<String>) =
sqlx::query_as("select changes, label from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
// Fusion par-dessus le dernier snapshot du fournisseur (celui de 987654 a été supprimé).
assert_eq!(dernier.0, json!({ "900001": { "before": "B", "after": "A" }, "900002": { "before": "B", "after": "C" } }));
assert!(dernier.1.unwrap().starts_with("Draft — "));
assert_eq!(en_cache.rows.read()[0].code_gamme.as_deref(), Some("C"), "cache patché (900002)");
// Le report dans grid_rows est asynchrone.
let mut vu = false;
for _ in 0..50 {
let g = get_grid_rows_gammes(F1, &["900002".into()]).await.unwrap();
if g["900002"].code_gamme.as_deref() == Some("C") {
vu = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(vu, "grid_rows mis à jour en arrière-plan");
// ── gammes à valider ───────────────────────────────────────────────────
let p = |c: &str, g: &str| PropositionGamme { codein: c.into(), gamme: g.into(), gamme_precedente: Some("B".into()) };
proposer_gammes(F1, &[p("900001", "Z"), p("900002", "A")], &[], &"x".repeat(150)).await.unwrap();
proposer_gammes(F1, &[p("900002", "Y")], &["900001".into()], "api-key").await.unwrap();
let liste = lister_gammes_a_valider(F1).await.unwrap();
assert_eq!(liste.len(), 1);
assert_eq!((liste[0].codein.as_str(), liste[0].gamme.as_str(), liste[0].auteur.as_deref()), ("900002", "Y", Some("api-key")));
assert!(liste[0].proposee_le.ends_with('Z'));
let par_article = gammes_a_valider_par_article(&["900002".into(), "900009".into()]).await.unwrap();
assert_eq!(par_article.get(&format!("{F1}|900002")).map(String::as_str), Some("Y"));
proposer_gammes(F1, &[p("900001", "Z")], &[], &"x".repeat(150)).await.unwrap();
let auteur: (String,) = sqlx::query_as("select auteur from gammes_a_valider where code_fournisseur = $1 and codein = '900001'")
.bind(F1)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(auteur.0.len(), 100);
// saveDraftChanges : les propositions traitées quittent la file.
let res = enregistrer_brouillon(
"987654",
&json!({ "codeFournisseur": F1, "magasin": "TOTAL", "changes": [{ "codein": "900002", "codeGammeBefore": "C", "codeGamme": "Y" }], "gammesAValiderTraitees": ["900002"] }),
)
.await;
assert_eq!(res, json!({ "success": true, "saved": 1 }));
let restantes: Vec<String> = lister_gammes_a_valider(F1).await.unwrap().into_iter().map(|g| g.codein).collect();
assert_eq!(restantes, vec!["900001".to_string()]);
supprimer_gammes_a_valider(F1, &["900001".into()]).await.unwrap();
assert!(lister_gammes_a_valider(F1).await.unwrap().is_empty());
let invalide = enregistrer_brouillon("0", &json!({ "codeFournisseur": F1 })).await;
assert_eq!(invalide["success"], false);
assert!(invalide["error"].as_str().unwrap().starts_with("Validation failed: [\n {\n \"expected\": \"string\""));
// ── route /api/v1/nomenclatures (sans HTTP : schéma + requêtes) ────────
let params = query_map(vec![("fournisseur".into(), F1.into()), ("niveau".into(), "2".into())]);
let parsed = super::api_schemas::parse_nomenclatures_query(&params).unwrap();
let postes = list_grid_nomenclatures(&parsed.fournisseur, parsed.niveau, None).await.unwrap();
assert_eq!(postes.iter().map(|p| p.code.as_str()).collect::<Vec<_>>(), vec!["3202"]);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}
+354
View File
@@ -0,0 +1,354 @@
//! CollectFlow - Core Grid Types (port de `src/types/grid.ts`).
//!
//! `ProductRow` circule tel quel en JSON : vers la Grille, dans `grid_rows.payload`
//! (jsonb) et dans l'API `/api/v1`. Les attributs serde reproduisent
//! `JSON.stringify` : noms camelCase, champs facultatifs omis quand ils sont
//! `undefined`, `null` conservé pour les champs `| null`, entiers sans décimale.
//!
//! Lecture tolérante : un payload écrit par la version Node se relit sans perte —
//! champ manquant → valeur par défaut, `null` numérique → `NaN` (réécrit `null`),
//! champ inconnu conservé dans `extra` et réécrit à l'identique.
use super::js;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
/// A/B/C/Z status codes for product categories, but can be any string from DB (like 'Aucune')
pub type GammeCode = String;
/// Série mensuelle `{ "YYYYMM": valeur }` (ou `"YYYY-MM"` pour le réseau Qlik).
pub type Serie = BTreeMap<String, f64>;
/// Ventilation par magasin puis par mois.
pub type SerieParMagasin = BTreeMap<String, Serie>;
/// A product row with its 12-month sales history and computed KPIs.
///
/// Ordre des champs = ordre de construction dans `getProductRows` (donc celui du
/// JSON produit par le calcul en direct).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProductRow {
#[serde(deserialize_with = "js::string::deserialize")]
pub codein: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code_fournisseur: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub nom_fournisseur: String,
/// Dernier fournisseur enregistré pour cet article (artfou1.no_id DESC). Peut différer du fournisseur consulté.
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fournisseur_principal_nom: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub gtin: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub reference: String,
/// Code centrale (clé jointure Qlik, format 10000XXXXXX)
#[serde(skip_serializing_if = "Option::is_none")]
pub code_centrale: Option<String>,
#[serde(deserialize_with = "js::string::deserialize")]
pub code1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau1: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle_niveau2: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub code3: String,
#[serde(deserialize_with = "js::string::deserialize")]
pub libelle3: String,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
/// Draft gamme value before saving
pub code_gamme_draft: Option<GammeCode>,
/// 12-month sales quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub sales12m: Serie,
/// End-of-month stock levels, indexed by YYYYMM key
#[serde(with = "js::map")]
pub stock12m: Serie,
/// Monthly reception quantities, indexed by YYYYMM key
#[serde(with = "js::map")]
pub receptions12m: Serie,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge: f64,
pub working_stores: Vec<String>,
/// SQL Server internal ID — nécessaire pour /api/articles/:noid/mensuel
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub noid: Option<f64>,
/// Conditionnement (pack size)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pcb: Option<f64>,
/// Prix de vente pratiqué, en euros (le plus élevé quand les magasins divergent :
/// `prixVenteByStore` porte le détail).
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_vente: Option<f64>,
/// Per-store breakdown (site → YYYYMM → qty) — always present even in TOTAL mode
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub sales12m_by_store: Option<SerieParMagasin>,
/// Per-store stock breakdown (site → YYYYMM → stock fin de mois)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub stock12m_by_store: Option<SerieParMagasin>,
/// Per-store reception breakdown (site → YYYYMM → qté reçue)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_nested_map")]
pub receptions12m_by_store: Option<SerieParMagasin>,
/// Per-store CA totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub ca_by_store: Option<Serie>,
/// Per-store quantity totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub quantite_by_store: Option<Serie>,
/// Per-store margin totals
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub marge_by_store: Option<Serie>,
/// Qté en commande fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub commandes_en_cours: Option<f64>,
/// Stock dispo agrégé tous sites
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_actuel: Option<f64>,
/// Qte en stock agrégé
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_total: Option<f64>,
/// Valeur stock agrégée
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub stock_valeur: Option<f64>,
/// PRMP (alias prixAchat)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub pa: Option<f64>,
/// PA / PRMP
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub prix_achat: Option<f64>,
/// ISO date de dernière vente
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_vente: Option<String>,
/// ISO date de dernière réception
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison: Option<String>,
/// Per-store last delivery/reception date (site → ISO date string)
#[serde(skip_serializing_if = "Option::is_none")]
pub derniere_livraison_by_store: Option<BTreeMap<String, String>>,
/// Prix de vente par magasin (site → PV) — `cube_pv`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")]
pub prix_vente_by_store: Option<Serie>,
/// Données réseau Qlik (~270 magasins)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub qte_reseau: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub nb_magasins_reseau: Option<f64>,
/// CA moyen par magasin réseau (productivité normalisée présence)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_par_magasin_reseau: Option<f64>,
/// Taux de marge réseau (ratio brut Qlik, ex 0.32 = 32%)
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub marge_pct_reseau: Option<f64>,
/// Taux de présence réseau = nbMagasinsReseau / 270
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub taux_presence_reseau: Option<f64>,
/// Quantité vendue réseau par mois : { "YYYY-MM": qté } (tendance / modal).
/// `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub qte_reseau_by_month: Option<Option<Serie>>,
/// Nombre de magasins vendeurs par mois : { "YYYY-MM": nb } (deuxième courbe de
/// la carte tendance). `None` = absent, `Some(None)` = `null`.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_null_map")]
pub nb_mag_reseau_by_month: Option<Option<Serie>>,
/// Fraîcheur des données réseau (ISO)
#[serde(skip_serializing_if = "Option::is_none")]
pub network_fetched_at: Option<String>,
/// Rotation normalisée : unités vendues / magasin / mois
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub units_per_store_per_month: Option<f64>,
/// CA normalisé : € / magasin / an
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub ca_per_store_per_year: Option<f64>,
/// Produit récent (≤ 3 mois de données)
#[serde(skip_serializing_if = "Option::is_none")]
pub is_recent: Option<bool>,
/// Seul produit du fournisseur dans le lot
#[serde(skip_serializing_if = "Option::is_none")]
pub is_last_product: Option<bool>,
/// Top 30% CA du lot fournisseur
#[serde(skip_serializing_if = "Option::is_none")]
pub is_top30_supplier: Option<bool>,
/// Moyennes de comparaison pour l'IA
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_fournisseur: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub avg_qty_rayon: Option<f64>,
/// Poids relatifs (%) du produit chez le fournisseur
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_ca: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_marge: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub share_qty: Option<f64>,
/// Référentiels globaux
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub total_fournisseur_ca: Option<f64>,
/// Version du format de la ligne, posée à l'écriture dans `grid_rows`. Une
/// ligne d'une version antérieure n'est pas servie telle quelle à la Grille
/// (des champs récents lui manqueraient) : elle est recalculée.
#[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")]
pub payload_version: Option<f64>,
/// Champs inconnus de ce type (payload écrit par une autre version) : conservés
/// et réécrits tels quels.
#[serde(flatten)]
pub extra: Map<String, Value>,
}
/// Summary bar totals for the currently visible/filtered rows
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridSummary {
#[serde(with = "js::num")]
pub total_products: f64,
#[serde(with = "js::num")]
pub total_rows: f64,
#[serde(with = "js::num")]
pub total_quantite: f64,
#[serde(with = "js::num")]
pub total_ca: f64,
#[serde(with = "js::num")]
pub total_marge: f64,
#[serde(with = "js::num")]
pub taux_marge_global: f64,
}
/// Filters applied to the grid
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct GridFilters {
pub magasin: Option<String>,
pub code_fournisseur: Option<String>,
pub code1: Option<String>,
pub code2: Option<String>,
/// Nomenclatures retenues (codes `code3`).
/// `None` = aucun filtre (tout s'affiche) ; `Some(vec![])` = rien de coché.
pub code3: Option<Vec<String>>,
pub code_gamme: Option<GammeCode>,
pub search: String,
}
/// One row in the data grid, representing a product at a store for one period.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct VenteProduit {
#[serde(with = "js::num")]
pub id: f64,
pub codein: String,
pub code_fournisseur: Option<String>,
pub nom_fournisseur: Option<String>,
pub libelle1: Option<String>,
pub gtin: Option<String>,
pub reference: Option<String>,
pub code_gamme: Option<GammeCode>,
pub code_gamme_init: Option<GammeCode>,
pub code3: Option<String>,
pub libelle3: Option<String>,
pub magasin: String,
pub code_magasin: Option<String>,
pub annee: Option<f64>,
pub mois: Option<f64>,
pub periode: String,
pub quantite: Option<f64>,
pub montant_mvt: Option<f64>,
pub marge_mvt: Option<f64>,
}
// ---------------------------------------------------------------------------
// Complément par magasin (`src/features/grid/lib/store-patch.ts`)
// ---------------------------------------------------------------------------
/// Chiffres d'un article pour le magasin complété.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatchEntry {
pub codein: String,
/// Ventes par mois (« YYYYMM »).
#[serde(with = "js::map")]
pub sales: Serie,
/// Stock de fin de mois, reporté sur les mois sans donnée.
#[serde(with = "js::map")]
pub stock: Serie,
#[serde(with = "js::map")]
pub receptions: Serie,
#[serde(with = "js::num")]
pub quantite: f64,
#[serde(with = "js::num")]
pub ca: f64,
#[serde(with = "js::num")]
pub marge: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct StorePatch {
pub magasin: String,
/// Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré.
pub periods: Vec<String>,
pub entries: Vec<StorePatchEntry>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Ligne telle que l'écrit la version Node (ordre jsonb, null, champ inconnu).
fn payload_node() -> Value {
json!({
"pa": 2.5, "pcb": 6, "gtin": "3700000000001", "noid": 4242,
"code1": "32", "code2": "3202", "code3": "320211",
"codein": "123456", "libelle1": "Bougie parfumée", "libelle3": "Bougies",
"sales12m": { "202510": 3, "202511": 0 }, "stock12m": { "202510": 10, "202511": 10 },
"totalCa": 37.5, "caByStore": { "292": 37.5 }, "codeGamme": "A",
"reference": "REF-1", "tauxMarge": 41.2, "prixAchat": 2.5, "totalMarge": 15.45,
"codeCentrale": "10000123456", "codeGammeInit": "B", "codeGammeDraft": null,
"nomFournisseur": "FOURNISSEUR TEST", "receptions12m": { "202510": 0, "202511": 0 },
"totalQuantite": 3, "workingStores": ["292"], "libelleNiveau1": "Déco", "libelleNiveau2": "Senteurs",
"codeFournisseur": "F001", "payloadVersion": 1, "sales12mByStore": { "292": { "202510": 3 }, "579": {} },
"qteReseauByMonth": null, "margePctReseau": null, "champFutur": { "x": [1, 2] },
"derniereLivraisonByStore": { "292": "2025-10-03" }
})
}
#[test]
fn aller_retour_payload_node() {
let brut = payload_node();
let row: ProductRow = serde_json::from_value(brut.clone()).unwrap();
assert_eq!(row.codein, "123456");
assert_eq!(row.noid, Some(4242.0));
assert_eq!(row.code_gamme_draft, None);
assert_eq!(row.qte_reseau_by_month, Some(None));
assert!(row.marge_pct_reseau.unwrap().is_nan());
assert!(row.nb_mag_reseau_by_month.is_none());
assert_eq!(row.extra.get("champFutur"), Some(&json!({ "x": [1, 2] })));
// Même contenu JSON après relecture et réécriture.
let reecrit = serde_json::to_value(&row).unwrap();
assert_eq!(reecrit, brut);
}
#[test]
fn champs_facultatifs_omis_et_entiers_sans_decimale() {
let row = ProductRow { codein: "1".into(), total_quantite: 12.0, total_ca: 10.5, ..Default::default() };
let s = serde_json::to_string(&row).unwrap();
assert!(s.starts_with(r#"{"codein":"1","codeFournisseur":"","nomFournisseur":"","libelle1":"""#));
assert!(s.contains(r#""codeGamme":null,"codeGammeInit":null,"codeGammeDraft":null"#));
assert!(s.contains(r#""totalQuantite":12,"totalCa":10.5"#));
assert!(!s.contains("noid"));
assert!(!s.contains("payloadVersion"));
}
}
+362
View File
@@ -0,0 +1,362 @@
//! Validation façon zod (v4) : mêmes contrôles, mêmes « issues ».
//!
//! Les routes `/api/v1` renvoient `parsed.error.issues` dans `error.details`, et
//! certaines actions recopient `parsed.error.message` (le JSON indenté des issues)
//! dans leur message d'erreur. Pour que l'appelant reçoive exactement le même
//! détail, on reproduit la forme des issues de zod 4 : clés dans le même ordre,
//! mêmes messages par défaut, même ordre de parcours (celui du schéma).
use super::js;
use serde_json::{json, Map, Value};
/// Plus grand entier « sûr » de JS (`Number.MAX_SAFE_INTEGER`).
pub const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
/// Segment de chemin d'une issue : clé d'objet ou indice de tableau.
#[derive(Debug, Clone)]
pub enum Seg {
K(String),
I(usize),
}
impl From<&str> for Seg {
fn from(s: &str) -> Self {
Seg::K(s.to_string())
}
}
impl From<usize> for Seg {
fn from(i: usize) -> Self {
Seg::I(i)
}
}
pub type Path = Vec<Seg>;
pub fn path_json(path: &[Seg]) -> Value {
Value::Array(
path.iter()
.map(|s| match s {
Seg::K(k) => Value::String(k.clone()),
Seg::I(i) => Value::from(*i),
})
.collect(),
)
}
pub fn child(path: &[Seg], seg: impl Into<Seg>) -> Path {
let mut p = path.to_vec();
p.push(seg.into());
p
}
/// Type reçu, tel que zod le nomme dans ses messages.
pub fn received(v: Option<&Value>) -> &'static str {
match v {
None => "undefined",
Some(Value::Null) => "null",
Some(Value::Bool(_)) => "boolean",
Some(Value::Number(_)) => "number",
Some(Value::String(_)) => "string",
Some(Value::Array(_)) => "array",
Some(Value::Object(_)) => "object",
}
}
/// Nombre affiché dans un message (`String(n)`).
fn n(x: f64) -> String {
js::js_number_to_string(x)
}
fn num_json(x: f64) -> Value {
if x.fract() == 0.0 && x.abs() <= MAX_SAFE_INTEGER {
Value::from(x as i64)
} else {
serde_json::Number::from_f64(x).map(Value::Number).unwrap_or(Value::Null)
}
}
/// Liste d'issues, dans l'ordre où zod les produit.
#[derive(Debug, Default, Clone)]
pub struct Issues(pub Vec<Value>);
impl Issues {
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
fn push(&mut self, mut issue: Map<String, Value>, path: &[Seg], message: String) {
issue.insert("path".into(), path_json(path));
issue.insert("message".into(), Value::String(message));
self.0.push(Value::Object(issue));
}
fn obj(v: Value) -> Map<String, Value> {
match v {
Value::Object(m) => m,
_ => Map::new(),
}
}
/// `invalid_type` : type attendu absent ou différent.
pub fn invalid_type(&mut self, path: &[Seg], expected: &str, got: Option<&Value>) {
let issue = Self::obj(json!({ "expected": expected, "code": "invalid_type" }));
self.push(issue, path, format!("Invalid input: expected {expected}, received {}", received(got)));
}
/// `invalid_type` d'un nombre non fini (`NaN`, `Infinity`) après coercition.
pub fn invalid_number(&mut self, path: &[Seg], x: f64) {
let recu = if x.is_nan() { "NaN" } else { "Infinity" };
let issue = Self::obj(json!({ "expected": "number", "code": "invalid_type", "received": recu }));
let msg = if x.is_nan() { "received NaN" } else { "received number" };
self.push(issue, path, format!("Invalid input: expected number, {msg}"));
}
/// `.int()` sur un nombre à décimales.
pub fn not_int(&mut self, path: &[Seg]) {
let issue = Self::obj(json!({ "expected": "int", "format": "safeint", "code": "invalid_type" }));
self.push(issue, path, "Invalid input: expected int, received number".into());
}
/// `.int()` hors de l'intervalle des entiers sûrs.
pub fn unsafe_int(&mut self, path: &[Seg], trop_grand: bool) {
let (code, cle, borne, sens, op) = if trop_grand {
("too_big", "maximum", MAX_SAFE_INTEGER, "Too big", "<=")
} else {
("too_small", "minimum", -MAX_SAFE_INTEGER, "Too small", ">=")
};
let mut issue = Map::new();
issue.insert("code".into(), json!(code));
issue.insert(cle.into(), num_json(borne));
issue.insert("note".into(), json!("Integers must be within the safe integer range."));
issue.insert("origin".into(), json!("int"));
issue.insert("inclusive".into(), json!(true));
self.push(issue, path, format!("{sens}: expected int to be {op}{}", n(borne)));
}
/// `too_small` (`origin` : `string`, `number` ou `array`).
pub fn too_small(&mut self, path: &[Seg], origin: &str, minimum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_small"));
issue.insert("minimum".into(), num_json(minimum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too small: expected string to have >={} characters", n(minimum)),
"array" => format!("Too small: expected array to have >={} items", n(minimum)),
_ => format!("Too small: expected {origin} to be >={}", n(minimum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `too_big` (`origin` : `string`, `number` ou `array`).
pub fn too_big(&mut self, path: &[Seg], origin: &str, maximum: f64, message: Option<&str>) {
let mut issue = Map::new();
issue.insert("origin".into(), json!(origin));
issue.insert("code".into(), json!("too_big"));
issue.insert("maximum".into(), num_json(maximum));
issue.insert("inclusive".into(), json!(true));
let defaut = match origin {
"string" => format!("Too big: expected string to have <={} characters", n(maximum)),
"array" => format!("Too big: expected array to have <={} items", n(maximum)),
_ => format!("Too big: expected {origin} to be <={}", n(maximum)),
};
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_value` d'un `z.enum([...])`.
pub fn invalid_value(&mut self, path: &[Seg], values: &[&str], message: Option<&str>) {
let issue = Self::obj(json!({ "code": "invalid_value", "values": values }));
let defaut = format!("Invalid option: expected one of {}", values.iter().map(|v| format!("\"{v}\"")).collect::<Vec<_>>().join("|"));
self.push(issue, path, message.map(str::to_string).unwrap_or(defaut));
}
/// `invalid_format` d'un `.regex(...)`.
pub fn invalid_regex(&mut self, path: &[Seg], pattern: &str, message: &str) {
let issue = Self::obj(json!({ "origin": "string", "code": "invalid_format", "format": "regex", "pattern": pattern }));
self.push(issue, path, message.to_string());
}
/// `custom` d'un `.refine(...)`.
pub fn custom(&mut self, path: &[Seg], message: &str) {
let issue = Self::obj(json!({ "code": "custom" }));
self.push(issue, path, message.to_string());
}
/// Les issues, telles que `parsed.error.issues`.
pub fn to_json(&self) -> Value {
Value::Array(self.0.clone())
}
/// `parsed.error.message` : `JSON.stringify(issues, null, 2)`.
pub fn message(&self) -> String {
serde_json::to_string_pretty(&self.0).unwrap_or_default()
}
/// `issues.map((i) => i.message).join(", ")`.
pub fn messages_joined(&self) -> String {
self.0.iter().map(|i| i.get("message").and_then(Value::as_str).unwrap_or_default().to_string()).collect::<Vec<_>>().join(", ")
}
}
// ---------------------------------------------------------------------------
// Briques de schéma
// ---------------------------------------------------------------------------
/// `z.object(...)` : la racine doit être un objet. `None` (issue posée) sinon.
pub fn object<'a>(v: Option<&'a Value>, path: &[Seg], issues: &mut Issues) -> Option<&'a Map<String, Value>> {
match v {
Some(Value::Object(m)) => Some(m),
autre => {
issues.invalid_type(path, "object", autre);
None
}
}
}
/// `z.string()` obligatoire.
pub fn string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<String> {
match v {
Some(Value::String(s)) => Some(s.clone()),
autre => {
issues.invalid_type(path, "string", autre);
None
}
}
}
/// `z.string().optional()` : `Ok(None)` si absent.
pub fn opt_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
None => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// `z.string().nullable()`.
pub fn nullable_string(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<Option<String>> {
match v {
Some(Value::Null) => Some(None),
autre => string(autre, path, issues).map(Some),
}
}
/// Bornes de longueur d'une chaîne (`.min(n)`, `.max(n)`), en unités UTF-16 comme JS.
pub fn check_len(
s: &str,
path: &[Seg],
min: Option<(usize, Option<&str>)>,
max: Option<(usize, Option<&str>)>,
issues: &mut Issues,
) -> bool {
let len = s.encode_utf16().count();
let mut ok = true;
if let Some((m, msg)) = min {
if len < m {
issues.too_small(path, "string", m as f64, msg);
ok = false;
}
}
if let Some((m, msg)) = max {
if len > m {
issues.too_big(path, "string", m as f64, msg);
ok = false;
}
}
ok
}
/// `z.number()` (pas de coercition) : nombre fini.
pub fn number(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<f64> {
match v {
Some(Value::Number(x)) => x.as_f64(),
autre => {
issues.invalid_type(path, "number", autre);
None
}
}
}
/// `z.boolean()`.
pub fn boolean(v: Option<&Value>, path: &[Seg], issues: &mut Issues) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
autre => {
issues.invalid_type(path, "boolean", autre);
None
}
}
}
/// `z.enum([...])` sur une valeur quelconque.
pub fn enum_value(v: Option<&Value>, values: &[&str], path: &[Seg], message: Option<&str>, issues: &mut Issues) -> Option<String> {
match v.and_then(Value::as_str) {
Some(s) if values.contains(&s) => Some(s.to_string()),
_ => {
issues.invalid_value(path, values, message);
None
}
}
}
/// `z.coerce.number().int().min(min)[.max(max)]` sur un paramètre d'URL.
pub fn coerce_int(s: &str, path: &[Seg], min: f64, max: Option<f64>, issues: &mut Issues) -> Option<i64> {
let x = js::js_number_from_str(s);
if !x.is_finite() {
issues.invalid_number(path, x);
return None;
}
if x.fract() != 0.0 {
issues.not_int(path);
return None;
}
let mut ok = true;
if x > MAX_SAFE_INTEGER {
issues.unsafe_int(path, true);
ok = false;
} else if x < -MAX_SAFE_INTEGER {
issues.unsafe_int(path, false);
ok = false;
}
if x < min {
issues.too_small(path, "number", min, None);
ok = false;
}
if let Some(m) = max {
if x > m {
issues.too_big(path, "number", m, None);
ok = false;
}
}
// `-0` reste un entier valide pour zod ; il ne peut de toute façon pas
// franchir un minimum de 1.
ok.then_some(x as i64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn forme_des_issues() {
let mut i = Issues::default();
let p = vec![Seg::from("niveau")];
assert!(coerce_int("abc", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1e20", &p, 1.0, Some(3.0), &mut i).is_none());
assert!(coerce_int("1.5", &p, 1.0, Some(3.0), &mut i).is_none());
assert_eq!(coerce_int(" 0x2 ", &p, 1.0, Some(3.0), &mut i), Some(2));
assert_eq!(
serde_json::to_string(&i.to_json()).unwrap(),
r#"[{"expected":"number","code":"invalid_type","received":"NaN","path":["niveau"],"message":"Invalid input: expected number, received NaN"},{"code":"too_big","maximum":9007199254740991,"note":"Integers must be within the safe integer range.","origin":"int","inclusive":true,"path":["niveau"],"message":"Too big: expected int to be <=9007199254740991"},{"origin":"number","code":"too_big","maximum":3,"inclusive":true,"path":["niveau"],"message":"Too big: expected number to be <=3"},{"expected":"int","format":"safeint","code":"invalid_type","path":["niveau"],"message":"Invalid input: expected int, received number"}]"#
);
}
#[test]
fn message_indente_comme_json_stringify() {
let mut i = Issues::default();
i.invalid_type(&[], "object", Some(&Value::Null));
assert_eq!(
i.message(),
"[\n {\n \"expected\": \"object\",\n \"code\": \"invalid_type\",\n \"path\": [],\n \"message\": \"Invalid input: expected object, received null\"\n }\n]"
);
}
}