mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-22 22:48:08 +02:00
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
This commit is contained in:
19 files changed
+6139
-2
No files matched your search
@@ -0,0 +1,402 @@
|
||||
//! Exports Excel de la Grille (port de `src/app/api/export/excel/route.ts` et
|
||||
//! `src/app/api/export/modified-gammes/route.ts`, ExcelJS → rust_xlsxwriter).
|
||||
//!
|
||||
//! Mêmes feuilles, colonnes, largeurs, styles d'en-tête, formats et noms de
|
||||
//! fichier. Le découpage du fichier d'import des gammes en lots de 5 000 lignes
|
||||
//! (`LIGNES_MAX_PAR_FICHIER_GAMMES`) se fait côté interface, qui appelle
|
||||
//! `/api/export/modified-gammes` une fois par lot : la route ne fait que mettre en
|
||||
//! forme les lignes reçues.
|
||||
|
||||
use super::js;
|
||||
use super::zod::{self, child, Issues, Seg};
|
||||
use crate::auth::SessionUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use axum::body::Bytes;
|
||||
use axum::http::{header, HeaderValue};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use rust_xlsxwriter::{Color, Format, FormatAlign, FormatBorder, FormatPattern, Workbook, Worksheet, XlsxError};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const CONTENT_TYPE_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
|
||||
/// Largeur ExcelJS (`width: 14`) : valeur écrite telle quelle dans `<col width>`.
|
||||
/// rust_xlsxwriter ajoute la marge de cellule à une largeur en caractères ; en
|
||||
/// pixels (7 px par caractère), il écrit exactement `width`.
|
||||
fn largeur(ws: &mut Worksheet, col: u16, width: u32) -> Result<(), XlsxError> {
|
||||
ws.set_column_width_pixels(col, width * 7)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Style d'en-tête commun aux deux exports.
|
||||
fn style_entete() -> Format {
|
||||
Format::new()
|
||||
.set_bold()
|
||||
.set_font_color(Color::RGB(0xFFFFFF))
|
||||
.set_pattern(FormatPattern::Solid)
|
||||
.set_background_color(Color::RGB(0x0F172A))
|
||||
.set_align(FormatAlign::Center)
|
||||
.set_border_bottom(FormatBorder::Thin)
|
||||
.set_border_bottom_color(Color::RGB(0x334155))
|
||||
}
|
||||
|
||||
fn xlsx_err(e: XlsxError) -> AppError {
|
||||
AppError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// `await req.json()` : un corps illisible lève une exception (500 côté Next).
|
||||
fn lire_json(body: &Bytes) -> AppResult<Value> {
|
||||
serde_json::from_slice(body).map_err(|e| AppError::internal(format!("Corps JSON illisible : {e}")))
|
||||
}
|
||||
|
||||
fn payload_invalide() -> Response {
|
||||
(axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": "Invalid payload" }))).into_response()
|
||||
}
|
||||
|
||||
/// `${prefixe}_${nomFournisseur.replace(/\s+/g, "_")}_${YYYY-MM-DD}.xlsx` (date UTC,
|
||||
/// comme `toISOString().slice(0, 10)`).
|
||||
pub fn nom_fichier(prefixe: &str, nom_fournisseur: &str) -> String {
|
||||
format!("{prefixe}_{}_{}.xlsx", js::replace_whitespace_runs(nom_fournisseur, "_"), chrono::Utc::now().format("%Y-%m-%d"))
|
||||
}
|
||||
|
||||
/// `attachment; filename="…"`. Comme Node, l'en-tête est écrit octet par octet en
|
||||
/// Latin-1 : un caractère au-delà de U+00FF (ou un caractère de contrôle) est
|
||||
/// refusé, ce qui faisait échouer la route Next en 500.
|
||||
pub fn content_disposition(filename: &str) -> AppResult<HeaderValue> {
|
||||
let mut octets = Vec::with_capacity(filename.len() + 32);
|
||||
octets.extend_from_slice(b"attachment; filename=\"");
|
||||
for c in filename.chars() {
|
||||
let v = c as u32;
|
||||
if v > 0xFF || (v < 0x20 && v != 0x09) || v == 0x7F {
|
||||
return Err(AppError::internal(format!("Invalid character in header content [\"Content-Disposition\"] : {c:?}")));
|
||||
}
|
||||
octets.push(v as u8);
|
||||
}
|
||||
octets.push(b'"');
|
||||
HeaderValue::from_bytes(&octets).map_err(|e| AppError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
fn reponse_xlsx(buffer: Vec<u8>, filename: &str) -> AppResult<Response> {
|
||||
let disposition = content_disposition(filename)?;
|
||||
Ok(([(header::CONTENT_TYPE, HeaderValue::from_static(CONTENT_TYPE_XLSX)), (header::CONTENT_DISPOSITION, disposition)], buffer)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/export/excel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ligne de `ExportSchema`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ExportRow {
|
||||
pub codein: String,
|
||||
pub libelle1: String,
|
||||
pub code3: String,
|
||||
pub libelle3: String,
|
||||
pub code_gamme: Option<String>,
|
||||
pub total_quantite: f64,
|
||||
pub total_ca: f64,
|
||||
pub taux_marge: f64,
|
||||
/// Before gamme for delta
|
||||
pub gamme_avant: Option<String>,
|
||||
}
|
||||
|
||||
/// `ExportSchema.safeParse(body)` → `(nomFournisseur, rows)`.
|
||||
pub fn parse_export(body: &Value) -> Result<(String, Vec<ExportRow>), Issues> {
|
||||
let mut issues = Issues::default();
|
||||
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
|
||||
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
|
||||
let chemin = vec![Seg::from("rows")];
|
||||
let mut rows = Vec::new();
|
||||
match o.get("rows") {
|
||||
Some(Value::Array(a)) => {
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
let ci = child(&chemin, i);
|
||||
let Some(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
|
||||
let s = |k: &str, issues: &mut Issues| zod::string(r.get(k), &child(&ci, k), issues);
|
||||
let codein = s("codein", &mut issues);
|
||||
let libelle1 = s("libelle1", &mut issues);
|
||||
let code3 = s("code3", &mut issues);
|
||||
let libelle3 = s("libelle3", &mut issues);
|
||||
let code_gamme = zod::nullable_string(r.get("codeGamme"), &child(&ci, "codeGamme"), &mut issues);
|
||||
let n = |k: &str, issues: &mut Issues| zod::number(r.get(k), &child(&ci, k), issues);
|
||||
let total_quantite = n("totalQuantite", &mut issues);
|
||||
let total_ca = n("totalCa", &mut issues);
|
||||
let taux_marge = n("tauxMarge", &mut issues);
|
||||
let gamme_avant = match r.get("gammeAvant") {
|
||||
None => Some(None),
|
||||
v => zod::nullable_string(v, &child(&ci, "gammeAvant"), &mut issues),
|
||||
};
|
||||
if let (
|
||||
Some(codein),
|
||||
Some(libelle1),
|
||||
Some(code3),
|
||||
Some(libelle3),
|
||||
Some(code_gamme),
|
||||
Some(total_quantite),
|
||||
Some(total_ca),
|
||||
Some(taux_marge),
|
||||
Some(gamme_avant),
|
||||
) = (codein, libelle1, code3, libelle3, code_gamme, total_quantite, total_ca, taux_marge, gamme_avant)
|
||||
{
|
||||
rows.push(ExportRow {
|
||||
codein,
|
||||
libelle1,
|
||||
code3,
|
||||
libelle3,
|
||||
code_gamme,
|
||||
total_quantite,
|
||||
total_ca,
|
||||
taux_marge,
|
||||
gamme_avant,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
autre => issues.invalid_type(&chemin, "array", autre),
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
Ok((nom.unwrap_or_default(), rows))
|
||||
}
|
||||
|
||||
/// Gamme colours
|
||||
fn couleur_gamme(code: &str) -> Option<u32> {
|
||||
match code {
|
||||
"A" => Some(0x064E3B),
|
||||
"B" => Some(0x1E3A5F),
|
||||
"C" => Some(0x78350F),
|
||||
"Z" => Some(0x4C0519),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Classeur « Assortiment » (tous les produits, gamme avant/après, chiffres 12 mois).
|
||||
pub fn classeur_assortiment(rows: &[ExportRow]) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
|
||||
let sheet = workbook.add_worksheet();
|
||||
sheet.set_name("Assortiment")?;
|
||||
sheet.set_freeze_panes(1, 0)?;
|
||||
|
||||
let entete = style_entete();
|
||||
let colonnes: [(&str, u32); 9] = [
|
||||
("Code", 14),
|
||||
("Désignation", 40),
|
||||
("Nomenclature", 25),
|
||||
("Gamme Avant", 14),
|
||||
("Gamme Après", 14),
|
||||
("Delta", 10),
|
||||
("Vol. 12m", 12),
|
||||
("CA 12m (€)", 14),
|
||||
("Marge (%)", 12),
|
||||
];
|
||||
for (i, (titre, w)) in colonnes.iter().enumerate() {
|
||||
largeur(sheet, i as u16, *w)?;
|
||||
sheet.write_string_with_format(0, i as u16, *titre, &entete)?;
|
||||
}
|
||||
sheet.set_row_height(0, 22)?;
|
||||
|
||||
let fmt_ca = Format::new().set_num_format("#,##0.00 \"€\"");
|
||||
let fmt_marge = Format::new().set_num_format("0.00\"%\"");
|
||||
let fmt_delta = Format::new().set_bold().set_font_color(Color::RGB(0xFBBF24));
|
||||
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let r = (i + 1) as u32;
|
||||
let delta = if row.gamme_avant != row.code_gamme {
|
||||
format!("{} → {}", row.gamme_avant.as_deref().unwrap_or("—"), row.code_gamme.as_deref().unwrap_or("—"))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
sheet.write_string(r, 0, &row.codein)?;
|
||||
sheet.write_string(r, 1, &row.libelle1)?;
|
||||
sheet.write_string(r, 2, &row.libelle3)?;
|
||||
sheet.write_string(r, 3, row.gamme_avant.as_deref().unwrap_or("—"))?;
|
||||
|
||||
// Colour the "Gamme Après" cell
|
||||
let gamme_apres = row.code_gamme.as_deref().unwrap_or("—");
|
||||
match row.code_gamme.as_deref().and_then(couleur_gamme) {
|
||||
Some(c) => {
|
||||
let f = Format::new()
|
||||
.set_pattern(FormatPattern::Solid)
|
||||
.set_background_color(Color::RGB(c))
|
||||
.set_bold()
|
||||
.set_font_color(Color::RGB(0xFFFFFF));
|
||||
sheet.write_string_with_format(r, 4, gamme_apres, &f)?;
|
||||
}
|
||||
None => {
|
||||
sheet.write_string(r, 4, gamme_apres)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight changed rows
|
||||
if delta.is_empty() {
|
||||
sheet.write_string(r, 5, &delta)?;
|
||||
} else {
|
||||
sheet.write_string_with_format(r, 5, &delta, &fmt_delta)?;
|
||||
}
|
||||
|
||||
sheet.write_number(r, 6, js::js_round(row.total_quantite))?;
|
||||
// Format numbers
|
||||
sheet.write_number_with_format(r, 7, js::js_to_fixed2(row.total_ca), &fmt_ca)?;
|
||||
sheet.write_number_with_format(r, 8, js::js_to_fixed2(row.taux_marge), &fmt_marge)?;
|
||||
}
|
||||
|
||||
workbook.save_to_buffer()
|
||||
}
|
||||
|
||||
/// `POST /api/export/excel`.
|
||||
pub async fn export_excel(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
|
||||
let body = lire_json(&body)?;
|
||||
let Ok((nom_fournisseur, rows)) = parse_export(&body) else { return Ok(payload_invalide()) };
|
||||
let buffer = classeur_assortiment(&rows).map_err(xlsx_err)?;
|
||||
reponse_xlsx(buffer, &nom_fichier("Assortiment", &nom_fournisseur))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/export/modified-gammes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ligne de `ExportModifiedGammesSchema`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GammeModifiee {
|
||||
pub codein: String,
|
||||
pub gtin: String,
|
||||
pub code_fournisseur: Option<String>,
|
||||
pub gamme: String,
|
||||
}
|
||||
|
||||
/// `ExportModifiedGammesSchema.safeParse(body)` → `(nomFournisseur, changes)`.
|
||||
pub fn parse_modified_gammes(body: &Value) -> Result<(String, Vec<GammeModifiee>), Issues> {
|
||||
let mut issues = Issues::default();
|
||||
let Some(o) = zod::object(Some(body), &[], &mut issues) else { return Err(issues) };
|
||||
let nom = zod::string(o.get("nomFournisseur"), &[Seg::from("nomFournisseur")], &mut issues);
|
||||
let chemin = vec![Seg::from("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(r) = zod::object(Some(x), &ci, &mut issues) else { continue };
|
||||
let codein = zod::string(r.get("codein"), &child(&ci, "codein"), &mut issues);
|
||||
let gtin = zod::string(r.get("gtin"), &child(&ci, "gtin"), &mut issues);
|
||||
let code_fournisseur = zod::opt_string(r.get("codeFournisseur"), &child(&ci, "codeFournisseur"), &mut issues);
|
||||
let gamme = zod::string(r.get("gamme"), &child(&ci, "gamme"), &mut issues);
|
||||
if let (Some(codein), Some(gtin), Some(code_fournisseur), Some(gamme)) = (codein, gtin, code_fournisseur, gamme) {
|
||||
changes.push(GammeModifiee { codein, gtin, code_fournisseur, gamme });
|
||||
}
|
||||
}
|
||||
}
|
||||
autre => issues.invalid_type(&chemin, "array", autre),
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
Ok((nom.unwrap_or_default(), changes))
|
||||
}
|
||||
|
||||
/// `x || "—"`.
|
||||
fn ou_tiret(s: &str) -> &str {
|
||||
if s.is_empty() {
|
||||
"—"
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Classeur « Gammes Modifiées » (CODE FOURNISSEUR, CODEIN, GAMME) : le fichier
|
||||
/// d'import des gammes.
|
||||
pub fn classeur_gammes_modifiees(changes: &[GammeModifiee]) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
workbook.set_properties(&rust_xlsxwriter::DocProperties::new().set_author("CollectFlow"));
|
||||
let sheet = workbook.add_worksheet();
|
||||
sheet.set_name("Gammes Modifiées")?;
|
||||
sheet.set_freeze_panes(1, 0)?;
|
||||
|
||||
// Format en texte pour eviter la notation scientifique excel ; centrer la
|
||||
// colonne Gamme. ExcelJS applique ces styles de colonne à toutes les cellules
|
||||
// déjà écrites, en-tête compris. « @ » = format intégré n° 49, comme ExcelJS.
|
||||
let texte = Format::new().set_num_format_index(49);
|
||||
let centre = Format::new().set_align(FormatAlign::Center);
|
||||
let entete = style_entete();
|
||||
let entete_texte = style_entete().set_num_format_index(49);
|
||||
|
||||
largeur(sheet, 0, 22)?;
|
||||
largeur(sheet, 1, 18)?;
|
||||
largeur(sheet, 2, 12)?;
|
||||
sheet.set_column_format(0, &texte)?;
|
||||
sheet.set_column_format(1, &texte)?;
|
||||
sheet.set_column_format(2, ¢re)?;
|
||||
|
||||
sheet.write_string_with_format(0, 0, "CODE FOURNISSEUR", &entete_texte)?;
|
||||
sheet.write_string_with_format(0, 1, "CODEIN", &entete_texte)?;
|
||||
sheet.write_string_with_format(0, 2, "GAMME", &entete)?;
|
||||
sheet.set_row_height(0, 22)?;
|
||||
|
||||
for (i, row) in changes.iter().enumerate() {
|
||||
let r = (i + 1) as u32;
|
||||
sheet.write_string_with_format(r, 0, ou_tiret(row.code_fournisseur.as_deref().unwrap_or("")), &texte)?;
|
||||
sheet.write_string_with_format(r, 1, ou_tiret(&row.codein), &texte)?;
|
||||
sheet.write_string_with_format(r, 2, ou_tiret(&row.gamme), ¢re)?;
|
||||
}
|
||||
|
||||
workbook.save_to_buffer()
|
||||
}
|
||||
|
||||
/// `POST /api/export/modified-gammes`.
|
||||
pub async fn export_modified_gammes(SessionUser(_utilisateur): SessionUser, body: Bytes) -> AppResult<Response> {
|
||||
let body = lire_json(&body)?;
|
||||
let Ok((nom_fournisseur, changes)) = parse_modified_gammes(&body) else { return Ok(payload_invalide()) };
|
||||
let buffer = classeur_gammes_modifiees(&changes).map_err(xlsx_err)?;
|
||||
reponse_xlsx(buffer, &nom_fichier("Modifications_Gammes", &nom_fournisseur))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Écrit les deux classeurs d'exemple dans `GRID_EXPORT_DIR` (pour inspection).
|
||||
#[test]
|
||||
fn classeurs_d_exemple() {
|
||||
let (_, rows) = parse_export(&json!({
|
||||
"nomFournisseur": "Le Fournisseur",
|
||||
"rows": [
|
||||
{ "codein": "123456", "libelle1": "Bougie parfumée", "code3": "320211", "libelle3": "Bougies", "codeGamme": "A", "totalQuantite": 12.5, "totalCa": 1234.125, "tauxMarge": 41.2345, "gammeAvant": "B" },
|
||||
{ "codein": "000789", "libelle1": "Vase", "code3": "320212", "libelle3": "Vases", "codeGamme": null, "totalQuantite": -2.5, "totalCa": 10, "tauxMarge": 0, "gammeAvant": null },
|
||||
{ "codein": "42", "libelle1": "", "code3": "", "libelle3": "", "codeGamme": "Y", "totalQuantite": 3, "totalCa": 0.125, "tauxMarge": 1.005, "gammeAvant": "Y" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let a = classeur_assortiment(&rows).unwrap();
|
||||
let (_, changes) = parse_modified_gammes(&json!({
|
||||
"nomFournisseur": "X",
|
||||
"changes": [
|
||||
{ "codein": "000123", "gtin": "3700", "codeFournisseur": "F001", "gamme": "A" },
|
||||
{ "codein": "", "gtin": "", "gamme": "" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
let b = classeur_gammes_modifiees(&changes).unwrap();
|
||||
assert!(a.starts_with(b"PK") && b.starts_with(b"PK"));
|
||||
if let Ok(dir) = std::env::var("GRID_EXPORT_DIR") {
|
||||
std::fs::write(format!("{dir}/rust_excel.xlsx"), a).unwrap();
|
||||
std::fs::write(format!("{dir}/rust_modified.xlsx"), b).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_et_entetes() {
|
||||
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [{ "codein": "a" }] })).is_err());
|
||||
assert!(parse_export(&json!({ "nomFournisseur": "x", "rows": [] })).is_ok());
|
||||
assert!(parse_modified_gammes(
|
||||
&json!({ "nomFournisseur": "x", "changes": [{ "codein": "a", "gtin": "g", "gamme": "A", "codeFournisseur": 3 }] })
|
||||
)
|
||||
.is_err());
|
||||
let nom = nom_fichier("Assortiment", "Le Fournisseur\tÉté");
|
||||
assert!(nom.starts_with("Assortiment_Le_Fournisseur_Été_") && nom.ends_with(".xlsx"));
|
||||
let h = content_disposition("Assortiment_Été.xlsx").unwrap();
|
||||
assert_eq!(h.as_bytes(), b"attachment; filename=\"Assortiment_\xc9t\xe9.xlsx\"");
|
||||
assert!(content_disposition("Fournisseur_œ.xlsx").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user