Serveur Rust : portage du module FF Nancy (base miroir, API, routes, actions, pages)

- ff::pg : portage complet de pg-ff-client.ts (mêmes requêtes SQL, fragments
  dynamiques via QueryBuilder, lectures tolérantes, caches process de la
  colonne parent de nomenclature et du lien mvtart → fournisseur).
- ff::api : client de l'API FF Nancy (URL résolue Paramètres → env → défaut,
  mémorisée 30 s ; délai FF_API_TIMEOUT_MS), pagination, mensuel, référentiel,
  commandes, normalisation du statut de synchro.
- ff::cache : getFournisseursCached / getDerniereReceptionCached.
- ff::diagnostic : diagnostiquer_api_ff (DNS, refus, délai, code HTTP).
- Routes /api/ff-status, /api/diag, /api/diag/stock-fournisseur, /api/test-db,
  /api/commandes-auto ; actions de la Grille et du cadencier ; pages dashboard,
  analytics, hit-parade, stock-negatif, commandes-auto, publicites.
- Test d'intégration (FF_TEST_DATABASE_URL) avec fausse API FF : toutes les
  requêtes et tous les appels HTTP joués sur une base de test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:24:28 +00:00
1 parent 559e6ed714
commit 7dd5919264
10 files changed
+5547 -10

No files matched your search

+172
View File
@@ -0,0 +1,172 @@
//! Conversions « à la JavaScript » sur des valeurs JSON brutes de l'API FF.
//!
//! Le code TS d'origine lisait les réponses de l'API sans schéma et convertissait
//! au fil de l'eau (`Number(x) || 0`, `String(x)`, tests de vérité). Ces aides
//! reproduisent ces conversions pour garder exactement les mêmes résultats.
use serde_json::Value;
/// `Number(v)` : `NaN` si la valeur n'est pas convertible.
pub fn js_number(v: &Value) -> f64 {
match v {
Value::Null => 0.0,
Value::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
Value::String(s) => {
let t = s.trim();
if t.is_empty() {
0.0
} else {
// Rust accepte « inf » / « nan », pas JS (seul « Infinity » l'est, cas ignoré).
let l = t.to_ascii_lowercase();
if l.contains("inf") || l.contains("nan") {
f64::NAN
} else {
t.parse::<f64>().unwrap_or(f64::NAN)
}
}
}
Value::Array(a) => match a.len() {
0 => 0.0,
1 => js_number(&a[0]),
_ => f64::NAN,
},
Value::Object(_) => f64::NAN,
}
}
/// `Number(v) || 0` (NaN et 0 ramenés à 0).
pub fn js_number_or0(v: &Value) -> f64 {
let n = js_number(v);
if n.is_nan() {
0.0
} else {
n
}
}
/// `Number(v)` sur une valeur éventuellement absente (`undefined` → NaN).
pub fn js_number_opt(v: Option<&Value>) -> f64 {
match v {
None => f64::NAN,
Some(v) => js_number(v),
}
}
/// Formatage d'un nombre comme `String(n)` en JS (12 → "12", 1.5 → "1.5").
pub fn js_number_to_string(f: f64) -> String {
if f.is_nan() {
"NaN".into()
} else if f.is_infinite() {
if f > 0.0 {
"Infinity".into()
} else {
"-Infinity".into()
}
} else if f.fract() == 0.0 && f.abs() < 1e21 {
format!("{}", f as i128)
} else {
format!("{f}")
}
}
/// `String(v)`.
pub fn js_string(v: &Value) -> String {
match v {
Value::Null => "null".into(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => match n.as_f64() {
Some(f) if n.is_f64() => js_number_to_string(f),
_ => n.to_string(),
},
Value::String(s) => s.clone(),
Value::Array(a) => a
.iter()
.map(|x| if x.is_null() { String::new() } else { js_string(x) })
.collect::<Vec<_>>()
.join(","),
Value::Object(_) => "[object Object]".into(),
}
}
/// Valeur « vraie » au sens JS (`if (v)`).
pub fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64().map(|f| f != 0.0 && !f.is_nan()).unwrap_or(true),
Value::String(s) => !s.is_empty(),
Value::Array(_) | Value::Object(_) => true,
}
}
/// `v?.[key]` : `None` si absent (undefined).
pub fn get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
v.as_object().and_then(|o| o.get(key))
}
/// `a ?? b ?? …` sur les clés d'un objet : première valeur ni absente ni `null`.
pub fn coalesce<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Value> {
keys.iter().filter_map(|k| get(v, k)).find(|x| !x.is_null())
}
/// Chaîne d'une valeur JSON, `None` si absente ou `null` (`x ?? undefined`).
pub fn opt_string(v: Option<&Value>) -> Option<String> {
match v {
None | Some(Value::Null) => None,
Some(x) => Some(js_string(x)),
}
}
/// Message d'une erreur et de ses causes, sans répéter une cause déjà incluse
/// dans le message précédent (les erreurs SQL recopient leur cause).
pub fn message_erreur(e: &anyhow::Error) -> String {
let mut parts: Vec<String> = vec![];
for cause in e.chain() {
let m = cause.to_string();
if !parts.iter().any(|p| p.contains(&m)) {
parts.push(m);
}
}
parts.join(": ")
}
/// `encodeURIComponent(String(v))`.
pub fn encode(v: &str) -> String {
urlencoding::encode(v).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn number_conversions() {
assert_eq!(js_number(&json!("12.5")), 12.5);
assert_eq!(js_number(&json!(" ")), 0.0);
assert!(js_number(&json!("abc")).is_nan());
assert_eq!(js_number_or0(&json!("abc")), 0.0);
assert_eq!(js_number(&json!(true)), 1.0);
assert_eq!(js_number(&Value::Null), 0.0);
assert!(js_number_opt(None).is_nan());
}
#[test]
fn string_conversions() {
assert_eq!(js_string(&json!(12)), "12");
assert_eq!(js_string(&json!(12.0)), "12");
assert_eq!(js_string(&json!(1.5)), "1.5");
assert_eq!(js_string(&json!("x")), "x");
assert_eq!(js_number_to_string(2.0), "2");
assert!(truthy(&json!("a")));
assert!(!truthy(&json!("")));
assert!(!truthy(&json!(0)));
}
}