Fusion du module Grille (phase 2 : calcul, /api/grid/rows, /api/v1)

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:55:24 +00:00
commit d52a942812
10 files changed
+2306 -19

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
//! CollectFlow — Affectation de gammes via l'API `/api/v1`
//! (port de `src/lib/api-gammes.ts`).
//!
//! L'API n'enregistre pas la gamme : elle la **propose**. La proposition est
//! rangée dans `gammes_a_valider` et apparaît dans la Grille comme une
//! modification non enregistrée — on y voit donc quels produits changent de gamme
//! avant de valider (« Enregistrer ») ou de rejeter (« Annuler les modifications »).
//! Tant qu'elle n'est pas validée, `codeGamme` reste inchangée et la proposition
//! est exposée dans `gammeAValider`.
//!
//! Tout ou rien : si un seul article est inconnu chez le fournisseur, rien n'est
//! proposé et la réponse liste les articles en cause.
use super::gammes_a_valider::{proposer_gammes, PropositionGamme};
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{get_grid_freshness, get_grid_rows_gammes};
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::response::Response;
use serde::Serialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ResultatGamme {
pub codein: String,
/// Gamme proposée.
pub gamme: String,
/// Gamme enregistrée au moment de l'appel (modifications validées comprises).
pub gamme_precedente: Option<String>,
/// Gamme présente en base FF, non modifiée par l'API.
pub code_gamme_serveur: Option<String>,
/// `true` = la gamme change : proposition déposée, à valider dans la Grille.
/// `false` = l'article a déjà cette gamme (une proposition antérieure en
/// attente pour lui est retirée).
pub modifie: bool,
/// `a_valider` quand `modifie`, sinon `inchangee`.
pub statut: &'static str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AffectationGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub resultats: Vec<ResultatGamme>,
}
/// Lit un corps JSON, ou renvoie une réponse 400 prête à être retournée.
pub fn lire_corps_json(body: &[u8]) -> Result<Value, Response> {
serde_json::from_slice(body)
.map_err(|_| fail(ApiErrorCode::BadRequest, "Corps de requête JSON attendu (Content-Type: application/json).", None))
}
/// Propose des gammes pour des articles d'un fournisseur (à valider dans la Grille).
///
/// Si le fournisseur n'a encore jamais été calculé, son instantané est calculé à la
/// demande (`compute`), comme sur `/grid` : sans lui, impossible de vérifier que les
/// articles lui appartiennent.
///
/// `auteur` : nom de la clé d'API ou de l'utilisateur — affiché dans la Grille.
pub async fn affecter_gammes(
code_fournisseur: &str,
changes: &[(String, String)],
compute: bool,
auteur: &str,
) -> Result<AffectationGammes, Response> {
// Un même article cité deux fois : la dernière valeur l'emporte (à la place
// de sa première occurrence, comme une Map JS).
let mut demandes: Vec<(String, String)> = Vec::new();
for (codein, gamme) in changes {
match demandes.iter_mut().find(|(c, _)| c == codein) {
Some(d) => d.1 = gamme.clone(),
None => demandes.push((codein.clone(), gamme.clone())),
}
}
let codeins: Vec<String> = demandes.iter().map(|(c, _)| c.clone()).collect();
let interne = |e: crate::error::AppError| fail(ApiErrorCode::InternalError, e.message, None);
if compute && get_grid_freshness(code_fournisseur).await.map_err(interne)?.is_none() {
tracing::info!("[api/v1/gammes] instantané absent pour {code_fournisseur} — calcul à la demande");
let calcul = get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await;
attendre_persistance(code_fournisseur).await;
if let Err(e) = calcul {
tracing::error!("[api/v1/gammes] calcul KO pour {code_fournisseur}: {e:#}");
return Err(fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {code_fournisseur} »."),
None,
));
}
}
let actuelles = get_grid_rows_gammes(code_fournisseur, &codeins).await.map_err(interne)?;
let inconnus: Vec<&String> = codeins.iter().filter(|c| !actuelles.contains_key(*c)).collect();
if !inconnus.is_empty() {
return Err(fail(
ApiErrorCode::NotFound,
format!(
"{} article(s) introuvable(s) chez le fournisseur « {code_fournisseur} » : aucune gamme n'a été enregistrée.",
inconnus.len()
),
Some(json!({ "fournisseur": code_fournisseur, "inconnus": inconnus })),
));
}
let mut resultats = Vec::with_capacity(demandes.len());
let mut propositions = Vec::new();
let mut annules = Vec::new();
let mut nom_fournisseur: Option<String> = None;
for (codein, gamme) in demandes {
let actuelle = &actuelles[&codein];
if nom_fournisseur.is_none() {
nom_fournisseur = actuelle.nom_fournisseur.clone();
}
let modifie = actuelle.code_gamme.as_deref() != Some(gamme.as_str());
if modifie {
propositions.push(PropositionGamme {
codein: codein.clone(),
gamme: gamme.clone(),
gamme_precedente: actuelle.code_gamme.clone(),
});
} else {
annules.push(codein.clone());
}
resultats.push(ResultatGamme {
codein,
gamme,
gamme_precedente: actuelle.code_gamme.clone(),
code_gamme_serveur: actuelle.code_gamme_init.clone(),
modifie,
statut: if modifie { "a_valider" } else { "inchangee" },
});
}
if let Err(e) = proposer_gammes(code_fournisseur, &propositions, &annules, auteur).await {
tracing::error!("[api/v1/gammes] proposition KO pour {code_fournisseur}: {}", e.message);
return Err(fail(ApiErrorCode::InternalError, "L'enregistrement des gammes à valider a échoué.", None));
}
tracing::info!("[api/v1/gammes] {code_fournisseur} — {} gamme(s) à valider proposée(s) par {auteur}", propositions.len());
Ok(AffectationGammes { code_fournisseur: code_fournisseur.to_string(), nom_fournisseur, resultats })
}
+551
View File
@@ -0,0 +1,551 @@
//! Routes `/api/v1` adossées au calcul de la grille (phase 2) : `/grid`,
//! `/products/search`, `/products/{codein}`, `/products/{codein}/gamme`,
//! `/gammes`, `/fournisseurs`, `/network/{codeCentrale}`.
use super::api_gammes::{affecter_gammes, lire_corps_json};
use super::api_schemas::{
parse_fournisseurs_query, parse_gammes_batch_body, parse_grid_query, parse_product_detail, parse_product_gamme_body,
parse_product_search, pick_fields, query_map, to_sort_key, GridQueryParams,
};
use super::enrich::enrich_rows;
use super::product_rows::{attendre_persistance, get_product_rows, GetProductRowsInput};
use super::store::{
get_grid_freshness, get_grid_row_by_codein, list_grid_suppliers, list_grid_suppliers_for_codein, query_grid_rows, GridFreshness,
GridQuery, SortOrder,
};
use super::types::ProductRow;
use crate::api_v1_response::{build_pagination, fail, ok, ApiErrorCode};
use crate::auth::ApiAuth;
use crate::error::AppError;
use axum::body::{Body, Bytes};
use axum::extract::{Path, Query};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use futures::channel::mpsc;
use futures::SinkExt;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
fn interne(e: AppError) -> Response {
fail(ApiErrorCode::InternalError, e.message, None)
}
fn meta(entrees: Vec<(&str, Value)>) -> Option<Map<String, Value>> {
Some(entrees.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
}
/// Lignes prêtes à sérialiser : enrichies (`enrich=1`) puis projetées (`fields`).
async fn lignes_json(rows: Vec<ProductRow>, enrichir: bool, fields: Option<&str>) -> Vec<Value> {
let valeurs: Vec<Value> = if enrichir {
enrich_rows(rows).await.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
} else {
rows.into_iter().map(|r| serde_json::to_value(r).unwrap_or(Value::Null)).collect()
};
valeurs.into_iter().map(|v| pick_fields(v, fields)).collect()
}
fn ordre(order: &str) -> Option<SortOrder> {
Some(if order == "asc" { SortOrder::Asc } else { SortOrder::Desc })
}
/// `GET /api/v1/grid?fournisseur=…&gamme=&code1..3=&search=&sort=&order=&page=&limit=&fields=&compute=`
///
/// Lignes de grille d'un fournisseur, filtrées / triées / paginées **en SQL** depuis
/// l'instantané persisté (`grid_rows`).
///
/// Si le fournisseur n'a pas encore d'instantané, l'endpoint le **calcule à la demande**
/// (`compute=1`, défaut) ; `compute=0` restaure le comportement strict : échec
/// immédiat en `202 not_ready`.
pub async fn get_grid(ApiAuth(auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_grid_query(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
// Distingue « fournisseur jamais calculé » de « filtres sans résultat » (200 vide).
let mut freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
let mut computed_on_demand = false;
if freshness.is_none() {
if q.compute == "0" {
return fail(
ApiErrorCode::NotReady,
format!(
"Aucun instantané pour le fournisseur « {} ». Relancez sans « compute=0 » pour le calculer à la demande.",
q.fournisseur
),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
tracing::info!("[api/v1/grid] instantané absent pour {} — calcul à la demande (via {})", q.fournisseur, auth.via);
// magasin TOTAL : c'est la seule variante persistée par getProductRows,
// et ProductRow embarque déjà les ventilations par magasin.
match get_product_rows(GetProductRowsInput::total(&q.fournisseur, false)).await {
// La persistance est lancée en arrière-plan par le calcul : on attend
// qu'elle soit écrite avant de relire l'instantané.
Ok(_) => attendre_persistance(&q.fournisseur).await,
Err(e) => {
tracing::error!("[api/v1/grid] calcul à la demande KO pour {}: {e:#}", q.fournisseur);
return fail(
ApiErrorCode::InternalError,
format!("Le calcul de la grille a échoué pour le fournisseur « {} ».", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
}
freshness = match get_grid_freshness(&q.fournisseur).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if freshness.is_none() {
// Calcul réussi mais aucune ligne : le code fournisseur n'existe pas, ou
// il n'a aucun article. À distinguer d'une panne.
return fail(
ApiErrorCode::NotFound,
format!("Aucun article pour le fournisseur « {} ». Vérifiez le code auprès de /api/v1/fournisseurs.", q.fournisseur),
Some(json!({ "fournisseur": q.fournisseur })),
);
}
computed_on_demand = true;
}
let freshness = freshness.unwrap_or(GridFreshness { row_count: 0, computed_at: None });
let filtres = GridQuery {
code_fournisseur: Some(q.fournisseur.clone()),
gamme: q.gamme.clone(),
code1: q.code1.clone(),
code2: q.code2.clone(),
code3: q.code3.clone(),
nomenclature: q.nomenclature.clone(),
search: q.search.clone(),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: None,
limit: None,
};
// Sans `limit`, la réponse peut porter tout un fournisseur — et certains en
// comptent plus de 130 000. On diffuse par lots, à mémoire constante.
let Some(limit) = q.limit else {
return stream_all_rows(filtres, q, freshness, computed_on_demand);
};
let result = match query_grid_rows(&GridQuery { page: Some(q.page), limit: Some(limit), ..filtres }).await {
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let nb = result.rows.len();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
let pagination = build_pagination(q.page, limit, result.total);
let has_more = pagination.has_more;
let mut m = meta(vec![
("fournisseur", json!(q.fournisseur)),
("computedAt", json!(result.computed_at)),
("snapshotComputedAt", json!(freshness.computed_at)),
("snapshotRowCount", json!(freshness.row_count)),
("enrichi", json!(enrichi)),
// true = l'instantané n'existait pas et vient d'être calculé par cet appel.
("computedOnDemand", json!(computed_on_demand)),
// true = cette réponse contient **toutes** les lignes du fournisseur.
("complet", json!(!has_more)),
])
.unwrap_or_default();
if has_more {
m.insert(
"avertissement".into(),
json!(format!(
"Réponse partielle : {nb} lignes sur {}, parce que « limit » a été précisé. Retirez « limit » pour obtenir tout le fournisseur en un appel, ou passez à page={}.",
result.total,
q.page + 1
)),
);
}
ok(rows, Some(pagination), Some(m))
}
/// Lots internes : compromis entre nombre d'allers-retours SQL et mémoire retenue.
const STREAM_BATCH: i64 = 1000;
/// Diffuse **toutes** les lignes du fournisseur en une seule réponse JSON, sans
/// jamais la construire entièrement en mémoire.
///
/// La forme reste celle des autres réponses (`{ data, pagination, meta }`) ;
/// `pagination` et `meta` sont écrits en dernier, une fois le total connu. Une
/// erreur survenant après les premiers octets ne peut plus changer le code HTTP :
/// on ferme alors le document avec `meta.erreur`.
fn stream_all_rows(filtres: GridQuery, q: GridQueryParams, freshness: GridFreshness, computed_on_demand: bool) -> Response {
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
let mut envoyees: i64 = 0;
let mut total: i64 = 0;
let mut computed_at: Option<String> = None;
let mut erreur: Option<String> = None;
let enrichi = q.sort.enrich == "1";
if tx.send(Ok(Bytes::from_static(b"{\"data\":["))).await.is_err() {
return;
}
let mut page = 1;
loop {
let lot = match query_grid_rows(&GridQuery { page: Some(page), limit: Some(STREAM_BATCH), ..filtres.clone() }).await {
Ok(l) => l,
Err(e) => {
erreur = Some(e.message);
break;
}
};
if page == 1 {
total = lot.total;
computed_at = lot.computed_at.clone();
}
let n = lot.rows.len() as i64;
if n == 0 {
break;
}
let mut morceau = Vec::new();
for r in lignes_json(lot.rows, enrichi, q.sort.fields.as_deref()).await {
if envoyees > 0 {
morceau.push(b',');
}
morceau.extend(serde_json::to_vec(&r).unwrap_or_default());
envoyees += 1;
}
if tx.send(Ok(Bytes::from(morceau))).await.is_err() {
return;
}
if n < STREAM_BATCH {
break;
}
page += 1;
}
if let Some(e) = &erreur {
tracing::error!("[api/v1/grid] diffusion interrompue pour {} après {envoyees} lignes: {e}", q.fournisseur);
}
let complet = erreur.is_none() && envoyees == total;
let mut meta = json!({
"fournisseur": q.fournisseur,
"computedAt": computed_at,
"snapshotComputedAt": freshness.computed_at,
"snapshotRowCount": freshness.row_count,
"enrichi": enrichi,
"computedOnDemand": computed_on_demand,
"diffuse": true,
"complet": complet,
});
if let Some(e) = erreur {
meta["erreur"] = json!(format!("Diffusion interrompue après {envoyees} lignes sur {total} : {e}"));
}
let fin = json!({
"pagination": { "page": 1, "limit": envoyees, "total": total, "totalPages": 1, "hasMore": !complet },
"meta": meta,
});
let texte = serde_json::to_string(&fin).unwrap_or_default();
let _ = tx.send(Ok(Bytes::from(format!("],{}", &texte[1..])))).await;
});
(
[
(header::CONTENT_TYPE, "application/json; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
// Empêche un proxy de tamponner la réponse entière avant de la relayer.
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/v1/products/search?q=…&fournisseur=&gamme=&sort=&order=&page=&limit=&fields=`
///
/// **Recherche transversale** sur libellé, codein, GTIN, référence et code centrale —
/// tous fournisseurs confondus. Lit uniquement l'instantané `grid_rows`.
pub async fn get_products_search(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let q = match parse_product_search(&query_map(params)) {
Ok(q) => q,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let result = match query_grid_rows(&GridQuery {
code_fournisseur: q.fournisseur.clone(),
gamme: q.gamme.clone(),
search: Some(q.q.clone()),
sort: to_sort_key(q.sort.sort.as_deref()),
order: ordre(&q.sort.order),
page: Some(q.page),
limit: Some(q.limit),
..Default::default()
})
.await
{
Ok(r) => r,
Err(e) => return interne(e),
};
let enrichi = q.sort.enrich == "1";
let total = result.total;
let computed_at = result.computed_at.clone();
let rows = lignes_json(result.rows, enrichi, q.sort.fields.as_deref()).await;
ok(
rows,
Some(build_pagination(q.page, q.limit, total)),
meta(vec![
("query", json!(q.q)),
("computedAt", json!(computed_at)),
("scope", json!(q.fournisseur.as_ref().map(|f| format!("fournisseur:{f}")).unwrap_or_else(|| "tous fournisseurs".into()))),
("enrichi", json!(enrichi)),
]),
)
}
/// `GET /api/v1/products/{codein}?fournisseur=…`
///
/// Fiche complète d'un produit, enrichie des métriques réseau Qlik lues **en cache**.
/// `fournisseur` lève l'ambiguïté d'un article référencé chez plusieurs fournisseurs ;
/// sans lui, l'instantané le plus récent est renvoyé.
pub async fn get_product(ApiAuth(_auth): ApiAuth, Path(codein): Path<String>, Query(params): Query<Vec<(String, String)>>) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let parsed = match parse_product_detail(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let mut found = match get_grid_row_by_codein(&codein, parsed.fournisseur.as_deref()).await {
Ok(f) => f,
Err(e) => return interne(e),
};
// Absent de l'instantané : on peut le calculer, mais seulement si l'appelant a
// précisé le fournisseur — le calcul se fait par lot fournisseur, pas par article.
if found.is_none() && parsed.compute == "1" {
if let Some(f) = parsed.fournisseur.as_deref() {
tracing::info!("[api/v1/products] {codein} absent — calcul du lot {f} à la demande");
let calcul = async {
get_product_rows(GetProductRowsInput::total(f, false)).await?;
attendre_persistance(f).await;
get_grid_row_by_codein(&codein, Some(f)).await.map_err(|e| anyhow::anyhow!(e.message))
};
match calcul.await {
Ok(r) => found = r,
Err(e) => tracing::error!("[api/v1/products] calcul KO pour {f}: {e:#}"),
}
}
}
let Some(found) = found else {
return fail(
ApiErrorCode::NotFound,
match &parsed.fournisseur {
Some(f) => format!("Produit « {codein} » introuvable chez le fournisseur « {f} »."),
None => format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur=… » pour que l'API calcule le lot à la demande."
),
},
None,
);
};
// Métriques réseau + gamme serveur relues maintenant : le payload date du dernier
// calcul de la grille, alors que ces deux données évoluent indépendamment.
let Some(enriched) = enrich_rows(vec![found.row]).await.into_iter().next() else {
return fail(ApiErrorCode::InternalError, "Enrichissement impossible.", None);
};
let network_fetched_at = enriched.network.as_ref().and_then(|n| n.fetched_at.clone());
let has_network = enriched.network.is_some();
ok(
enriched,
None,
meta(vec![
("computedAt", json!(found.computed_at)),
("networkFetchedAt", json!(network_fetched_at)),
("hasNetwork", json!(has_network)),
]),
)
}
fn maintenant_iso() -> String {
crate::db::iso_utc(chrono::Utc::now())
}
/// `PUT /api/v1/products/{codein}/gamme` `{ "gamme": "A", "fournisseur"?: "…" }`
///
/// Propose une gamme pour un article. La proposition reste **à valider** dans la
/// Grille. `fournisseur` n'est requis que si l'article est référencé chez plusieurs
/// fournisseurs, ou s'il n'apparaît pas encore dans l'instantané.
pub async fn put_product_gamme(ApiAuth(auth): ApiAuth, Path(codein): Path<String>, body: Bytes) -> Response {
if codein.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codein' requis.", None);
}
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_product_gamme_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let fournisseur = match parsed.fournisseur {
Some(f) => f,
None => {
let fournisseurs = match list_grid_suppliers_for_codein(&codein).await {
Ok(f) => f,
Err(e) => return interne(e),
};
if fournisseurs.is_empty() {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucun instantané pour le produit « {codein} ». Ajoutez « fournisseur » au corps de la requête pour que l'API calcule le lot à la demande."
),
None,
);
}
if fournisseurs.len() > 1 {
return fail(
ApiErrorCode::BadRequest,
format!("Le produit « {codein} » est référencé chez plusieurs fournisseurs : précisez « fournisseur »."),
Some(json!({ "fournisseurs": fournisseurs })),
);
}
fournisseurs[0].code_fournisseur.clone()
}
};
let res = match affecter_gammes(&fournisseur, &[(codein, parsed.gamme)], parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let mut data = match res.resultats.first().map(serde_json::to_value) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
data.insert("codeFournisseur".into(), json!(res.code_fournisseur));
data.insert("nomFournisseur".into(), json!(res.nom_fournisseur));
ok(Value::Object(data), None, meta(vec![("enregistreLe", json!(maintenant_iso()))]))
}
/// `POST /api/v1/gammes` `{ "fournisseur": "…", "changes": [{ "codein": "…", "gamme": "A" }, …] }`
///
/// Propose la gamme de plusieurs articles d'un fournisseur en un appel, à valider
/// dans la Grille. Tout ou rien : un article inconnu chez le fournisseur fait
/// échouer l'appel (404).
pub async fn post_gammes(ApiAuth(auth): ApiAuth, body: Bytes) -> Response {
let corps = match lire_corps_json(&body) {
Ok(c) => c,
Err(r) => return r,
};
let parsed = match parse_gammes_batch_body(&corps) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Corps de requête invalide.", Some(issues.to_json())),
};
let changes: Vec<(String, String)> = parsed.changes.into_iter().map(|c| (c.codein, c.gamme)).collect();
let res = match affecter_gammes(&parsed.fournisseur, &changes, parsed.compute, &auth.subject).await {
Ok(r) => r,
Err(r) => return r,
};
let demandes = res.resultats.len();
let modifies = res.resultats.iter().filter(|r| r.modifie).count();
let a_valider = res.resultats.iter().filter(|r| r.statut == "a_valider").count();
ok(
&res.resultats,
None,
meta(vec![
("codeFournisseur", json!(res.code_fournisseur)),
("nomFournisseur", json!(res.nom_fournisseur)),
("demandes", json!(demandes)),
("modifies", json!(modifies)),
("aValider", json!(a_valider)),
("enregistreLe", json!(maintenant_iso())),
]),
)
}
/// `GET /api/v1/fournisseurs?search=…&withData=1`
///
/// Liste des fournisseurs : tout le référentiel (`fouident`), enrichi de la
/// fraîcheur de l'instantané de grille quand il existe. `withData=1` restreint aux
/// seuls fournisseurs déjà présents dans l'instantané.
pub async fn get_fournisseurs(ApiAuth(_auth): ApiAuth, Query(params): Query<Vec<(String, String)>>) -> Response {
let parsed = match parse_fournisseurs_query(&query_map(params)) {
Ok(p) => p,
Err(issues) => return fail(ApiErrorCode::BadRequest, "Paramètres invalides.", Some(issues.to_json())),
};
let snapshot = match list_grid_suppliers().await {
Ok(s) => s,
Err(e) => return interne(e),
};
if parsed.with_data.as_deref() == Some("1") {
let term = parsed.search.as_ref().map(|s| s.to_lowercase());
let data: Vec<Value> = snapshot
.iter()
.filter(|s| match &term {
None => true,
Some(t) => {
s.code_fournisseur.to_lowercase().contains(t.as_str())
|| s.nom_fournisseur.as_deref().unwrap_or("").to_lowercase().contains(t.as_str())
}
})
.map(|s| {
json!({ "code": s.code_fournisseur, "nom": s.nom_fournisseur, "hasSnapshot": true, "rowCount": s.row_count, "computedAt": s.computed_at })
})
.collect();
let total = data.len();
return ok(data, None, meta(vec![("total", json!(total)), ("scope", json!("instantané uniquement"))]));
}
let by_code: HashMap<&str, &super::store::GridSupplier> = snapshot.iter().map(|s| (s.code_fournisseur.as_str(), s)).collect();
let fournisseurs = crate::ff::pg::pg_get_fournisseurs(parsed.search.as_deref()).await;
let data: Vec<Value> = fournisseurs
.iter()
.map(|f| {
let snap = by_code.get(f.code.as_str());
json!({
"code": f.code,
"nom": f.nom,
"hasSnapshot": snap.is_some(),
"rowCount": snap.map(|s| s.row_count).unwrap_or(0),
"computedAt": snap.and_then(|s| s.computed_at.clone()),
})
})
.collect();
let total = data.len();
ok(data, None, meta(vec![("total", json!(total)), ("withSnapshot", json!(by_code.len()))]))
}
/// `GET /api/v1/network/{codeCentrale}`
///
/// Métriques réseau Qlik d'un produit et sa courbe de quantités mensuelles.
/// Lecture **exclusive** de la table `qlik_network_metrics` : aucun appel à Qlik.
pub async fn get_network(ApiAuth(_auth): ApiAuth, Path(code_centrale): Path<String>) -> Response {
if code_centrale.is_empty() {
return fail(ApiErrorCode::BadRequest, "Paramètre 'codeCentrale' requis.", None);
}
let metrics = match crate::qlik::network_cache::get_network_metrics_by_code_centrale(std::slice::from_ref(&code_centrale)).await {
Ok(m) => m,
Err(e) => return fail(ApiErrorCode::InternalError, format!("{e:#}"), None),
};
let Some(found) = metrics.get(&code_centrale) else {
return fail(
ApiErrorCode::NotFound,
format!(
"Aucune donnée réseau en cache pour le code centrale « {code_centrale} ». Lancez une synchronisation Qlik ou une recherche réseau."
),
None,
);
};
let mut data = Map::new();
data.insert("codeCentrale".into(), json!(code_centrale));
if let Ok(Value::Object(m)) = serde_json::to_value(found) {
data.extend(m);
}
ok(Value::Object(data), None, meta(vec![("fetchedAt", json!(found.fetched_at)), ("source", json!("cache"))]))
}
+148
View File
@@ -0,0 +1,148 @@
//! CollectFlow — Enrichissement des lignes servies par `/api/v1`
//! (port de `src/lib/api-enrich.ts`).
//!
//! L'instantané `grid_rows` fige la ligne au moment du calcul. Deux informations
//! doivent pourtant refléter l'état **courant** au moment de l'appel :
//!
//! 1. **Métriques réseau Qlik** — la table `qlik_network_metrics` est mise à jour par
//! les synchronisations et les recherches réseau, indépendamment du calcul de la
//! grille. On les relit donc à la lecture, et on les expose telles quelles quand
//! elles existent (`network: null` sinon).
//!
//! 2. **Gamme serveur** — `codeGamme` peut être surchargée par un snapshot de session.
//! L'API expose la gamme **non modifiée**, celle réellement présente en base,
//! relue à chaque appel.
//!
//! Ce n'est pas un recalcul : des lectures indexées bornées au nombre de lignes.
use super::gammes_a_valider::gammes_a_valider_par_article;
use super::months;
use super::network_trend::{compute_network_trend, trend_label, Direction};
use super::types::ProductRow;
use crate::ff::pg::pg_get_gammes_by_codeins;
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet, HashMap};
/// Tendance réseau 12 mois, telle qu'affichée dans la colonne « Tendance » de la Grille.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProductTrend {
pub direction: Direction,
/// Variation des 4 derniers mois vs les 4 premiers (0.23 = +23 %). `null` si aucune base.
#[serde(serialize_with = "super::js::opt_num::serialize")]
pub pct: Option<f64>,
/// Libellé prêt à afficher : « Forte hausse », « Nouveau », « Stable »…
pub label: &'static str,
/// `true` = tendance calculée sur la quantité **par magasin vendeur**.
pub sur_qte_par_magasin: bool,
/// Aucune vente sur les 4 premiers mois, des ventes sur les 4 derniers.
pub nouveau: bool,
/// Nombre de mois de la série (12 quand elle est complète).
pub months_used: usize,
}
/// Ligne enrichie : la ligne, puis la gamme serveur, la proposition en attente,
/// les métriques réseau et la tendance (ordre des clés du TS).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnrichedProductRow {
#[serde(flatten)]
pub row: ProductRow,
/// Gamme telle qu'elle existe sur le serveur PostgreSQL — non modifiée.
pub code_gamme_serveur: Option<String>,
/// Gamme proposée par l'API et pas encore validée dans la Grille.
pub gamme_a_valider: Option<String>,
/// Métriques réseau Qlik en cache, ou `null` si le produit n'en a pas.
pub network: Option<NetworkMetricCached>,
/// Tendance réseau sur les 12 derniers mois, `null` sans série mensuelle.
pub trend: Option<ProductTrend>,
}
/// Enrichit un lot de lignes. Les lectures sont indépendantes et lancées en
/// parallèle ; un échec de l'une n'empêche pas les autres (l'API reste servie, avec
/// la valeur de l'instantané en repli).
pub async fn enrich_rows(rows: Vec<ProductRow>) -> Vec<EnrichedProductRow> {
if rows.is_empty() {
return vec![];
}
let mut vus = BTreeSet::new();
let codes_centraux: Vec<String> =
rows.iter().filter_map(|r| r.code_centrale.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let mut vus = BTreeSet::new();
let codeins: Vec<String> = rows.iter().map(|r| r.codein.clone()).filter(|c| !c.is_empty() && vus.insert(c.clone())).collect();
let (network, gammes, a_valider) = tokio::join!(
async {
if codes_centraux.is_empty() {
return HashMap::new();
}
get_network_metrics_by_code_centrale(&codes_centraux).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] métriques réseau KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
if codeins.is_empty() {
return HashMap::new();
}
pg_get_gammes_by_codeins(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes serveur KO: {}", crate::error::short(&e, 160));
HashMap::new()
})
},
async {
gammes_a_valider_par_article(&codeins).await.unwrap_or_else(|e| {
tracing::error!("[api-enrich] gammes à valider KO: {}", crate::error::short(&e.message, 160));
HashMap::new()
})
},
);
let aujourd_hui = months::today();
rows.into_iter()
.map(|mut row| {
let metrics = row.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| network.get(c)).cloned();
// Gamme absente de la base = pas de gamme, et non « inconnue ».
let gamme_serveur = gammes.get(&row.codein).cloned();
let gamme_a_valider = a_valider.get(&format!("{}|{}", row.code_fournisseur, row.codein)).cloned();
// `codeGammeInit` porte déjà la sémantique « état serveur » dans la Grille :
// on la garde alignée pour que les deux champs ne divergent jamais.
if !gammes.is_empty() {
row.code_gamme_init = gamme_serveur.clone();
}
// Les colonnes réseau de la ligne datent du calcul : on les réaligne sur le
// cache quand il a quelque chose à dire.
if let Some(m) = &metrics {
row.ca_reseau = Some(m.ca_reseau);
row.qte_reseau = Some(m.qte_reseau);
row.nb_magasins_reseau = Some(m.nb_magasins_reseau);
row.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
row.marge_pct_reseau = Some(m.marge_pct_reseau);
row.qte_reseau_by_month = Some(m.qte_by_month.clone());
row.network_fetched_at = m.fetched_at.clone();
}
// Tendance : mêmes entrées que la colonne « Tendance » de la Grille — la
// série des quantités **et** celle du nombre de magasins vendeurs.
let serie = row.qte_reseau_by_month.clone().flatten();
let nb_mag_by_month: Option<BTreeMap<String, f64>> = metrics
.as_ref()
.and_then(|m| m.metrics_by_month.as_ref())
.map(|d| d.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| !n.is_nan()).unwrap_or(0.0))).collect());
let t = compute_network_trend(serie.as_ref(), nb_mag_by_month.as_ref(), aujourd_hui);
let trend = t.has_data.then(|| ProductTrend {
direction: t.direction,
pct: t.pct,
label: trend_label(t.pct, t.nouveau),
sur_qte_par_magasin: t.sur_qte_par_magasin,
nouveau: t.nouveau,
months_used: t.values.len(),
});
EnrichedProductRow { row, code_gamme_serveur: gamme_serveur, gamme_a_valider, network: metrics, trend }
})
.collect()
}
+135
View File
@@ -0,0 +1,135 @@
//! Routes internes de la Grille (ports de `src/app/api/grid/rows/route.ts` et
//! `src/app/api/grid/rows/store-patch/route.ts`).
use super::js::JsNum;
use super::product_rows::{get_product_rows, get_store_patch, GetProductRowsInput};
use super::types::ProductRow;
use crate::auth::SessionUser;
use axum::body::{Body, Bytes};
use axum::extract::Query;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use futures::channel::mpsc;
use futures::SinkExt;
use serde::Serialize;
use serde_json::json;
/// `searchParams.get(nom)` : première occurrence.
pub fn premier<'a>(params: &'a [(String, String)], nom: &str) -> Option<&'a str> {
params.iter().find(|(k, _)| k == nom).map(|(_, v)| v.as_str())
}
#[derive(Serialize)]
struct Lot<'a> {
#[serde(rename = "type")]
type_: &'static str,
rows: &'a [ProductRow],
loaded: JsNum,
total: usize,
}
/// Indice de `Array.prototype.slice` (troncature, bornage à la longueur).
fn indice_slice(x: f64, len: usize) -> usize {
if x.is_nan() {
0
} else {
x.trunc().clamp(0.0, len as f64) as usize
}
}
fn ndjson(v: &impl Serialize) -> Bytes {
let mut out = serde_json::to_vec(v).unwrap_or_default();
out.push(b'\n');
Bytes::from(out)
}
/// `GET /api/grid/rows?fournisseur=…&magasin=…&chunkSize=…&refresh=1`
///
/// Flux NDJSON : `{ type: "start" }`, puis des lots `{ type: "chunk", rows, loaded,
/// total }`, puis `{ type: "done" }` — ou `{ type: "error", error }`.
pub async fn get_grid_rows(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let Some(code_fournisseur) = premier(&params, "fournisseur").filter(|f| !f.is_empty()).map(str::to_string) else {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Missing fournisseur" }))).into_response();
};
let magasin = premier(&params, "magasin").unwrap_or("TOTAL").to_string();
let chunk_size = {
let brut = premier(&params, "chunkSize").map(super::js::js_number_from_str).unwrap_or(150.0);
// Math.max(50, Math.min(500, x)) : NaN se propage.
if brut.is_nan() {
f64::NAN
} else {
brut.clamp(50.0, 500.0)
}
};
// `code1`/`code2`/`code3` ne sont pas lus : la Grille filtre en local
// (getProductRows ne s'en sert pas).
let force_refresh = premier(&params, "refresh") == Some("1");
// Client parti (changement de fournisseur, page quittée) : on arrête de
// sérialiser et d'envoyer. Le calcul lui-même continue — il est partagé et
// mis en cache, la prochaine ouverture en profitera.
let (mut tx, rx) = mpsc::channel::<Result<Bytes, std::io::Error>>(4);
tokio::spawn(async move {
if tx.send(Ok(ndjson(&json!({ "type": "start", "loaded": 0, "total": null })))).await.is_err() {
return;
}
let input = GetProductRowsInput { code_fournisseur, magasin: Some(magasin), force_refresh };
match get_product_rows(input).await {
Ok(shared) => {
let total = shared.len();
let mut start = 0.0f64;
while start < total as f64 {
let ligne = {
let rows = shared.rows.read();
let len = rows.len();
let (a, b) = (indice_slice(start, len), indice_slice(start + chunk_size, len));
let lot = &rows[a..b.max(a)];
ndjson(&Lot { type_: "chunk", rows: lot, loaded: JsNum((start + lot.len() as f64).min(total as f64)), total })
};
if tx.send(Ok(ligne)).await.is_err() {
return;
}
start += chunk_size;
tokio::task::yield_now().await;
}
let _ = tx.send(Ok(ndjson(&json!({ "type": "done", "loaded": total, "total": total })))).await;
}
Err(e) => {
let _ = tx.send(Ok(ndjson(&json!({ "type": "error", "error": e.to_string() })))).await;
}
}
});
(
[
(header::CONTENT_TYPE, "application/x-ndjson; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
(header::HeaderName::from_static("x-accel-buffering"), "no"),
],
Body::from_stream(rx),
)
.into_response()
}
/// `GET /api/grid/rows/store-patch?fournisseur=…&magasin=…`
///
/// Complément de l'API FF pour un magasin (cf. `store_patch`). La Grille charge
/// toujours les lignes « tous magasins » et applique ce complément à son arrivée :
/// le changement de magasin n'attend plus rien.
pub async fn get_grid_store_patch(SessionUser(_utilisateur): SessionUser, Query(params): Query<Vec<(String, String)>>) -> Response {
let code_fournisseur = premier(&params, "fournisseur").unwrap_or("");
let magasin = premier(&params, "magasin").unwrap_or("");
// Un code inconnu ferait de chaque article un candidat : autant d'appels
// inutiles à l'API FF. « Tous magasins » n'a pas de complément.
if code_fournisseur.is_empty() || !crate::domain::MAGASINS.iter().any(|m| m.code == magasin) {
return (StatusCode::BAD_REQUEST, Json(json!({ "error": "Paramètres fournisseur et magasin requis." }))).into_response();
}
match get_store_patch(code_fournisseur, magasin).await {
Ok(patch) => ([(header::CACHE_CONTROL, "no-store")], Json(&*patch)).into_response(),
Err(e) => {
tracing::error!("[store-patch] {code_fournisseur}/{magasin} KO: {}", crate::error::short(&e, 200));
(StatusCode::BAD_GATEWAY, Json(json!({ "error": "Complément indisponible." }))).into_response()
}
}
}
+23 -9
View File
@@ -1,27 +1,30 @@
//! Module `grid` — grille d'assortiment.
//!
//! Phase 1 (ce qui ne dépend ni de la base FF ni de Qlik) :
//! - [`types`] : `ProductRow` et types associés (JSON identique au TS) ;
//! - [`store`] : instantané persisté `grid_rows` (lu par `/api/v1`) ;
//! - [`rows_cache`] : cache mémoire des lignes calculées (10 min) ;
//! - [`product_rows`] : calcul des lignes (`getProductRows`), branché sur
//! [`rows_cache`] (cache mémoire 10 min) et [`store`] (instantané `grid_rows`) ;
//! - [`network_trend`], [`months`], [`store_patch`] : logique pure ;
//! - [`gammes_a_valider`], [`enregistrer`], [`snapshots`] : gammes et snapshots ;
//! - [`exports`] : exports Excel ; [`api_schemas`], [`api_v1`], [`openapi`] : API publique.
//!
//! Phase 2 : calcul de la grille (`getProductRows`), enrichissement `/api/v1`
//! (`api-enrich`), routes `/api/v1/grid`, `/products/*`, `/gammes`,
//! `/fournisseurs`, `/network/*` et `/api/grid/rows*`.
//! - [`grid_rows`] : `/api/grid/rows` (NDJSON) et `/api/grid/rows/store-patch` ;
//! - [`exports`] : exports Excel ;
//! - API publique : [`api_schemas`], [`enrich`], [`api_gammes`], [`api_v1`],
//! [`api_v1_grid`], [`openapi`].
pub mod api_gammes;
pub mod api_schemas;
pub mod api_v1;
pub mod api_v1_grid;
pub mod enregistrer;
pub mod enrich;
pub mod exports;
pub mod gammes;
pub mod gammes_a_valider;
pub mod grid_rows;
pub mod js;
pub mod months;
pub mod network_trend;
pub mod openapi;
pub mod product_rows;
pub mod rows_cache;
pub mod session;
pub mod snapshots;
@@ -32,9 +35,11 @@ pub mod zod;
#[cfg(test)]
mod tests_db;
#[cfg(test)]
mod tests_ff;
use crate::rpc::Handler;
use axum::routing::{get, post};
use axum::routing::{get, post, put};
use axum::Router;
/// Routes HTTP du module.
@@ -44,6 +49,15 @@ pub fn router() -> Router {
.route("/api/export/modified-gammes", post(exports::export_modified_gammes))
.route("/api/v1/nomenclatures", get(api_v1::get_nomenclatures))
.route("/api/v1/openapi.json", get(openapi::get_openapi))
.route("/api/grid/rows", get(grid_rows::get_grid_rows))
.route("/api/grid/rows/store-patch", get(grid_rows::get_grid_store_patch))
.route("/api/v1/grid", get(api_v1_grid::get_grid))
.route("/api/v1/products/search", get(api_v1_grid::get_products_search))
.route("/api/v1/products/{codein}", get(api_v1_grid::get_product))
.route("/api/v1/products/{codein}/gamme", put(api_v1_grid::put_product_gamme))
.route("/api/v1/gammes", post(api_v1_grid::post_gammes))
.route("/api/v1/fournisseurs", get(api_v1_grid::get_fournisseurs))
.route("/api/v1/network/{codeCentrale}", get(api_v1_grid::get_network))
}
/// Actions (`POST /api/actions/{nom}`) du module.
+879
View File
@@ -0,0 +1,879 @@
//! Calcul des lignes de la Grille (port de `src/features/grid/api/get-product-rows.ts`).
//!
//! Sept requêtes SQL en parallèle sur la base miroir FF, jointure avec le cache
//! réseau Qlik, report des gammes du dernier snapshot. Le résultat est gardé
//! 10 minutes en mémoire ([`super::rows_cache`]) et persisté dans `grid_rows`
//! (magasin « TOTAL » seulement), d'où il est resservi tant qu'il date du mois
//! en cours (`serveFromSnapshot`).
use super::months;
use super::network_trend::NB_MAGASINS_RESEAU;
use super::rows_cache::{self, GridRows, RowsFuture, SharedRows, StorePatchFuture};
use super::store::{read_grid_snapshot, upsert_grid_rows_shared};
use super::store_patch::{apply_store_patch_in_place, build_store_patch_entries, store_patch_candidates};
use super::types::{ProductRow, StorePatch, StorePatchEntry};
use crate::ff::api::{build_last12_months_range, get_mensuel_by_articles, FfArticle};
use crate::ff::pg::{self, PgStockRow};
use crate::qlik::network_cache::{get_network_metrics_by_code_centrale, NetworkMetricCached};
use futures::future::{BoxFuture, FutureExt};
use serde_json::{Map, Value};
use sqlx::Row;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::Arc;
use std::time::Duration;
/// Paramètres de `getProductRows`. (`filters` n'est pas repris : le TS ne s'en
/// servait pas, la Grille filtre en local.)
#[derive(Debug, Clone, Default)]
pub struct GetProductRowsInput {
pub code_fournisseur: String,
/// `None` = « TOTAL ».
pub magasin: Option<String>,
pub force_refresh: bool,
}
impl GetProductRowsInput {
pub fn total(code_fournisseur: &str, force_refresh: bool) -> Self {
Self { code_fournisseur: code_fournisseur.to_string(), magasin: Some("TOTAL".into()), force_refresh }
}
fn magasin(&self) -> &str {
self.magasin.as_deref().unwrap_or("TOTAL")
}
}
/// Au-delà, l'instantané est servi mais un recalcul est relancé en arrière-plan.
const SNAPSHOT_REFRESH_AFTER: Duration = Duration::from_secs(20 * 60 * 60);
fn court(e: &dyn std::fmt::Display) -> String {
crate::error::short(e, 200)
}
/// `Number(x) || 0` sur une valeur déjà numérique.
fn or0(v: f64) -> f64 {
if v.is_nan() {
0.0
} else {
v
}
}
/// Rafraîchit UNIQUEMENT la colonne INIT (codeGammeInit = gamme serveur courante)
/// sur des lignes servies depuis le cache. La colonne Gamme (codeGamme) n'est pas
/// touchée : elle conserve la valeur issue du snapshot. Ainsi l'état serveur (INIT)
/// est toujours à jour à chaque chargement, même sur un hit du cache 10 min — et le
/// marqueur "modifié" (Gamme ≠ INIT) se résorbe dès que le serveur rattrape la modif.
async fn refresh_gamme_init(rows: &GridRows, code_fournisseur: &str) {
match pg::pg_get_gammes_by_fournisseur(code_fournisseur).await {
Ok(gamme_map) => appliquer_gammes_init(rows, &gamme_map),
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
}
fn appliquer_gammes_init(rows: &GridRows, gamme_map: &HashMap<String, String>) {
if gamme_map.is_empty() {
return;
}
for row in rows.rows.write().iter_mut() {
if let Some(live) = gamme_map.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
/// Écrit l'instantané de grille lu par /api/v1 (table `grid_rows`).
///
/// Seul `magasin = TOTAL` est persisté : `ProductRow` embarque déjà les ventilations
/// par magasin (sales12mByStore, caByStore…), inutile de stocker trois variantes.
/// Toute erreur est avalée : la persistance ne doit jamais casser l'affichage.
async fn persist_grid_snapshot(input: &GetProductRowsInput, rows: &GridRows) {
if input.magasin() != "TOTAL" {
return;
}
match upsert_grid_rows_shared(&input.code_fournisseur, rows).await {
Ok(n) => tracing::info!("[getProductRows] instantané persisté: {n} lignes pour {}", input.code_fournisseur),
Err(e) => tracing::error!("[getProductRows] persistance grid_rows KO: {}", court(&e.message)),
}
}
/// Lignes de la Grille d'un fournisseur (cache 10 min, instantané, sinon calcul).
pub fn get_product_rows(input: GetProductRowsInput) -> BoxFuture<'static, anyhow::Result<SharedRows>> {
async move {
let cache_key = rows_cache::grid_rows_cache_key(&input.code_fournisseur, input.magasin.as_deref());
if !input.force_refresh {
if let Some(cached) = rows_cache::get_fresh(&cache_key) {
// La colonne INIT doit refléter l'état serveur à CHAQUE chargement,
// y compris sur un hit de cache (les données lourdes restent, elles, cachées).
refresh_gamme_init(&cached, &input.code_fournisseur).await;
return Ok(cached);
}
}
// Un calcul déjà en cours est réutilisé même en rechargement forcé : il part
// de données fraîches, et en lancer un second en parallèle doublait la charge
// (double clic, onglet rouvert…) sans rien apporter.
if let Some(pending) = rows_cache::pending_get(&cache_key) {
return pending.await.map_err(|e| anyhow::anyhow!(e));
}
// Instantané persisté (calcul de la nuit ou de la dernière ouverture) : servi
// en une lecture au lieu d'un recalcul de 10 à 40 s. Jamais en rechargement
// forcé — c'est précisément ce que l'utilisateur demande d'éviter.
if !input.force_refresh {
if let Some(servi) = serve_from_snapshot(&input, &cache_key).await {
return Ok(servi);
}
}
let version_au_depart = rows_cache::gamme_save_version(&input.code_fournisseur);
// Le calcul est lancé à part, comme une promesse JS : il continue même si
// l'appelant s'en va (client déconnecté), et il est partagé entre appelants.
let (tx, rx) = tokio::sync::oneshot::channel::<Result<SharedRows, String>>();
let partage: RowsFuture =
async move { rx.await.unwrap_or_else(|_| Err("calcul de la grille interrompu".to_string())) }.boxed().shared();
rows_cache::pending_insert(&cache_key, partage.clone());
let entree = input.clone();
let cle = cache_key.clone();
tokio::spawn(async move {
let resultat = async {
let mut rows = build_product_rows(&entree).await?;
if rows_cache::gamme_save_version(&entree.code_fournisseur) != version_au_depart {
// Gammes enregistrées pendant le calcul : on reprend le dernier snapshot.
apply_snapshot_changes(&mut rows, load_latest_snapshot_changes(&entree.code_fournisseur).await.as_ref());
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(&cle, shared.clone());
rows_cache::pending_remove(&cle);
// Persiste l'instantané pour /api/v1 : le calcul vient d'avoir lieu, on
// arrête simplement de jeter le résultat. Volontairement NON bloquant —
// l'affichage de la Grille ne doit pas attendre l'écriture.
let a_persister = shared.clone();
let code = entree.code_fournisseur.clone();
let ecriture: Persistance =
tokio::spawn(async move { persist_grid_snapshot(&entree, &a_persister).await }).map(|_| ()).boxed().shared();
PERSISTANCES.lock().insert(code.clone(), ecriture.clone());
tokio::spawn(async move {
ecriture.clone().await;
let mut p = PERSISTANCES.lock();
if p.get(&code).map(|e| e.ptr_eq(&ecriture)).unwrap_or(false) {
p.remove(&code);
}
});
anyhow::Ok(shared)
}
.await;
let resultat = resultat.map_err(|e| {
rows_cache::pending_remove(&cle);
format!("{e:#}")
});
let _ = tx.send(resultat);
});
partage.await.map_err(|e| anyhow::anyhow!(e))
}
.boxed()
}
/// Écriture de l'instantané en cours, par fournisseur.
type Persistance = futures::future::Shared<BoxFuture<'static, ()>>;
static PERSISTANCES: std::sync::LazyLock<parking_lot::Mutex<HashMap<String, Persistance>>> =
std::sync::LazyLock::new(|| parking_lot::Mutex::new(HashMap::new()));
/// Attend la fin de l'écriture de l'instantané lancée par le dernier calcul du
/// fournisseur (s'il y en a une en cours).
///
/// Écart volontaire avec le TS : `/api/v1` relisait `grid_rows` juste après un
/// calcul à la demande alors que la persistance, non attendue, pouvait ne pas
/// être finie — d'où un faux « aucun article ». Les routes attendent ici l'écriture.
pub async fn attendre_persistance(code_fournisseur: &str) {
let en_cours = PERSISTANCES.lock().get(code_fournisseur).cloned();
if let Some(e) = en_cours {
e.await;
}
}
/// `getProductRows({ codeFournisseur, magasin: "TOTAL", forceRefresh: true })` :
/// recalcul complet (synchro nocturne, préchauffage). Renvoie le nombre de lignes.
pub async fn get_product_rows_for_sync(code_fournisseur: &str) -> anyhow::Result<usize> {
let rows = get_product_rows(GetProductRowsInput::total(code_fournisseur, true)).await?;
Ok(rows.len())
}
/// Sert la Grille depuis `grid_rows` quand c'est possible : mêmes lignes que le
/// calcul en direct (elles en sont issues), avec la gamme FF du moment (INIT) et
/// les dernières gammes enregistrées reportées dessus. Hors « tous magasins »,
/// le rattrapage du magasin choisi est appliqué comme après un calcul.
///
/// `None` = pas d'instantané utilisable (absent, ancien format, autre mois) :
/// l'appelant recalcule.
async fn serve_from_snapshot(input: &GetProductRowsInput, cache_key: &str) -> Option<SharedRows> {
let code = input.code_fournisseur.as_str();
let magasin = input.magasin();
let snapshot = match read_grid_snapshot(code).await {
Ok(s) => s,
Err(e) => {
tracing::error!("[getProductRows] lecture grid_rows KO: {}", court(&e.message));
return None;
}
};
let snapshot = snapshot.filter(|s| !s.rows.is_empty())?;
let computed_at = snapshot.computed_at;
let mut rows = snapshot.rows;
// Les colonnes réseau de l'instantané datent de son calcul : on les relit dans
// le cache Qlik, comme le calcul en direct. Sans cela, une synchro Qlik faite
// depuis (au changement de mois notamment) restait invisible jusqu'au
// prochain recalcul, et la tendance réseau disparaissait.
let codes: Vec<String> = rows.iter().map(|r| r.code_centrale.clone().unwrap_or_default()).collect();
let (changes, network, gammes) =
tokio::join!(load_latest_snapshot_changes(code), fetch_network_metrics(&codes), pg::pg_get_gammes_by_fournisseur(code));
match gammes {
Ok(g) if !g.is_empty() => {
for row in rows.iter_mut() {
if let Some(live) = g.get(&row.codein) {
row.code_gamme_init = Some(live.clone());
}
}
}
Ok(_) => {}
Err(e) => tracing::error!("[getProductRows] refreshGammeInit error: {}", court(&e)),
}
apply_snapshot_changes(&mut rows, changes.as_ref());
enrich_with_network_metrics(&mut rows, network.as_ref());
if magasin != "TOTAL" {
let range = build_last12_months_range();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, &range.date_debut, &range.date_fin, &last12_periods()).await;
}
let shared = GridRows::new(rows);
rows_cache::cache_grid_rows(cache_key, shared.clone());
// Instantané qui n'a pas été recalculé par la nuit : on le rafraîchit sans
// faire attendre l'utilisateur (la prochaine ouverture en profitera).
// Toujours le calcul « tous magasins » : c'est lui qui réécrit l'instantané.
let age = chrono::Utc::now().naive_utc() - computed_at;
if age.to_std().map(|d| d > SNAPSHOT_REFRESH_AFTER).unwrap_or(false)
&& !rows_cache::pending_contains(&rows_cache::grid_rows_cache_key(code, Some("TOTAL")))
{
let code = code.to_string();
tokio::spawn(async move {
if let Err(e) = get_product_rows(GetProductRowsInput::total(&code, true)).await {
tracing::error!("[getProductRows] rafraîchissement en arrière-plan KO: {}", court(&e));
}
});
}
Some(shared)
}
/// Les 12 derniers mois complets, clés « YYYYMM » triées (même fenêtre que le calcul).
fn last12_periods() -> Vec<String> {
months::get_last12_months(months::today())
}
#[derive(Default, Clone, Copy)]
struct PeriodData {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
/// `Math.max(...valeurs)` : `NaN` si l'une l'est.
fn js_max(valeurs: impl Iterator<Item = f64>) -> f64 {
valeurs.fold(f64::NEG_INFINITY, |m, v| if m.is_nan() || v.is_nan() { f64::NAN } else { m.max(v) })
}
/// Le calcul complet (`buildProductRows`).
async fn build_product_rows(input: &GetProductRowsInput) -> anyhow::Result<Vec<ProductRow>> {
let code_fournisseur = input.code_fournisseur.as_str();
let magasin = input.magasin();
tracing::info!("\n>>> [getProductRows] supplier: {code_fournisseur}, magasin: {magasin}");
let range = build_last12_months_range();
let (date_debut, date_fin) = (range.date_debut.as_str(), range.date_fin.as_str());
// ─── Phase 1 : 7 requêtes SQL en parallèle ────────────────────────────
// Phases 8 et 9 lancées dès maintenant : elles n'attendent que la liste des
// articles (codes centraux) ou le seul code fournisseur.
let articles_et_reseau = async {
let articles = pg::pg_get_articles_by_fournisseur(code_fournisseur).await.unwrap_or_else(|e| {
tracing::error!("[getProductRows] pgGetArticlesByFournisseur ERROR: {e:#}");
vec![]
});
let codes: Vec<String> = articles
.iter()
.map(|a| a.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()).unwrap_or_default())
.collect();
let network = fetch_network_metrics(&codes).await;
(articles, network)
};
let ou_vide = |nom: &'static str| move |e: &anyhow::Error| tracing::error!("[getProductRows] {nom} ERROR: {e:#}");
let ((articles, network), mensuel_rows, gamme_map, nom_map, stock_map, commandes_map, prix_vente_map, snapshot_changes) = tokio::join!(
articles_et_reseau,
async {
pg::pg_get_mensuel_by_fournisseur(code_fournisseur, date_debut, date_fin)
.await
.inspect_err(ou_vide("pgGetMensuelByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_gammes_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetGammesByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_nomenclature_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetNomenclatureByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_stock_by_fournisseur(code_fournisseur).await.inspect_err(ou_vide("pgGetStockByFournisseur")).unwrap_or_default()
},
async {
pg::pg_get_commandes_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetCommandesByFournisseur"))
.unwrap_or_default()
},
async {
pg::pg_get_prix_vente_by_fournisseur(code_fournisseur)
.await
.inspect_err(ou_vide("pgGetPrixVenteByFournisseur"))
.unwrap_or_default()
},
load_latest_snapshot_changes(code_fournisseur),
);
tracing::info!("[getProductRows] {} articles, {} mensuel rows, {} gammes", articles.len(), mensuel_rows.len(), gamme_map.len());
// ─── Phase 2 : Fenêtre temporelle 12 mois complets ───────────────────
let sorted_periods = last12_periods();
// ─── Phase 3 : Seed productMap depuis articles ────────────────────────
// Ordre d'insertion conservé (Map JS) : index par codein.
let mut products: Vec<ProductRow> = Vec::with_capacity(articles.len());
let mut index: HashMap<String, usize> = HashMap::with_capacity(articles.len());
for art in &articles {
if art.codein.is_empty() || index.contains_key(&art.codein) {
continue;
}
index.insert(art.codein.clone(), products.len());
let non_nul = |v: Option<f64>| v.filter(|x| *x != 0.0 && !x.is_nan());
products.push(ProductRow {
codein: art.codein.clone(),
code_fournisseur: art.codefou.clone(),
nom_fournisseur: art.nomfou.clone().unwrap_or_default(),
fournisseur_principal_code: art.codefou_principal.clone(),
fournisseur_principal_nom: art.nomfou_principal.clone(),
libelle1: art.libelle1.clone().unwrap_or_default(),
gtin: art.gtin.clone().unwrap_or_default(),
reference: art.reference.clone().unwrap_or_default(),
code_centrale: art.code_centrale.as_deref().filter(|c| !c.is_empty()).map(|c| c.trim().to_string()),
code_gamme: None,
code_gamme_init: None,
code_gamme_draft: None,
total_quantite: 0.0,
total_ca: 0.0,
total_marge: 0.0,
taux_marge: 0.0,
working_stores: vec![],
noid: (art.no_id != 0).then_some(art.no_id as f64),
pcb: non_nul(art.pcb),
prix_vente: non_nul(art.pv_central),
..Default::default()
});
}
// ─── Phase 4 : Agréger mensuelRows → byPeriod par codein ─────────────
// SQL a déjà fait l'agrégation par (codein, site, mois).
// On somme les 2 sites (TOTAL) ET on accumule par site pour le switch client-side.
let allowed: BTreeSet<&str> = sorted_periods.iter().map(String::as_str).collect();
let mut mensuel_by_codein: Vec<(String, HashMap<String, PeriodData>)> = Vec::new();
let mut mensuel_index: HashMap<String, usize> = HashMap::new();
let mut mensuel_by_site: HashMap<String, BTreeMap<String, HashMap<String, PeriodData>>> = HashMap::new();
let mut store_months: HashMap<String, BTreeSet<String>> = HashMap::new();
for row in &mensuel_rows {
let periode = row.mois.replacen('-', "", 1); // "2026-02" → "202602"
if !allowed.contains(periode.as_str()) {
continue;
}
let stock = or0(row.stock_fin_mois.unwrap_or(f64::NAN));
// Accumulation TOTAL (toujours tous sites)
let i = *mensuel_index.entry(row.codein.clone()).or_insert_with(|| {
mensuel_by_codein.push((row.codein.clone(), HashMap::new()));
mensuel_by_codein.len() - 1
});
let p = mensuel_by_codein[i].1.entry(periode.clone()).or_default();
p.stock += stock;
p.qty += or0(row.qte_vendue);
p.ca += or0(row.ca_ht);
p.marge += or0(row.marge);
p.receptions += or0(row.qte_recue);
// Accumulation par site (pour switch client-side)
let sp = mensuel_by_site.entry(row.codein.clone()).or_default().entry(row.site.clone()).or_default().entry(periode).or_default();
sp.qty += or0(row.qte_vendue);
sp.ca += or0(row.ca_ht);
sp.marge += or0(row.marge);
sp.stock = stock; // SQL donne déjà le dernier mouvement
sp.receptions += or0(row.qte_recue);
// Suivi magasins actifs (pour workingStores) : 1 vente suffit
if row.qte_vendue > 0.0 {
store_months.entry(row.codein.clone()).or_default().insert(row.site.clone());
}
}
// ─── Phase 5 : Remplir ProductRow depuis mensuelByCodein ─────────────
for (codein, by_period) in &mensuel_by_codein {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let mut last_stock = 0.0;
for periode in &sorted_periods {
match by_period.get(periode) {
Some(p) => {
product.sales12m.insert(periode.clone(), p.qty);
product.stock12m.insert(periode.clone(), p.stock);
product.receptions12m.insert(periode.clone(), p.receptions);
product.total_quantite += p.qty;
product.total_ca += p.ca;
product.total_marge += p.marge;
last_stock = p.stock;
}
None => {
product.sales12m.insert(periode.clone(), 0.0);
product.stock12m.insert(periode.clone(), last_stock); // carry-forward
product.receptions12m.insert(periode.clone(), 0.0);
}
}
}
product.taux_marge = if product.total_ca > 0.0 { (product.total_marge / product.total_ca) * 100.0 } else { 0.0 };
// Breakdowns par site pour le switch client-side
if let Some(site_map) = mensuel_by_site.get(codein) {
let mut sales_by_store = BTreeMap::new();
let mut stock_by_store = BTreeMap::new();
let mut receptions_by_store = BTreeMap::new();
let mut ca_by_store = BTreeMap::new();
let mut quantite_by_store = BTreeMap::new();
let mut marge_by_store = BTreeMap::new();
for (site, site_by_period) in site_map {
let (mut sales, mut stock, mut receptions) = (BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
let (mut s_qty, mut s_ca, mut s_marge) = (0.0, 0.0, 0.0);
let mut last_site_stock = 0.0;
for periode in &sorted_periods {
let sp = site_by_period.get(periode);
sales.insert(periode.clone(), sp.map(|p| p.qty).unwrap_or(0.0));
receptions.insert(periode.clone(), sp.map(|p| p.receptions).unwrap_or(0.0));
match sp {
Some(p) => {
stock.insert(periode.clone(), p.stock);
last_site_stock = p.stock;
}
None => {
stock.insert(periode.clone(), last_site_stock); // carry-forward
}
}
s_qty += sp.map(|p| p.qty).unwrap_or(0.0);
s_ca += sp.map(|p| p.ca).unwrap_or(0.0);
s_marge += sp.map(|p| p.marge).unwrap_or(0.0);
}
sales_by_store.insert(site.clone(), sales);
stock_by_store.insert(site.clone(), stock);
receptions_by_store.insert(site.clone(), receptions);
quantite_by_store.insert(site.clone(), s_qty);
ca_by_store.insert(site.clone(), s_ca);
marge_by_store.insert(site.clone(), s_marge);
}
// Le stock TOTAL se recalcule ici, à partir des séries par site.
//
// Il était sommé plus haut depuis les lignes mensuelles brutes, ce
// qui perdait tout magasin SANS MOUVEMENT dans le mois : le stock
// est un niveau, pas un flux — un magasin qui n'a rien vendu ni reçu
// détient toujours sa marchandise. Les séries par site, elles, sont
// reportées d'un mois sur l'autre : leur somme est la bonne.
if !stock_by_store.is_empty() {
for periode in &sorted_periods {
let total: f64 = stock_by_store.values().map(|s: &BTreeMap<String, f64>| s.get(periode).copied().unwrap_or(0.0)).sum();
product.stock12m.insert(periode.clone(), total);
}
}
product.sales12m_by_store = Some(sales_by_store);
product.stock12m_by_store = Some(stock_by_store);
product.receptions12m_by_store = Some(receptions_by_store);
product.ca_by_store = Some(ca_by_store);
product.quantite_by_store = Some(quantite_by_store);
product.marge_by_store = Some(marge_by_store);
}
// workingStores : sites avec au moins 1 vente sur les 12 derniers mois
if let Some(sites) = store_months.get(codein) {
product.working_stores = sites.iter().cloned().collect();
}
// Commandes en cours
if let Some(&cmd) = commandes_map.get(codein) {
if cmd != 0.0 && !cmd.is_nan() {
product.commandes_en_cours = Some(cmd);
}
}
}
// ─── Phase 6 : Gammes (saison active) ────────────────────────────────
for (codein, gamme) in &gamme_map {
let Some(&pi) = index.get(codein) else { continue };
products[pi].code_gamme_init = Some(gamme.clone());
products[pi].code_gamme = Some(gamme.clone());
}
// ─── Phase 6b : Nomenclature (famille / sous-famille) ────────────────
for (codein, nom) in &nom_map {
let Some(&pi) = index.get(codein) else { continue };
let p = &mut products[pi];
p.code3 = nom.code3.clone();
p.libelle3 = nom.libelle3.clone();
p.code2 = nom.code2.clone().unwrap_or_default();
p.libelle_niveau2 = nom.libelle2.clone().unwrap_or_default();
p.code1 = nom.code1.clone().unwrap_or_default();
p.libelle_niveau1 = nom.libelle1nom.clone().unwrap_or_default();
}
// ─── Phase 7 : Stock temps réel (cube_stock) ─────────────────────────
for (codein, stocks) in &stock_map {
let Some(&pi) = index.get(codein) else { continue };
let product = &mut products[pi];
let somme = |f: fn(&PgStockRow) -> Option<f64>| stocks.iter().map(|r| or0(f(r).unwrap_or(f64::NAN))).sum::<f64>();
product.stock_actuel = Some(somme(|r| r.stockdispo));
product.stock_total = Some(somme(|r| r.qte));
product.stock_valeur = Some(somme(|r| r.valstock));
if let Some(prmp) = stocks.iter().filter_map(|r| r.prmp).find(|p| *p > 0.0) {
product.pa = Some(prmp);
product.prix_achat = Some(prmp);
}
let plus_recente = |dates: Vec<&String>| -> String {
dates.into_iter().fold(String::new(), |best, d| if !d.is_empty() && *d > best { d.clone() } else { best })
};
let dern_vente = plus_recente(stocks.iter().filter_map(|r| r.dernierevente.as_ref()).collect());
if !dern_vente.is_empty() {
product.derniere_vente = Some(dern_vente);
}
let dern_liv = plus_recente(stocks.iter().filter_map(|r| r.dernierereception.as_ref()).collect());
if !dern_liv.is_empty() {
product.derniere_livraison = Some(dern_liv);
}
// Map store-specific delivery dates
let mut par_site = BTreeMap::new();
for r in stocks {
if let Some(d) = r.dernierereception.as_ref().filter(|d| !d.is_empty() && !r.site.is_empty()) {
par_site.insert(r.site.clone(), d.clone());
}
}
product.derniere_livraison_by_store = Some(par_site);
}
// ─── Phase 7b : Prix de vente (cube_pv, sinon le PV du cube de stock) ─
//
// Le vrai prix vit dans `cube_pv`, par article ET par site. On garde le
// détail par magasin — deux magasins peuvent ne pas pratiquer le même prix,
// et l'afficher vaut mieux que le moyenner.
for product in products.iter_mut() {
match prix_vente_map.get(&product.codein).filter(|m| !m.is_empty()) {
Some(par_site) => product.prix_vente_by_store = Some(par_site.clone()),
None => {
// Filet de secours : le cube de stock porte le même PV, mais
// seulement pour les articles qui ont une ligne de stock.
let mut depuis_stock = BTreeMap::new();
for s in stock_map.get(&product.codein).map(|v| v.as_slice()).unwrap_or_default() {
let pv = s.pv.unwrap_or(f64::NAN);
let site = s.site.trim();
if !site.is_empty() && pv.is_finite() && pv > 0.0 {
depuis_stock.insert(site.to_string(), pv);
}
}
if !depuis_stock.is_empty() {
product.prix_vente_by_store = Some(depuis_stock);
}
}
}
// Le plus élevé quand les magasins divergent : une moyenne inventerait
// un prix qu'aucune caisse ne pratique. L'écart est signalé à l'écran.
if let Some(prix) = product.prix_vente_by_store.as_ref().filter(|m| !m.is_empty()) {
product.prix_vente = Some(js_max(prix.values().copied()));
}
}
// ─── Phase 8 : Données réseau Qlik (CA / Qté / nb magasins par code centrale) ──
enrich_with_network_metrics(&mut products, network.as_ref());
// ─── Phase 9 : Restaurer gammes depuis dernier snapshot ──────────────
// La colonne Gamme (codeGamme) conserve la valeur du snapshot telle quelle.
// La colonne INIT (codeGammeInit) reste, elle, l'état LIVE du serveur
// rechargé en Phase 6 — jamais écrasée ici.
apply_snapshot_changes(&mut products, snapshot_changes.as_ref());
// ─── Phase 10 : Filtrer gamme Y sans ventes ──────────────────────────
let tous = products.len();
let mut rows: Vec<ProductRow> =
products.into_iter().filter(|p| p.code_gamme.as_deref() != Some("Y") || p.total_quantite > 0.0).collect();
reconcile_selected_store_from_mensuel_api(&mut rows, magasin, date_debut, date_fin, &sorted_periods).await;
let exclus_y = tous - rows.len();
tracing::info!(
"[getProductRows] {} produits ({exclus_y} gamme Y sans ventes exclus), {} avec ventes",
rows.len(),
mensuel_by_codein.len()
);
Ok(rows)
}
/// Articles complétés par l'API FF, au plus, lors d'un changement de magasin
/// (`GRID_STORE_RECONCILE_MAX`, 300 par défaut).
fn plafond_complement() -> f64 {
match std::env::var("GRID_STORE_RECONCILE_MAX") {
Ok(v) => super::js::js_number_from_str(&v),
Err(_) => 300.0,
}
}
/// `candidates.slice(0, plafond)`.
fn js_slice_end(len: usize, fin: f64) -> usize {
if fin.is_nan() {
return 0;
}
let fin = fin.trunc();
if fin < 0.0 {
(len as f64 + fin).max(0.0) as usize
} else {
fin.min(len as f64) as usize
}
}
/// Article candidat au complément, copié hors verrou.
struct Candidat {
codein: String,
libelle1: String,
codefou: String,
noid: Option<f64>,
}
fn candidats(rows: &[ProductRow], magasin: &str) -> Vec<Candidat> {
store_patch_candidates(rows, magasin)
.into_iter()
.map(|r| Candidat { codein: r.codein.clone(), libelle1: r.libelle1.clone(), codefou: r.code_fournisseur.clone(), noid: r.noid })
.collect()
}
/// Interroge l'API FF pour les articles du magasin à compléter (cf. store-patch).
async fn fetch_store_patch_entries(
candidates: Vec<Candidat>,
magasin: &str,
date_debut: &str,
date_fin: &str,
periods: &[String],
) -> Vec<StorePatchEntry> {
if candidates.is_empty() {
return vec![];
}
// Ce rattrapage fait UNE requête HTTP par article : sur un gros fournisseur,
// les candidats se comptent par milliers et le changement de magasin se fige
// plusieurs minutes. On le borne, et on dit ce qui a été laissé de côté
// plutôt que de tronquer en silence.
let nb = candidates.len();
let retenus = &candidates[..js_slice_end(nb, plafond_complement())];
if nb > retenus.len() {
tracing::warn!(
"[getProductRows] rattrapage magasin {magasin} borné à {} articles sur {nb} : les autres gardent la valeur SQL.",
retenus.len()
);
}
let articles: Vec<FfArticle> = retenus
.iter()
.map(|c| FfArticle {
codein: c.codein.clone(),
libelle1: c.libelle1.clone(),
codefou: c.codefou.clone(),
noid: c.noid.filter(|n| n.is_finite()).map(|n| n as i64),
..Default::default()
})
.collect();
let mensuel_map = get_mensuel_by_articles(&articles, date_debut, date_fin, 25).await;
let codes: Vec<String> = retenus.iter().map(|c| c.codein.clone()).collect();
build_store_patch_entries(&codes, magasin, periods, &mensuel_map)
}
async fn reconcile_selected_store_from_mensuel_api(
rows: &mut [ProductRow],
magasin: &str,
date_debut: &str,
date_fin: &str,
sorted_periods: &[String],
) {
if magasin == "TOTAL" {
return;
}
let entries = fetch_store_patch_entries(candidats(rows, magasin), magasin, date_debut, date_fin, sorted_periods).await;
let fixed = apply_store_patch_in_place(rows, magasin, sorted_periods, &entries);
if fixed > 0 {
tracing::info!("[getProductRows] {fixed} produits corrigés via API mensuelle pour magasin {magasin}");
}
}
/// Complément de l'API FF pour un magasin, calculé sur les lignes « tous
/// magasins » (jamais modifiées ici). La Grille l'applique à son arrivée, sans
/// recharger les lignes. Les demandes simultanées partagent le même calcul, et
/// un échec n'est jamais gardé.
pub async fn get_store_patch(code_fournisseur: &str, magasin: &str) -> anyhow::Result<Arc<StorePatch>> {
let rows = match rows_cache::peek_fresh(&rows_cache::grid_rows_cache_key(code_fournisseur, Some("TOTAL"))) {
Some(r) => r,
None => get_product_rows(GetProductRowsInput::total(code_fournisseur, false)).await?,
};
let patch: StorePatchFuture = {
let mut table = rows.store_patches.lock();
match table.get(magasin) {
Some(p) => p.clone(),
None => {
let periods = last12_periods();
let range = build_last12_months_range();
let candidates = candidats(&rows.rows.read(), magasin);
let magasin_s = magasin.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let entries = fetch_store_patch_entries(candidates, &magasin_s, &range.date_debut, &range.date_fin, &periods).await;
let _ = tx.send(Ok(Arc::new(StorePatch { magasin: magasin_s, periods, entries })));
});
let en_cours: StorePatchFuture =
async move { rx.await.unwrap_or_else(|_| Err("complément interrompu".to_string())) }.boxed().shared();
table.insert(magasin.to_string(), en_cours.clone());
en_cours
}
}
};
match patch.clone().await {
Ok(p) => Ok(p),
Err(e) => {
let mut table = rows.store_patches.lock();
if table.get(magasin).map(|p| p.ptr_eq(&patch)).unwrap_or(false) {
table.remove(magasin);
}
Err(anyhow::anyhow!(e))
}
}
}
/// Extrait la série « nombre de magasins vendeurs » du détail mensuel complet.
///
/// Un mois PRÉSENT dans le détail est un mois extrait : s'il n'a pas de `nbMag`,
/// c'est qu'aucun magasin n'a vendu, donc zéro. Renvoie `None` plutôt qu'un objet
/// vide pour que l'UI distingue « pas de donnée » de « zéro magasin ».
fn nb_mag_par_mois(m: &NetworkMetricCached) -> Option<BTreeMap<String, f64>> {
let detail = m.metrics_by_month.as_ref()?;
let par_mois: BTreeMap<String, f64> =
detail.iter().map(|(mois, v)| (mois.clone(), v.nb_mag.filter(|n| n.is_finite()).unwrap_or(0.0))).collect();
(!par_mois.is_empty()).then_some(par_mois)
}
/// Phase 9 : la colonne Gamme (codeGamme) reprend la valeur du snapshot ;
/// codeGammeInit reste figé (état serveur).
pub fn apply_snapshot_changes(rows: &mut [ProductRow], changes: Option<&Map<String, Value>>) {
let Some(changes) = changes else { return };
for row in rows.iter_mut() {
let after = changes.get(&row.codein).and_then(|c| c.get("after"));
if super::js::js_truthy(after) {
row.code_gamme = Some(super::js::js_string(after));
}
}
}
/// Dernières gammes enregistrées pour ce fournisseur (Phase 9), ou `None`.
pub async fn load_latest_snapshot_changes(code_fournisseur: &str) -> Option<Map<String, Value>> {
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("select changes from session_snapshots where code_fournisseur = $1 order by created_at desc limit 1")
.bind(code_fournisseur)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| r.try_get::<Value, _>("changes")).transpose()?)
}
.await;
match res {
Ok(Some(Value::Object(m))) => Some(m),
Ok(_) => None,
Err(e) => {
tracing::error!("[getProductRows] Snapshot restore error: {e:#}");
None
}
}
}
/// Lit les métriques réseau Qlik (cache qlik_network_metrics) des codes centraux
/// donnés. `None` en cas d'erreur : la grille s'affiche alors sans données réseau.
async fn fetch_network_metrics(codes_centrale: &[String]) -> Option<HashMap<String, NetworkMetricCached>> {
let mut vus = BTreeSet::new();
let codes: Vec<String> = codes_centrale.iter().filter(|c| !c.is_empty() && vus.insert(c.as_str())).cloned().collect();
if codes.is_empty() {
return None;
}
match get_network_metrics_by_code_centrale(&codes).await {
Ok(m) => Some(m),
Err(e) => {
tracing::error!("[getProductRows] enrichWithNetworkMetrics error: {e:#}");
None
}
}
}
/// Enrichit les produits avec les métriques réseau Qlik, jointes par code centrale.
/// Dégradation propre si la sync Qlik n'a jamais tourné ou si le code centrale
/// n'est pas encore disponible.
pub fn enrich_with_network_metrics(products: &mut [ProductRow], metrics: Option<&HashMap<String, NetworkMetricCached>>) {
let Some(metrics) = metrics.filter(|m| !m.is_empty()) else { return };
for product in products.iter_mut() {
let Some(m) = product.code_centrale.as_deref().filter(|c| !c.is_empty()).and_then(|c| metrics.get(c)) else { continue };
product.ca_reseau = Some(m.ca_reseau);
product.qte_reseau = Some(m.qte_reseau);
product.nb_magasins_reseau = Some(m.nb_magasins_reseau);
product.ca_par_magasin_reseau = Some(m.ca_par_magasin_reseau);
product.marge_pct_reseau = Some(m.marge_pct_reseau);
product.taux_presence_reseau = Some(m.nb_magasins_reseau / NB_MAGASINS_RESEAU);
product.qte_reseau_by_month = Some(m.qte_by_month.clone());
product.nb_mag_reseau_by_month = Some(nb_mag_par_mois(m));
product.network_fetched_at = m.fetched_at.clone();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slice_et_max_comme_js() {
assert_eq!(js_slice_end(10, 300.0), 10);
assert_eq!(js_slice_end(10, 3.7), 3);
assert_eq!(js_slice_end(10, -2.0), 8);
assert_eq!(js_slice_end(10, f64::NAN), 0);
assert_eq!(js_max([1.0, 3.0, 2.0].into_iter()), 3.0);
assert!(js_max([1.0, f64::NAN].into_iter()).is_nan());
}
#[test]
fn report_des_gammes_du_snapshot() {
let mut rows = vec![
ProductRow { codein: "1".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "2".into(), code_gamme: Some("B".into()), ..Default::default() },
ProductRow { codein: "3".into(), code_gamme: Some("B".into()), ..Default::default() },
];
let changes = serde_json::json!({ "1": { "before": "B", "after": "Z" }, "2": { "before": "B", "after": "" } });
apply_snapshot_changes(&mut rows, changes.as_object());
let g: Vec<_> = rows.iter().map(|r| r.code_gamme.as_deref().unwrap()).collect();
assert_eq!(g, vec!["Z", "B", "B"]);
}
}
+31 -7
View File
@@ -159,16 +159,40 @@ pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> Ap
if code_fournisseur.is_empty() || rows.is_empty() {
return Ok(0);
}
let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect();
upsert_lots(code_fournisseur, values.len(), |plage| values[plage].iter().map(|r| (*r).clone()).collect()).await
}
/// Même écriture depuis les lignes partagées du cache mémoire : chaque lot est
/// copié sous verrou, puis écrit sans le tenir (pas de copie intégrale d'un
/// fournisseur de 130 000 articles).
pub async fn upsert_grid_rows_shared(code_fournisseur: &str, rows: &super::rows_cache::GridRows) -> AppResult<usize> {
let indices: Vec<usize> = {
let lignes = rows.rows.read();
if code_fournisseur.is_empty() || lignes.is_empty() {
return Ok(0);
}
lignes.iter().enumerate().filter(|(_, r)| !r.codein.is_empty()).map(|(i, _)| i).collect()
};
upsert_lots(code_fournisseur, indices.len(), |plage| {
let lignes = rows.rows.read();
indices[plage].iter().filter_map(|i| lignes.get(*i).cloned()).collect()
})
.await
}
/// Écrit `nb` lignes par lots de [`CHUNK_UPSERT`] (`lot(plage)` fournit les lignes
/// d'une plage), puis purge les lignes du fournisseur restées sur un calcul antérieur.
async fn upsert_lots(code_fournisseur: &str, nb: usize, lot: impl Fn(std::ops::Range<usize>) -> Vec<ProductRow>) -> AppResult<usize> {
if nb == 0 {
return Ok(0);
}
let pool = db::pool()?;
let computed_at = maintenant_utc_ms();
let values: Vec<&ProductRow> = rows.iter().filter(|r| !r.codein.is_empty()).collect();
if values.is_empty() {
return Ok(0);
}
let mut written = 0usize;
for batch in values.chunks(CHUNK_UPSERT) {
for debut in (0..nb).step_by(CHUNK_UPSERT) {
let batch = lot(debut..(debut + CHUNK_UPSERT).min(nb));
let mut qb: QueryBuilder<Postgres> = QueryBuilder::new(
"insert into grid_rows (code_fournisseur, codein, nom_fournisseur, libelle1, gtin, reference, \
code_centrale, code1, code2, code3, code_gamme, code_gamme_init, total_ca, total_quantite, \
@@ -177,7 +201,7 @@ pub async fn upsert_grid_rows(code_fournisseur: &str, rows: &[ProductRow]) -> Ap
);
let mut erreur_payload = None;
qb.push_values(batch.iter(), |mut b, r| {
let mut payload = (*r).clone();
let mut payload = r.clone();
payload.payload_version = Some(GRID_PAYLOAD_VERSION);
let payload = serde_json::to_value(&payload).unwrap_or_else(|e| {
erreur_payload = Some(e.to_string());
+92 -3
View File
@@ -4,13 +4,77 @@
//! Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains
//! articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire,
//! pour ces articles seulement, les séries et totaux du magasin.
//!
//! `buildStorePatchEntries` (qui lit les entrées mensuelles de l'API FF) viendra
//! avec le calcul de la grille (phase 2).
use super::types::{ProductRow, StorePatchEntry};
use crate::ff::api::FfMensuelEntry;
use crate::ff::js::js_number_or0;
use std::collections::HashMap;
/// Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes.
///
/// `retenus` : codes article des candidats retenus (cf. [`store_patch_candidates`]).
pub fn build_store_patch_entries(
retenus: &[String],
magasin: &str,
periods: &[String],
mensuel: &HashMap<String, Vec<FfMensuelEntry>>,
) -> Vec<StorePatchEntry> {
struct Valeurs {
qty: f64,
ca: f64,
marge: f64,
stock: f64,
receptions: f64,
}
let mut resultat = Vec::new();
for codein in retenus {
let entries: Vec<&FfMensuelEntry> =
mensuel.get(codein).map(|v| v.iter().filter(|e| e.site == magasin).collect()).unwrap_or_default();
if entries.is_empty() {
continue;
}
let mut by_period: HashMap<String, Valeurs> = HashMap::new();
for entry in entries {
let period = entry.mois.replacen('-', "", 1);
if !periods.contains(&period) {
continue;
}
let ventes = entry.ventes.as_ref();
// qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on
// les nie au lieu de Math.abs pour que les retours restent déduits.
let qty = -ventes.map(|v| js_number_or0(&v.qte_vendue)).unwrap_or(0.0);
let ca = -ventes.map(|v| js_number_or0(&v.ca_ht)).unwrap_or(0.0);
let marge = ventes.map(|v| js_number_or0(&v.marge)).unwrap_or(0.0);
let stock = js_number_or0(&entry.stock_fin_mois);
let receptions = entry.receptions.as_ref().map(|r| js_number_or0(&r.qte_recue)).unwrap_or(0.0);
by_period.insert(period, Valeurs { qty, ca, marge, stock, receptions });
}
let api_qty: f64 = by_period.values().map(|v| v.qty).sum();
if api_qty == 0.0 {
continue;
}
let mut e = StorePatchEntry { codein: codein.clone(), ..Default::default() };
let mut last_stock = 0.0;
for period in periods {
let value = by_period.get(period);
e.sales.insert(period.clone(), value.map(|v| v.qty).unwrap_or(0.0));
e.receptions.insert(period.clone(), value.map(|v| v.receptions).unwrap_or(0.0));
if let Some(v) = value {
last_stock = v.stock;
}
e.stock.insert(period.clone(), last_stock);
e.quantite += value.map(|v| v.qty).unwrap_or(0.0);
e.ca += value.map(|v| v.ca).unwrap_or(0.0);
e.marge += value.map(|v| v.marge).unwrap_or(0.0);
}
resultat.push(e);
}
resultat
}
/// Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin.
pub fn store_patch_candidates<'a>(rows: &'a [ProductRow], magasin: &str) -> Vec<&'a ProductRow> {
rows.iter()
@@ -77,6 +141,31 @@ pub fn apply_store_patch_in_place(rows: &mut [ProductRow], magasin: &str, period
mod tests {
use super::*;
#[test]
fn complement_depuis_l_api() {
let periods = vec!["202601".to_string(), "202602".to_string(), "202603".to_string()];
let entree = |mois: &str, site: &str, qte: &str| -> FfMensuelEntry {
serde_json::from_value(serde_json::json!({
"mois": mois, "site": site, "stock_fin_mois": "5.000",
"ventes": { "qte_vendue": qte, "ca_ht": "-26.66", "marge": "12.76" }, "receptions": null
}))
.unwrap()
};
let mut mensuel = HashMap::new();
mensuel.insert(
"A1".to_string(),
vec![entree("2026-01", "292", "-2.000"), entree("2026-02", "579", "-9"), entree("2025-01", "292", "-4")],
);
mensuel.insert("A2".to_string(), vec![entree("2026-01", "292", "0")]);
let e = build_store_patch_entries(&["A1".into(), "A2".into(), "A3".into()], "292", &periods, &mensuel);
assert_eq!(e.len(), 1, "A2 sans vente, A3 sans donnée");
assert_eq!(e[0].quantite, 2.0);
assert!((e[0].ca - 26.66).abs() < 1e-9);
assert_eq!(e[0].sales["202602"], 0.0);
assert_eq!(e[0].stock["202603"], 5.0, "stock reporté");
assert_eq!(serde_json::to_value(&e[0]).unwrap()["receptions"], serde_json::json!({ "202601": 0, "202602": 0, "202603": 0 }));
}
#[test]
fn complement_reporte_sur_les_totaux() {
let periods = vec!["202601".to_string(), "202602".to_string()];
+2
View File
@@ -60,6 +60,8 @@ async fn grid_rows_snapshots_gammes_a_valider() {
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
// Une autre base a pu servir à un test précédent du même processus.
crate::db::refresh();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
+302
View File
@@ -0,0 +1,302 @@
//! Test d'intégration du calcul de la grille (phase 2) sur une base de test
//! contenant les tables FF (`ff_fixture`), avec une fausse API FF Nancy.
//!
//! ```sh
//! GRID_FF_TEST_DATABASE_URL=postgres://postgres:postgres@localhost/ff_fixture cargo test grid::tests_ff -- --nocapture
//! ```
//!
//! Écrit dans `grid_rows`, `session_snapshots`, `gammes_a_valider`, `users` et
//! `api_keys` de la base de test (nettoyés à la fin).
use super::enrich::enrich_rows;
use super::months;
use super::product_rows::*;
use super::rows_cache;
use super::store::*;
use crate::auth::Claims;
use axum::body::Body;
use axum::extract::Path;
use axum::http::Request;
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use tower::ServiceExt;
const CLE_API: &str = "cf_cle-de-test-grille-phase-2";
/// Fausse API FF : seul l'historique mensuel sert au complément par magasin.
async fn fausse_api(mois: String) -> String {
let app = Router::new().route(
"/api/articles/{id}/mensuel",
get(move |Path(id): Path<String>| {
let mois = mois.clone();
async move {
Json(json!({ "data": [
{ "mois": mois, "site": "579", "stock_fin_mois": "7.000",
"ventes": { "qte_vendue": "-3.000", "ca_ht": "-30.00", "marge": "9.5" },
"receptions": { "qte_recue": "2" }, "id": id },
{ "mois": mois, "site": "292", "stock_fin_mois": "1",
"ventes": { "qte_vendue": "-100", "ca_ht": "-1", "marge": "0" } }
] }))
}
}),
);
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}")
}
async fn appel(req: Request<Body>) -> (u16, Vec<u8>) {
let res = super::router().oneshot(req).await.unwrap();
let status = res.status().as_u16();
(status, axum::body::to_bytes(res.into_body(), usize::MAX).await.unwrap().to_vec())
}
fn get_cle(uri: &str) -> Request<Body> {
Request::builder().uri(uri).header("x-api-key", CLE_API).body(Body::empty()).unwrap()
}
fn json_de(b: &[u8]) -> Value {
serde_json::from_slice(b).unwrap_or_else(|e| panic!("JSON illisible ({e}) : {}", String::from_utf8_lossy(b)))
}
async fn nettoyer(pool: &sqlx::PgPool) {
for sql in [
"delete from grid_rows where code_fournisseur in ('F001', 'F002', 'F404')",
"delete from session_snapshots where code_fournisseur in ('F001', 'F002')",
"delete from gammes_a_valider where code_fournisseur in ('F001', 'F002')",
"delete from api_keys where name = 'test-grille'",
"delete from users where username = 'test-grille'",
] {
sqlx::query(sql).execute(pool).await.unwrap();
}
}
#[tokio::test]
async fn calcul_de_la_grille_et_routes() {
let Ok(url) = std::env::var("GRID_FF_TEST_DATABASE_URL") else {
eprintln!("GRID_FF_TEST_DATABASE_URL absent : test d'intégration du calcul ignoré.");
return;
};
let _verrou = rows_cache::TEST_LOCK.lock();
let dir = std::env::temp_dir().join(format!("grid-ff-tests-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::env::set_var("DATA_DIR", &dir);
std::env::set_var("DATABASE_URL", &url);
std::env::set_var("AUTH_SECRET", "secret-de-test");
crate::db::refresh();
let periods = months::get_last12_months(months::today());
let dernier_mois = months::ff_month_to_qlik(periods.last().unwrap());
std::env::set_var("FF_API_BASE_URL", fausse_api(dernier_mois).await);
crate::ff::api::reset_ff_api_base_cache();
let pool = crate::db::pool().unwrap();
sqlx::raw_sql(include_str!("../../sql/init.sql")).execute(&pool).await.unwrap();
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
// ── calcul complet ─────────────────────────────────────────────────────
let rows = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
let lignes = rows.snapshot();
println!("── lignes calculées\n{}", serde_json::to_string_pretty(&lignes).unwrap());
assert!(!lignes.is_empty());
// `articles.codein` est un char(10) dans la base de test : codes complétés d'espaces.
let tapis = lignes.iter().find(|r| r.codein.trim() == "100001").expect("article 100001");
let code_tapis = tapis.codein.clone();
let tapis_url = urlencoding::encode(&code_tapis).into_owned();
assert_eq!(tapis.sales12m.len(), 12);
assert_eq!(tapis.code_centrale.as_deref(), Some("10000111111"));
assert_eq!(tapis.ca_reseau, Some(50000.0), "métriques réseau du cache Qlik");
assert_eq!(tapis.taux_presence_reseau, Some(1.0));
assert!(tapis.sales12m_by_store.as_ref().unwrap().contains_key("292"));
assert!(tapis.working_stores.contains(&"292".to_string()));
assert_eq!(tapis.stock_actuel, Some(4.0), "5 + -1 sur les deux sites");
assert_eq!(tapis.prix_vente, Some(12.9), "le PV le plus élevé des deux magasins");
assert!(tapis.total_quantite > 0.0 && tapis.total_ca > 0.0);
// Persistance (lancée en arrière-plan) puis instantané.
attendre_persistance("F001").await;
let fraicheur = get_grid_freshness("F001").await.unwrap().expect("instantané écrit");
assert_eq!(fraicheur.row_count as usize, lignes.len());
// Cache : même tableau partagé.
let encore = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(std::sync::Arc::ptr_eq(&rows, &encore));
// Instantané resservi après invalidation du cache, gammes du snapshot reportées.
sqlx::query("insert into session_snapshots (code_fournisseur, magasin, changes, type) values ('F001', 'TOTAL', $1, 'snapshot')")
.bind(json!({ code_tapis.clone(): { "before": null, "after": "Z" } }))
.execute(&pool)
.await
.unwrap();
rows_cache::invalidate_grid_rows_cache(Some("F001"));
let servies = get_product_rows(GetProductRowsInput::total("F001", false)).await.unwrap();
assert!(!std::sync::Arc::ptr_eq(&rows, &servies));
assert_eq!(servies.len(), lignes.len());
let s = servies.snapshot();
assert_eq!(s.iter().find(|r| r.codein == code_tapis).unwrap().code_gamme.as_deref(), Some("Z"));
assert!(s.iter().all(|r| r.payload_version == Some(GRID_PAYLOAD_VERSION)), "lignes issues de grid_rows");
// Synchro : recalcul forcé, nombre de lignes.
assert_eq!(get_product_rows_for_sync("F001").await.unwrap(), lignes.len());
// ── complément par magasin (fausse API FF) ─────────────────────────────
let patch = get_store_patch("F001", "579").await.unwrap();
println!("── complément 579\n{}", serde_json::to_string_pretty(&*patch).unwrap());
assert_eq!(patch.magasin, "579");
assert_eq!(patch.periods, periods);
assert!(!patch.entries.is_empty());
assert!(patch.entries.iter().all(|e| e.quantite == 3.0 && e.ca == 30.0));
let meme = get_store_patch("F001", "579").await.unwrap();
assert!(std::sync::Arc::ptr_eq(&patch, &meme), "complément partagé");
// Vue d'un magasin : rattrapage appliqué aux lignes.
let vue_579 =
get_product_rows(GetProductRowsInput { code_fournisseur: "F001".into(), magasin: Some("579".into()), force_refresh: false })
.await
.unwrap();
assert!(vue_579.snapshot().iter().any(|r| r.quantite_by_store.as_ref().and_then(|q| q.get("579")).copied() == Some(3.0)));
// ── enrichissement /api/v1 ─────────────────────────────────────────────
let enrichies =
enrich_rows(query_grid_rows(&GridQuery { code_fournisseur: Some("F001".into()), ..Default::default() }).await.unwrap().rows).await;
let e = enrichies.iter().find(|r| r.row.codein == code_tapis).unwrap();
assert_eq!(e.network.as_ref().map(|n| n.ca_reseau), Some(50000.0));
let v = serde_json::to_value(e).unwrap();
assert!(v.get("codeGammeServeur").is_some() && v.get("gammeAValider").is_some() && v.get("trend").is_some());
assert_eq!(v["codein"], json!(code_tapis));
// ── routes ─────────────────────────────────────────────────────────────
let (id_user,): (i32,) =
sqlx::query_as("insert into users (username, password_hash, role) values ('test-grille', 'x', 'admin') returning id")
.fetch_one(&pool)
.await
.unwrap();
sqlx::query("insert into api_keys (name, key_prefix, key_hash, role) values ('test-grille', 'cf_cle', $1, 'user')")
.bind(crate::auth::api_key::hash_api_key(CLE_API))
.execute(&pool)
.await
.unwrap();
let claims =
Claims { id: id_user.to_string(), name: "test-grille".into(), role: "admin".into(), exp: chrono::Utc::now().timestamp() + 3600 };
let cookie = format!("{}={}", crate::auth::session::COOKIE_NAME, crate::auth::session::encode(&claims));
let get_session = |uri: &str| Request::builder().uri(uri).header("cookie", cookie.clone()).body(Body::empty()).unwrap();
// /api/grid/rows (NDJSON)
let (st, corps) = appel(get_session("/api/grid/rows?fournisseur=F001&magasin=TOTAL&chunkSize=1")).await;
assert_eq!(st, 200);
let messages: Vec<Value> = String::from_utf8(corps).unwrap().lines().map(|l| json_de(l.as_bytes())).collect();
assert_eq!(messages[0], json!({ "type": "start", "loaded": 0, "total": null }));
let chunks: Vec<&Value> = messages.iter().filter(|m| m["type"] == "chunk").collect();
// chunkSize=1 est relevé au minimum de 50 : un seul lot ici.
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0]["rows"].as_array().unwrap().len(), lignes.len());
assert_eq!(chunks[0]["loaded"], lignes.len());
assert_eq!(messages.last().unwrap(), &json!({ "type": "done", "loaded": lignes.len(), "total": lignes.len() }));
let (st, _) = appel(get_session("/api/grid/rows")).await;
assert_eq!(st, 400);
let (st, _) = appel(Request::builder().uri("/api/grid/rows?fournisseur=F001").body(Body::empty()).unwrap()).await;
assert_eq!(st, 401);
// /api/grid/rows/store-patch
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=579")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["magasin"], "579");
let (st, corps) = appel(get_session("/api/grid/rows/store-patch?fournisseur=F001&magasin=TOTAL")).await;
assert_eq!((st, json_de(&corps)), (400, json!({ "error": "Paramètres fournisseur et magasin requis." })));
// /api/v1/grid : diffusion complète, puis page
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001")).await;
assert_eq!(st, 200);
let tout = json_de(&corps);
assert_eq!(tout["data"].as_array().unwrap().len(), lignes.len());
assert_eq!(tout["meta"]["diffuse"], true);
assert_eq!(tout["meta"]["complet"], true);
assert_eq!(tout["pagination"]["total"], lignes.len());
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F001&limit=1&fields=totalCa&sort=codein&order=asc")).await;
assert_eq!(st, 200);
let page = json_de(&corps);
assert_eq!(page["data"][0].as_object().unwrap().keys().collect::<Vec<_>>(), vec!["totalCa", "codein"]);
assert_eq!(page["meta"]["complet"], json!(lignes.len() <= 1));
if lignes.len() > 1 {
assert!(page["meta"]["avertissement"].as_str().unwrap().contains("page=2"));
}
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002&compute=0")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (202, json!("not_ready")));
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F002")).await;
let f002 = json_de(&corps);
assert_eq!(st, 200, "{f002}");
assert_eq!(f002["meta"]["computedOnDemand"], true);
let (st, corps) = appel(get_cle("/api/v1/grid?fournisseur=F404")).await;
assert_eq!((st, json_de(&corps)["error"]["code"].clone()), (404, json!("not_found")));
// /api/v1/products/*
let (st, corps) = appel(get_cle("/api/v1/products/search?q=tapis&fields=libelle1")).await;
assert_eq!(st, 200);
let rech = json_de(&corps);
assert_eq!(rech["data"][0]["codein"], json!(code_tapis));
assert_eq!(rech["meta"]["scope"], "tous fournisseurs");
let (st, corps) = appel(get_cle(&format!("/api/v1/products/{tapis_url}?fournisseur=F001"))).await;
assert_eq!(st, 200);
let fiche = json_de(&corps);
assert_eq!(fiche["meta"]["hasNetwork"], true);
assert_eq!(fiche["data"]["network"]["caReseau"], 50000);
let (st, _) = appel(get_cle("/api/v1/products/999999")).await;
assert_eq!(st, 404);
// Gammes : proposition à valider, puis lot avec un inconnu (tout ou rien).
let put = Request::builder()
.method("PUT")
.uri(format!("/api/v1/products/{tapis_url}/gamme"))
.header("x-api-key", CLE_API)
.header("content-type", "application/json")
.body(Body::from(r#"{"gamme":"A","fournisseur":"F001"}"#))
.unwrap();
let (st, corps) = appel(put).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["data"]["statut"], "a_valider");
assert_eq!(rep["data"]["codeFournisseur"], "F001");
let a_valider = super::gammes_a_valider::lister_gammes_a_valider("F001").await.unwrap();
assert_eq!((a_valider[0].codein.as_str(), a_valider[0].auteur.as_deref()), (code_tapis.as_str(), Some("test-grille")));
let post = |corps: &str| {
Request::builder().method("POST").uri("/api/v1/gammes").header("x-api-key", CLE_API).body(Body::from(corps.to_string())).unwrap()
};
let (st, corps) = appel(post(
&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "B" }, { "codein": "XXX", "gamme": "A" }] })
.to_string(),
))
.await;
let rep = json_de(&corps);
assert_eq!(st, 404);
assert_eq!(rep["error"]["details"]["inconnus"], json!(["XXX"]));
let (st, corps) = appel(post("pas du json")).await;
assert_eq!(
(st, json_de(&corps)["error"]["message"].clone()),
(400, json!("Corps de requête JSON attendu (Content-Type: application/json)."))
);
let (st, corps) = appel(post(&json!({ "fournisseur": "F001", "changes": [{ "codein": code_tapis, "gamme": "Z" }] }).to_string())).await;
let rep = json_de(&corps);
assert_eq!(st, 200, "{rep}");
assert_eq!(rep["meta"]["demandes"], 1);
// /api/v1/fournisseurs, /api/v1/network
let (st, corps) = appel(get_cle("/api/v1/fournisseurs")).await;
assert_eq!(st, 200);
let fous = json_de(&corps);
assert!(fous["data"].as_array().unwrap().iter().any(|f| f["code"] == "F001" && f["hasSnapshot"] == true));
let (st, corps) = appel(get_cle("/api/v1/fournisseurs?withData=1&search=f00")).await;
assert_eq!(st, 200);
assert_eq!(json_de(&corps)["meta"]["scope"], "instantané uniquement");
let (st, corps) = appel(get_cle("/api/v1/network/10000111111")).await;
assert_eq!(st, 200);
let net = json_de(&corps);
assert_eq!(net["data"].as_object().unwrap().keys().next().unwrap(), "codeCentrale");
assert_eq!(net["meta"]["source"], "cache");
let (st, _) = appel(get_cle("/api/v1/network/inconnu")).await;
assert_eq!(st, 404);
nettoyer(&pool).await;
rows_cache::invalidate_grid_rows_cache(None);
}