Files
MeowSpool/backend/src/adapters/inbound/filament_handler.rs
T

342 lines
10 KiB
Rust

use axum::{
extract::{Path, Query, State},
http::{header, StatusCode},
response::IntoResponse,
Extension, Json,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use uuid::Uuid;
use validator::Validate;
use crate::{
adapters::inbound::middleware::auth::CurrentUser,
application::filament_service::{
CreateFilamentInput, DashboardStats, MaterialSummary, UpdateFilamentInput,
},
domain::filament::Filament,
error::AppError,
ports::FilamentFilter,
router::AppState,
};
// -------------------------------------------------------------------------
// Query params
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Default)]
pub struct ListFilamentsQuery {
pub material: Option<String>,
pub brand: Option<String>,
pub search: Option<String>,
pub stock_level: Option<String>,
pub sort: Option<String>,
#[serde(default = "default_page")]
pub page: u32,
#[serde(default = "default_per_page")]
pub per_page: u32,
}
fn default_page() -> u32 { 1 }
fn default_per_page() -> u32 { 20 }
#[derive(Debug, Deserialize)]
pub struct LabelQuery {
#[serde(default = "default_width_mm")]
pub width_mm: u32,
#[serde(default = "default_height_mm")]
pub height_mm: u32,
/// Campos a incluir, separados por vírgula: color,name,material_brand,net_weight,print_temp,qrcode
/// Se ausente, inclui todos.
pub fields: Option<String>,
/// DPI alvo da impressora. Padrão: 96 (tela). Use 203 para Niimbot/térmicas de baixo custo,
/// 300 para Brother/Dymo premium. Afeta resolução do QR Code no SVG e dimensionamento físico.
pub dpi: Option<u32>,
}
fn default_width_mm() -> u32 { 50 }
fn default_height_mm() -> u32 { 30 }
// -------------------------------------------------------------------------
// Request / Response DTOs
// -------------------------------------------------------------------------
#[derive(Debug, Deserialize, Validate)]
pub struct CreateFilamentRequest {
#[validate(length(min = 1, max = 20))]
pub material: String,
#[validate(length(min = 1, max = 100))]
pub brand: String,
#[validate(length(max = 100))]
pub model: Option<String>,
#[validate(length(min = 4, max = 7))]
pub color_hex: String,
pub spool_preset_id: Uuid,
#[validate(range(min = 1))]
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
#[validate(length(max = 1000))]
pub notes: Option<String>,
}
#[derive(Debug, Deserialize, Validate)]
pub struct UpdateFilamentRequest {
#[validate(length(min = 1, max = 20))]
pub material: String,
#[validate(length(min = 1, max = 100))]
pub brand: String,
#[validate(length(max = 100))]
pub model: Option<String>,
#[validate(length(min = 4, max = 7))]
pub color_hex: String,
pub spool_preset_id: Uuid,
#[validate(range(min = 1))]
pub total_weight_g: i32,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
#[validate(length(max = 1000))]
pub notes: Option<String>,
pub client_updated_at: Option<OffsetDateTime>,
}
#[derive(Debug, Serialize)]
pub struct FilamentResponse {
pub id: Uuid,
pub user_id: Uuid,
pub material: String,
pub brand: String,
pub model: Option<String>,
pub color_hex: String,
pub spool_preset_id: Uuid,
pub total_weight_g: i32,
pub net_weight_g: i32,
pub stock_percentage: u8,
pub temp_hotend_c: Option<i32>,
pub temp_bed_c: Option<i32>,
pub flow_factor_pct: Option<f64>,
pub notes: Option<String>,
pub updated_at: OffsetDateTime,
pub created_at: OffsetDateTime,
}
impl From<Filament> for FilamentResponse {
fn from(f: Filament) -> Self {
let stock_percentage = f.stock_percentage();
Self {
id: f.id,
user_id: f.user_id,
material: f.material.to_string(),
brand: f.brand,
model: f.model,
color_hex: f.color_hex,
spool_preset_id: f.spool_preset_id,
total_weight_g: f.total_weight_g,
net_weight_g: f.net_weight_g,
stock_percentage,
temp_hotend_c: f.temp_hotend_c,
temp_bed_c: f.temp_bed_c,
flow_factor_pct: f.flow_factor_pct,
notes: f.notes,
updated_at: f.updated_at,
created_at: f.created_at,
}
}
}
#[derive(Debug, Serialize)]
pub struct DashboardResponse {
pub total_stock_kg: f64,
pub low_stock_count: i64,
pub by_material: Vec<MaterialSummaryResponse>,
pub low_stock_filaments: Vec<FilamentResponse>,
pub recent_filaments: Vec<FilamentResponse>,
}
#[derive(Debug, Serialize)]
pub struct MaterialSummaryResponse {
pub material: String,
pub count: i64,
pub total_kg: f64,
}
impl From<MaterialSummary> for MaterialSummaryResponse {
fn from(m: MaterialSummary) -> Self {
Self {
material: m.material,
count: m.count,
total_kg: m.total_kg,
}
}
}
impl From<DashboardStats> for DashboardResponse {
fn from(s: DashboardStats) -> Self {
Self {
total_stock_kg: s.total_stock_kg,
low_stock_count: s.low_stock_count,
by_material: s.by_material.into_iter().map(Into::into).collect(),
low_stock_filaments: s.low_stock_filaments.into_iter().map(Into::into).collect(),
recent_filaments: s.recent_filaments.into_iter().map(Into::into).collect(),
}
}
}
// -------------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------------
pub async fn dashboard_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
) -> Result<impl IntoResponse, AppError> {
let stats = state.filament_service.dashboard(user.id).await?;
Ok(Json(DashboardResponse::from(stats)))
}
pub async fn list_filaments_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Query(query): Query<ListFilamentsQuery>,
) -> Result<impl IntoResponse, AppError> {
let filter = FilamentFilter {
material: query.material,
brand: query.brand,
search: query.search,
stock_level: query.stock_level,
sort: None, // TODO: parse sort string para SortOrder
page: query.page,
per_page: query.per_page,
};
let filaments = state.filament_service.list(user.id, filter).await?;
let response: Vec<FilamentResponse> = filaments.into_iter().map(Into::into).collect();
Ok(Json(response))
}
pub async fn create_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Json(req): Json<CreateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let input = CreateFilamentInput {
material: req.material,
brand: req.brand,
model: req.model,
color_hex: req.color_hex,
spool_preset_id: req.spool_preset_id,
total_weight_g: req.total_weight_g,
temp_hotend_c: req.temp_hotend_c,
temp_bed_c: req.temp_bed_c,
flow_factor_pct: req.flow_factor_pct,
notes: req.notes,
};
let filament = state.filament_service.create(user.id, input).await?;
Ok((StatusCode::CREATED, Json(FilamentResponse::from(filament))))
}
pub async fn get_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let filament = state.filament_service.get(id, user.id).await?;
Ok(Json(FilamentResponse::from(filament)))
}
pub async fn update_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateFilamentRequest>,
) -> Result<impl IntoResponse, AppError> {
req.validate()?;
let input = UpdateFilamentInput {
material: req.material,
brand: req.brand,
model: req.model,
color_hex: req.color_hex,
spool_preset_id: req.spool_preset_id,
total_weight_g: req.total_weight_g,
temp_hotend_c: req.temp_hotend_c,
temp_bed_c: req.temp_bed_c,
flow_factor_pct: req.flow_factor_pct,
notes: req.notes,
client_updated_at: req.client_updated_at,
};
let filament = state.filament_service.update(id, user.id, input).await?;
Ok(Json(FilamentResponse::from(filament)))
}
pub async fn delete_filament_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
state.filament_service.delete(id, user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn get_qrcode_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, AppError> {
let png = state.filament_service.generate_qrcode_png(id, user.id).await?;
Ok((
[(header::CONTENT_TYPE, "image/png")],
png,
))
}
pub async fn export_label_pdf_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Query(query): Query<LabelQuery>,
) -> Result<impl IntoResponse, AppError> {
let pdf = state
.filament_service
.generate_label_pdf(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
.await?;
let filename = format!("meowspool-label-{id}.pdf");
Ok((
[
(header::CONTENT_TYPE, "application/pdf".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
pdf,
))
}
pub async fn export_label_handler(
State(state): State<AppState>,
Extension(user): Extension<CurrentUser>,
Path(id): Path<Uuid>,
Query(query): Query<LabelQuery>,
) -> Result<impl IntoResponse, AppError> {
let svg = state
.filament_service
.generate_label_svg(id, user.id, query.width_mm, query.height_mm, query.fields.as_deref(), query.dpi)
.await?;
let filename = format!("meowspool-label-{id}.svg");
Ok((
[
(header::CONTENT_TYPE, "image/svg+xml".to_string()),
(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{filename}\""),
),
],
svg,
))
}