use std::sync::Arc; use base64::{engine::general_purpose::STANDARD, Engine}; use time::OffsetDateTime; use uuid::Uuid; use crate::{ domain::filament::{Filament, Material}, error::AppError, ports::{FilamentFilter, FilamentRepository, SpoolPresetRepository}, }; /// DTO de entrada para criação de filamento. #[derive(Debug)] pub struct CreateFilamentInput { pub material: String, pub brand: String, pub model: Option, pub color_hex: String, pub spool_preset_id: Uuid, pub total_weight_g: i32, pub temp_hotend_c: Option, pub temp_bed_c: Option, pub flow_factor_pct: Option, pub notes: Option, } /// DTO de entrada para atualização de filamento. #[derive(Debug)] pub struct UpdateFilamentInput { pub material: String, pub brand: String, pub model: Option, pub color_hex: String, pub spool_preset_id: Uuid, pub total_weight_g: i32, pub temp_hotend_c: Option, pub temp_bed_c: Option, pub flow_factor_pct: Option, pub notes: Option, /// Timestamp do cliente para resolução de conflito offline-first pub client_updated_at: Option, } /// Estatísticas do dashboard. #[derive(Debug)] pub struct DashboardStats { pub total_stock_kg: f64, pub low_stock_count: i64, pub by_material: Vec, pub low_stock_filaments: Vec, pub recent_filaments: Vec, } #[derive(Debug)] pub struct MaterialSummary { pub material: String, pub count: i64, pub total_kg: f64, } /// Casos de uso relacionados ao inventário de filamentos. pub struct FilamentService { repo: Arc, preset_repo: Arc, } impl FilamentService { pub fn new(repo: Arc, preset_repo: Arc) -> Self { Self { repo, preset_repo } } // ------------------------------------------------------------------------- // CRUD // ------------------------------------------------------------------------- pub async fn create(&self, user_id: Uuid, input: CreateFilamentInput) -> Result { let preset = self .preset_repo .find_by_id(input.spool_preset_id) .await? .ok_or(AppError::NotFound)?; let net_weight_g = (input.total_weight_g - preset.spool_weight_g).max(0); let now = OffsetDateTime::now_utc(); let filament = Filament { id: Uuid::new_v4(), user_id, material: Material::from(input.material), brand: input.brand, model: input.model, color_hex: input.color_hex, spool_preset_id: input.spool_preset_id, total_weight_g: input.total_weight_g, net_weight_g, temp_hotend_c: input.temp_hotend_c, temp_bed_c: input.temp_bed_c, flow_factor_pct: input.flow_factor_pct, notes: input.notes, updated_at: now, created_at: now, }; self.repo.create(&filament).await } pub async fn get(&self, id: Uuid, user_id: Uuid) -> Result { self.repo .find_by_id(id, user_id) .await? .ok_or(AppError::NotFound) } pub async fn list( &self, user_id: Uuid, filter: FilamentFilter, ) -> Result, AppError> { self.repo.list(user_id, filter).await } pub async fn update( &self, id: Uuid, user_id: Uuid, input: UpdateFilamentInput, ) -> Result { let mut existing = self .repo .find_by_id(id, user_id) .await? .ok_or(AppError::NotFound)?; // Resolução de conflito offline-first: rejeita se servidor é mais recente if let Some(client_ts) = input.client_updated_at { if existing.updated_at > client_ts { return Err(AppError::Conflict( "server version is newer than client version".into(), )); } } let preset = self .preset_repo .find_by_id(input.spool_preset_id) .await? .ok_or(AppError::NotFound)?; let net_weight_g = (input.total_weight_g - preset.spool_weight_g).max(0); existing.material = Material::from(input.material); existing.brand = input.brand; existing.model = input.model; existing.color_hex = input.color_hex; existing.spool_preset_id = input.spool_preset_id; existing.total_weight_g = input.total_weight_g; existing.net_weight_g = net_weight_g; existing.temp_hotend_c = input.temp_hotend_c; existing.temp_bed_c = input.temp_bed_c; existing.flow_factor_pct = input.flow_factor_pct; existing.notes = input.notes; existing.updated_at = OffsetDateTime::now_utc(); self.repo.update(&existing).await } pub async fn delete(&self, id: Uuid, user_id: Uuid) -> Result<(), AppError> { self.repo .find_by_id(id, user_id) .await? .ok_or(AppError::NotFound)?; self.repo.delete(id, user_id).await } // ------------------------------------------------------------------------- // Dashboard // ------------------------------------------------------------------------- pub async fn dashboard(&self, user_id: Uuid) -> Result { let all = self.repo.list(user_id, FilamentFilter::default()).await?; let total_stock_kg = all.iter().map(|f| f.net_weight_g as f64).sum::() / 1000.0; let low_stock_count = all.iter().filter(|f| f.is_low_stock()).count() as i64; // Agrupamento por material let mut by_material: std::collections::HashMap = Default::default(); for f in &all { let entry = by_material.entry(f.material.to_string()).or_default(); entry.0 += 1; entry.1 += f.net_weight_g as f64 / 1000.0; } let mut by_material: Vec = by_material .into_iter() .map(|(material, (count, total_kg))| MaterialSummary { material, count, total_kg, }) .collect(); by_material.sort_by(|a, b| b.count.cmp(&a.count)); let low_stock_filaments: Vec = all .iter() .filter(|f| f.is_low_stock()) .cloned() .collect(); let mut recent = all.clone(); recent.sort_by(|a, b| b.created_at.cmp(&a.created_at)); let recent_filaments = recent.into_iter().take(5).collect(); Ok(DashboardStats { total_stock_kg, low_stock_count, by_material, low_stock_filaments, recent_filaments, }) } // ------------------------------------------------------------------------- // QR Code // ------------------------------------------------------------------------- pub async fn generate_qrcode_png(&self, id: Uuid, user_id: Uuid) -> Result, AppError> { self.get(id, user_id).await?; let deep_link = format!("meowspool://filaments/{id}"); let code = qrcode::QrCode::new(deep_link.as_bytes()) .map_err(|e| AppError::Internal(anyhow::anyhow!("QR Code generation failed: {e}")))?; let image = code .render::>() .min_dimensions(200, 200) .build(); let mut buf = Vec::new(); image::DynamicImage::ImageLuma8(image) .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png) .map_err(|e| AppError::Internal(anyhow::anyhow!("PNG encoding failed: {e}")))?; Ok(buf) } // ------------------------------------------------------------------------- // SVG Label // ------------------------------------------------------------------------- pub async fn generate_label_svg( &self, id: Uuid, user_id: Uuid, width_mm: u32, height_mm: u32, ) -> Result { let filament = self.get(id, user_id).await?; // Gerar QR Code como PNG base64 para embutir no SVG let qr_png = self.generate_qrcode_png(id, user_id).await?; let qr_b64 = STANDARD.encode(&qr_png); // Escala: 1mm = ~3.78px (96dpi) let px_per_mm = 3.78_f64; let width_px = (width_mm as f64 * px_per_mm) as u32; let height_px = (height_mm as f64 * px_per_mm) as u32; let qr_size = (height_px as f64 * 0.8) as u32; let model_name = filament.model.as_deref().unwrap_or("—"); let net_weight = filament.net_weight_g; let material = filament.material.to_string(); let brand = &filament.brand; let color = &filament.color_hex; let svg = format!( r##" {model_name} {brand} · {material} {net_weight}g "##, width_px = width_px, height_px = height_px, h = height_px - 12, color = color, model_name = escape_xml(model_name), brand = escape_xml(brand), material = escape_xml(&material), net_weight = net_weight, qr_size = qr_size, qr_x = width_px - qr_size - 4, qr_y = (height_px - qr_size) / 2, qr_b64 = qr_b64, ); Ok(svg) } } fn escape_xml(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") }