Files
CollectFlow/server/src/grid/enregistrer.rs
T
Claude 22a6c59488 Serveur Rust : module grid, phase 1 (instantané, snapshots, gammes, exports, API)
Portage de tout ce qui, dans la Grille, ne dépend ni de la base FF ni de Qlik :
- types.rs : ProductRow et types associés, JSON identique à JSON.stringify
  (camelCase, champs facultatifs omis, entiers sans décimale, NaN → null) et
  relecture sans perte des payloads grid_rows écrits par la version Node ;
- store.rs : grid_rows (upsert par lots de 200 + purge, instantané, report des
  gammes, requêtes filtrées/triées/paginées via QueryBuilder et liste blanche
  de tri, fiche, fournisseurs, fraîcheur, nomenclatures) ;
- rows_cache.rs : cache mémoire 10 min des lignes calculées (clé, LRU,
  patchGridRowsCache, invalidation, calculs en cours, compléments par magasin) ;
- network_trend.rs, months.rs, store_patch.rs : logique pure testée ;
- gammes_a_valider.rs, enregistrer.rs, snapshots.rs : actions getSnapshots,
  deleteSnapshot, saveSnapshot, saveDraftChanges, getGammesAValider,
  rejeterGammesAValider (validation zod reproduite, mêmes messages) ;
- exports.rs : /api/export/excel et /api/export/modified-gammes
  (rust_xlsxwriter, mêmes feuilles, styles, largeurs et formats) ;
- api_schemas.rs, zod.rs : schémas /api/v1, issues zod 4 à l'identique ;
- api_v1.rs, openapi.rs : /api/v1/nomenclatures et /api/v1/openapi.json
  (document identique octet pour octet à celui de la version Node).

Écart volontaire : listGridNomenclatures groupe par position (GROUP BY 1) ; la
requête drizzle répétait left(code3, $n) avec un autre paramètre et échouait
systématiquement sous PostgreSQL.

Test d'intégration (grid_rows, snapshots, gammes à valider) activé par
GRID_TEST_DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
2026-10-12 19:33:53 +00:00

294 lines
13 KiB
Rust

//! Enregistrement des gammes (port de `src/features/grid/api/enregistrer-gammes.ts`,
//! `save-draft-changes.ts` et `gammes-a-valider-actions.ts`).
use super::gammes_a_valider::{lister_gammes_a_valider, supprimer_gammes_a_valider};
use super::session::{final_user_id, verifier_session};
use super::snapshots::date_fr;
use super::zod::{self, child, Issues, Seg};
use super::{rows_cache, store};
use crate::db;
use crate::error::AppResult;
use crate::rpc::Ctx;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use sqlx::Row;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChangementGamme {
pub codein: String,
/// Gamme avant la modification (état serveur), conservée pour l'historique.
pub code_gamme_before: Option<String>,
pub code_gamme: String,
}
#[derive(Debug, Clone, Default)]
pub struct EnregistrementGammes {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
pub user_id: Option<i64>,
/// Libellé du snapshot, « Draft — date » par défaut.
pub label: Option<String>,
}
/// Enregistre des gammes pour un fournisseur — chemin unique de la Grille et de /api/v1.
///
/// Les changements sont fusionnés par-dessus le dernier snapshot du fournisseur,
/// puis reportés dans le cache mémoire de la Grille et dans l'instantané `grid_rows`.
/// Lève une erreur si l'écriture du snapshot échoue.
pub async fn enregistrer_gammes(input: &EnregistrementGammes) -> AppResult<usize> {
let EnregistrementGammes { code_fournisseur, nom_fournisseur, magasin, changes, user_id, label } = input;
if changes.is_empty() {
return Ok(0);
}
let pool = db::pool()?;
// Charger le dernier snapshot existant pour merger les changements
let existing = 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?;
let prev_changes: Map<String, Value> = match existing.map(|r| r.try_get::<Value, _>("changes")) {
Some(Ok(Value::Object(m))) => m,
_ => Map::new(),
};
// Merger les nouveaux changements par-dessus l'existant
let mut merged_changes = prev_changes;
for c in changes {
merged_changes.insert(c.codein.clone(), json!({ "before": c.code_gamme_before, "after": c.code_gamme }));
}
// Upsert : insérer un nouveau snapshot "draft" pour ce fournisseur
sqlx::query(
"insert into session_snapshots (user_id, code_fournisseur, nom_fournisseur, magasin, label, changes, summary_json, type) \
values ($1, $2, $3, $4, $5, $6, null, 'snapshot')",
)
.bind(user_id)
.bind(code_fournisseur)
.bind(nom_fournisseur)
.bind(magasin)
.bind(label.clone().unwrap_or_else(|| format!("Draft — {}", date_fr())))
.bind(Value::Object(merged_changes))
.execute(&pool)
.await?;
// Les lignes en cache reçoivent les nouvelles gammes au lieu d'être jetées :
// l'invalidation imposait un recalcul complet (jusqu'à ~40 s) à la
// réouverture suivante. L'instantané lu par /api/v1 suit, sans bloquer.
rows_cache::patch_grid_rows_cache(code_fournisseur, changes.iter().map(|c| (c.codein.as_str(), c.code_gamme.as_str())));
let code = code_fournisseur.clone();
let paires: Vec<(String, String)> = changes.iter().map(|c| (c.codein.clone(), c.code_gamme.clone())).collect();
tokio::spawn(async move {
if let Err(e) = store::update_grid_rows_gamme(&code, paires.iter().map(|(c, g)| (c.as_str(), g.as_str()))).await {
tracing::error!("[enregistrerGammes] mise à jour grid_rows KO: {}", crate::error::short(&e.message, 200));
}
});
Ok(changes.len())
}
// ---------------------------------------------------------------------------
// saveDraftChanges
// ---------------------------------------------------------------------------
/// Données validées de `SaveDraftsSchema`.
#[derive(Debug, Clone)]
pub struct SaveDraftsInput {
pub code_fournisseur: String,
pub nom_fournisseur: Option<String>,
pub magasin: String,
pub changes: Vec<ChangementGamme>,
/// Propositions de l'API examinées dans la Grille : validées (présentes dans
/// `changes`) ou écartées. Elles quittent la file « à valider ».
pub gammes_a_valider_traitees: Vec<String>,
}
/// `z.array(z.string())`.
fn tableau_de_chaines(v: Option<&Value>, chemin: &[Seg], issues: &mut Issues) -> Option<Vec<String>> {
let Some(Value::Array(a)) = v else {
issues.invalid_type(chemin, "array", v);
return None;
};
let mut out = Vec::with_capacity(a.len());
let mut ok = true;
for (i, x) in a.iter().enumerate() {
match zod::string(Some(x), &child(chemin, i), issues) {
Some(s) => out.push(s),
None => ok = false,
}
}
ok.then_some(out)
}
/// `SaveDraftsSchema.safeParse(raw)`.
pub fn parse_save_drafts(raw: &Value) -> Result<SaveDraftsInput, Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let p = |k: &str| vec![Seg::from(k)];
let code_fournisseur = zod::string(o.get("codeFournisseur"), &p("codeFournisseur"), &mut issues);
let nom_fournisseur = zod::opt_string(o.get("nomFournisseur"), &p("nomFournisseur"), &mut issues);
let magasin = zod::string(o.get("magasin"), &p("magasin"), &mut issues);
let chemin = p("changes");
let mut changes = Vec::new();
match o.get("changes") {
Some(Value::Array(a)) => {
for (i, x) in a.iter().enumerate() {
let ci = child(&chemin, i);
let Some(c) = zod::object(Some(x), &ci, &mut issues) else { continue };
let codein = zod::string(c.get("codein"), &child(&ci, "codein"), &mut issues);
let before = zod::nullable_string(c.get("codeGammeBefore"), &child(&ci, "codeGammeBefore"), &mut issues);
let gamme = zod::string(c.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
if let (Some(codein), Some(code_gamme_before), Some(code_gamme)) = (codein, before, gamme) {
changes.push(ChangementGamme { codein, code_gamme_before, code_gamme });
}
}
}
autre => issues.invalid_type(&chemin, "array", autre),
}
let traitees = match o.get("gammesAValiderTraitees") {
None => Some(Vec::new()),
v => tableau_de_chaines(v, &p("gammesAValiderTraitees"), &mut issues),
};
if !issues.is_empty() {
return Err(issues);
}
Ok(SaveDraftsInput {
code_fournisseur: code_fournisseur.unwrap_or_default(),
nom_fournisseur: nom_fournisseur.flatten(),
magasin: magasin.unwrap_or_default(),
changes,
gammes_a_valider_traitees: traitees.unwrap_or_default(),
})
}
/// `saveDraftChanges` une fois la session vérifiée : `{ success, saved, error? }`.
pub async fn enregistrer_brouillon(user_raw_id: &str, raw: &Value) -> Value {
let d = match parse_save_drafts(raw) {
Ok(d) => d,
Err(issues) => return json!({ "success": false, "saved": 0, "error": format!("Validation failed: {}", issues.message()) }),
};
let user_id = final_user_id(user_raw_id).map(|v| v as i64);
let res: AppResult<usize> = async {
let saved = enregistrer_gammes(&EnregistrementGammes {
code_fournisseur: d.code_fournisseur.clone(),
nom_fournisseur: d.nom_fournisseur.clone(),
magasin: d.magasin.clone(),
changes: d.changes.clone(),
user_id,
label: None,
})
.await?;
supprimer_gammes_a_valider(&d.code_fournisseur, &d.gammes_a_valider_traitees).await?;
Ok(saved)
}
.await;
match res {
Ok(saved) => json!({ "success": true, "saved": saved }),
Err(e) => json!({ "success": false, "saved": 0, "error": e.message }),
}
}
/// Action `saveDraftChanges(raw)`.
async fn save_draft_changes(ctx: Ctx) -> AppResult<Value> {
let utilisateur = match verifier_session(&ctx).await? {
Ok(u) => u,
Err(message) => return Ok(json!({ "success": false, "saved": 0, "error": message })),
};
Ok(enregistrer_brouillon(&utilisateur.id, &ctx.raw(0)).await)
}
// ---------------------------------------------------------------------------
// Gammes à valider (actions)
// ---------------------------------------------------------------------------
/// Action `getGammesAValider(codeFournisseur)` : gammes proposées par l'API pour
/// un fournisseur, en attente de validation.
async fn get_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "propositions": [], "error": message }));
}
let code = super::js::js_string(ctx.args.first());
match lister_gammes_a_valider(&code).await {
Ok(propositions) => Ok(json!({ "success": true, "propositions": propositions })),
Err(e) => Ok(json!({ "success": false, "propositions": [], "error": e.message })),
}
}
/// `RejetSchema.safeParse(raw)` → `(codeFournisseur, codeins)`.
pub fn parse_rejet(raw: &Value) -> Result<(String, Vec<String>), Issues> {
let mut issues = Issues::default();
let Some(o) = zod::object(Some(raw), &[], &mut issues) else { return Err(issues) };
let chemin = vec![Seg::from("codeFournisseur")];
let code = zod::string(o.get("codeFournisseur"), &chemin, &mut issues);
if let Some(c) = &code {
zod::check_len(c, &chemin, Some((1, None)), None, &mut issues);
}
let codeins = tableau_de_chaines(o.get("codeins"), &[Seg::from("codeins")], &mut issues);
if !issues.is_empty() {
return Err(issues);
}
Ok((code.unwrap_or_default(), codeins.unwrap_or_default()))
}
/// Action `rejeterGammesAValider(raw)` : rejette des propositions de l'API
/// (« Annuler les modifications » dans la Grille).
async fn rejeter_gammes_a_valider(ctx: Ctx) -> AppResult<Value> {
if let Err(message) = verifier_session(&ctx).await? {
return Ok(json!({ "success": false, "error": message }));
}
let (code, codeins) = match parse_rejet(&ctx.raw(0)) {
Ok(v) => v,
Err(issues) => return Ok(json!({ "success": false, "error": format!("Validation failed: {}", issues.message()) })),
};
match supprimer_gammes_a_valider(&code, &codeins).await {
Ok(()) => Ok(json!({ "success": true })),
Err(e) => Ok(json!({ "success": false, "error": e.message })),
}
}
pub fn actions() -> Vec<(&'static str, crate::rpc::Handler)> {
vec![
("saveDraftChanges", crate::handler!(save_draft_changes)),
("getGammesAValider", crate::handler!(get_gammes_a_valider)),
("rejeterGammesAValider", crate::handler!(rejeter_gammes_a_valider)),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validation_brouillon_comme_zod() {
let raw = json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a" }], "gammesAValiderTraitees": "x" });
let issues = parse_save_drafts(&raw).unwrap_err();
assert_eq!(
serde_json::to_string(&issues.to_json()).unwrap(),
r#"[{"expected":"string","code":"invalid_type","path":["changes",0,"codeGammeBefore"],"message":"Invalid input: expected string, received undefined"},{"expected":"string","code":"invalid_type","path":["changes",0,"codeGamme"],"message":"Invalid input: expected string, received undefined"},{"expected":"array","code":"invalid_type","path":["gammesAValiderTraitees"],"message":"Invalid input: expected array, received string"}]"#
);
let ok = parse_save_drafts(
&json!({ "codeFournisseur": "1", "magasin": "T", "changes": [{ "codein": "a", "codeGammeBefore": null, "codeGamme": "B" }] }),
)
.unwrap();
assert_eq!(ok.changes[0].code_gamme, "B");
assert!(ok.gammes_a_valider_traitees.is_empty());
}
#[test]
fn validation_rejet_comme_zod() {
let issues = parse_rejet(&json!({ "codeFournisseur": "", "codeins": [1] })).unwrap_err();
assert_eq!(
issues.message(),
"[\n {\n \"origin\": \"string\",\n \"code\": \"too_small\",\n \"minimum\": 1,\n \"inclusive\": true,\n \"path\": [\n \"codeFournisseur\"\n ],\n \"message\": \"Too small: expected string to have >=1 characters\"\n },\n {\n \"expected\": \"string\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"codeins\",\n 0\n ],\n \"message\": \"Invalid input: expected string, received number\"\n }\n]"
);
assert_eq!(parse_rejet(&json!(5)).unwrap_err().0.len(), 1);
}
}