Fusion du module FF Nancy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 19:25:09 +00:00
commit 7373996c19
10 files changed
+5547 -10

No files matched your search

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