mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-20 05:28:11 +02:00
Serveur Rust : portage du module FF Nancy (base miroir, API, routes, actions, pages)
- ff::pg : portage complet de pg-ff-client.ts (mêmes requêtes SQL, fragments dynamiques via QueryBuilder, lectures tolérantes, caches process de la colonne parent de nomenclature et du lien mvtart → fournisseur). - ff::api : client de l'API FF Nancy (URL résolue Paramètres → env → défaut, mémorisée 30 s ; délai FF_API_TIMEOUT_MS), pagination, mensuel, référentiel, commandes, normalisation du statut de synchro. - ff::cache : getFournisseursCached / getDerniereReceptionCached. - ff::diagnostic : diagnostiquer_api_ff (DNS, refus, délai, code HTTP). - Routes /api/ff-status, /api/diag, /api/diag/stock-fournisseur, /api/test-db, /api/commandes-auto ; actions de la Grille et du cadencier ; pages dashboard, analytics, hit-parade, stock-negatif, commandes-auto, publicites. - Test d'intégration (FF_TEST_DATABASE_URL) avec fausse API FF : toutes les requêtes et tous les appels HTTP joués sur une base de test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
10 files changed
+5547
-10
No files matched your search
@@ -0,0 +1,341 @@
|
||||
//! Données des anciennes pages serveur du module FF (`GET /api/pages/{nom}`) :
|
||||
//! accueil, ventes par mois, meilleures ventes, stocks à surveiller,
|
||||
//! commandes fournisseurs et publicités.
|
||||
|
||||
use super::actions::lister_cadences;
|
||||
use super::api::get_ff_api_base;
|
||||
use super::cache::get_fournisseurs_cached;
|
||||
use super::js::{get, js_number, js_number_to_string, message_erreur};
|
||||
use super::pg::{
|
||||
pg_get_ca_by_fournisseur, pg_get_ca_by_nomenclature, pg_get_commandes_auto, pg_get_dashboard_data, pg_get_hit_parade,
|
||||
pg_get_sans_vente_6_mois, pg_get_stock_negatif, pg_get_stock_sans_vente, PgCommandeAutoRow,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::ff_cache::{cached, CacheOpts};
|
||||
use crate::rpc::{json, Ctx};
|
||||
use chrono::{Datelike, Local, NaiveDate};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json as json_value, Value};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Message d'erreur affiché par l'ancien `ErrorState`.
|
||||
fn erreur_page(e: anyhow::Error) -> AppError {
|
||||
AppError::internal(message_erreur(&e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accueil
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `pgGetDashboardData()`, gardé 30 minutes : ce sont les ventes de la veille,
|
||||
/// elles ne changent pas dans la journée.
|
||||
pub async fn page_dashboard(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let opts = CacheOpts { ttl: Some(Duration::from_secs(30 * 60)), cache_if: None };
|
||||
match cached("dashboard-data", opts, || async { pg_get_dashboard_data().await }).await {
|
||||
Ok(d) => json(d),
|
||||
Err(e) => {
|
||||
tracing::error!("[Dashboard] pgGetDashboardData failed: {}", message_erreur(&e));
|
||||
let msg: String = message_erreur(&e).chars().take(500).collect();
|
||||
Err(AppError::internal(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ventes par mois
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// « YYYY-MM » d'une date.
|
||||
fn format_mois(d: NaiveDate) -> String {
|
||||
format!("{}-{:02}", d.year(), d.month())
|
||||
}
|
||||
|
||||
/// Calcul du mois N-1 (même mois année précédente).
|
||||
pub fn get_mois_n1(mois: &str) -> String {
|
||||
let mut parts = mois.split('-').map(|p| p.trim().parse::<i32>().ok());
|
||||
match (parts.next().flatten(), parts.next().flatten()) {
|
||||
(Some(year), Some(month)) => {
|
||||
// new Date(year, month - 13) : -13 mois, débordements normalisés.
|
||||
let total = year * 12 + (month - 13);
|
||||
let (y, m0) = (total.div_euclid(12), total.rem_euclid(12));
|
||||
format!("{y}-{:02}", m0 + 1)
|
||||
}
|
||||
_ => "NaN-NaN".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lignes de `pgGetCaByFournisseur` (mode `fournisseur`) ou
|
||||
/// `pgGetCaByNomenclature` (toute autre valeur), mises en cache.
|
||||
pub async fn page_analytics(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
// Toute autre valeur que « fournisseur » affichait déjà la vue par famille.
|
||||
let mode_fournisseur = ctx.q("mode").unwrap_or("fournisseur") == "fournisseur";
|
||||
|
||||
// Mois par défaut = dernier mois complet (le mois précédent) : le mois en
|
||||
// cours, incomplet, se comparerait mal à un mois N-1 entier.
|
||||
let today = Local::now().date_naive();
|
||||
let premier = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
let dernier_mois_complet = format_mois(premier.pred_opt().unwrap_or(premier));
|
||||
let mois = ctx.q("mois").map(|s| s.to_string()).unwrap_or(dernier_mois_complet);
|
||||
let mois_n1 = ctx.q("moisN1").map(|s| s.to_string()).unwrap_or_else(|| get_mois_n1(&mois));
|
||||
|
||||
let res = if mode_fournisseur {
|
||||
let (m, n1) = (mois.clone(), mois_n1.clone());
|
||||
cached(&format!("ca-fournisseur:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
|
||||
pg_get_ca_by_fournisseur(&m, &n1).await
|
||||
})
|
||||
.await
|
||||
.and_then(|rows| Ok(serde_json::to_value(rows)?))
|
||||
} else {
|
||||
let (m, n1) = (mois.clone(), mois_n1.clone());
|
||||
cached(&format!("ca-nomenclature:{mois}:{mois_n1}"), CacheOpts::default(), move || async move {
|
||||
pg_get_ca_by_nomenclature(&m, &n1).await
|
||||
})
|
||||
.await
|
||||
.and_then(|rows| Ok(serde_json::to_value(rows)?))
|
||||
};
|
||||
res.map_err(|e| {
|
||||
tracing::error!("[analytics] chargement impossible : {}", message_erreur(&e));
|
||||
erreur_page(e)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meilleures ventes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Lignes de `pgGetHitParade(debut, fin)` (par défaut : du 1er du mois à aujourd'hui).
|
||||
pub async fn page_hit_parade(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let today = Local::now().date_naive();
|
||||
let first_of_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).unwrap_or(today);
|
||||
let date_debut = ctx.q("debut").map(|s| s.to_string()).unwrap_or_else(|| first_of_month.format("%Y-%m-%d").to_string());
|
||||
let date_fin = ctx.q("fin").map(|s| s.to_string()).unwrap_or_else(|| today.format("%Y-%m-%d").to_string());
|
||||
|
||||
let (d, f) = (date_debut.clone(), date_fin.clone());
|
||||
match cached(&format!("hit-parade:{date_debut}:{date_fin}"), CacheOpts::default(), move || async move {
|
||||
pg_get_hit_parade(&d, &f).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(rows) => json(rows),
|
||||
Err(e) => {
|
||||
tracing::error!("[hit-parade] chargement impossible : {}", message_erreur(&e));
|
||||
Err(erreur_page(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stocks à surveiller
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` pour un magasin (ou tous).
|
||||
pub async fn page_stock_negatif(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let magasin = ctx.q("magasin").unwrap_or("").to_string();
|
||||
|
||||
// Données FF recopiées chaque nuit : mises en cache (cf. ff_cache).
|
||||
let site = if magasin.is_empty() { None } else { Some(magasin.clone()) };
|
||||
let (s1, s2, s3) = (site.clone(), site.clone(), site);
|
||||
let (k1, k2, k3) =
|
||||
(format!("stock-negatif:{magasin}"), format!("stock-sans-vente:{magasin}"), format!("stock-sans-vente-6-mois:{magasin}"));
|
||||
let res = tokio::try_join!(
|
||||
cached(&k1, CacheOpts::default(), move || async move { pg_get_stock_negatif(s1.as_deref()).await }),
|
||||
cached(&k2, CacheOpts::default(), move || async move { pg_get_stock_sans_vente(s2.as_deref()).await }),
|
||||
cached(&k3, CacheOpts::default(), move || async move {
|
||||
pg_get_sans_vente_6_mois(s3.as_deref()).await
|
||||
}),
|
||||
);
|
||||
match res {
|
||||
Ok((rows_negatif, rows_sans_vente, rows_sans_vente_6_mois)) => Ok(json_value!({
|
||||
"rowsNegatif": rows_negatif,
|
||||
"rowsSansVente": rows_sans_vente,
|
||||
"rowsSansVente6Mois": rows_sans_vente_6_mois,
|
||||
})),
|
||||
Err(e) => {
|
||||
tracing::error!("[stock-negatif] chargement impossible : {}", message_erreur(&e));
|
||||
Err(erreur_page(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commandes fournisseurs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `{ rows, cadences, fournisseurs }`.
|
||||
pub async fn page_commandes_auto(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let (rows, cadences, fournisseurs) = tokio::join!(
|
||||
// Propositions de l'API FF (un appel, plus un par fournisseur sans franco) :
|
||||
// gardées 2 minutes, pour que chaque modification du cadencier — qui
|
||||
// recharge la page — n'attende pas de nouveau toute l'API.
|
||||
cached(
|
||||
"commandes-auto",
|
||||
CacheOpts { ttl: Some(Duration::from_secs(2 * 60)), cache_if: Some(|rows: &Vec<PgCommandeAutoRow>| !rows.is_empty()) },
|
||||
|| async { anyhow::Ok(pg_get_commandes_auto().await) },
|
||||
),
|
||||
lister_cadences(),
|
||||
get_fournisseurs_cached(),
|
||||
);
|
||||
Ok(json_value!({
|
||||
"rows": rows.map_err(erreur_page)?,
|
||||
"cadences": cadences,
|
||||
"fournisseurs": fournisseurs.map_err(erreur_page)?,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Publicités
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
/// Délai maximal d'un appel à l'API FF : sans lui, une API muette figeait la page.
|
||||
const FF_API_TIMEOUT: Duration = Duration::from_millis(15_000);
|
||||
/// Réponses de l'API gardées 10 minutes (les erreurs ne le sont jamais) : la liste
|
||||
/// et l'historique étaient re-téléchargés à chaque clic sur un filtre ou une page.
|
||||
const PUB_CACHE_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct PublicitesResult {
|
||||
data: Value,
|
||||
total: f64,
|
||||
pages: f64,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct HistoriqueResult {
|
||||
data: Value,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
fn client_publicites() -> reqwest::Client {
|
||||
reqwest::Client::builder().timeout(FF_API_TIMEOUT).build().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_publicites_api(statut: String, page: String) -> PublicitesResult {
|
||||
let mut params: Vec<(&str, String)> = vec![("page", page), ("limit", PAGE_SIZE.to_string())];
|
||||
if !statut.is_empty() && statut != "toutes" {
|
||||
params.push(("statut", statut));
|
||||
}
|
||||
let base = get_ff_api_base().await;
|
||||
let res = async {
|
||||
let res = client_publicites()
|
||||
.get(format!("{base}/api/publicites"))
|
||||
.query(¶ms)
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-store")
|
||||
.send()
|
||||
.await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(PublicitesResult {
|
||||
data: json_value!([]),
|
||||
total: 0.0,
|
||||
pages: 0.0,
|
||||
error: Some(format!(
|
||||
"Erreur API: {} {}",
|
||||
res.status().as_u16(),
|
||||
super::api::status_text(res.status())
|
||||
)),
|
||||
});
|
||||
}
|
||||
let body: Value = res.json().await?;
|
||||
let champ = |k: &str| get(&body, k).filter(|v| !v.is_null());
|
||||
Ok(PublicitesResult {
|
||||
data: champ("publicites").cloned().unwrap_or_else(|| json_value!([])),
|
||||
total: champ("total").map(js_number).unwrap_or(0.0),
|
||||
pages: champ("pages").map(js_number).unwrap_or(0.0),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
res.unwrap_or_else(|e| PublicitesResult {
|
||||
data: json_value!([]),
|
||||
total: 0.0,
|
||||
pages: 0.0,
|
||||
error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))),
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_historique_api() -> HistoriqueResult {
|
||||
let base = get_ff_api_base().await;
|
||||
let res = async {
|
||||
let res = client_publicites()
|
||||
.get(format!("{base}/api/publicites/historique"))
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-store")
|
||||
.send()
|
||||
.await?;
|
||||
if !res.status().is_success() {
|
||||
return anyhow::Ok(HistoriqueResult {
|
||||
data: json_value!([]),
|
||||
error: Some(format!("Erreur historique: {}", res.status().as_u16())),
|
||||
});
|
||||
}
|
||||
let body: Value = res.json().await?;
|
||||
Ok(HistoriqueResult {
|
||||
data: get(&body, "publicites").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json_value!([])),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
.await;
|
||||
res.unwrap_or_else(|e| HistoriqueResult { data: json_value!([]), error: Some(format!("Impossible de joindre l'API: {}", message_erreur(&e))) })
|
||||
}
|
||||
|
||||
/// `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }`.
|
||||
pub async fn page_publicites(ctx: Ctx) -> AppResult<Value> {
|
||||
ctx.session().await?;
|
||||
let statut = ctx.query.get("statut").cloned().unwrap_or_else(|| "toutes".into());
|
||||
// Math.max(1, Number(page) || 1)
|
||||
let page_num = {
|
||||
let n = ctx.query.get("page").map(|p| js_number(&Value::String(p.clone()))).unwrap_or(f64::NAN);
|
||||
let n = if n.is_nan() || n == 0.0 { 1.0 } else { n };
|
||||
n.max(1.0)
|
||||
};
|
||||
let page = js_number_to_string(page_num);
|
||||
|
||||
let (s, p) = (statut.clone(), page.clone());
|
||||
let cle = format!("publicites:{statut}:{page}");
|
||||
let (pub_result, histo_result) = tokio::join!(
|
||||
cached(
|
||||
&cle,
|
||||
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &PublicitesResult| r.error.is_none()) },
|
||||
move || async move { anyhow::Ok(fetch_publicites_api(s, p).await) },
|
||||
),
|
||||
cached(
|
||||
"publicites-historique",
|
||||
CacheOpts { ttl: Some(PUB_CACHE_TTL), cache_if: Some(|r: &HistoriqueResult| r.error.is_none()) },
|
||||
|| async { anyhow::Ok(fetch_historique_api().await) },
|
||||
),
|
||||
);
|
||||
let pub_result = pub_result.map_err(erreur_page)?;
|
||||
let histo_result = histo_result.map_err(erreur_page)?;
|
||||
|
||||
let mut out = serde_json::Map::new();
|
||||
out.insert("publicites".into(), pub_result.data);
|
||||
out.insert("total".into(), json_value!(pub_result.total));
|
||||
out.insert("pages".into(), json_value!(pub_result.pages));
|
||||
if let Some(e) = pub_result.error {
|
||||
out.insert("erreurPublicites".into(), Value::String(e));
|
||||
}
|
||||
out.insert("historique".into(), histo_result.data);
|
||||
if let Some(e) = histo_result.error {
|
||||
out.insert("erreurHistorique".into(), Value::String(e));
|
||||
}
|
||||
out.insert("pageSize".into(), json_value!(PAGE_SIZE));
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mois_n1() {
|
||||
assert_eq!(get_mois_n1("2026-03"), "2025-03");
|
||||
assert_eq!(get_mois_n1("2026-12"), "2025-12");
|
||||
assert_eq!(get_mois_n1("2026-01"), "2025-01");
|
||||
assert_eq!(get_mois_n1("abc"), "NaN-NaN");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user