mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 02:37:55 +02:00
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:
10 files changed
+5547
-10
No files matched your search
@@ -0,0 +1,311 @@
|
||||
//! Actions du module FF : `getFournisseurs`, `getMagasins`
|
||||
//! (`src/features/grid/actions.ts`) et le cadencier de commande
|
||||
//! (`src/features/commandes-auto/actions.ts`).
|
||||
|
||||
use super::api::get_sites_from_api;
|
||||
use super::cache::{get_derniere_reception_cached, get_fournisseurs_cached};
|
||||
use super::js::{js_number, message_erreur};
|
||||
use crate::db::pool;
|
||||
use crate::error::AppResult;
|
||||
use crate::rpc::{json, Ctx};
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grille
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Liste de tous les fournisseurs (PostgreSQL FF).
|
||||
pub async fn get_fournisseurs(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
// Relu à chaque navigation dans la Grille (changement de fournisseur, de
|
||||
// magasin…) : le référentiel ne change qu'avec la recopie nocturne.
|
||||
json(get_fournisseurs_cached().await?)
|
||||
}
|
||||
|
||||
/// Liste des magasins (sites).
|
||||
pub async fn get_magasins(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
json(get_sites_from_api().await)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cadencier
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Statut d'une cadence : "a_commander" | "bientot" | "ok" | "inconnu".
|
||||
pub type CadenceStatut = &'static str;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CadenceView {
|
||||
pub id: i32,
|
||||
pub codefou: String,
|
||||
pub nomfou: String,
|
||||
pub site: String,
|
||||
pub intervalle_semaines: i32,
|
||||
pub actif: bool,
|
||||
/// Date ISO de la dernière réception, ou null si inconnue
|
||||
pub derniere_reception: Option<String>,
|
||||
/// Date ISO d'échéance (dernière réception + X semaines), ou null
|
||||
pub echeance: Option<String>,
|
||||
/// Jours restants avant l'échéance (négatif = en retard), ou null
|
||||
pub jours_restants: Option<i64>,
|
||||
pub statut: CadenceStatut,
|
||||
}
|
||||
|
||||
const MS_PAR_JOUR: i64 = 24 * 60 * 60 * 1000;
|
||||
/// Seuil (en jours) en dessous duquel une cadence passe en "bientôt à commander"
|
||||
const SEUIL_BIENTOT_JOURS: i64 = 7;
|
||||
|
||||
fn calc_statut(jours_restants: Option<i64>) -> CadenceStatut {
|
||||
match jours_restants {
|
||||
None => "inconnu",
|
||||
Some(j) if j <= 0 => "a_commander",
|
||||
Some(j) if j <= SEUIL_BIENTOT_JOURS => "bientot",
|
||||
Some(_) => "ok",
|
||||
}
|
||||
}
|
||||
|
||||
/// Liste des fournisseurs (référentiel) pour le sélecteur de cadence.
|
||||
pub async fn get_fournisseurs_pour_cadence(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
json(get_fournisseurs_cached().await?)
|
||||
}
|
||||
|
||||
struct CadenceRow {
|
||||
id: i32,
|
||||
code_fournisseur: String,
|
||||
nom_fournisseur: Option<String>,
|
||||
site: String,
|
||||
intervalle_semaines: i32,
|
||||
actif: bool,
|
||||
}
|
||||
|
||||
/// Toutes les cadences enrichies du calcul d'échéance (sans contrôle d'accès).
|
||||
/// Liste vide en cas d'erreur.
|
||||
pub async fn lister_cadences() -> Vec<CadenceView> {
|
||||
let res = async {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif FROM commande_cadences",
|
||||
)
|
||||
.fetch_all(&pool()?)
|
||||
.await?;
|
||||
let rows: Vec<CadenceRow> = rows
|
||||
.iter()
|
||||
.map(|r| CadenceRow {
|
||||
id: r.try_get("id").unwrap_or_default(),
|
||||
code_fournisseur: r.try_get("code_fournisseur").unwrap_or_default(),
|
||||
nom_fournisseur: r.try_get("nom_fournisseur").unwrap_or_default(),
|
||||
site: r.try_get("site").unwrap_or_default(),
|
||||
intervalle_semaines: r.try_get("intervalle_semaines").unwrap_or_default(),
|
||||
actif: r.try_get("actif").unwrap_or_default(),
|
||||
})
|
||||
.collect();
|
||||
// Réceptions des seuls fournisseurs du cadencier : la lecture sur tout
|
||||
// l'historique de tous les fournisseurs était la plus lourde de la page.
|
||||
let codes: Vec<String> = rows.iter().map(|r| r.code_fournisseur.clone()).collect();
|
||||
let receptions = get_derniere_reception_cached(&codes).await?;
|
||||
anyhow::Ok((rows, receptions))
|
||||
}
|
||||
.await;
|
||||
let (rows, receptions) = match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::error!("[cadence] listCadences error: {}", message_erreur(&e));
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let now = Local::now().with_timezone(&Utc).timestamp_millis();
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let reception_iso = receptions.get(&format!("{}|{}", r.code_fournisseur, r.site)).cloned();
|
||||
|
||||
let mut echeance_iso = None;
|
||||
let mut jours_restants = None;
|
||||
if let Some(iso) = reception_iso.as_deref() {
|
||||
if let Ok(d) = DateTime::parse_from_rfc3339(iso) {
|
||||
let echeance = d.timestamp_millis() + r.intervalle_semaines as i64 * 7 * MS_PAR_JOUR;
|
||||
echeance_iso = DateTime::<Utc>::from_timestamp_millis(echeance).map(crate::db::iso_utc);
|
||||
jours_restants = Some(((echeance - now) as f64 / MS_PAR_JOUR as f64).ceil() as i64);
|
||||
}
|
||||
}
|
||||
|
||||
CadenceView {
|
||||
id: r.id,
|
||||
nomfou: r.nom_fournisseur.clone().unwrap_or_else(|| r.code_fournisseur.clone()),
|
||||
codefou: r.code_fournisseur,
|
||||
site: r.site,
|
||||
intervalle_semaines: r.intervalle_semaines,
|
||||
actif: r.actif,
|
||||
derniere_reception: reception_iso,
|
||||
echeance: echeance_iso,
|
||||
jours_restants,
|
||||
statut: if r.actif { calc_statut(jours_restants) } else { "inconnu" },
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Retourne toutes les cadences enrichies du calcul d'échéance à partir
|
||||
/// de la dernière réception (FF Nancy).
|
||||
pub async fn list_cadences(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
json(lister_cadences().await)
|
||||
}
|
||||
|
||||
/// Réponse des actions d'écriture du cadencier.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ResultatCadence {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl ResultatCadence {
|
||||
fn ok() -> Self {
|
||||
Self { ok: true, error: None }
|
||||
}
|
||||
fn ko(error: Option<String>) -> Self {
|
||||
Self { ok: false, error }
|
||||
}
|
||||
}
|
||||
|
||||
/// `Math.round` (arrondi vers +∞ pour les demis).
|
||||
fn math_round(x: f64) -> f64 {
|
||||
(x + 0.5).floor()
|
||||
}
|
||||
|
||||
/// Crée ou met à jour la cadence d'un fournisseur sur un site.
|
||||
/// Entrée : `{ codefou, nomfou?, site, intervalleSemaines, actif? }`.
|
||||
pub async fn upsert_cadence(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.session().await {
|
||||
return json(ResultatCadence::ko(Some(e.message)));
|
||||
}
|
||||
let input = ctx.raw(0);
|
||||
if !input.is_object() {
|
||||
return Err(crate::error::AppError::internal("Cannot read properties of undefined (reading 'codefou')"));
|
||||
}
|
||||
|
||||
let texte = |k: &str| input.get(k).and_then(|v| v.as_str()).map(|s| s.trim().to_string());
|
||||
let codefou = texte("codefou").unwrap_or_default();
|
||||
let site = texte("site").unwrap_or_default();
|
||||
let intervalle = math_round(js_number(input.get("intervalleSemaines").unwrap_or(&Value::Null)));
|
||||
// `input.intervalleSemaines` absent → Number(undefined) = NaN.
|
||||
let intervalle = if input.get("intervalleSemaines").is_none() { f64::NAN } else { intervalle };
|
||||
|
||||
if codefou.is_empty() || site.is_empty() {
|
||||
return json(ResultatCadence::ko(Some("Fournisseur et magasin requis.".into())));
|
||||
}
|
||||
if !intervalle.is_finite() || !(1.0..=104.0).contains(&intervalle) {
|
||||
return json(ResultatCadence::ko(Some("L'intervalle doit être compris entre 1 et 104 semaines.".into())));
|
||||
}
|
||||
let nomfou: Option<String> = input.get("nomfou").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let actif = input.get("actif").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let res = async {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO commande_cadences (code_fournisseur, nom_fournisseur, site, intervalle_semaines, actif)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (code_fournisseur, site) DO UPDATE SET
|
||||
nom_fournisseur = $2,
|
||||
intervalle_semaines = $4,
|
||||
actif = $5,
|
||||
updated_at = $6"#,
|
||||
)
|
||||
.bind(&codefou)
|
||||
.bind(&nomfou)
|
||||
.bind(&site)
|
||||
.bind(intervalle as i32)
|
||||
.bind(actif)
|
||||
.bind(Local::now().naive_local())
|
||||
.execute(&pool()?)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => json(ResultatCadence::ok()),
|
||||
Err(e) => {
|
||||
tracing::error!("[cadence] upsertCadence error: {}", message_erreur(&e));
|
||||
json(ResultatCadence::ko(Some("Échec de l'enregistrement.".into())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Active / désactive une cadence sans la supprimer.
|
||||
/// Arguments : `(codefou, site, actif)`.
|
||||
pub async fn toggle_cadence(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.session().await {
|
||||
return json(ResultatCadence::ko(Some(e.message)));
|
||||
}
|
||||
let codefou: String = ctx.arg(0)?;
|
||||
let site: String = ctx.arg(1)?;
|
||||
let actif: bool = ctx.arg(2)?;
|
||||
|
||||
let res = async {
|
||||
sqlx::query("UPDATE commande_cadences SET actif = $1, updated_at = $2 WHERE code_fournisseur = $3 AND site = $4")
|
||||
.bind(actif)
|
||||
.bind(Local::now().naive_local())
|
||||
.bind(&codefou)
|
||||
.bind(&site)
|
||||
.execute(&pool()?)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => json(ResultatCadence::ok()),
|
||||
Err(e) => {
|
||||
tracing::error!("[cadence] toggleCadence error: {}", message_erreur(&e));
|
||||
json(ResultatCadence::ko(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime définitivement une cadence.
|
||||
/// Arguments : `(codefou, site)`.
|
||||
pub async fn remove_cadence(ctx: Ctx) -> AppResult<Value> {
|
||||
if let Err(e) = ctx.session().await {
|
||||
return json(ResultatCadence::ko(Some(e.message)));
|
||||
}
|
||||
let codefou: String = ctx.arg(0)?;
|
||||
let site: String = ctx.arg(1)?;
|
||||
|
||||
let res = async {
|
||||
sqlx::query("DELETE FROM commande_cadences WHERE code_fournisseur = $1 AND site = $2")
|
||||
.bind(&codefou)
|
||||
.bind(&site)
|
||||
.execute(&pool()?)
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => json(ResultatCadence::ok()),
|
||||
Err(e) => {
|
||||
tracing::error!("[cadence] removeCadence error: {}", message_erreur(&e));
|
||||
json(ResultatCadence::ko(None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn statuts() {
|
||||
assert_eq!(calc_statut(None), "inconnu");
|
||||
assert_eq!(calc_statut(Some(0)), "a_commander");
|
||||
assert_eq!(calc_statut(Some(-3)), "a_commander");
|
||||
assert_eq!(calc_statut(Some(7)), "bientot");
|
||||
assert_eq!(calc_statut(Some(8)), "ok");
|
||||
assert_eq!(math_round(2.5), 3.0);
|
||||
assert_eq!(math_round(-1.5), -1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
//! CollectFlow — client de l'API FF Nancy (port de `src/lib/api-ff-client.ts`).
|
||||
//!
|
||||
//! Client HTTP centralisé pour l'API REST https://api.ffnancy.fr
|
||||
//! Sync nuit SQL Server → données fraîches J-1
|
||||
//!
|
||||
//! Stratégie mouvements : appeler SANS filtre genremvt (tous types)
|
||||
//! - genremvt = 3 → alimenter sales12m (ventes)
|
||||
//! - Tous types → lire qtestock pour reconstruire stock12m
|
||||
|
||||
use super::js::{coalesce, encode, get, js_number, js_number_or0, js_string, message_erreur, opt_string, truthy};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const FF_API_BASE_DEFAULT: &str = "https://api.ffnancy.fr";
|
||||
const FF_API_BASE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Délai maximal d'un appel à l'API FF (`FF_API_TIMEOUT_MS`, 30 s par défaut).
|
||||
/// Sans délai, une API muette figeait indéfiniment la page qui l'attendait.
|
||||
pub fn ff_fetch_timeout() -> Duration {
|
||||
let ms = std::env::var("FF_API_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<f64>().ok())
|
||||
.filter(|v| v.is_finite() && *v > 0.0)
|
||||
.unwrap_or(30_000.0);
|
||||
Duration::from_millis(ms as u64)
|
||||
}
|
||||
|
||||
/// Client HTTP de tout appel à l'API FF (équivalent de `ffFetchInit()`) :
|
||||
/// jamais de cache HTTP, et un délai maximal.
|
||||
pub fn ff_client() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.timeout(ff_fetch_timeout())
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new())
|
||||
})
|
||||
}
|
||||
|
||||
/// `fetch(url, ffFetchInit())`.
|
||||
pub async fn ff_get(url: &str) -> reqwest::Result<reqwest::Response> {
|
||||
ff_client().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await
|
||||
}
|
||||
|
||||
/// Libellé HTTP d'un statut (équivalent de `res.statusText`).
|
||||
pub fn status_text(status: reqwest::StatusCode) -> &'static str {
|
||||
status.canonical_reason().unwrap_or("")
|
||||
}
|
||||
|
||||
static FF_API_BASE_CACHE: LazyLock<Mutex<Option<(String, Instant)>>> = LazyLock::new(|| Mutex::new(None));
|
||||
|
||||
/// URL de l'API FF Nancy, résolue **au moment de l'appel**.
|
||||
///
|
||||
/// Ordre de priorité : réglage enregistré dans Paramètres → variable
|
||||
/// d'environnement `FF_API_BASE_URL` → valeur par défaut. Auparavant l'URL était
|
||||
/// figée au chargement du module depuis l'environnement seul : impossible de la
|
||||
/// corriger depuis l'application quand le serveur changeait d'adresse.
|
||||
///
|
||||
/// Le résultat est mémorisé 30 s pour ne pas relire le fichier à chaque requête.
|
||||
pub async fn get_ff_api_base() -> String {
|
||||
if let Some((value, at)) = FF_API_BASE_CACHE.lock().as_ref() {
|
||||
if at.elapsed() < FF_API_BASE_TTL {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
// Fichier absent ou illisible : on retombe sur l'environnement.
|
||||
let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let env = std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty());
|
||||
let value = configured
|
||||
.or(env)
|
||||
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
*FF_API_BASE_CACHE.lock() = Some((value.clone(), Instant::now()));
|
||||
value
|
||||
}
|
||||
|
||||
/// Vide le cache — appelé après un enregistrement dans Paramètres.
|
||||
pub fn reset_ff_api_base_cache() {
|
||||
*FF_API_BASE_CACHE.lock() = None;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types API (shapes des réponses brutes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfFournisseur {
|
||||
pub codefou: String,
|
||||
pub nomfou: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfArticle {
|
||||
pub codein: String,
|
||||
pub libelle1: String,
|
||||
pub codefou: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nomfou: Option<String>,
|
||||
/// conditionnement
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pcb: Option<f64>,
|
||||
/// prix de vente central
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pv_central: Option<f64>,
|
||||
/// SQL Server internal ID (pour endpoint /mensuel)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub noid: Option<i64>,
|
||||
/// référence fournisseur (ref_fou_principale)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reference: Option<String>,
|
||||
/// code-barres EAN/GTIN
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub gtin: Option<String>,
|
||||
/// article actif
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub actif: Option<bool>,
|
||||
/// article suspendu
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub suspendu: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfMouvement {
|
||||
pub codein: String,
|
||||
/// 3 = vente client
|
||||
pub genremvt: f64,
|
||||
/// ISO date (YYYY-MM-DD)
|
||||
pub datemvt: String,
|
||||
pub qte: f64,
|
||||
/// montant HT
|
||||
pub montant: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub marge: Option<f64>,
|
||||
/// stock APRÈS ce mouvement — clé pour stock12m
|
||||
pub qtestock: f64,
|
||||
pub site: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub codefou_reel: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nom_fournisseur_reel: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfStock {
|
||||
pub codein: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub site: Option<String>,
|
||||
pub stockdispo: f64,
|
||||
pub qte: f64,
|
||||
pub valstock: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub prmp: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pv: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dernieresventes: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dernierereception: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nbjoursdernsventes: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfCommande {
|
||||
pub codein: String,
|
||||
pub qtecde: f64,
|
||||
}
|
||||
|
||||
/// Une table de `/api/sync/status`, forme normalisée.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FfSyncTable {
|
||||
pub nom: String,
|
||||
pub derniere_sync: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub nb_lignes: Option<f64>,
|
||||
/// "ok" ou état d'erreur renvoyé par la synchronisation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub statut: Option<String>,
|
||||
#[serde(default)]
|
||||
pub erreur: Option<String>,
|
||||
}
|
||||
|
||||
/// Statut de synchronisation. `tables` reste en JSON brut : la forme déjà
|
||||
/// attendue (`{ lastSync, tables }`) était renvoyée telle quelle par le TS.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FfSyncStatus {
|
||||
pub last_sync: String,
|
||||
pub tables: Vec<Value>,
|
||||
}
|
||||
|
||||
/// Normalise la réponse de `/api/sync/status`.
|
||||
///
|
||||
/// L'API renvoie `{ sync: [{ table_name, last_sync, rows_synced, status, error_msg }] }`,
|
||||
/// alors que l'application attendait `{ lastSync, tables: [{ nom, derniereSync, nbLignes }] }`.
|
||||
/// Les champs lus n'existaient donc pas : le panneau des Paramètres affichait une
|
||||
/// coquille vide sans la moindre erreur, puisque la requête HTTP réussissait.
|
||||
///
|
||||
/// Les deux formes sont acceptées ici, pour rester compatible si l'API évolue.
|
||||
pub fn normalize_sync_status(raw: &Value) -> Option<FfSyncStatus> {
|
||||
let d = raw.as_object()?;
|
||||
|
||||
// Forme déjà attendue par l'application.
|
||||
if let Some(Value::Array(tables)) = d.get("tables") {
|
||||
let last = d.get("lastSync").filter(|v| !v.is_null()).map(js_string).unwrap_or_default();
|
||||
return Some(FfSyncStatus { last_sync: last, tables: tables.clone() });
|
||||
}
|
||||
|
||||
// Forme réelle de l'API FF Nancy.
|
||||
let sync = match d.get("sync") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let tables: Vec<FfSyncTable> = sync
|
||||
.iter()
|
||||
.map(|t| FfSyncTable {
|
||||
nom: coalesce(t, &["table_name", "nom"]).map(js_string).unwrap_or_else(|| "—".into()),
|
||||
derniere_sync: coalesce(t, &["last_sync", "derniereSync"]).map(js_string).unwrap_or_default(),
|
||||
nb_lignes: Some(coalesce(t, &["rows_synced", "nbLignes"]).map(js_number_or0).unwrap_or(0.0)),
|
||||
statut: get(t, "status").filter(|v| !v.is_null()).map(js_string),
|
||||
erreur: get(t, "error_msg").filter(|v| !v.is_null()).map(js_string),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pas de date globale dans la réponse : on prend la plus récente des tables.
|
||||
let mut dates: Vec<&String> = tables.iter().map(|t| &t.derniere_sync).filter(|s| !s.is_empty()).collect();
|
||||
dates.sort();
|
||||
let last_sync = dates.last().map(|s| s.to_string()).unwrap_or_default();
|
||||
|
||||
let tables = tables.into_iter().map(|t| serde_json::to_value(t).unwrap_or(Value::Null)).collect();
|
||||
Some(FfSyncStatus { last_sync, tables })
|
||||
}
|
||||
|
||||
/// Stock agrégé sur tous les sites pour un codein
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AggregatedStock {
|
||||
/// sum stockdispo
|
||||
pub stock_actuel: f64,
|
||||
/// sum qte
|
||||
pub stock_total: f64,
|
||||
/// sum valstock
|
||||
pub stock_valeur: f64,
|
||||
/// prmp (premier site non-zéro)
|
||||
pub pa: f64,
|
||||
/// pv premier site
|
||||
pub prix_vente: f64,
|
||||
/// max date tous sites
|
||||
pub derniere_vente: String,
|
||||
/// max dernierereception tous sites
|
||||
pub derniere_livraison: String,
|
||||
/// min tous sites
|
||||
pub nb_jours_derniere_vente: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination générique
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Liste contenue dans une réponse : tableau direct, ou enveloppe courante.
|
||||
pub fn extract_list(data: &Value) -> Vec<Value> {
|
||||
if let Value::Array(a) = data {
|
||||
return a.clone();
|
||||
}
|
||||
let Some(d) = data.as_object() else { return vec![] };
|
||||
// Essaie les wrappers courants
|
||||
for key in [
|
||||
"items", "data", "result", "results", "records", "list", "mouvements", "articles", "fournisseurs", "ventes", "stocks",
|
||||
"commandes",
|
||||
] {
|
||||
if let Some(Value::Array(a)) = d.get(key) {
|
||||
return a.clone();
|
||||
}
|
||||
}
|
||||
// Cherche la première propriété qui est un tableau
|
||||
for val in d.values() {
|
||||
if let Value::Array(a) = val {
|
||||
return a.clone();
|
||||
}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn first_keys(items: &[Value]) -> String {
|
||||
match items.first().and_then(|v| v.as_object()) {
|
||||
Some(o) => o.keys().cloned().collect::<Vec<_>>().join(","),
|
||||
None => {
|
||||
if items.is_empty() {
|
||||
"empty".into()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_json(url: &str) -> anyhow::Result<Option<Value>> {
|
||||
let res = ff_get(url).await?;
|
||||
if !res.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(res.json::<Value>().await?))
|
||||
}
|
||||
|
||||
/// Toutes les pages d'une liste paginée. Une erreur réseau est propagée ; une
|
||||
/// réponse HTTP en échec donne une page vide.
|
||||
pub async fn fetch_all_pages(build_url: impl Fn(u32) -> String, page_size: usize) -> anyhow::Result<Vec<Value>> {
|
||||
// Page 1 — récupère les items et tente d'obtenir le total pour paralléliser
|
||||
let url1 = build_url(1);
|
||||
tracing::info!("[api-ff] GET {url1}");
|
||||
let res1 = ff_get(&url1).await?;
|
||||
if !res1.status().is_success() {
|
||||
tracing::error!("[api-ff] HTTP {} — {url1}", res1.status().as_u16());
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let data1: Value = res1.json().await?;
|
||||
let items1 = extract_list(&data1);
|
||||
tracing::info!("[api-ff] page 1: {} items, keys: {}", items1.len(), first_keys(&items1));
|
||||
|
||||
// Si la première page est incomplète, pas besoin de continuer
|
||||
if items1.len() < page_size {
|
||||
return Ok(items1);
|
||||
}
|
||||
|
||||
// Cherche un total dans la réponse pour paralléliser les pages suivantes
|
||||
let total = js_number(coalesce(&data1, &["count", "total", "nb_total", "totalCount"]).unwrap_or(&json!(0)));
|
||||
|
||||
if total > page_size as f64 {
|
||||
let total_pages = (total / page_size as f64).ceil() as u32;
|
||||
let remaining: Vec<u32> = (2..=total_pages).collect();
|
||||
// Fetch en chunks de 10 pages en parallèle
|
||||
let mut all = items1;
|
||||
for chunk in remaining.chunks(10) {
|
||||
let results = futures::future::try_join_all(chunk.iter().map(|&p| {
|
||||
let url = build_url(p);
|
||||
async move {
|
||||
tracing::info!("[api-ff] GET {url}");
|
||||
anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
for r in results {
|
||||
all.extend(r);
|
||||
}
|
||||
}
|
||||
return Ok(all);
|
||||
}
|
||||
|
||||
// Fallback parallèle sans total : fetch 10 pages à la fois, arrêt dès qu'une page est incomplète
|
||||
let mut all = items1;
|
||||
let mut start_page = 2u32;
|
||||
loop {
|
||||
let results = futures::future::try_join_all((0..10).map(|i| {
|
||||
let p = start_page + i;
|
||||
let url = build_url(p);
|
||||
async move {
|
||||
tracing::info!("[api-ff] GET {url}");
|
||||
anyhow::Ok(fetch_json(&url).await?.map(|d| extract_list(&d)).unwrap_or_default())
|
||||
}
|
||||
}))
|
||||
.await?;
|
||||
let mut done = false;
|
||||
for items in results {
|
||||
let n = items.len();
|
||||
all.extend(items);
|
||||
if n < page_size {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if done {
|
||||
break;
|
||||
}
|
||||
start_page += 10;
|
||||
}
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers date
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DateRange {
|
||||
pub date_debut: String,
|
||||
pub date_fin: String,
|
||||
}
|
||||
|
||||
/// Retourne la fenêtre des 12 mois complets précédant le mois en cours.
|
||||
/// En mars 2026 → { dateDebut: "2025-03-01", dateFin: "2026-02-28" }
|
||||
pub fn build_last12_months_range() -> DateRange {
|
||||
build_last12_months_range_at(Local::now().date_naive())
|
||||
}
|
||||
|
||||
pub fn build_last12_months_range_at(today: NaiveDate) -> DateRange {
|
||||
let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
// Fin : dernier jour du mois précédent le mois courant
|
||||
let end_month = first_of_month.pred_opt().unwrap_or(first_of_month);
|
||||
// Début : 12 mois avant le mois courant
|
||||
let start_month = first_of_month.checked_sub_months(chrono::Months::new(12)).unwrap_or(first_of_month);
|
||||
DateRange {
|
||||
date_debut: start_month.format("%Y-%m-%d").to_string(),
|
||||
date_fin: end_month.format("%Y-%m-%d").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Articles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn opt_f64(v: Option<&Value>) -> Option<f64> {
|
||||
v.filter(|x| !x.is_null()).map(js_number).filter(|f| !f.is_nan())
|
||||
}
|
||||
|
||||
pub async fn get_articles_by_fournisseur(codefou: &str) -> anyhow::Result<Vec<FfArticle>> {
|
||||
let base = get_ff_api_base().await;
|
||||
let enc = encode(codefou);
|
||||
let raw = fetch_all_pages(|page| format!("{base}/api/articles?codefou={enc}&page={page}&limit=500"), 500).await?;
|
||||
Ok(raw
|
||||
.iter()
|
||||
.map(|r| FfArticle {
|
||||
codein: coalesce(r, &["codein", "Codein", "code_article", "codeArticle"])
|
||||
.map(js_string)
|
||||
.unwrap_or_else(|| get(r, "id").filter(|v| !v.is_null()).map(js_string).unwrap_or_default()),
|
||||
libelle1: coalesce(r, &["libelle1", "Libelle1", "libelle", "designation", "nom"]).map(js_string).unwrap_or_default(),
|
||||
codefou: coalesce(r, &["codefou_principal", "codefou", "Codefou", "code_fournisseur", "codeFournisseur"])
|
||||
.map(js_string)
|
||||
.unwrap_or_else(|| codefou.to_string()),
|
||||
nomfou: Some(
|
||||
coalesce(r, &["nom_fou_principal", "nomfou", "Nomfou", "nom_fournisseur", "nomFournisseur"])
|
||||
.map(js_string)
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
pcb: opt_f64(coalesce(r, &["pcb_principal", "pcb", "Pcb", "conditionnement", "colisage"])),
|
||||
pv_central: opt_f64(coalesce(r, &["pv_central", "PvCentral", "prix_vente_mini", "pv", "prixVente"])),
|
||||
noid: opt_f64(coalesce(r, &["no_id", "noid", "NoId"])).map(|f| f as i64),
|
||||
reference: opt_string(coalesce(r, &["ref_fou_principale", "reference", "ref_fournisseur"])),
|
||||
gtin: opt_string(coalesce(r, &["gtin", "Gtin", "ean", "barcode"])),
|
||||
actif: get(r, "actif").filter(|v| !v.is_null()).map(|v| {
|
||||
let n = js_number(v);
|
||||
n != 0.0 && !n.is_nan()
|
||||
}),
|
||||
suspendu: get(r, "suspendu").filter(|v| !v.is_null()).map(|v| {
|
||||
let n = js_number(v);
|
||||
n != 0.0 && !n.is_nan()
|
||||
}),
|
||||
})
|
||||
.filter(|a| !a.codein.is_empty())
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mensuel — stock fin de mois + ventes + réceptions par article et par site
|
||||
// GET /api/articles/:no_id/mensuel?dateDebut=&dateFin=
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Chaîne tolérante : un nombre devient sa forme texte, `null` une chaîne vide.
|
||||
fn de_string_lenient<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
Ok(if v.is_null() { String::new() } else { js_string(&v) })
|
||||
}
|
||||
|
||||
/// Ventes du mois. Valeurs telles que renvoyées par l'API (chaînes numériques,
|
||||
/// ex. `"-2.000"`) : lire avec [`super::js::js_number`].
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfMensuelVentes {
|
||||
#[serde(default)]
|
||||
pub nb_passages: Value,
|
||||
/// négatif (ex: "-2.000")
|
||||
#[serde(default)]
|
||||
pub qte_vendue: Value,
|
||||
/// négatif (ex: "-26.6600")
|
||||
#[serde(default)]
|
||||
pub ca_ht: Value,
|
||||
#[serde(default)]
|
||||
pub ca_ttc: Value,
|
||||
/// positif (ex: "12.7600")
|
||||
#[serde(default)]
|
||||
pub marge: Value,
|
||||
#[serde(default)]
|
||||
pub taux_marge: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfMensuelReceptions {
|
||||
#[serde(default)]
|
||||
pub nb_receptions: Value,
|
||||
#[serde(default)]
|
||||
pub qte_recue: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct FfMensuelEntry {
|
||||
/// "YYYY-MM"
|
||||
#[serde(default, deserialize_with = "de_string_lenient")]
|
||||
pub mois: String,
|
||||
/// code site ("292", "579")
|
||||
#[serde(default, deserialize_with = "de_string_lenient")]
|
||||
pub site: String,
|
||||
/// stock fin de mois
|
||||
#[serde(default)]
|
||||
pub stock_fin_mois: Value,
|
||||
/// PRMP fin de mois
|
||||
#[serde(default)]
|
||||
pub prmp_fin_mois: Value,
|
||||
#[serde(default)]
|
||||
pub ventes: Option<FfMensuelVentes>,
|
||||
#[serde(default)]
|
||||
pub receptions: Option<FfMensuelReceptions>,
|
||||
/// Autres champs de la réponse, conservés tels quels.
|
||||
#[serde(flatten, default)]
|
||||
pub autres: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Récupère les données mensuelles (stock + ventes + réceptions) pour un lot d'articles.
|
||||
/// Utilise no_id comme identifiant (obligatoire pour l'API).
|
||||
/// Retourne Map<codein, FfMensuelEntry[]>
|
||||
pub async fn get_mensuel_by_articles(
|
||||
articles: &[FfArticle],
|
||||
date_debut: &str,
|
||||
date_fin: &str,
|
||||
batch_size: usize,
|
||||
) -> HashMap<String, Vec<FfMensuelEntry>> {
|
||||
let mut result = HashMap::new();
|
||||
let base = get_ff_api_base().await;
|
||||
|
||||
for batch in articles.chunks(batch_size.max(1)) {
|
||||
let found = futures::future::join_all(batch.iter().map(|art| {
|
||||
let base = base.clone();
|
||||
async move {
|
||||
let no_id = art.noid.filter(|n| *n != 0)?;
|
||||
let url = format!("{base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(&no_id.to_string()));
|
||||
let res = async {
|
||||
let res = ff_get(&url).await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(None);
|
||||
}
|
||||
let data: Value = res.json().await?;
|
||||
let entries: Vec<FfMensuelEntry> = match get(&data, "data") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|e| serde_json::from_value(e.clone()).ok()).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
Ok(Some(entries))
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(Some(entries)) if !entries.is_empty() => Some((art.codein.clone(), entries)),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
tracing::error!("[api-ff] getMensuel error for {}: {}", art.codein, message_erreur(&err));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
result.extend(found.into_iter().flatten());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Referentiel — article complet : nomenclature, gammes, stock, prix, fournisseurs
|
||||
// GET /api/articles/:no_id/referentiel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Référentiel complet d'un article, JSON brut de l'API :
|
||||
/// `{ article: { no_id, codein, nom_code, nom_libelle, nom_niveau, nom_chemin_pere },
|
||||
/// gammes: [{ gamme_code, gamme_libelle, saison_code, saison_libelle }],
|
||||
/// stock: [{ site, stockdispo, qte, prmp, valstock, pv, dernierevente, dernierereception, stockmort }],
|
||||
/// prix: { achat, vente_par_site: [{ site, pv }] } | null,
|
||||
/// fournisseurs: [{ codefou, ref_fou, pcb, prixachat }],
|
||||
/// performance: { derniere_vente, derniere_entree, qte_totale_vendue, ca_ttc_total, marge_totale } | null }`
|
||||
pub type FfReferentiel = Value;
|
||||
|
||||
/// Récupère le référentiel complet (nomenclature, gamme, stock, prix) pour un lot d'articles.
|
||||
/// Utilise no_id comme identifiant.
|
||||
/// Retourne Map<codein, FfReferentiel>
|
||||
pub async fn get_referentiel_by_articles(articles: &[FfArticle], batch_size: usize) -> HashMap<String, FfReferentiel> {
|
||||
let mut result = HashMap::new();
|
||||
let base = get_ff_api_base().await;
|
||||
|
||||
for batch in articles.chunks(batch_size.max(1)) {
|
||||
let found = futures::future::join_all(batch.iter().map(|art| {
|
||||
let base = base.clone();
|
||||
async move {
|
||||
let no_id = art.noid.filter(|n| *n != 0)?;
|
||||
let url = format!("{base}/api/articles/{}/referentiel", encode(&no_id.to_string()));
|
||||
let res = async {
|
||||
let res = ff_get(&url).await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(None);
|
||||
}
|
||||
let data: Value = res.json().await?;
|
||||
Ok(get(&data, "article").filter(|a| truthy(a)).is_some().then_some(data))
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(Some(data)) => Some((art.codein.clone(), data)),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::error!("[api-ff] getReferentiel error for {}: {}", art.codein, message_erreur(&err));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
result.extend(found.into_iter().flatten());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commandes en cours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_commandes_by_fournisseur(codefou: &str) -> HashMap<String, f64> {
|
||||
let mut result: HashMap<String, f64> = HashMap::new();
|
||||
let base = get_ff_api_base().await;
|
||||
let res = async {
|
||||
let res = ff_get(&format!("{base}/api/commandes/articles?codefou={}", encode(codefou))).await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(());
|
||||
}
|
||||
let data: Value = res.json().await?;
|
||||
let list = if data.is_array() { Some(&data) } else { coalesce(&data, &["items", "data"]) };
|
||||
let list = match list {
|
||||
None => return Ok(()),
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(_) => anyhow::bail!("list is not iterable"),
|
||||
};
|
||||
for c in list {
|
||||
let codein = get(c, "codein").map(js_string).unwrap_or_else(|| "undefined".into());
|
||||
let qte = get(c, "qtecde").filter(|v| !v.is_null()).map(js_number).unwrap_or(0.0);
|
||||
*result.entry(codein).or_insert(0.0) += qte;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = res {
|
||||
tracing::error!("[api-ff] getCommandesByFournisseur error: {}", message_erreur(&err));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sites (magasins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn get_sites_from_api() -> Vec<super::pg::CodeNom> {
|
||||
vec![
|
||||
super::pg::CodeNom { code: "292".into(), nom: "Frouard (Nancy)".into() },
|
||||
super::pg::CodeNom { code: "579".into(), nom: "Houdemont".into() },
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn last12_range() {
|
||||
let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 3, 15).unwrap());
|
||||
assert_eq!(r, DateRange { date_debut: "2025-03-01".into(), date_fin: "2026-02-28".into() });
|
||||
let r = build_last12_months_range_at(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
|
||||
assert_eq!(r, DateRange { date_debut: "2025-01-01".into(), date_fin: "2025-12-31".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_status_forms() {
|
||||
let api = json!({ "sync": [
|
||||
{ "table_name": "mvtart", "last_sync": "2026-01-02T03:00:00Z", "rows_synced": "12", "status": "ok", "error_msg": null },
|
||||
{ "table_name": "articles", "last_sync": "2026-01-03T03:00:00Z", "rows_synced": null, "status": null, "error_msg": "x" }
|
||||
]});
|
||||
let s = serde_json::to_value(normalize_sync_status(&api).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
s,
|
||||
json!({ "lastSync": "2026-01-03T03:00:00Z", "tables": [
|
||||
{ "nom": "mvtart", "derniereSync": "2026-01-02T03:00:00Z", "nbLignes": 12.0, "statut": "ok", "erreur": null },
|
||||
{ "nom": "articles", "derniereSync": "2026-01-03T03:00:00Z", "nbLignes": 0.0, "erreur": "x" }
|
||||
]})
|
||||
);
|
||||
let deja = json!({ "lastSync": "a", "tables": [{ "nom": "t" }] });
|
||||
let s = serde_json::to_value(normalize_sync_status(&deja).unwrap()).unwrap();
|
||||
assert_eq!(s, json!({ "lastSync": "a", "tables": [{ "nom": "t" }] }));
|
||||
assert!(normalize_sync_status(&json!({ "x": 1 })).is_none());
|
||||
assert!(normalize_sync_status(&json!(null)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_list_wrappers() {
|
||||
assert_eq!(extract_list(&json!([1, 2])).len(), 2);
|
||||
assert_eq!(extract_list(&json!({ "articles": [1] })).len(), 1);
|
||||
assert_eq!(extract_list(&json!({ "foo": 1, "bar": [1, 2, 3] })).len(), 3);
|
||||
assert!(extract_list(&json!("x")).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Lectures FF partagées par plusieurs pages, mises en cache
|
||||
//! (port de `getFournisseursCached` / `getDerniereReceptionCached` de `src/lib/ff-cache.ts`).
|
||||
|
||||
use super::pg::{pg_get_derniere_reception_par_fournisseur, pg_get_fournisseurs, CodeNom};
|
||||
use crate::ff_cache::{cached, CacheOpts};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
/// Référentiel fournisseurs (Grille, cadencier). Vide = erreur avalée, non gardé.
|
||||
pub async fn get_fournisseurs_cached() -> anyhow::Result<Vec<CodeNom>> {
|
||||
cached(
|
||||
"fournisseurs",
|
||||
CacheOpts { ttl: None, cache_if: Some(|rows: &Vec<CodeNom>| !rows.is_empty()) },
|
||||
|| async { anyhow::Ok(pg_get_fournisseurs(None).await) },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dernière réception par fournisseur et magasin, pour les fournisseurs du
|
||||
/// cadencier seulement : de loin la lecture la plus lourde de la page Commandes.
|
||||
/// Clé = liste triée, donc recalculée quand une cadence est ajoutée ou retirée.
|
||||
/// Vide = erreur avalée (ou aucune réception), non gardé.
|
||||
pub async fn get_derniere_reception_cached(codes: &[String]) -> anyhow::Result<HashMap<String, String>> {
|
||||
let liste: Vec<String> = codes.iter().map(|c| c.trim().to_string()).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
if liste.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let key = format!("derniere-reception:{}", liste.join(","));
|
||||
cached(
|
||||
&key,
|
||||
CacheOpts { ttl: None, cache_if: Some(|map: &HashMap<String, String>| !map.is_empty()) },
|
||||
move || async move { anyhow::Ok(pg_get_derniere_reception_par_fournisseur(Some(&liste)).await) },
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! CollectFlow — Diagnostic de l'API FF Nancy
|
||||
//! (port de `src/features/settings/ff-api-diagnostic.ts`).
|
||||
//!
|
||||
//! Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la
|
||||
//! route `/api/ff-status` (tout utilisateur connecté). Chaque appelant fait son
|
||||
//! propre contrôle d'accès.
|
||||
|
||||
use super::api::{normalize_sync_status, status_text, FfSyncStatus, FF_API_BASE_DEFAULT};
|
||||
use super::js::truthy;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Résultat du diagnostic : `{ success: true, url, ms, status }` ou
|
||||
/// `{ success: false, url, error }`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DiagnosticApiFf {
|
||||
pub success: bool,
|
||||
pub url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<FfSyncStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl DiagnosticApiFf {
|
||||
fn echec(url: &str, error: String) -> Self {
|
||||
Self { success: false, url: url.to_string(), ms: None, status: None, error: Some(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cause réseau d'une erreur `reqwest` : (code façon Node, message).
|
||||
fn cause_reseau(e: &reqwest::Error) -> Option<(&'static str, String)> {
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(e);
|
||||
let mut messages = vec![];
|
||||
while let Some(s) = source {
|
||||
if let Some(io) = s.downcast_ref::<std::io::Error>() {
|
||||
match io.kind() {
|
||||
std::io::ErrorKind::ConnectionRefused => return Some(("ECONNREFUSED", io.to_string())),
|
||||
std::io::ErrorKind::ConnectionReset => return Some(("ECONNRESET", io.to_string())),
|
||||
std::io::ErrorKind::HostUnreachable => return Some(("EHOSTUNREACH", io.to_string())),
|
||||
std::io::ErrorKind::NetworkUnreachable => return Some(("ENETUNREACH", io.to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
messages.push(s.to_string());
|
||||
source = s.source();
|
||||
}
|
||||
let tout = messages.join(" ").to_lowercase();
|
||||
if tout.contains("dns error") || tout.contains("failed to lookup address") || tout.contains("name or service not known") {
|
||||
return Some(("ENOTFOUND", messages.last().cloned().unwrap_or_default()));
|
||||
}
|
||||
if tout.contains("connection refused") {
|
||||
return Some(("ECONNREFUSED", messages.last().cloned().unwrap_or_default()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Message complet d'une erreur et de ses causes.
|
||||
fn message_complet(e: &dyn std::error::Error) -> String {
|
||||
let mut parts = vec![e.to_string()];
|
||||
let mut source = e.source();
|
||||
while let Some(s) = source {
|
||||
let m = s.to_string();
|
||||
if !parts.iter().any(|p| p.contains(&m)) {
|
||||
parts.push(m);
|
||||
}
|
||||
source = s.source();
|
||||
}
|
||||
parts.join(": ")
|
||||
}
|
||||
|
||||
/// Teste l'API FF Nancy et renvoie un diagnostic **exploitable**.
|
||||
///
|
||||
/// L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
||||
/// si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue
|
||||
/// donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP.
|
||||
pub async fn diagnostiquer_api_ff(ff_api_base_url: Option<String>) -> DiagnosticApiFf {
|
||||
let saisie = ff_api_base_url.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let base = match saisie {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
let configured = tokio::task::spawn_blocking(|| crate::config::read_config().ff_api_base_url)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|s| !s.is_empty());
|
||||
configured
|
||||
.or_else(|| std::env::var("FF_API_BASE_URL").ok().filter(|s| !s.is_empty()))
|
||||
.unwrap_or_else(|| FF_API_BASE_DEFAULT.to_string())
|
||||
}
|
||||
};
|
||||
let base = base.trim_end_matches('/').to_string();
|
||||
let url = format!("{base}/api/sync/status");
|
||||
let started = Instant::now();
|
||||
|
||||
let client = reqwest::Client::builder().timeout(Duration::from_millis(8000)).build().unwrap_or_default();
|
||||
match client.get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await {
|
||||
Ok(res) => {
|
||||
let ms = started.elapsed().as_millis() as u64;
|
||||
if !res.status().is_success() {
|
||||
let txt = status_text(res.status());
|
||||
return DiagnosticApiFf::echec(
|
||||
&url,
|
||||
format!(
|
||||
"Le serveur a répondu HTTP {} ({}) en {ms} ms.",
|
||||
res.status().as_u16(),
|
||||
if txt.is_empty() { "sans message" } else { txt }
|
||||
),
|
||||
);
|
||||
}
|
||||
let body = res.json::<Value>().await.ok().filter(truthy);
|
||||
let Some(body) = body else {
|
||||
return DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais corps illisible (JSON attendu).".into());
|
||||
};
|
||||
// La réponse brute de l'API n'a pas la forme attendue par l'interface :
|
||||
// sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200.
|
||||
match normalize_sync_status(&body) {
|
||||
None => DiagnosticApiFf::echec(&url, "Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).".into()),
|
||||
Some(status) => DiagnosticApiFf { success: true, url, ms: Some(ms), status: Some(status), error: None },
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let ms = started.elapsed().as_millis() as u64;
|
||||
let raw = message_complet(&e);
|
||||
// La cause réelle se cache derrière l'erreur de haut niveau : on la déplie.
|
||||
let hint = if e.is_timeout() {
|
||||
format!("Aucune réponse en {ms} ms — serveur injoignable ou trop lent.")
|
||||
} else {
|
||||
match cause_reseau(&e) {
|
||||
Some(("ENOTFOUND", _)) => "Nom d'hôte introuvable (DNS) — vérifiez l'URL.".to_string(),
|
||||
Some(("ECONNREFUSED", _)) => "Connexion refusée — le service n'écoute pas sur cette adresse.".to_string(),
|
||||
Some((code, msg)) => {
|
||||
if msg.is_empty() {
|
||||
code.to_string()
|
||||
} else {
|
||||
format!("{code} — {msg}")
|
||||
}
|
||||
}
|
||||
None => raw.clone(),
|
||||
}
|
||||
};
|
||||
tracing::error!("[Settings] FF API test KO ({url}): {raw}");
|
||||
DiagnosticApiFf::echec(&url, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Test d'intégration du module FF sur une base de test.
|
||||
//!
|
||||
//! Ne s'exécute que si `FF_TEST_DATABASE_URL` désigne une base contenant les
|
||||
//! tables de l'application et des tables FF minimales (articles, artfou1,
|
||||
//! mvtart, cube_stock…). Une fausse API FF Nancy est servie localement : toutes
|
||||
//! les requêtes SQL et tous les appels HTTP du module sont joués au moins une
|
||||
//! fois (erreurs de syntaxe SQL, de décodage de types, formes JSON).
|
||||
//!
|
||||
//! FF_TEST_DATABASE_URL=postgres://…/ff_fixture cargo test ff::fixture_tests -- --nocapture
|
||||
|
||||
use super::*;
|
||||
use crate::auth::Claims;
|
||||
use crate::rpc::Ctx;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::http::Request;
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tower::ServiceExt;
|
||||
|
||||
/// Fausse API FF Nancy (formes de réponse reprises du code d'origine).
|
||||
async fn fausse_api() -> String {
|
||||
async fn dashboard(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
let date = q.get("date").cloned().unwrap_or_default();
|
||||
Json(json!({
|
||||
"date": date,
|
||||
"sites": [
|
||||
{ "site": "292", "ca_ttc": "1500.50", "ca_ttc_n1": 1200, "trafic": 80, "trafic_n1": "70" },
|
||||
{ "site": "579", "ca_ttc": 900, "ca_ttc_n1": 0, "trafic": 40, "trafic_n1": 35 }
|
||||
],
|
||||
"top10_ca": [
|
||||
{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": "45.5", "qte_vendue": 3, "marge": 12 },
|
||||
{ "codein": "100002", "libelle1": "CREME", "site": "292", "ca_ttc": 60, "qte_vendue": 1, "marge": 2 },
|
||||
{ "codein": "100003", "libelle1": "BOUGIE", "site": "579", "ca_ttc": 10, "qte_vendue": 1, "marge": 1 }
|
||||
],
|
||||
"top10_qte": [{ "codein": "100001", "libelle1": "TAPIS", "site": "292", "ca_ttc": 45.5, "qte_vendue": 3, "marge": 12 }],
|
||||
"top10_marge": []
|
||||
}))
|
||||
}
|
||||
async fn articles(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
if let Some(c) = q.get("codein") {
|
||||
if c == "100003" {
|
||||
return Json(json!({ "articles": [] }));
|
||||
}
|
||||
return Json(json!({ "articles": [{ "no_id": if c == "100001" { 10 } else { 11 }, "codein": c }] }));
|
||||
}
|
||||
let page: u32 = q.get("page").and_then(|p| p.parse().ok()).unwrap_or(1);
|
||||
if page > 1 {
|
||||
return Json(json!({ "articles": [] }));
|
||||
}
|
||||
Json(json!({ "count": 2, "articles": [
|
||||
{ "codein": "100001", "libelle1": "TAPIS", "codefou_principal": "F001", "pcb_principal": "6", "no_id": 10, "actif": "1", "suspendu": 0 },
|
||||
{ "Codein": "100002", "libelle": "CREME", "pv": 3.5, "noid": "11", "gtin": "300" }
|
||||
]}))
|
||||
}
|
||||
async fn referentiel(Path(id): Path<String>) -> Json<Value> {
|
||||
Json(json!({
|
||||
"article": { "no_id": id, "codein": "x" },
|
||||
"stock": [{ "site": "292", "qte": "4" }, { "site": "579", "qte": 2 }],
|
||||
"fournisseurs": [{ "codefou": "F001", "nom_fou": " Fournisseur Un ", "preference": 0 }, { "codefou": "F002", "nom_fou": null, "preference": 1 }]
|
||||
}))
|
||||
}
|
||||
async fn dernier(Path(id): Path<String>) -> Json<Value> {
|
||||
if id == "10" {
|
||||
Json(json!({ "fournisseurs_par_site": [{ "site": "292", "nom_fournisseur": "" }, { "site": "579", "nom_fournisseur": " Dernier Fou " }] }))
|
||||
} else {
|
||||
Json(json!({ "fournisseurs_par_site": [] }))
|
||||
}
|
||||
}
|
||||
async fn mensuel(Path(id): Path<String>) -> Json<Value> {
|
||||
Json(json!({ "data": [{ "mois": "2026-01", "site": 292, "stock_fin_mois": "5.000", "prmp_fin_mois": "1",
|
||||
"ventes": { "nb_passages": "2", "qte_vendue": "-2.000", "ca_ht": "-26.66", "ca_ttc": "-30", "marge": "12.76", "taux_marge": "40" },
|
||||
"receptions": null, "id": id }] }))
|
||||
}
|
||||
async fn commandes_auto(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
Json(json!({ "count": 3, "query": q, "propositions": [
|
||||
{ "site": "292", "codefou": "F001", "nom_fou": "Fournisseur Un", "franco_ht": "150", "nb_articles": 4, "montant_propo_ht": 99.5, "franco_atteint": "NON", "ecart_franco": -50.5 },
|
||||
{ "site": "579", "codefou": "F002", "nom_fou": "Fournisseur Deux", "franco_ht": null, "nb_articles": 1, "montant_propo_ht": 10, "franco_atteint": "N/A", "ecart_franco": 0 },
|
||||
{ "site": "292", "codefou": "F009", "nomfou": "Sans franco", "franco": 0, "nb_articles": 1, "montant_cde": 5, "franco_atteint": 1 }
|
||||
]}))
|
||||
}
|
||||
async fn commandes_auto_detail(Path(codefou): Path<String>, Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
match (codefou.as_str(), q.get("site").map(|s| s.as_str())) {
|
||||
("F002", None) => Json(json!({ "resume": [{ "franco_ht": "300" }] })),
|
||||
("F009", Some("000")) => Json(json!({ "summary": { "franco": 75 } })),
|
||||
_ => Json(json!({ "error": "inconnu" })),
|
||||
}
|
||||
}
|
||||
async fn commandes_articles() -> Json<Value> {
|
||||
Json(json!([{ "codein": "100001", "qtecde": 5 }, { "codein": "100001", "qtecde": 3 }, { "codein": "100002" }]))
|
||||
}
|
||||
async fn sync_status() -> Json<Value> {
|
||||
Json(json!({ "sync": [{ "table_name": "mvtart", "last_sync": "2026-10-12T02:00:00Z", "rows_synced": 42, "status": "ok", "error_msg": null }] }))
|
||||
}
|
||||
async fn publicites(Query(q): Query<HashMap<String, String>>) -> Json<Value> {
|
||||
Json(json!({ "publicites": [{ "tcr_code": "P1", "intitule": "Promo", "query": q }], "total": "1", "pages": 1 }))
|
||||
}
|
||||
async fn historique() -> axum::http::StatusCode {
|
||||
axum::http::StatusCode::BAD_GATEWAY
|
||||
}
|
||||
|
||||
use axum::routing::get;
|
||||
let app = axum::Router::new()
|
||||
.route("/api/performance/dashboard", get(dashboard))
|
||||
.route("/api/articles", get(articles))
|
||||
.route("/api/articles/{id}/referentiel", get(referentiel))
|
||||
.route("/api/articles/{id}/dernier-fournisseur", get(dernier))
|
||||
.route("/api/articles/{id}/mensuel", get(mensuel))
|
||||
.route("/api/commandes-auto", get(commandes_auto))
|
||||
.route("/api/commandes-auto/{codefou}", get(commandes_auto_detail))
|
||||
.route("/api/commandes/articles", get(commandes_articles))
|
||||
.route("/api/sync/status", get(sync_status))
|
||||
.route("/api/fournisseurs", get(|| async { Json(json!({ "fournisseurs": [{ "codefou": "F001" }] })) }))
|
||||
.route("/api/publicites", get(publicites))
|
||||
.route("/api/publicites/historique", get(historique));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
fn montrer(nom: &str, v: impl serde::Serialize) -> Value {
|
||||
let v = serde_json::to_value(v).unwrap();
|
||||
println!("── {nom}\n{}", serde_json::to_string_pretty(&v).unwrap());
|
||||
v
|
||||
}
|
||||
|
||||
async fn appel_route(token: &str, uri: &str) -> (u16, Value) {
|
||||
let req = Request::builder()
|
||||
.uri(uri)
|
||||
.header("cookie", format!("{}={token}", crate::auth::session::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = router().oneshot(req).await.unwrap();
|
||||
let status = res.status().as_u16();
|
||||
let bytes = axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap();
|
||||
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn ctx(claims: &Claims, query: &[(&str, &str)], args: Vec<Value>) -> Ctx {
|
||||
Ctx {
|
||||
token: Some(claims.clone()),
|
||||
query: query.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
|
||||
args,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn module_ff_sur_base_de_test() {
|
||||
let Ok(url) = std::env::var("FF_TEST_DATABASE_URL") else {
|
||||
eprintln!("FF_TEST_DATABASE_URL absent : test d'intégration FF ignoré.");
|
||||
return;
|
||||
};
|
||||
let data_dir = std::env::temp_dir().join(format!("cf-ff-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&data_dir).unwrap();
|
||||
std::env::set_var("DATA_DIR", &data_dir);
|
||||
std::env::set_var("DATABASE_URL", &url);
|
||||
std::env::set_var("AUTH_SECRET", "secret-de-test");
|
||||
let base = fausse_api().await;
|
||||
std::env::set_var("FF_API_BASE_URL", &base);
|
||||
api::reset_ff_api_base_cache();
|
||||
|
||||
let p = crate::db::pool().unwrap();
|
||||
sqlx::query("INSERT INTO users (id, username, password_hash, role) VALUES (9001, 'test-ff', 'x', 'admin') ON CONFLICT DO NOTHING")
|
||||
.execute(&p)
|
||||
.await
|
||||
.unwrap();
|
||||
let claims = Claims { id: "9001".into(), name: "test-ff".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 };
|
||||
let token = crate::auth::session::encode(&claims);
|
||||
let r12 = api::build_last12_months_range();
|
||||
let (d, f) = (r12.date_debut.as_str(), r12.date_fin.as_str());
|
||||
let codes = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
|
||||
// ── pg.rs
|
||||
let fous = montrer("pg_get_fournisseurs", pg::pg_get_fournisseurs(None).await);
|
||||
assert_eq!(fous.as_array().unwrap().len(), 2, "F003 (nom vide) écarté");
|
||||
montrer("pg_get_fournisseurs(search)", pg::pg_get_fournisseurs(Some("deux")).await);
|
||||
let arts = pg::pg_get_articles_by_fournisseur("F001").await.unwrap();
|
||||
montrer("pg_get_articles_by_fournisseur", &arts);
|
||||
assert!(arts.iter().all(|a| a.codein.trim() != "100004"), "article suspendu exclu");
|
||||
assert!(arts.iter().any(|a| a.code_centrale.as_deref() == Some("10000111111") && a.pa == Some(4.5)));
|
||||
montrer("pg_get_mensuel_by_fournisseur", pg::pg_get_mensuel_by_fournisseur("F001", d, f).await.unwrap());
|
||||
montrer("pg_get_gammes_by_fournisseur", pg::pg_get_gammes_by_fournisseur("F001").await.unwrap());
|
||||
let g = pg::pg_get_gammes_by_codeins(&codes(&["100001", " 100002 ", "", "100001"])).await.unwrap();
|
||||
montrer("pg_get_gammes_by_codeins", &g);
|
||||
assert_eq!(g.get("100001").map(|s| s.as_str()), Some("C"));
|
||||
montrer("pg_get_nomenclature_by_fournisseur", pg::pg_get_nomenclature_by_fournisseur("F001").await.unwrap());
|
||||
montrer("pg_get_stock_by_fournisseur", pg::pg_get_stock_by_fournisseur("F001").await.unwrap());
|
||||
let pv = pg::pg_get_prix_vente_by_fournisseur("F001").await.unwrap();
|
||||
montrer("pg_get_prix_vente_by_fournisseur", &pv);
|
||||
assert_eq!(pv.values().next().and_then(|m| m.get("292")).copied(), Some(12.5), "site élagué");
|
||||
montrer("pg_get_commandes_by_fournisseur", pg::pg_get_commandes_by_fournisseur("F001").await.unwrap());
|
||||
let mois = chrono::Local::now().date_naive().format("%Y-%m").to_string();
|
||||
let n1 = pages::get_mois_n1(&mois);
|
||||
montrer("pg_get_ca_by_fournisseur", pg::pg_get_ca_by_fournisseur(&mois, &n1).await.unwrap());
|
||||
montrer("pg_get_ca_by_nomenclature", pg::pg_get_ca_by_nomenclature(&r12.date_fin[..7], &mois).await.unwrap());
|
||||
let hp = pg::pg_get_hit_parade(d, &chrono::Local::now().date_naive().to_string()).await.unwrap();
|
||||
montrer("pg_get_hit_parade", &hp);
|
||||
assert!(!hp.is_empty());
|
||||
montrer("pg_get_stock_for_codeins", pg::pg_get_stock_for_codeins(&codes(&["100001", "100003"])).await.unwrap());
|
||||
montrer("pg_diag_fournisseur_derniere_entree", pg::pg_diag_fournisseur_derniere_entree().await.unwrap());
|
||||
montrer("pg_get_stock_sans_vente", pg::pg_get_stock_sans_vente(None).await.unwrap());
|
||||
montrer("pg_get_stock_sans_vente(292)", pg::pg_get_stock_sans_vente(Some("292")).await.unwrap());
|
||||
let neg = pg::pg_get_stock_negatif(None).await.unwrap();
|
||||
montrer("pg_get_stock_negatif", &neg);
|
||||
assert!(!neg.is_empty());
|
||||
montrer("pg_get_stock_negatif(579)", pg::pg_get_stock_negatif(Some("579")).await.unwrap());
|
||||
montrer("pg_get_sans_vente_6_mois", pg::pg_get_sans_vente_6_mois(None).await.unwrap());
|
||||
montrer("pg_get_sans_vente_6_mois(579)", pg::pg_get_sans_vente_6_mois(Some("579")).await.unwrap());
|
||||
let rec = pg::pg_get_derniere_reception_par_fournisseur(None).await;
|
||||
montrer("pg_get_derniere_reception_par_fournisseur(None)", &rec);
|
||||
assert!(!rec.is_empty());
|
||||
montrer("pg_get_derniere_reception_par_fournisseur(F001)", pg::pg_get_derniere_reception_par_fournisseur(Some(&codes(&["F001 "]))).await);
|
||||
assert!(pg::pg_get_derniere_reception_par_fournisseur(Some(&[])).await.is_empty());
|
||||
let search = pg::pg_search_produits("tapis poussiere", 50).await;
|
||||
montrer("pg_search_produits", &search);
|
||||
assert_eq!(search.len(), 1);
|
||||
assert!(search[0].has_reseau);
|
||||
montrer("pg_search_produits(numérique)", pg::pg_search_produits("100003", 50).await);
|
||||
montrer("pg_search_produits(code centrale)", pg::pg_search_produits("10000222", 50).await);
|
||||
montrer("pg_search_produits(!!!)", pg::pg_search_produits("!!!", 50).await);
|
||||
montrer("pg_get_produits_by_code_centrale", pg::pg_get_produits_by_code_centrale(&codes(&["10000222222", "inconnu"])).await);
|
||||
assert_eq!(pg::pg_get_codein_by_code_centrale("10000222222").await.as_deref(), Some("100002"));
|
||||
let det = pg::pg_get_produit_detail("100001").await;
|
||||
montrer("pg_get_produit_detail", &det);
|
||||
assert_eq!(det.as_ref().and_then(|d| d.code1.clone()).as_deref(), Some("33"), "hiérarchie remontée");
|
||||
montrer("pg_get_fournisseurs_by_codein", pg::pg_get_fournisseurs_by_codein("100003").await);
|
||||
montrer("pg_get_stock_by_codein", pg::pg_get_stock_by_codein("100001").await);
|
||||
assert_eq!(pg::pg_get_commandes_by_codein("100001").await, 30.0);
|
||||
montrer("pg_get_gamme_history_by_codein", pg::pg_get_gamme_history_by_codein("100001").await);
|
||||
montrer("pg_get_mensuel_by_codein", pg::pg_get_mensuel_by_codein("100001", d, f).await);
|
||||
let opp = pg::pg_get_opportunites_famille(3, d, f, 30).await;
|
||||
montrer("pg_get_opportunites_famille", &opp);
|
||||
assert_eq!(opp.len(), 2);
|
||||
|
||||
// ── API FF (fausse)
|
||||
let dash = pg::pg_get_dashboard_data().await.unwrap();
|
||||
let dv = montrer("pg_get_dashboard_data", &dash);
|
||||
assert_eq!(dv["top10BySite"][0]["ca"][1]["fournisseur"], json!("Dernier Fou"));
|
||||
assert_eq!(dv["top10BySite"][0]["ca"][0]["fournisseur"], json!("F002"));
|
||||
let ca = montrer("pg_get_commandes_auto", pg::pg_get_commandes_auto().await);
|
||||
assert_eq!(ca[1]["franco"], json!(300.0));
|
||||
assert_eq!(ca[2]["franco"], json!(75.0));
|
||||
let ffarts = api::get_articles_by_fournisseur("F001").await.unwrap();
|
||||
montrer("get_articles_by_fournisseur", &ffarts);
|
||||
montrer("get_mensuel_by_articles", api::get_mensuel_by_articles(&ffarts, d, f, 50).await);
|
||||
montrer("get_referentiel_by_articles", api::get_referentiel_by_articles(&ffarts, 50).await);
|
||||
montrer("get_commandes_by_fournisseur", api::get_commandes_by_fournisseur("F001").await);
|
||||
montrer("diagnostiquer_api_ff", diagnostic::diagnostiquer_api_ff(None).await);
|
||||
montrer("diagnostiquer_api_ff(injoignable)", diagnostic::diagnostiquer_api_ff(Some("http://127.0.0.1:9".into())).await);
|
||||
|
||||
// ── cache.rs
|
||||
montrer("get_derniere_reception_cached", cache::get_derniere_reception_cached(&codes(&["F002", "F001"])).await.unwrap());
|
||||
|
||||
// ── Routes
|
||||
for uri in ["/api/ff-status", "/api/diag/stock-fournisseur", "/api/test-db?codein=100001", "/api/commandes-auto?site=292", "/api/diag"] {
|
||||
let (status, body) = appel_route(&token, uri).await;
|
||||
montrer(&format!("GET {uri} → {status}"), &body);
|
||||
assert_eq!(status, 200, "{uri}");
|
||||
}
|
||||
|
||||
// ── Actions
|
||||
let c = |args: Vec<Value>| ctx(&claims, &[], args);
|
||||
montrer("getFournisseurs", actions::get_fournisseurs(c(vec![])).await.unwrap());
|
||||
montrer("getMagasins", actions::get_magasins(c(vec![])).await.unwrap());
|
||||
montrer("getFournisseursPourCadence", actions::get_fournisseurs_pour_cadence(c(vec![])).await.unwrap());
|
||||
montrer("upsertCadence", actions::upsert_cadence(c(vec![json!({ "codefou": " F002 ", "site": "292", "intervalleSemaines": "2.5" })])).await.unwrap());
|
||||
montrer("upsertCadence(KO)", actions::upsert_cadence(c(vec![json!({ "codefou": "F002", "site": "292", "intervalleSemaines": 0 })])).await.unwrap());
|
||||
montrer("toggleCadence", actions::toggle_cadence(c(vec![json!("F002"), json!("292"), json!(false)])).await.unwrap());
|
||||
let cad = montrer("listCadences", actions::list_cadences(c(vec![])).await.unwrap());
|
||||
assert!(cad.as_array().unwrap().iter().any(|x| x["derniereReception"].is_string() && x["joursRestants"].is_number()));
|
||||
montrer("removeCadence", actions::remove_cadence(c(vec![json!("F002"), json!("292")])).await.unwrap());
|
||||
let sans = Ctx::default();
|
||||
assert_eq!(actions::get_fournisseurs(sans.clone()).await.unwrap_err().status.as_u16(), 401);
|
||||
assert_eq!(actions::remove_cadence(sans.clone()).await.unwrap(), json!({ "ok": false, "error": "Session expirée : reconnectez-vous." }));
|
||||
|
||||
// ── Pages
|
||||
montrer("page dashboard", pages::page_dashboard(ctx(&claims, &[], vec![])).await.unwrap());
|
||||
montrer("page analytics", pages::page_analytics(ctx(&claims, &[("mode", "fournisseur")], vec![])).await.unwrap());
|
||||
montrer("page analytics (nomenclature)", pages::page_analytics(ctx(&claims, &[("mode", "x"), ("mois", &mois)], vec![])).await.unwrap());
|
||||
montrer("page hit-parade", pages::page_hit_parade(ctx(&claims, &[], vec![])).await.unwrap());
|
||||
montrer("page stock-negatif", pages::page_stock_negatif(ctx(&claims, &[("magasin", "292")], vec![])).await.unwrap());
|
||||
montrer("page commandes-auto", pages::page_commandes_auto(ctx(&claims, &[], vec![])).await.unwrap());
|
||||
let pubs = montrer("page publicites", pages::page_publicites(ctx(&claims, &[("statut", "en_cours"), ("page", "2")], vec![])).await.unwrap());
|
||||
assert_eq!(pubs["pageSize"], json!(50));
|
||||
assert!(pubs["erreurHistorique"].is_string());
|
||||
assert!(pubs.get("erreurPublicites").is_none());
|
||||
assert_eq!(pages::page_dashboard(sans).await.unwrap_err().status.as_u16(), 401);
|
||||
|
||||
sqlx::query("DELETE FROM users WHERE id = 9001").execute(&p).await.unwrap();
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
+37
-3
@@ -1,21 +1,55 @@
|
||||
//! Module `ff` — données FF Nancy (base miroir PostgreSQL et API REST) — à porter.
|
||||
//! Module `ff` — données FF Nancy : base miroir PostgreSQL (`pg`), API REST
|
||||
//! (`api`), lectures partagées en cache (`cache`), diagnostic de l'API
|
||||
//! (`diagnostic`), routes, actions et données de pages.
|
||||
|
||||
pub mod actions;
|
||||
pub mod api;
|
||||
pub mod cache;
|
||||
pub mod diagnostic;
|
||||
pub mod js;
|
||||
pub mod pages;
|
||||
pub mod pg;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod fixture_tests;
|
||||
|
||||
use crate::handler;
|
||||
use crate::rpc::Handler;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
|
||||
/// Routes HTTP du module.
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/api/ff-status", get(routes::ff_status))
|
||||
.route("/api/diag", get(routes::diag))
|
||||
.route("/api/diag/stock-fournisseur", get(routes::diag_stock_fournisseur))
|
||||
.route("/api/test-db", get(routes::test_db))
|
||||
.route("/api/commandes-auto", get(routes::commandes_auto))
|
||||
}
|
||||
|
||||
/// Actions (`POST /api/actions/{nom}`) du module.
|
||||
pub fn actions() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
vec![
|
||||
("getFournisseurs", handler!(actions::get_fournisseurs)),
|
||||
("getMagasins", handler!(actions::get_magasins)),
|
||||
("getFournisseursPourCadence", handler!(actions::get_fournisseurs_pour_cadence)),
|
||||
("listCadences", handler!(actions::list_cadences)),
|
||||
("upsertCadence", handler!(actions::upsert_cadence)),
|
||||
("toggleCadence", handler!(actions::toggle_cadence)),
|
||||
("removeCadence", handler!(actions::remove_cadence)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Données de pages (`GET /api/pages/{nom}`) du module.
|
||||
pub fn pages() -> Vec<(&'static str, Handler)> {
|
||||
vec![]
|
||||
vec![
|
||||
("dashboard", handler!(pages::page_dashboard)),
|
||||
("analytics", handler!(pages::page_analytics)),
|
||||
("hit-parade", handler!(pages::page_hit_parade)),
|
||||
("stock-negatif", handler!(pages::page_stock_negatif)),
|
||||
("commandes-auto", handler!(pages::page_commandes_auto)),
|
||||
("publicites", handler!(pages::page_publicites)),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Données des anciennes pages serveur du module FF (`GET /api/pages/{nom}`) :
|
||||
//! accueil, ventes par mois, meilleures ventes, stocks à surveiller,
|
||||
//! commandes fournisseurs et publicités.
|
||||
|
||||
use super::actions::lister_cadences;
|
||||
use super::api::get_ff_api_base;
|
||||
use super::cache::get_fournisseurs_cached;
|
||||
use super::js::{get, js_number, js_number_to_string, message_erreur};
|
||||
use super::pg::{
|
||||
pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade,
|
||||
pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::ff_cache::{cached, CacheOpts};
|
||||
use crate::rpc::{json, Ctx};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json as json_value, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Message d'erreur affiché par l'ancien `ErrorState`.
|
||||
fn erreur_page(e: anyhow::Error) -> AppError {
|
||||
AppError::internal(message_erreur(&e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accueil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `pgGetDashboardData()`, gardé 30 minutes : ce sont les ventes de la veille,
|
||||
/// elles ne changent pas dans la journée.
|
||||
pub async fn page_dashboard(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let opts = CacheOpts { ttl: Some(Duration::from_secs(30 * 60)), cache_if: None };
|
||||
match cached("dashboard-data", opts, || async { pg_get_dashboard_data().await }).await {
|
||||
Ok(d) => json(d),
|
||||
Err(e) => {
|
||||
tracing::error!("[Dashboard] pgGetDashboardData failed: {}", message_erreur(&e));
|
||||
let msg: String = message_erreur(&e).chars().take(500).collect();
|
||||
Err(AppError::internal(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ventes par mois
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// « YYYY-MM » d'une date.
|
||||
fn format_mois(d: NaiveDate) -> String {
|
||||
format!("{}-{:02}", d.year(), d.month())
|
||||
}
|
||||
|
||||
/// Calcul du mois N-1 (même mois année précédente).
|
||||
pub fn get_mois_n1(mois: &str) -> String {
|
||||
let mut parts = mois.split('-').map(|p| p.trim().parse::<i32>().ok());
|
||||
match (parts.next().flatten(), parts.next().flatten()) {
|
||||
(Some(year), Some(month)) => {
|
||||
// new Date(year, month - 13) : -13 mois, débordements normalisés.
|
||||
let total = year * 12 + (month - 13);
|
||||
let (y, m0) = (total.div_euclid(12), total.rem_euclid(12));
|
||||
format!("{y}-{:02}", m0 + 1)
|
||||
}
|
||||
_ => "NaN-NaN".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lignes de `pgGetCaByFournisseur` (mode `fournisseur`) ou
|
||||
/// `pgGetCaByNomenclature` (toute autre valeur), mises en cache.
|
||||
pub async fn page_analytics(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
|
||||
let mode_fournisseur = ctx.q("mode").unwrap_or("fournisseur") == "fournisseur";
|
||||
|
||||
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
|
||||
// cours, incomplet, se comparerait mal à un mois N-1 entier.
|
||||
let today = Local::now().date_naive();
|
||||
let premier = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
let dernier_mois_complet = format_mois(premier.pred_opt().unwrap_or(premier));
|
||||
let mois = ctx.q("mois").map(|s| s.to_string()).unwrap_or(dernier_mois_complet);
|
||||
let mois_n1 = ctx.q("moisN1").map(|s| s.to_string()).unwrap_or_else(|| get_mois_n1(&mois));
|
||||
|
||||
let res = if mode_fournisseur {
|
||||
let (m, n1) = (mois.clone(), mois_n1.clone());
|
||||
cached(&format!("ca-fournisseur:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
|
||||
pg_get_ca_by_fournisseur(&m, &n1).await
|
||||
})
|
||||
.await
|
||||
.and_then(|rows| Ok(serde_json::to_value(rows)?))
|
||||
} else {
|
||||
let (m, n1) = (mois.clone(), mois_n1.clone());
|
||||
cached(&format!("ca-nomenclature:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
|
||||
pg_get_ca_by_nomenclature(&m, &n1).await
|
||||
})
|
||||
.await
|
||||
.and_then(|rows| Ok(serde_json::to_value(rows)?))
|
||||
};
|
||||
res.map_err(|e| {
|
||||
tracing::error!("[analytics] chargement impossible : {}", message_erreur(&e));
|
||||
erreur_page(e)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meilleures ventes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lignes de `pgGetHitParade(debut, fin)` (par défaut : du 1er du mois à aujourd'hui).
|
||||
pub async fn page_hit_parade(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let today = Local::now().date_naive();
|
||||
let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
let date_debut = ctx.q("debut").map(|s| s.to_string()).unwrap_or_else(|| first_of_month.format("%Y-%m-%d").to_string());
|
||||
let date_fin = ctx.q("fin").map(|s| s.to_string()).unwrap_or_else(|| today.format("%Y-%m-%d").to_string());
|
||||
|
||||
let (d, f) = (date_debut.clone(), date_fin.clone());
|
||||
match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move {
|
||||
pg_get_hit_parade(&d, &f).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rows) => json(rows),
|
||||
Err(e) => {
|
||||
tracing::error!("[hit-parade] chargement impossible : {}", message_erreur(&e));
|
||||
Err(erreur_page(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stocks à surveiller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` pour un magasin (ou tous).
|
||||
pub async fn page_stock_negatif(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let magasin = ctx.q("magasin").unwrap_or("").to_string();
|
||||
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. ff_cache).
|
||||
let site = if magasin.is_empty() { None } else { Some(magasin.clone()) };
|
||||
let (s1, s2, s3) = (site.clone(), site.clone(), site);
|
||||
let (k1, k2, k3) =
|
||||
(format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}"));
|
||||
let res = tokio::try_join!(
|
||||
cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }),
|
||||
cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }),
|
||||
cached(&k3, CacheOpts::default(), move || async move {
|
||||
pg_get_sans_vente_6_mois(s3.as_deref()).await
|
||||
}),
|
||||
);
|
||||
match res {
|
||||
Ok((rows_negatif, rows_sans_vente, rows_sans_vente_6_mois)) => Ok(json_value!({
|
||||
"rowsNegatif": rows_negatif,
|
||||
"rowsSansVente": rows_sans_vente,
|
||||
"rowsSansVente6Mois": rows_sans_vente_6_mois,
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("[stock-negatif] chargement impossible : {}", message_erreur(&e));
|
||||
Err(erreur_page(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commandes fournisseurs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `{ rows, cadences, fournisseurs }`.
|
||||
pub async fn page_commandes_auto(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let (rows, cadences, fournisseurs) = tokio::join!(
|
||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||
// recharge la page — n'attende pas de nouveau toute l'API.
|
||||
cached(
|
||||
"commandes-auto",
|
||||
CacheOpts { ttl: Some(Duration::from_secs(2 * 60)), cache_if: Some(|rows: &Vec<PgCommandeAutoRow>| !rows.is_empty()) },
|
||||
|| async { anyhow::Ok(pg_get_commandes_auto().await) },
|
||||
),
|
||||
lister_cadences(),
|
||||
get_fournisseurs_cached(),
|
||||
);
|
||||
Ok(json_value!({
|
||||
"rows": rows.map_err(erreur_page)?,
|
||||
"cadences": cadences,
|
||||
"fournisseurs": fournisseurs.map_err(erreur_page)?,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publicités
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
/// Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page.
|
||||
const FF_API_TIMEOUT: Duration = Duration::from_millis(15_000);
|
||||
/// Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
|
||||
/// et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
|
||||
const PUB_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PublicitesResult {
|
||||
data: Value,
|
||||
total: f64,
|
||||
pages: f64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct HistoriqueResult {
|
||||
data: Value,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn client_publicites() -> reqwest::Client {
|
||||
reqwest::Client::builder().timeout(FF_API_TIMEOUT).build().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult {
|
||||
let mut params: Vec<(&str, String)> = vec![("page", page), ("limit", PAGE_SIZE.to_string())];
|
||||
if !statut.is_empty() && statut != "toutes" {
|
||||
params.push(("statut", statut));
|
||||
}
|
||||
let base = get_ff_api_base().await;
|
||||
let res = async {
|
||||
let res = client_publicites()
|
||||
.get(format!("{base}/api/publicites"))
|
||||
.query(¶ms)
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-store")
|
||||
.send()
|
||||
.await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(PublicitesResult {
|
||||
data: json_value!([]),
|
||||
total: 0.0,
|
||||
pages: 0.0,
|
||||
error: Some(format!(
|
||||
"Erreur API: {} {}",
|
||||
res.status().as_u16(),
|
||||
super::api::status_text(res.status())
|
||||
)),
|
||||
});
|
||||
}
|
||||
let body: Value = res.json().await?;
|
||||
let champ = |k: &str| get(&body, k).filter(|v| !v.is_null());
|
||||
Ok(PublicitesResult {
|
||||
data: champ("publicites").cloned().unwrap_or_else(|| json_value!([])),
|
||||
total: champ("total").map(js_number).unwrap_or(0.0),
|
||||
pages: champ("pages").map(js_number).unwrap_or(0.0),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
res.unwrap_or_else(|e| PublicitesResult {
|
||||
data: json_value!([]),
|
||||
total: 0.0,
|
||||
pages: 0.0,
|
||||
error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_historique_api() -> HistoriqueResult {
|
||||
let base = get_ff_api_base().await;
|
||||
let res = async {
|
||||
let res = client_publicites()
|
||||
.get(format!("{base}/api/publicites/historique"))
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-store")
|
||||
.send()
|
||||
.await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(HistoriqueResult {
|
||||
data: json_value!([]),
|
||||
error: Some(format!("Erreur historique: {}", res.status().as_u16())),
|
||||
});
|
||||
}
|
||||
let body: Value = res.json().await?;
|
||||
Ok(HistoriqueResult {
|
||||
data: get(&body, "publicites").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json_value!([])),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
res.unwrap_or_else(|e| HistoriqueResult { data: json_value!([]), error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))) })
|
||||
}
|
||||
|
||||
/// `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }`.
|
||||
pub async fn page_publicites(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let statut = ctx.query.get("statut").cloned().unwrap_or_else(|| "toutes".into());
|
||||
// Math.max(1, Number(page) || 1)
|
||||
let page_num = {
|
||||
let n = ctx.query.get("page").map(|p| js_number(&Value::String(p.clone()))).unwrap_or(f64::NAN);
|
||||
let n = if n.is_nan() || n == 0.0 { 1.0 } else { n };
|
||||
n.max(1.0)
|
||||
};
|
||||
let page = js_number_to_string(page_num);
|
||||
|
||||
let (s, p) = (statut.clone(), page.clone());
|
||||
let cle = format!("publicites:{statut}:{page}");
|
||||
let (pub_result, histo_result) = tokio::join!(
|
||||
cached(
|
||||
&cle,
|
||||
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &PublicitesResult| r.error.is_none()) },
|
||||
move || async move { anyhow::Ok(fetch_publicites_api(s, p).await) },
|
||||
),
|
||||
cached(
|
||||
"publicites-historique",
|
||||
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &HistoriqueResult| r.error.is_none()) },
|
||||
|| async { anyhow::Ok(fetch_historique_api().await) },
|
||||
),
|
||||
);
|
||||
let pub_result = pub_result.map_err(erreur_page)?;
|
||||
let histo_result = histo_result.map_err(erreur_page)?;
|
||||
|
||||
let mut out = serde_json::Map::new();
|
||||
out.insert("publicites".into(), pub_result.data);
|
||||
out.insert("total".into(), json_value!(pub_result.total));
|
||||
out.insert("pages".into(), json_value!(pub_result.pages));
|
||||
if let Some(e) = pub_result.error {
|
||||
out.insert("erreurPublicites".into(), Value::String(e));
|
||||
}
|
||||
out.insert("historique".into(), histo_result.data);
|
||||
if let Some(e) = histo_result.error {
|
||||
out.insert("erreurHistorique".into(), Value::String(e));
|
||||
}
|
||||
out.insert("pageSize".into(), json_value!(PAGE_SIZE));
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mois_n1() {
|
||||
assert_eq!(get_mois_n1("2026-03"), "2025-03");
|
||||
assert_eq!(get_mois_n1("2026-12"), "2025-12");
|
||||
assert_eq!(get_mois_n1("2026-01"), "2025-01");
|
||||
assert_eq!(get_mois_n1("abc"), "NaN-NaN");
|
||||
}
|
||||
}
|
||||
+2981
-7
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,516 @@
|
||||
//! Routes HTTP du module FF : `/api/ff-status`, `/api/diag`,
|
||||
//! `/api/diag/stock-fournisseur`, `/api/test-db`, `/api/commandes-auto`.
|
||||
|
||||
use super::api::{ff_client, get_ff_api_base};
|
||||
use super::diagnostic::diagnostiquer_api_ff;
|
||||
use super::js::{encode, js_string, message_erreur};
|
||||
use super::pg::{pg_diag_fournisseur_derniere_entree, pg_get_stock_for_codeins};
|
||||
use crate::auth::{AdminUser, SessionUser};
|
||||
use crate::db::{pool, rows_to_json};
|
||||
use axum::extract::Query;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// `String(e)` d'une erreur JS (`Error: message`).
|
||||
fn string_erreur(e: &dyn std::fmt::Display) -> String {
|
||||
format!("Error: {e}")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/ff-status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/ff-status — état de l'API REST FF Nancy.
|
||||
///
|
||||
/// S'appuie sur le même diagnostic que le bouton « Tester » des Paramètres, afin
|
||||
/// qu'un échec dise *pourquoi* (DNS, connexion refusée, délai dépassé, code HTTP)
|
||||
/// et *quelle adresse* a été appelée. L'ancienne version renvoyait un 503 sans
|
||||
/// détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
pub async fn ff_status(_user: SessionUser) -> Response {
|
||||
let res = diagnostiquer_api_ff(None).await;
|
||||
if !res.success {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({ "error": "API FF Nancy non disponible", "url": res.url, "detail": res.error })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Json(res.status).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/diag/stock-fournisseur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Diagnostic de l'attribution « fournisseur de la dernière entrée » utilisée par
|
||||
/// la page Gestion de Stock.
|
||||
///
|
||||
/// `colonneDetectee: null` signifie que `mvtart` ne porte aucune des colonnes
|
||||
/// fournisseur connues : la page retombe alors sur `artfou1.preference = 1`.
|
||||
/// `colonnesMvtart` liste ce qui existe réellement, pour compléter la liste
|
||||
/// blanche `MVTART_FOU_CANDIDATES` si le nom diffère.
|
||||
pub async fn diag_stock_fournisseur(_admin: AdminUser) -> Response {
|
||||
match pg_diag_fournisseur_derniere_entree().await {
|
||||
Ok(d) => Json(d).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": string_erreur(&message_erreur(&e)) }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/test-db
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn test_db(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Response {
|
||||
let codein = q.get("codein").filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| "334152".into());
|
||||
let res = async {
|
||||
let map = pg_get_stock_for_codeins(std::slice::from_ref(&codein)).await?;
|
||||
let fou = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
a.codein::text AS codein,
|
||||
COALESCE(fi.nom, af.code, 'Sans fournisseur')::text AS fournisseur
|
||||
FROM articles a
|
||||
LEFT JOIN artfou1 af ON af.art_no_id = a.no_id AND af.preference = 1
|
||||
LEFT JOIN fouident fi ON fi.code = af.code
|
||||
WHERE a.codein::text = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&codein)
|
||||
.fetch_all(&pool()?)
|
||||
.await?;
|
||||
let stock: Map<String, Value> = map.into_iter().map(|(k, v)| (k, serde_json::to_value(v).unwrap_or(Value::Null))).collect();
|
||||
anyhow::Ok(json!({ "stock": stock, "fou": rows_to_json(&fou) }))
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(v) => Json(v).into_response(),
|
||||
Err(e) => {
|
||||
// Pas de pile d'appels dans la réponse : elle reste dans les journaux du serveur.
|
||||
tracing::error!("[api/test-db] {}", message_erreur(&e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": message_erreur(&e) }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/commandes-auto
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// GET /api/commandes-auto[?site=292&codefou=N009]
|
||||
///
|
||||
/// Proxy vers https://api.ffnancy.fr/api/commandes-auto
|
||||
/// Transfère les query params site et codefou si présents.
|
||||
pub async fn commandes_auto(_user: SessionUser, Query(q): Query<HashMap<String, String>>) -> Response {
|
||||
let res = async {
|
||||
let mut params: Vec<(&str, &str)> = vec![];
|
||||
if let Some(site) = q.get("site").filter(|s| !s.is_empty()) {
|
||||
params.push(("site", site));
|
||||
}
|
||||
if let Some(codefou) = q.get("codefou").filter(|s| !s.is_empty()) {
|
||||
params.push(("codefou", codefou));
|
||||
}
|
||||
let base = get_ff_api_base().await;
|
||||
let url = format!("{base}/api/commandes-auto");
|
||||
let res = ff_client().get(&url).query(¶ms).send().await?;
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
return anyhow::Ok(
|
||||
(status, Json(json!({ "error": format!("API upstream HTTP {}", status.as_u16()) }))).into_response(),
|
||||
);
|
||||
}
|
||||
let data: Value = res.json().await?;
|
||||
Ok(Json(data).into_response())
|
||||
}
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!("[api/commandes-auto] {}", message_erreur(&e));
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "error": "Erreur serveur" }))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/diag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Résumé d'une sonde : statut HTTP et aperçu du contenu.
|
||||
async fn probe(url: String) -> Value {
|
||||
let res = async {
|
||||
let res = reqwest::Client::new().get(&url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
|
||||
let status = res.status().as_u16();
|
||||
let text = res.text().await?;
|
||||
anyhow::Ok((status, text))
|
||||
}
|
||||
.await;
|
||||
let (status, text) = match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({ "error": string_erreur(&message_erreur(&e)), "url": url }),
|
||||
};
|
||||
let parsed: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text.chars().take(800).collect()));
|
||||
let keys_of = |v: Option<&Value>| -> Value {
|
||||
match v.and_then(|x| x.as_object()) {
|
||||
Some(o) => Value::from(o.keys().cloned().collect::<Vec<_>>()),
|
||||
None => json!([]),
|
||||
}
|
||||
};
|
||||
let summary = match &parsed {
|
||||
Value::Array(a) => json!({ "count": a.len(), "firstKeys": keys_of(a.first()), "sample": a.iter().take(2).cloned().collect::<Vec<_>>() }),
|
||||
Value::Object(o) => {
|
||||
let keys: Vec<String> = o.keys().cloned().collect();
|
||||
match o.iter().find(|(_, v)| v.is_array()) {
|
||||
Some((k, Value::Array(arr))) => json!({
|
||||
"wrapper": k,
|
||||
"count": arr.len(),
|
||||
"firstKeys": keys_of(arr.first()),
|
||||
"sample": arr.iter().take(2).cloned().collect::<Vec<_>>(),
|
||||
}),
|
||||
// Objet simple : montrer toutes les clés et le contenu complet
|
||||
_ => json!({ "keys": keys, "full": parsed }),
|
||||
}
|
||||
}
|
||||
other => other.clone(),
|
||||
};
|
||||
json!({ "status": status, "url": url, "summary": summary })
|
||||
}
|
||||
|
||||
/// Première liste d'une réponse : tableau direct ou première propriété tableau.
|
||||
fn premiere_liste(data: &Value) -> Vec<Value> {
|
||||
match data {
|
||||
Value::Array(a) => a.clone(),
|
||||
Value::Object(o) => o.values().find_map(|v| v.as_array().cloned()).unwrap_or_default(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_json(url: &str) -> anyhow::Result<Value> {
|
||||
let res = reqwest::Client::new().get(url).header(reqwest::header::CACHE_CONTROL, "no-store").send().await?;
|
||||
Ok(res.json::<Value>().await?)
|
||||
}
|
||||
|
||||
/// Champ texte d'un élément (`(x as Record).champ as string ?? null`).
|
||||
fn champ(v: Option<&Value>, key: &str) -> Option<String> {
|
||||
v.and_then(|x| x.get(key)).filter(|x| !x.is_null()).map(js_string)
|
||||
}
|
||||
|
||||
pub async fn diag(_admin: AdminUser, Query(q): Query<HashMap<String, String>>) -> Response {
|
||||
let ff_api_base = get_ff_api_base().await;
|
||||
|
||||
// Date range: 12 derniers mois
|
||||
let today = Local::now().date_naive();
|
||||
let first = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
let date_fin = first.pred_opt().unwrap_or(first).format("%Y-%m-%d").to_string();
|
||||
let date_debut = first.checked_sub_months(chrono::Months::new(12)).unwrap_or(first).format("%Y-%m-%d").to_string();
|
||||
|
||||
// Récupérer un codefou réel + un codein réel depuis l'API
|
||||
let mut codefou: Option<String> = q.get("codefou").filter(|s| !s.is_empty()).cloned();
|
||||
let mut first_codein: Option<String> = q.get("codein").filter(|s| !s.is_empty()).cloned();
|
||||
let forced_noid: Option<String> = q.get("noid").filter(|s| !s.is_empty()).cloned(); // ex: ?noid=129632
|
||||
|
||||
if codefou.is_none() {
|
||||
if let Ok(data) = get_json(&format!("{ff_api_base}/api/fournisseurs?limit=1")).await {
|
||||
let list = premiere_liste(&data);
|
||||
codefou = champ(list.first(), "codefou");
|
||||
}
|
||||
if codefou.is_none() {
|
||||
codefou = Some("D005".into());
|
||||
}
|
||||
}
|
||||
let codefou = codefou.unwrap_or_default();
|
||||
let codefou_enc = encode(&codefou);
|
||||
|
||||
if first_codein.is_none() {
|
||||
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await {
|
||||
let list = premiere_liste(&data);
|
||||
first_codein = champ(list.first(), "codein");
|
||||
}
|
||||
}
|
||||
|
||||
// Si un noid est forcé, on probe directement mensuel + referentiel pour cet article
|
||||
let mensuel_noid_forced = async {
|
||||
match &forced_noid {
|
||||
Some(n) => {
|
||||
probe(format!("{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}", encode(n))).await
|
||||
}
|
||||
None => json!({ "skipped": "no ?noid= param" }),
|
||||
}
|
||||
};
|
||||
let referentiel_noid_forced = async {
|
||||
match &forced_noid {
|
||||
Some(n) => probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(n))).await,
|
||||
None => json!({ "skipped": "no ?noid= param" }),
|
||||
}
|
||||
};
|
||||
// Test mensuel avec no_id auto-résolu
|
||||
let mensuel_noid = async {
|
||||
if first_codein.is_none() {
|
||||
return json!({ "skipped": "no article" });
|
||||
}
|
||||
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=1")).await {
|
||||
let list = premiere_liste(&data);
|
||||
if let Some(noid) = champ(list.first(), "no_id").filter(|s| !s.is_empty()) {
|
||||
return probe(format!(
|
||||
"{ff_api_base}/api/articles/{}/mensuel?dateDebut={date_debut}&dateFin={date_fin}",
|
||||
encode(&noid)
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
json!({ "skipped": "no no_id available" })
|
||||
};
|
||||
// Referentiel complet (nomenclature + gamme + stock + prix)
|
||||
let referentiel = async {
|
||||
if first_codein.is_none() {
|
||||
return json!({ "skipped": "no codefou" });
|
||||
}
|
||||
if let Ok(data) = get_json(&format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=5")).await {
|
||||
let list = premiere_liste(&data);
|
||||
let art = list.get(4).or(list.first());
|
||||
if let Some(no_id) = champ(art, "no_id").filter(|s| !s.is_empty()) {
|
||||
return probe(format!("{ff_api_base}/api/articles/{}/referentiel", encode(&no_id))).await;
|
||||
}
|
||||
}
|
||||
json!({ "skipped": "no no_id" })
|
||||
};
|
||||
|
||||
let (fournisseurs, articles, mvt_date_debut, mensuel_noid, referentiel, mensuel_forced, referentiel_forced) = tokio::join!(
|
||||
probe(format!("{ff_api_base}/api/fournisseurs?limit=2")),
|
||||
probe(format!("{ff_api_base}/api/articles?codefou={codefou_enc}&limit=2")),
|
||||
probe(format!(
|
||||
"{ff_api_base}/api/mouvements/articles?codefou={codefou_enc}&dateDebut={date_debut}&dateFin={date_fin}&limit=2"
|
||||
)),
|
||||
mensuel_noid,
|
||||
referentiel,
|
||||
mensuel_noid_forced,
|
||||
referentiel_noid_forced,
|
||||
);
|
||||
|
||||
// Test PostgreSQL + diagnostic ventesProduits
|
||||
let (postgresql, ventes_produits_diag) = match async {
|
||||
let pool = pool()?;
|
||||
let r = sqlx::query("SELECT COUNT(*) as count FROM session_snapshots").fetch_all(&pool).await?;
|
||||
|
||||
// Diagnostic ventesProduits : cherche par les 2 codes possibles d'AUXENCE
|
||||
let vp_by_name = sqlx::query(
|
||||
r#"
|
||||
SELECT code_fournisseur, COUNT(*) as nb_rows,
|
||||
COUNT(code_gamme) as nb_gamme,
|
||||
COUNT(code_gamme_init) as nb_gamme_init,
|
||||
COUNT(code3) as nb_code3
|
||||
FROM ventes_produits
|
||||
WHERE code_fournisseur IN ('AUXENCE', 'A025', $1)
|
||||
GROUP BY code_fournisseur
|
||||
"#,
|
||||
)
|
||||
.bind(&codefou)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
// Sample de quelques codeins dans ventesProduits pour ce fournisseur
|
||||
let vp_sample = sqlx::query(
|
||||
r#"
|
||||
SELECT codein, code_fournisseur, code_gamme, code_gamme_init, code3, libelle3
|
||||
FROM ventes_produits
|
||||
WHERE code_fournisseur IN ('AUXENCE', 'A025', $1)
|
||||
LIMIT 3
|
||||
"#,
|
||||
)
|
||||
.bind(&codefou)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
|
||||
let snapshots = r.first().map(|row| crate::db::f64_or0(row, "count")).unwrap_or(0.0);
|
||||
anyhow::Ok((
|
||||
json!({ "ok": true, "snapshots": snapshots }),
|
||||
json!({ "byFournisseur": rows_to_json(&vp_by_name), "sample": rows_to_json(&vp_sample) }),
|
||||
))
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let msg = string_erreur(&message_erreur(&e));
|
||||
(json!({ "ok": false, "error": msg }), json!({ "error": msg }))
|
||||
}
|
||||
};
|
||||
|
||||
// Diagnostic analytics CA TTC
|
||||
let mvtart_diag = match async {
|
||||
let pool = pool()?;
|
||||
// 1. Totaux mars 2025 avec mntmvtht et mntmvtttc pour comparer avec API (HT=225k, TTC=269k)
|
||||
let totaux = sqlx::query(
|
||||
r#"
|
||||
SELECT site,
|
||||
ABS(SUM(mntmvtht))::float AS sum_ht,
|
||||
ABS(SUM(mntmvtttc))::float AS sum_ttc,
|
||||
COUNT(*) AS nb_lignes
|
||||
FROM mvtart
|
||||
WHERE genremvt = 3
|
||||
AND TO_CHAR(datmvt, 'YYYY-MM') = '2025-03'
|
||||
AND site IN ('292', '579')
|
||||
GROUP BY site
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
// 2. Distribution des valeurs de preference dans artfou1
|
||||
let pref_dist = sqlx::query(
|
||||
r#"
|
||||
SELECT preference, COUNT(*) AS nb
|
||||
FROM artfou1
|
||||
GROUP BY preference
|
||||
ORDER BY preference
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
// 3. Ventes mars 2025 non attribuées (aucune ligne artfou1 avec preference=1)
|
||||
let sans_preference = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(DISTINCT m.artnoid) AS nb_articles_sans_pref
|
||||
FROM mvtart m
|
||||
JOIN articles a ON a.no_id = m.artnoid
|
||||
WHERE m.genremvt = 3
|
||||
AND TO_CHAR(m.datmvt, 'YYYY-MM') = '2025-03'
|
||||
AND m.site IN ('292','579')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM artfou1 af
|
||||
WHERE af.art_no_id = a.no_id AND af.preference = 1
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
anyhow::Ok(json!({
|
||||
"totaux_202503": rows_to_json(&totaux),
|
||||
"artfou1_preference_distribution": rows_to_json(&pref_dist),
|
||||
"articles_vendus_sans_pref1": rows_to_json(&sans_preference),
|
||||
}))
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
|
||||
};
|
||||
|
||||
// Diagnostic franco : colonnes DB avec "franco" + sample fouident + raw commandes-auto
|
||||
let franco_diag = match async {
|
||||
let pool = pool()?;
|
||||
let franco_columns = sqlx::query(
|
||||
r#"
|
||||
SELECT table_name, column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE column_name ILIKE '%franco%'
|
||||
OR column_name ILIKE '%min_cmd%'
|
||||
OR column_name ILIKE '%mincde%'
|
||||
OR column_name ILIKE '%min_commande%'
|
||||
ORDER BY table_name, column_name
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
// Sample des 3 premières lignes de fouident pour voir toutes les colonnes
|
||||
let fouident_sample = rows_to_json(&sqlx::query("SELECT * FROM fouident LIMIT 3").fetch_all(&pool).await?);
|
||||
let foucad_sample = rows_to_json(&sqlx::query("SELECT * FROM foucad LIMIT 5").fetch_all(&pool).await?);
|
||||
let cols = |rows: &[Value]| -> Value {
|
||||
match rows.first().and_then(|r| r.as_object()) {
|
||||
Some(o) => Value::from(o.keys().cloned().collect::<Vec<_>>()),
|
||||
None => json!([]),
|
||||
}
|
||||
};
|
||||
anyhow::Ok(json!({
|
||||
"francoColumns": rows_to_json(&franco_columns),
|
||||
"fouidentColumns": cols(&fouident_sample),
|
||||
"fouidentSample": fouident_sample,
|
||||
"foucadColumns": cols(&foucad_sample),
|
||||
"foucadSample": foucad_sample,
|
||||
}))
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
|
||||
};
|
||||
|
||||
// Raw commandes-auto : récupère toutes les lignes et filtre celles avec franco_ht manquant
|
||||
let commandes_auto_franco_audit = match get_json(&format!("{ff_api_base}/api/commandes-auto")).await {
|
||||
Ok(ca_data) => {
|
||||
let ca_rows: Vec<Value> = match &ca_data {
|
||||
Value::Array(a) => a.clone(),
|
||||
d => d
|
||||
.get("propositions")
|
||||
.filter(|v| !v.is_null())
|
||||
.or_else(|| d.get("data").filter(|v| !v.is_null()))
|
||||
.and_then(|v| v.as_array().cloned())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let franco = |r: &Value| r.get("franco_ht").filter(|v| !v.is_null()).map(super::js::js_number);
|
||||
let with_franco = ca_rows.iter().filter(|r| franco(r).map(|f| f > 0.0).unwrap_or(false)).count();
|
||||
let without: Vec<&Value> = ca_rows.iter().filter(|r| franco(r).map(|f| f == 0.0).unwrap_or(true)).collect();
|
||||
json!({
|
||||
"total": ca_rows.len(),
|
||||
"avecFranco": with_franco,
|
||||
"sansFranco": without.len(),
|
||||
"lignesSansFranco": without.iter().map(|r| {
|
||||
let mut o = Map::new();
|
||||
for k in ["site", "codefou", "nom_fou", "franco_ht", "franco_atteint"] {
|
||||
if let Some(v) = r.get(k) {
|
||||
o.insert(k.to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(o)
|
||||
}).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
Err(e) => json!({ "error": string_erreur(&message_erreur(&e)) }),
|
||||
};
|
||||
|
||||
// Probe des fournisseurs N/A pour trouver où est leur franco
|
||||
let (detail_m036_292, detail_m030_579, fournisseur_m036, fournisseur_m030) = tokio::join!(
|
||||
probe(format!("{ff_api_base}/api/commandes-auto/M036?site=292")),
|
||||
probe(format!("{ff_api_base}/api/commandes-auto/M030?site=579")),
|
||||
probe(format!("{ff_api_base}/api/fournisseurs/M036")),
|
||||
probe(format!("{ff_api_base}/api/fournisseurs/M030")),
|
||||
);
|
||||
let franco_na_probe = json!({
|
||||
"detailM036_292": detail_m036_292,
|
||||
"detailM030_579": detail_m030_579,
|
||||
"fournisseurM036": fournisseur_m036,
|
||||
"fournisseurM030": fournisseur_m030,
|
||||
});
|
||||
|
||||
// Raw commandes-auto (premier fournisseur) pour voir les champs réels retournés par l'API
|
||||
let raw_commandes_auto = probe(format!("{ff_api_base}/api/commandes-auto")).await;
|
||||
|
||||
Json(json!({
|
||||
"timestamp": crate::db::iso_utc(chrono::Utc::now()),
|
||||
"dateRange": { "dateDebut": date_debut, "dateFin": date_fin },
|
||||
"codefouTested": codefou,
|
||||
"firstCodeinTested": first_codein,
|
||||
"forcedNoid": forced_noid.unwrap_or_else(|| "(none — pass ?noid=129632 to test a specific article)".into()),
|
||||
"fournisseurs": fournisseurs,
|
||||
"articles": articles,
|
||||
"mouvements_dateDebut": mvt_date_debut,
|
||||
"endpoints_auto": {
|
||||
"articles/:no_id/mensuel": mensuel_noid,
|
||||
"articles/:no_id/referentiel": referentiel,
|
||||
},
|
||||
"endpoints_forced_noid": {
|
||||
"articles/:noid/mensuel": mensuel_forced,
|
||||
"articles/:noid/referentiel": referentiel_forced,
|
||||
},
|
||||
"postgresql": postgresql,
|
||||
"ventesProduitsDiag": ventes_produits_diag,
|
||||
"mvtartDiag": mvtart_diag,
|
||||
"francoDiag": franco_diag,
|
||||
"rawCommandesAuto": raw_commandes_auto,
|
||||
"commandesAutoFrancoAudit": commandes_auto_franco_audit,
|
||||
"francoNaProbe": franco_na_probe,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Reference in new issue
Block a user