From 22a6c594882f5b6d5b13ab12b6f949d04dc2ab77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 19:33:53 +0000 Subject: [PATCH] =?UTF-8?q?Serveur=20Rust=20:=20module=20grid,=20phase=201?= =?UTF-8?q?=20(instantan=C3=A9,=20snapshots,=20gammes,=20exports,=20API)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf --- server/src/grid/api_schemas.rs | 488 ++++++++++++ server/src/grid/api_v1.rs | 68 ++ server/src/grid/enregistrer.rs | 293 +++++++ server/src/grid/exports.rs | 402 ++++++++++ server/src/grid/gammes.rs | 70 ++ server/src/grid/gammes_a_valider.rs | 129 +++ server/src/grid/js.rs | 472 +++++++++++ server/src/grid/mod.rs | 42 +- server/src/grid/months.rs | 97 +++ server/src/grid/network_trend.rs | 350 +++++++++ server/src/grid/openapi.rs | 1133 +++++++++++++++++++++++++++ server/src/grid/rows_cache.rs | 273 +++++++ server/src/grid/session.rs | 95 +++ server/src/grid/snapshots.rs | 407 ++++++++++ server/src/grid/store.rs | 699 +++++++++++++++++ server/src/grid/store_patch.rs | 109 +++ server/src/grid/tests_db.rs | 298 +++++++ server/src/grid/types.rs | 354 +++++++++ server/src/grid/zod.rs | 362 +++++++++ 19 files changed, 6139 insertions(+), 2 deletions(-) create mode 100644 server/src/grid/api_schemas.rs create mode 100644 server/src/grid/api_v1.rs create mode 100644 server/src/grid/enregistrer.rs create mode 100644 server/src/grid/exports.rs create mode 100644 server/src/grid/gammes.rs create mode 100644 server/src/grid/gammes_a_valider.rs create mode 100644 server/src/grid/js.rs create mode 100644 server/src/grid/months.rs create mode 100644 server/src/grid/network_trend.rs create mode 100644 server/src/grid/openapi.rs create mode 100644 server/src/grid/rows_cache.rs create mode 100644 server/src/grid/session.rs create mode 100644 server/src/grid/snapshots.rs create mode 100644 server/src/grid/store.rs create mode 100644 server/src/grid/store_patch.rs create mode 100644 server/src/grid/tests_db.rs create mode 100644 server/src/grid/types.rs create mode 100644 server/src/grid/zod.rs diff --git a/server/src/grid/api_schemas.rs b/server/src/grid/api_schemas.rs new file mode 100644 index 0000000..fa14d5a --- /dev/null +++ b/server/src/grid/api_schemas.rs @@ -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 { + pairs.into_iter().collect() +} + +fn p(k: &str) -> Vec { + vec![Seg::from(k)] +} + +/// `z.string().min(1[, message]).optional()` sur un paramètre d'URL. +fn opt_non_vide(q: &HashMap, k: &str, message: Option<&str>, issues: &mut Issues) -> Option { + 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, k: &str, message: &str, issues: &mut Issues) -> Option { + 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, k: &str, values: &[&str], defaut: Option<&str>, issues: &mut Issues) -> Option { + 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, k: &str, defaut: Option, max: Option, issues: &mut Issues) -> Option { + 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, + /// `"asc"` | `"desc"` (défaut `"desc"`). + pub order: String, + /// Liste de champs de ProductRow à conserver, séparés par des virgules. + pub fields: Option, + /// `"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, 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, + pub code1: Option, + pub code2: Option, + pub code3: Option, + /// Préfixe de nomenclature (« 32 », « 3202 », « 320211 »), tel que renvoyé par + /// /api/v1/nomenclatures. À préférer à code1/code2. + pub nomenclature: Option, + pub search: Option, + /// `"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, +} + +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) -> Result { + 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, + pub gamme: Option, + pub sort: SortParams, + pub page: i64, + /// Défaut 100, sans plafond. + pub limit: i64, +} + +/// `productSearchSchema.safeParse(...)`. +pub fn parse_product_search(q: &HashMap) -> Result { + 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, + /// `"1"` = uniquement les fournisseurs déjà présents dans l'instantané de grille. + pub with_data: Option, +} + +/// `fournisseursQuerySchema.safeParse(...)`. +pub fn parse_fournisseurs_query(q: &HashMap) -> Result { + 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, +} + +/// `nomenclaturesQuerySchema.safeParse(...)`. +pub fn parse_nomenclatures_query(q: &HashMap) -> Result { + 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, + /// `"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) -> Result { + 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 { + 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, k: &str, issues: &mut Issues) -> Option { + 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, + /// `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 { + 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, + pub compute: bool, +} + +/// `gammesBatchBodySchema.safeParse(corps)`. +pub fn parse_gammes_batch_body(corps: &Value) -> Result { + 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 { + sort.and_then(GridSortKey::from_key) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn q(pairs: &[(&str, &str)]) -> HashMap { + 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); + } +} diff --git a/server/src/grid/api_v1.rs b/server/src/grid/api_v1.rs new file mode 100644 index 0000000..b22f845 --- /dev/null +++ b/server/src/grid/api_v1.rs @@ -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>) -> Response { + let params = query_map(params); + let parsed = match parse_nomenclatures_query(¶ms) { + 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)) +} diff --git a/server/src/grid/enregistrer.rs b/server/src/grid/enregistrer.rs new file mode 100644 index 0000000..e4ed60e --- /dev/null +++ b/server/src/grid/enregistrer.rs @@ -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, + pub code_gamme: String, +} + +#[derive(Debug, Clone, Default)] +pub struct EnregistrementGammes { + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub changes: Vec, + pub user_id: Option, + /// Libellé du snapshot, « Draft — date » par défaut. + pub label: Option, +} + +/// 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 { + 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 = match existing.map(|r| r.try_get::("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, + pub magasin: String, + pub changes: Vec, + /// 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, +} + +/// `z.array(z.string())`. +fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option> { + 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 { + 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 = 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 { + 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 { + 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), 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 { + 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); + } +} diff --git a/server/src/grid/exports.rs b/server/src/grid/exports.rs new file mode 100644 index 0000000..c38ffe7 --- /dev/null +++ b/server/src/grid/exports.rs @@ -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 ``. +/// 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 { + 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 { + 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, filename: &str) -> AppResult { + 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, + pub total_quantite: f64, + pub total_ca: f64, + pub taux_marge: f64, + /// Before gamme for delta + pub gamme_avant: Option, +} + +/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`. +pub fn parse_export(body: &Value) -> Result<(String, Vec), 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 { + 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, 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 { + 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, + pub gamme: String, +} + +/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`. +pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec), 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, 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, ¢re)?; + + 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), ¢re)?; + } + + workbook.save_to_buffer() +} + +/// `POST /api/export/modified-gammes`. +pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult { + 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()); + } +} diff --git a/server/src/grid/gammes.rs b/server/src/grid/gammes.rs new file mode 100644 index 0000000..6293ad9 --- /dev/null +++ b/server/src/grid/gammes.rs @@ -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()); + } +} diff --git a/server/src/grid/gammes_a_valider.rs b/server/src/grid/gammes_a_valider.rs new file mode 100644 index 0000000..ae69276 --- /dev/null +++ b/server/src/grid/gammes_a_valider.rs @@ -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, +} + +#[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, + pub auteur: Option, + /// 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 = + 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> { + 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::("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> { + 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(()) +} diff --git a/server/src/grid/js.rs b/server/src/grid/js.rs new file mode 100644 index 0000000..f47f43f --- /dev/null +++ b/server/src/grid/js.rs @@ -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 = + 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::().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::().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::>() + .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::().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 { + 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::().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) -> Map { + 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(&self, s: S) -> Result { + 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(v: &f64, s: S) -> Result { + serde::Serialize::serialize(&JsNum(*v), s) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) + } +} + +/// `Option` d'un champ facultatif (présent à `null` → `Some(NaN)`). +pub mod opt_num { + use super::*; + pub fn serialize(v: &Option, s: S) -> Result { + match v { + Some(x) => serde::Serialize::serialize(&JsNum(*x), s), + None => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Some(Option::::deserialize(d)?.unwrap_or(f64::NAN))) + } +} + +fn ser_map(m: &BTreeMap, s: S) -> Result { + 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, D::Error> { + let brut = Option::>>::deserialize(d)?.unwrap_or_default(); + Ok(brut.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()) +} + +/// `Record` obligatoire. +pub mod map { + use super::*; + pub fn serialize(m: &BTreeMap, s: S) -> Result { + ser_map(m, s) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + de_map(d) + } +} + +/// `Record` facultatif. +pub mod opt_map { + use super::*; + pub fn serialize(m: &Option>, s: S) -> Result { + match m { + Some(m) => ser_map(m, s), + None => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let brut = Option::>>::deserialize(d)?; + Ok(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect())) + } +} + +/// `Record | null` facultatif : absent (`None`), `null` +/// (`Some(None)`) ou renseigné. +pub mod opt_null_map { + use super::*; + pub fn serialize(m: &Option>>, s: S) -> Result { + match m { + Some(Some(m)) => ser_map(m, s), + _ => s.serialize_none(), + } + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>>, D::Error> { + let brut = Option::>>::deserialize(d)?; + Ok(Some(brut.map(|m| m.into_iter().map(|(k, v)| (k, v.unwrap_or(f64::NAN))).collect()))) + } +} + +/// `Record>` facultatif (ventilations par magasin). +pub mod opt_nested_map { + use super::*; + pub type Nested = BTreeMap>; + + struct Interne<'a>(&'a BTreeMap); + impl serde::Serialize for Interne<'_> { + fn serialize(&self, s: S) -> Result { + ser_map(self.0, s) + } + } + + pub fn serialize(m: &Option, s: S) -> Result { + 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, D::Error> { + let brut = Option::>>>>::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 { + Ok(Option::::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"); + } +} diff --git a/server/src/grid/mod.rs b/server/src/grid/mod.rs index 5687605..75a9cef 100644 --- a/server/src/grid/mod.rs +++ b/server/src/grid/mod.rs @@ -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. diff --git a/server/src/grid/months.rs b/server/src/grid/months.rs new file mode 100644 index 0000000..a93ebab --- /dev/null +++ b/server/src/grid/months.rs @@ -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 { + (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> { + 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"); + } +} diff --git a/server/src/grid/network_trend.rs b/server/src/grid/network_trend.rs new file mode 100644 index 0000000..ed79bde --- /dev/null +++ b/server/src/grid/network_trend.rs @@ -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(&self, s: S) -> Result { + s.collect_seq(self.0.iter().map(|v| js::JsNum(*v))) + } +} + +fn ser_nums(v: &[f64], s: S) -> Result { + Nums(v).serialize(s) +} +fn ser_opt_nums(v: &Option>, s: S) -> Result { + 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, + /// "YYYY-MM" + pub labels: Vec, + /// 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>, + /// `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, + /// 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 { + (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, 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>, now: NaiveDate) -> Option<(Vec, 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>, + nb_mag_by_month: Option<&BTreeMap>, + 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 = 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> = + 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::() / 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>, + labels: Option<&[String]>, +) -> Option<(Vec, Vec)> { + 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, 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 { + 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"); + } +} diff --git a/server/src/grid/openapi.rs b/server/src/grid/openapi.rs new file mode 100644 index 0000000..6d1e444 --- /dev/null +++ b/server/src/grid/openapi.rs @@ -0,0 +1,1133 @@ +//! `GET /api/v1/openapi.json` — spécification OpenAPI 3.1 de l'API CollectFlow +//! (port de `src/app/api/v1/openapi.json/route.ts`). +//! +//! **Volontairement accessible sans authentification.** Le document ne décrit que la +//! structure de l'API (chemins, paramètres, schémas) et ne contient aucune donnée +//! métier ; or ChatGPT et les autres plateformes d'agents importent le schéma par URL, +//! avant même que la clé ne soit configurée. Exiger une clé ici rendrait l'import +//! impossible. +//! +//! `servers` est construit depuis l'hôte appelant (ou COLLECTFLOW_PUBLIC_URL) : les +//! Actions ChatGPT exigent une URL **absolue**, une URL relative étant rejetée. +//! +//! Le document est construit comme en TS (mêmes fragments partagés : +//! `paginationParams`, `sortParams`, `erreur`), avec des objets à clés ordonnées +//! pour que le JSON produit soit identique à celui de la version Node. + +use super::gammes::GAMMES; +use super::store::GRID_SORT_KEYS; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{Map, Value}; + +/// Objet JSON aux clés ordonnées comme un objet JS : indices entiers croissants +/// d'abord (les codes de réponse « 200 », « 401 », « 404 »…), puis les autres clés +/// dans l'ordre d'écriture. +fn o(entrees: [(&str, Value); N]) -> Value { + let mut m = Map::new(); + for (k, v) in entrees { + m.insert(k.to_string(), v); + } + Value::Object(super::js::js_object_order(m)) +} + +/// Origine publique : `COLLECTFLOW_PUBLIC_URL` (sans `/` final), sinon celle de la +/// requête (`req.nextUrl.origin`), reconstruite depuis les en-têtes — ceux du +/// mandataire inverse en priorité. +pub fn origine(headers: &HeaderMap) -> String { + let configured = std::env::var("COLLECTFLOW_PUBLIC_URL") + .ok() + .map(|v| super::js::js_trim(&v).trim_end_matches('/').to_string()) + .filter(|v| !v.is_empty()); + if let Some(c) = configured { + return c; + } + let premier = |nom: &str| { + headers + .get(nom) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + }; + let proto = premier("x-forwarded-proto").unwrap_or_else(|| "http".into()); + let hote = premier("x-forwarded-host").or_else(|| premier("host")).unwrap_or_else(|| "localhost".into()); + format!("{proto}://{hote}") +} + +/// Document OpenAPI complet pour une origine donnée. +pub fn build_spec(origin: &str) -> Value { + let grid_sort_keys: Vec<&str> = GRID_SORT_KEYS.to_vec(); + let pagination_params: Vec = vec![ + o([ + ("name", Value::from("page")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("minimum", Value::from(1)), + ("default", Value::from(1)), + ])), + ("description", Value::from("Numéro de page.")), + ]), + o([ + ("name", Value::from("limit")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("minimum", Value::from(1)), + ])), + ("description", Value::from("Lignes par page, sans plafond. Sur /grid, OMETTRE ce paramètre renvoie toutes les lignes du fournisseur en un seul appel — c'est le mode recommandé. Sur les autres endpoints, le défaut est 100.")), + ]), + ]; + + let sort_params: Vec = vec![ + o([ + ("name", Value::from("sort")), + ("in", Value::from("query")), + ("schema", o([("type", Value::from("string")), ("enum", Value::from(grid_sort_keys.clone()))])), + ("description", Value::from("Colonne de tri.")), + ]), + o([ + ("name", Value::from("order")), + ("in", Value::from("query")), + ( + "schema", + o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("asc"), Value::from("desc")])), + ("default", Value::from("desc")), + ]), + ), + ("description", Value::from("Sens du tri.")), + ]), + o([ + ("name", Value::from("fields")), + ("in", Value::from("query")), + ("schema", o([("type", Value::from("string"))])), + ( + "description", + Value::from(concat!( + "Colonnes à retourner, séparées par des virgules (ex. « codein,libelle1,totalCa »). ", + "À utiliser systématiquement : une ligne complète contient les séries mensuelles et les ", + "ventilations par magasin, ce qui est très volumineux. `codein` est toujours inclus." + )), + ), + ]), + o([ + ("name", Value::from("enrich")), + ("in", Value::from("query")), + ( + "schema", + o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ("default", Value::from("1")), + ]), + ), + ( + "description", + Value::from(concat!( + "1 (défaut) : relit les métriques réseau Qlik (`network`) et la gamme serveur ", + "(`codeGammeServeur`) au moment de l'appel. 0 : sert l'instantané brut." + )), + ), + ]), + ]; + + let codes_gamme: Vec<&str> = GAMMES.iter().map(|g| g.code).collect(); + let legende_gammes = GAMMES.iter().map(|g| format!("{} ({})", g.code, g.nom)).collect::>().join(", "); + let erreur = |description: Value| { + o([ + ("description", description), + ("content", o([("application/json", o([("schema", o([("$ref", Value::from("#/components/schemas/Error"))]))]))])), + ]) + }; + + let spec = o([ + ("openapi", Value::from("3.1.0")), + ("info", o([ + ("title", Value::from("CollectFlow API")), + ("version", Value::from("1.0.0")), + ("description", Value::from(concat!("Données de la grille CollectFlow : ventes sur 12 mois par magasin, stock, marges, ", "gammes et métriques du réseau Qlik (~270 magasins Foir'Fouille), plus la recherche de produits.\n\n", "**N'importe quel fournisseur peut être interrogé directement.** Les réponses sont servies ", "depuis un instantané persisté ; si le fournisseur demandé n'en a pas encore, `/grid` le calcule ", "à la demande. Ce premier appel prend alors plusieurs secondes, les suivants sont immédiats. ", "Passez `compute=0` pour refuser ce calcul et obtenir un `202 not_ready` immédiat.\n\n", "**Aucun endpoint ne contacte Qlik en direct** : les métriques réseau viennent d'un cache ", "alimenté par une synchronisation séparée.\n\n", "Deux informations sont relues à chaque appel car elles évoluent indépendamment : les métriques ", "réseau Qlik (`network`, `null` s'il n'y en a pas) et la gamme serveur non modifiée ", "(`codeGammeServeur`).\n\n", "**Gammes** : `PUT /products/{codein}/gamme` (un article) et `POST /gammes` (plusieurs) ", "**proposent** une gamme. La proposition reste **à valider** : elle apparaît dans la Grille ", "comme une modification non enregistrée, pour voir quels produits changent de gamme, et ", "n'est appliquée qu'une fois validée par un utilisateur (« Enregistrer »). D'ici là, ", "`codeGamme` est inchangée et la proposition figure dans `gammeAValider`. Une fois validée, ", "`codeGammeServeur` reste la gamme en base FF jusqu'à l'import des gammes modifiées."))), + ])), + ("servers", Value::Array(vec![ + o([ + ("url", Value::from(format!("{}/api/v1", origin))), + ("description", Value::from("API CollectFlow")), + ]), + ])), + ("security", Value::Array(vec![ + o([ + ("ApiKeyAuth", Value::Array(vec![])), + ]), + ])), + ("components", o([ + ("securitySchemes", o([ + ("ApiKeyAuth", o([ + ("type", Value::from("apiKey")), + ("in", Value::from("header")), + ("name", Value::from("X-API-Key")), + ])), + ])), + ("schemas", o([ + ("Error", o([ + ("type", Value::from("object")), + ("properties", o([ + ("error", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![ + Value::from("unauthorized"), + Value::from("forbidden"), + Value::from("bad_request"), + Value::from("not_found"), + Value::from("not_ready"), + Value::from("internal_error"), + ])), + ])), + ("message", o([ + ("type", Value::from("string")), + ])), + ])), + ])), + ])), + ])), + ("Pagination", o([ + ("type", Value::from("object")), + ("properties", o([ + ("page", o([ + ("type", Value::from("integer")), + ])), + ("limit", o([ + ("type", Value::from("integer")), + ])), + ("total", o([ + ("type", Value::from("integer")), + ])), + ("totalPages", o([ + ("type", Value::from("integer")), + ])), + ("hasMore", o([ + ("type", Value::from("boolean")), + ("description", Value::from("true = il reste des pages. Sur /grid, meta.complet dit la même chose en positif.")), + ])), + ])), + ])), + ("Fournisseur", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur, à passer au paramètre `fournisseur`.")), + ])), + ("nom", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ("hasSnapshot", o([ + ("type", Value::from("boolean")), + ("description", Value::from("false = données pas encore disponibles pour ce fournisseur.")), + ])), + ("rowCount", o([ + ("type", Value::from("integer")), + ])), + ("computedAt", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("format", Value::from("date-time")), + ])), + ])), + ])), + ("NetworkMetrics", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Métriques du réseau Qlik. `null` quand le produit n'a pas de données réseau.")), + ("properties", o([ + ("caReseau", o([ + ("type", Value::from("number")), + ])), + ("qteReseau", o([ + ("type", Value::from("number")), + ])), + ("nbMagasinsReseau", o([ + ("type", Value::from("integer")), + ("description", Value::from("Nombre de magasins du réseau vendant le produit (sur ~270).")), + ])), + ("caParMagasinReseau", o([ + ("type", Value::from("number")), + ("description", Value::from("CA moyen par magasin : normalise la présence, préférable au CA brut pour comparer.")), + ])), + ("margePctReseau", o([ + ("type", Value::from("number")), + ("description", Value::from("Taux de marge, ratio brut (0.32 = 32 %).")), + ])), + ("qteByMonth", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Quantités vendues par mois, clés « YYYY-MM ».")), + ])), + ("fetchedAt", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("format", Value::from("date-time")), + ])), + ])), + ])), + ("Product", o([ + ("type", Value::from("object")), + ("description", Value::from(concat!("Ligne de grille : un article chez un fournisseur. La liste de propriétés ci-dessous ", "n'est pas exhaustive — la réponse contient l'intégralité de la ligne (une soixantaine ", "de champs). Utilisez `fields` pour n'en garder qu'une partie."))), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ("description", Value::from("Identifiant interne de l'article.")), + ])), + ("codeFournisseur", o([ + ("type", Value::from("string")), + ])), + ("nomFournisseur", o([ + ("type", Value::from("string")), + ])), + ("libelle1", o([ + ("type", Value::from("string")), + ("description", Value::from("Libellé de l'article.")), + ])), + ("gtin", o([ + ("type", Value::from("string")), + ])), + ("reference", o([ + ("type", Value::from("string")), + ])), + ("codeCentrale", o([ + ("type", Value::from("string")), + ("description", Value::from("Code centrale — clé de jointure avec le réseau Qlik.")), + ])), + ("codeGammeServeur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from(format!("Gamme **non modifiée**, telle qu'elle existe en base. C'est celle à utiliser pour raisonner. `null` = aucune gamme. Valeurs : {}.", legende_gammes))), + ])), + ("codeGamme", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme courante, modifications validées dans CollectFlow comprises.")), + ])), + ("gammeAValider", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme proposée par l'API et pas encore validée dans la Grille. `null` = aucune proposition en attente.")), + ])), + ("totalCa", o([ + ("type", Value::from("number")), + ("description", Value::from("CA sur 12 mois, nos magasins.")), + ])), + ("totalQuantite", o([ + ("type", Value::from("number")), + ])), + ("totalMarge", o([ + ("type", Value::from("number")), + ])), + ("tauxMarge", o([ + ("type", Value::from("number")), + ])), + ("stockActuel", o([ + ("type", Value::from("number")), + ])), + ("pcb", o([ + ("type", Value::from("number")), + ("description", Value::from("Conditionnement (nombre d'unités par colis).")), + ])), + ("prixAchat", o([ + ("type", Value::from("number")), + ])), + ("prixVente", o([ + ("type", Value::from("number")), + ])), + ("derniereVente", o([ + ("type", Value::from("string")), + ("format", Value::from("date")), + ])), + ("nbJoursDerniereVente", o([ + ("type", Value::from("integer")), + ])), + ("sales12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Quantités vendues par mois, clés « YYYYMM ».")), + ])), + ("stock12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Stock de fin de mois, clés « YYYYMM ».")), + ])), + ("receptions12m", o([ + ("type", Value::from("object")), + ("description", Value::from("Quantités reçues par mois, clés « YYYYMM ».")), + ])), + ("sales12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Ventes par magasin (292, 579) puis par mois.")), + ])), + ("stock12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Stock de fin de mois par magasin puis par mois.")), + ])), + ("receptions12mByStore", o([ + ("type", Value::from("object")), + ("description", Value::from("Réceptions par magasin puis par mois.")), + ])), + ("qteReseauByMonth", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from("Quantités vendues par le réseau, clés « YYYY-MM » — série derrière la tendance.")), + ])), + ("network", o([ + ("$ref", Value::from("#/components/schemas/NetworkMetrics")), + ])), + ("trend", o([ + ("$ref", Value::from("#/components/schemas/Trend")), + ])), + ])), + ])), + ("ResultatGamme", o([ + ("type", Value::from("object")), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ])), + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ("description", Value::from("Gamme proposée.")), + ])), + ("gammePrecedente", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme enregistrée au moment de l'appel.")), + ])), + ("codeGammeServeur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ("description", Value::from("Gamme en base FF, non modifiée par l'API.")), + ])), + ("modifie", o([ + ("type", Value::from("boolean")), + ("description", Value::from("true = proposition déposée, à valider dans la Grille. false = l'article a déjà cette gamme (une proposition en attente pour lui est retirée).")), + ])), + ("statut", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("a_valider"), Value::from("inchangee")])), + ("description", Value::from("`a_valider` : en attente de validation dans la Grille.")), + ])), + ])), + ])), + ("Trend", o([ + ("type", Value::Array(vec![Value::from("object"), Value::from("null")])), + ("description", Value::from(concat!("Tendance réseau sur 12 mois, par régression linéaire — la même que la colonne ", "« Tendance » de l'application. `null` si aucune série mensuelle n'est disponible."))), + ("properties", o([ + ("direction", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("up"), Value::from("down"), Value::from("flat")])), + ("description", Value::from("Seuil à ±8 % de variation modélisée.")), + ])), + ("pct", o([ + ("type", Value::Array(vec![Value::from("number"), Value::from("null")])), + ("description", Value::from("Variation modélisée sur la période (0.23 = +23 %).")), + ])), + ("label", o([ + ("type", Value::from("string")), + ("description", Value::from("Libellé prêt à afficher : « Forte hausse », « Stable »…")), + ])), + ("monthsUsed", o([ + ("type", Value::from("integer")), + ("description", Value::from("Nombre de mois réellement utilisés (12 au maximum).")), + ])), + ])), + ])), + ])), + ])), + ("paths", o([ + ("/fournisseurs", o([ + ("get", o([ + ("operationId", Value::from("listerFournisseurs")), + ("summary", Value::from("Lister les fournisseurs")), + ("description", Value::from(concat!("Renvoie les fournisseurs et indique, via `hasSnapshot`, lesquels ont des données ", "immédiatement disponibles. À appeler en premier quand le code fournisseur est inconnu."))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("search")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre sur le nom ou le code.")), + ]), + o([ + ("name", Value::from("withData")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ])), + ("description", Value::from("1 = uniquement ceux dont les données sont disponibles.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Liste des fournisseurs")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Fournisseur")), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/nomenclatures", o([ + ("get", o([ + ("operationId", Value::from("listerNomenclaturesFournisseur")), + ("summary", Value::from("Postes de nomenclature d'un fournisseur")), + ("description", Value::from(concat!("Répartition des articles d'un fournisseur par poste de nomenclature, avec le nombre ", "d'articles et le CA de chacun. **À utiliser avant /grid sur un gros fournisseur** : ", "certains dépassent 130 000 articles. On liste les postes (quelques dizaines de lignes), ", "puis on interroge /grid poste par poste avec le filtre indiqué par `meta.filtreGrid` ", "(code1, code2 ou code3)."))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ]), + o([ + ("name", Value::from("niveau")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("integer")), + ("enum", Value::Array(vec![Value::from(1), Value::from(2), Value::from(3)])), + ("default", Value::from(1)), + ])), + ("description", Value::from("1 = univers, 2 = famille, 3 = sous-famille.")), + ]), + o([ + ("name", Value::from("parent")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Préfixe du poste parent, ex. « 32 » pour lister ses familles.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Postes de nomenclature")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("type", Value::from("object")), + ("properties", o([ + ("code", o([ + ("type", Value::from("string")), + ("description", Value::from("À passer au filtre code1/code2/code3 de /grid.")), + ])), + ("libelle", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ("nbArticles", o([ + ("type", Value::from("integer")), + ])), + ("totalCa", o([ + ("type", Value::from("number")), + ])), + ("totalQuantite", o([ + ("type", Value::from("number")), + ])), + ])), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("202", o([ + ("description", Value::from("Fournisseur pas encore calculé")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("400", o([ + ("description", Value::from("Paramètres invalides")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/grid", o([ + ("get", o([ + ("operationId", Value::from("listerProduitsFournisseur")), + ("summary", Value::from("Lister les produits d'un fournisseur")), + ("description", Value::from(concat!("Lignes de grille d'un fournisseur : ventes 12 mois, stock, marges, gammes et métriques ", "réseau. Fonctionne pour **n'importe quel** fournisseur : si ses données n'ont jamais été ", "calculées, l'API les calcule à la demande (premier appel de quelques secondes, ", "`meta.computedOnDemand = true`). Un `404` signifie que le code fournisseur n'existe pas ", "ou n'a aucun article — le signaler plutôt que de conclure à une panne."))), + ("parameters", Value::Array([vec![ + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Code fournisseur (voir listerFournisseurs).")), + ]), + o([ + ("name", Value::from("gamme")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from(format!("Filtre sur la gamme ({}).", codes_gamme.join(", ")))), + ]), + o([ + ("name", Value::from("code1")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 1.")), + ]), + o([ + ("name", Value::from("code2")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 2.")), + ]), + o([ + ("name", Value::from("code3")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre nomenclature niveau 3 (code exact).")), + ]), + o([ + ("name", Value::from("nomenclature")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Préfixe de nomenclature renvoyé par listerNomenclaturesFournisseur : 32, 3202 ou 320211. À préférer à code1/code2, qui peuvent être vides.")), + ]), + o([ + ("name", Value::from("search")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre texte : libellé, codein, GTIN, référence ou code centrale.")), + ]), + o([ + ("name", Value::from("compute")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ("enum", Value::Array(vec![Value::from("0"), Value::from("1")])), + ("default", Value::from("1")), + ])), + ("description", Value::from(concat!("1 (défaut) : calcule les données du fournisseur si elles n'existent pas encore. ", "0 : refuse le calcul et répond immédiatement 202 not_ready."))), + ]), + ], sort_params.clone(), pagination_params.clone()].concat())), + ("responses", o([ + ("200", o([ + ("description", Value::from("Produits du fournisseur")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ])), + ("pagination", o([ + ("$ref", Value::from("#/components/schemas/Pagination")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("202", o([ + ("description", Value::from("Données pas encore calculées (uniquement avec compute=0)")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("400", o([ + ("description", Value::from("Paramètres invalides")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Fournisseur inconnu ou sans article")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/search", o([ + ("get", o([ + ("operationId", Value::from("rechercherProduits")), + ("summary", Value::from("Rechercher des produits, tous fournisseurs confondus")), + ("description", Value::from(concat!("Recherche sur le libellé, le codein, le GTIN, la référence et le code centrale, sans ", "avoir à connaître le fournisseur. À utiliser dès qu'un produit est désigné par son nom."))), + ("parameters", Value::Array([vec![ + o([ + ("name", Value::from("q")), + ("in", Value::from("query")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ("maxLength", Value::from(120)), + ])), + ("description", Value::from("Termes recherchés.")), + ]), + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Restreint à un fournisseur.")), + ]), + o([ + ("name", Value::from("gamme")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Filtre sur la gamme.")), + ]), + ], sort_params.clone(), pagination_params.clone()].concat())), + ("responses", o([ + ("200", o([ + ("description", Value::from("Produits correspondants")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ])), + ("pagination", o([ + ("$ref", Value::from("#/components/schemas/Pagination")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/{codein}", o([ + ("get", o([ + ("operationId", Value::from("obtenirProduit")), + ("summary", Value::from("Fiche complète d'un produit")), + ("description", Value::from("Toutes les données d'un article : séries mensuelles, ventilation par magasin, stock, marges, gammes et métriques réseau.")), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codein")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Identifiant interne de l'article.")), + ]), + o([ + ("name", Value::from("fournisseur")), + ("in", Value::from("query")), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs.")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Fiche produit")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("$ref", Value::from("#/components/schemas/Product")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Produit absent de l'instantané")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ("/products/{codein}/gamme", o([ + ("put", o([ + ("operationId", Value::from("changerGammeProduit")), + ("summary", Value::from("Proposer la gamme d'un produit (à valider)")), + ("description", Value::from(format!("Propose une gamme pour un article. Elle reste à valider dans la Grille, où elle apparaît comme une modification non enregistrée. Valeurs : {}. `fournisseur` n'est requis que si l'article est référencé chez plusieurs fournisseurs (réponse 400 listant les fournisseurs) ou s'il n'a encore jamais été calculé. Pour plusieurs articles d'un même fournisseur, préférer changerGammes.", legende_gammes))), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codein")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Identifiant interne de l'article.")), + ]), + ])), + ("requestBody", o([ + ("required", Value::from(true)), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("gamme")])), + ("properties", o([ + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ])), + ("fournisseur", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur de l'article.")), + ])), + ("compute", o([ + ("type", Value::from("boolean")), + ("default", Value::from(true)), + ("description", Value::from("Calcule le fournisseur à la demande s'il n'a jamais été calculé.")), + ])), + ])), + ])), + ])), + ])), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Gamme proposée, à valider")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("allOf", Value::Array(vec![ + o([ + ("$ref", Value::from("#/components/schemas/ResultatGamme")), + ]), + o([ + ("type", Value::from("object")), + ("properties", o([ + ("codeFournisseur", o([ + ("type", Value::from("string")), + ])), + ("nomFournisseur", o([ + ("type", Value::Array(vec![Value::from("string"), Value::from("null")])), + ])), + ])), + ]), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("400", erreur(Value::from("Gamme invalide, ou article chez plusieurs fournisseurs sans « fournisseur »"))), + ("401", erreur(Value::from("Clé d'API absente, invalide ou révoquée"))), + ("404", erreur(Value::from("Article introuvable chez ce fournisseur"))), + ])), + ])), + ])), + ("/gammes", o([ + ("post", o([ + ("operationId", Value::from("changerGammes")), + ("summary", Value::from("Proposer la gamme de plusieurs produits (à valider)")), + ("description", Value::from(format!("Propose en un appel les gammes de plusieurs articles d'un même fournisseur, à valider dans la Grille. Tout ou rien : si un article est inconnu chez le fournisseur, rien n'est proposé et la réponse 404 liste les articles en cause dans `error.details.inconnus`. Valeurs : {}.", legende_gammes))), + ("requestBody", o([ + ("required", Value::from(true)), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("fournisseur"), Value::from("changes")])), + ("properties", o([ + ("fournisseur", o([ + ("type", Value::from("string")), + ("description", Value::from("Code fournisseur.")), + ])), + ("changes", o([ + ("type", Value::from("array")), + ("minItems", Value::from(1)), + ("maxItems", Value::from(10000)), + ("items", o([ + ("type", Value::from("object")), + ("required", Value::Array(vec![Value::from("codein"), Value::from("gamme")])), + ("properties", o([ + ("codein", o([ + ("type", Value::from("string")), + ])), + ("gamme", o([ + ("type", Value::from("string")), + ("enum", Value::from(codes_gamme.clone())), + ])), + ])), + ])), + ])), + ("compute", o([ + ("type", Value::from("boolean")), + ("default", Value::from(true)), + ("description", Value::from("Calcule le fournisseur à la demande s'il n'a jamais été calculé.")), + ])), + ])), + ])), + ])), + ])), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Gammes proposées, à valider")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("type", Value::from("array")), + ("items", o([ + ("$ref", Value::from("#/components/schemas/ResultatGamme")), + ])), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("400", erreur(Value::from("Corps de requête invalide"))), + ("401", erreur(Value::from("Clé d'API absente, invalide ou révoquée"))), + ("404", erreur(Value::from("Un ou plusieurs articles introuvables chez ce fournisseur"))), + ])), + ])), + ])), + ("/network/{codeCentrale}", o([ + ("get", o([ + ("operationId", Value::from("obtenirMetriquesReseau")), + ("summary", Value::from("Métriques du réseau Qlik pour un produit")), + ("description", Value::from("Performances du produit sur l'ensemble du réseau Foir'Fouille (~270 magasins) et courbe des quantités sur 12 mois glissants.")), + ("parameters", Value::Array(vec![ + o([ + ("name", Value::from("codeCentrale")), + ("in", Value::from("path")), + ("required", Value::from(true)), + ("schema", o([ + ("type", Value::from("string")), + ])), + ("description", Value::from("Code centrale du produit (format 10000XXXXXX).")), + ]), + ])), + ("responses", o([ + ("200", o([ + ("description", Value::from("Métriques réseau")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("type", Value::from("object")), + ("properties", o([ + ("data", o([ + ("$ref", Value::from("#/components/schemas/NetworkMetrics")), + ])), + ("meta", o([ + ("type", Value::from("object")), + ])), + ])), + ])), + ])), + ])), + ])), + ("404", o([ + ("description", Value::from("Aucune donnée réseau pour ce code centrale")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ("401", o([ + ("description", Value::from("Clé d'API absente, invalide ou révoquée")), + ("content", o([ + ("application/json", o([ + ("schema", o([ + ("$ref", Value::from("#/components/schemas/Error")), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ])), + ]); + + spec +} + +/// `GET /api/v1/openapi.json`. +pub async fn get_openapi(headers: HeaderMap) -> Response { + let spec = build_spec(&origine(&headers)); + // CORS ouvert en lecture : le schéma est public et peut être chargé par un outil tiers. + ([(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")], Json(spec)).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_complet() { + let spec = build_spec("http://exemple.test:5643"); + assert_eq!(spec["servers"][0]["url"], "http://exemple.test:5643/api/v1"); + assert_eq!(spec["paths"]["/grid"]["get"]["parameters"].as_array().unwrap().len(), 14); + assert_eq!( + spec["components"]["schemas"]["ResultatGamme"]["properties"]["gamme"]["enum"], + serde_json::json!(["A", "B", "C", "Y", "Z"]) + ); + // Comparaison octet par octet avec le document produit par la version Node + // (`OPENAPI_REFERENCE` : chemin du JSON de référence, origine ci-dessus). + if let Ok(chemin) = std::env::var("OPENAPI_REFERENCE") { + let reference = std::fs::read_to_string(&chemin).unwrap(); + let produit = serde_json::to_string(&spec).unwrap(); + if produit != reference.trim() { + std::fs::write(format!("{chemin}.rust"), &produit).unwrap(); + } + assert_eq!(produit, reference.trim()); + } + } + + #[test] + fn origine_depuis_les_en_tetes() { + let mut h = HeaderMap::new(); + h.insert("host", "collectflow.local:5643".parse().unwrap()); + assert_eq!(origine(&h), "http://collectflow.local:5643"); + h.insert("x-forwarded-proto", "https".parse().unwrap()); + h.insert("x-forwarded-host", "cf.exemple.fr".parse().unwrap()); + assert_eq!(origine(&h), "https://cf.exemple.fr"); + } +} diff --git a/server/src/grid/rows_cache.rs b/server/src/grid/rows_cache.rs new file mode 100644 index 0000000..bad8051 --- /dev/null +++ b/server/src/grid/rows_cache.rs @@ -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, String>>>; + +/// Lignes d'une entrée du cache, partagées et modifiables sur place. +#[derive(Default)] +pub struct GridRows { + pub rows: RwLock>, + /// `storePatches` : magasin → complément, rattaché à ce tableau de lignes. + pub store_patches: Mutex>, +} + +impl GridRows { + pub fn new(rows: Vec) -> 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 { + self.rows.read().clone() + } +} + +pub type SharedRows = Arc; + +/// Calcul en cours, partagé : l'erreur est un message (le calcul TS relance l'erreur +/// à chaque appelant). +pub type RowsFuture = Shared>>; + +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, + /// Nombre d'enregistrements de gammes par fournisseur (cf. [`patch_grid_rows_cache`]). + gamme_save_version: HashMap, +} + +static ETAT: LazyLock> = 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 { + 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 { + 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 { + 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) { + 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 = { + 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); + } +} diff --git a/server/src/grid/session.rs b/server/src/grid/session.rs new file mode 100644 index 0000000..e481962 --- /dev/null +++ b/server/src/grid/session.rs @@ -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> { + 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 { + 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 { + 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 { + 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) -> 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"]); + } +} diff --git a/server/src/grid/snapshots.rs b/server/src/grid/snapshots.rs new file mode 100644 index 0000000..b95e1f7 --- /dev/null +++ b/server/src/grid/snapshots.rs @@ -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, + pub code_fournisseur: String, + pub nom_fournisseur: Option, + pub magasin: String, + pub changes: Value, + pub summary_json: Option, + pub label: Option, + #[serde(rename = "type")] + pub type_: Option, + pub created_at: Option, +} + +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::, _>("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, 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 { + 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 = 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 = 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::().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 { + 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, + pub magasin: String, + pub label: Option, + /// codein → `{ before, after }`. + pub changes: Map, + pub type_: Option, + /// `{ totalRows, totalCa, totalMarge, tauxMargeGlobal }`. + pub summary: Option, +} + +/// `SaveSnapshotSchema.safeParse(raw)`. +pub fn parse_save_snapshot(raw: &Value) -> Result { + let mut issues = Issues::default(); + let racine: Vec = 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, d: &SaveSnapshotInput) -> Result { + 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 { + 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); + } +} diff --git a/server/src/grid/store.rs b/server/src/grid/store.rs new file mode 100644 index 0000000..7c37fe6 --- /dev/null +++ b/server/src/grid/store.rs @@ -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 { + 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, + pub gamme: Option, + pub code1: Option, + pub code2: Option, + pub code3: Option, + /// Préfixe de nomenclature (« 32 », « 3202 », « 320211 ») — filtre sur code3. + pub nomenclature: Option, + /// Recherche libre : libellé, codein, GTIN, référence, code centrale. + pub search: Option, + pub sort: Option, + pub order: Option, + pub page: Option, + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GridQueryResult { + pub rows: Vec, + pub total: i64, + /// Calcul le plus ancien parmi les lignes renvoyées — fraîcheur de la donnée. + pub computed_at: Option, +} + +/// 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) -> 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) { + 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 { + 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 = 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, + /// 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> { + 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 = 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) -> AppResult<()> { + // Map TS : ordre de première apparition de chaque gamme. + let mut par_gamme: Vec<(String, Vec)> = 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 { + 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 { + 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 = 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 = 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 = None; + let mut rows = Vec::with_capacity(found.len()); + for r in &found { + if let Ok(Some(c)) = r.try_get::, _>("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::("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, +} + +/// 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> { + let pool = db::pool()?; + let mut qb: QueryBuilder = 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 = 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, +} + +/// Fournisseurs chez lesquels un article figure dans l'instantané. +pub async fn list_grid_suppliers_for_codein(codein: &str) -> AppResult> { + 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, + /// Gamme serveur au moment du calcul de la grille. + pub code_gamme_init: Option, + pub nom_fournisseur: Option, +} + +/// 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> { + 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::("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, +} + +/// 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> { + 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::("row_count").map(i64::from).unwrap_or(0); + if row_count == 0 { + return Ok(None); + } + let computed_at: Option = 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, + pub row_count: i64, + pub computed_at: Option, +} + +/// Fournisseurs présents dans l'instantané, avec leur fraîcheur. +pub async fn list_grid_suppliers() -> AppResult> { + 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::("row_count").map(i64::from).unwrap_or(0), + computed_at: r.try_get::, _>("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, + 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> { + 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 = 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 = 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 = 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); + } +} diff --git a/server/src/grid/store_patch.rs b/server/src/grid/store_patch.rs new file mode 100644 index 0000000..bb94270 --- /dev/null +++ b/server/src/grid/store_patch.rs @@ -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()]); + } +} diff --git a/server/src/grid/tests_db.rs b/server/src/grid/tests_db.rs new file mode 100644 index 0000000..61583b7 --- /dev/null +++ b/server/src/grid/tests_db.rs @@ -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) = + 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!["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 = + 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![("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!["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| -> Vec { + 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) = + 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 = 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(¶ms).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!["3202"]); + + nettoyer(&pool).await; + rows_cache::invalidate_grid_rows_cache(None); +} diff --git a/server/src/grid/types.rs b/server/src/grid/types.rs new file mode 100644 index 0000000..6478d05 --- /dev/null +++ b/server/src/grid/types.rs @@ -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; +/// Ventilation par magasin puis par mois. +pub type SerieParMagasin = BTreeMap; + +/// 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub fournisseur_principal_nom: Option, + #[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, + #[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, + pub code_gamme_init: Option, + /// Draft gamme value before saving + pub code_gamme_draft: Option, + /// 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, + /// 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, + /// Conditionnement (pack size) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub pcb: Option, + /// 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, + /// 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, + /// 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, + /// 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, + /// Per-store CA totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub ca_by_store: Option, + /// Per-store quantity totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub quantite_by_store: Option, + /// Per-store margin totals + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_map")] + pub marge_by_store: Option, + /// Qté en commande fournisseur + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub commandes_en_cours: Option, + /// Stock dispo agrégé tous sites + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_actuel: Option, + /// Qte en stock agrégé + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_total: Option, + /// Valeur stock agrégée + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub stock_valeur: Option, + /// PRMP (alias prixAchat) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub pa: Option, + /// PA / PRMP + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub prix_achat: Option, + /// ISO date de dernière vente + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_vente: Option, + /// ISO date de dernière réception + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_livraison: Option, + /// Per-store last delivery/reception date (site → ISO date string) + #[serde(skip_serializing_if = "Option::is_none")] + pub derniere_livraison_by_store: Option>, + /// 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, + /// Données réseau Qlik (~270 magasins) + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub ca_reseau: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub qte_reseau: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub nb_magasins_reseau: Option, + /// 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, + /// 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, + /// Taux de présence réseau = nbMagasinsReseau / 270 + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub taux_presence_reseau: Option, + /// 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>, + /// 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>, + /// Fraîcheur des données réseau (ISO) + #[serde(skip_serializing_if = "Option::is_none")] + pub network_fetched_at: Option, + /// 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, + /// CA normalisé : € / magasin / an + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub ca_per_store_per_year: Option, + /// Produit récent (≤ 3 mois de données) + #[serde(skip_serializing_if = "Option::is_none")] + pub is_recent: Option, + /// Seul produit du fournisseur dans le lot + #[serde(skip_serializing_if = "Option::is_none")] + pub is_last_product: Option, + /// Top 30% CA du lot fournisseur + #[serde(skip_serializing_if = "Option::is_none")] + pub is_top30_supplier: Option, + /// Moyennes de comparaison pour l'IA + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub avg_qty_fournisseur: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub avg_qty_rayon: Option, + /// Poids relatifs (%) du produit chez le fournisseur + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_ca: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_marge: Option, + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub share_qty: Option, + /// Référentiels globaux + #[serde(skip_serializing_if = "Option::is_none", with = "js::opt_num")] + pub total_fournisseur_ca: Option, + /// 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, + /// Champs inconnus de ce type (payload écrit par une autre version) : conservés + /// et réécrits tels quels. + #[serde(flatten)] + pub extra: Map, +} + +/// 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, + pub code_fournisseur: Option, + pub code1: Option, + pub code2: Option, + /// Nomenclatures retenues (codes `code3`). + /// `None` = aucun filtre (tout s'affiche) ; `Some(vec![])` = rien de coché. + pub code3: Option>, + pub code_gamme: Option, + 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, + pub nom_fournisseur: Option, + pub libelle1: Option, + pub gtin: Option, + pub reference: Option, + pub code_gamme: Option, + pub code_gamme_init: Option, + pub code3: Option, + pub libelle3: Option, + pub magasin: String, + pub code_magasin: Option, + pub annee: Option, + pub mois: Option, + pub periode: String, + pub quantite: Option, + pub montant_mvt: Option, + pub marge_mvt: Option, +} + +// --------------------------------------------------------------------------- +// 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, + pub entries: Vec, +} + +#[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")); + } +} diff --git a/server/src/grid/zod.rs b/server/src/grid/zod.rs new file mode 100644 index 0000000..41b585f --- /dev/null +++ b/server/src/grid/zod.rs @@ -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 for Seg { + fn from(i: usize) -> Self { + Seg::I(i) + } +} + +pub type Path = Vec; + +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) -> 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); + +impl Issues { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + fn push(&mut self, mut issue: Map, 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 { + 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::>().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::>().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> { + 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 { + 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> { + 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> { + 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 { + 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 { + 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 { + 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, issues: &mut Issues) -> Option { + 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]" + ); + } +}