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