342 lines
10 KiB
Rust
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,
|
|
))
|
|
}
|