//! Plugin de impressão ESC/POS via USB para TM-T20 e compatíveis use serde::{Deserialize, Serialize}; use std::sync::Mutex; use thiserror::Error; #[derive(Error, Debug)] #[allow(dead_code)] pub enum PrinterError { #[error("Impressora não encontrada. Verifique se está conectada via USB e ligada.")] NotFound, #[error("Falha ao abrir impressora: {0}")] OpenFailed(String), #[error("Falha ao enviar dados: {0}")] WriteFailed(String), #[error("Configuração inválida: {0}")] ConfigError(String), } impl serde::Serialize for PrinterError { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } // ── Comandos ESC/POS via plugins::escpos ──────────────────────────────────── use crate::plugins::escpos; // ── Struct de dados do recibo ──────────────────────────────────────────────── /// Linha de uma tabela no recibo (despesas, sangrias, vales, etc.) #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct ReciboLinha { #[serde(default)] pub desc: Option, #[serde(rename = "hora", default)] pub hora: Option, #[serde(rename = "nome", default)] pub nome: Option, #[serde(default)] pub retirada: Option, #[serde(default)] pub valor: Option, #[serde(default)] pub obs: Option, #[serde(default)] pub gerente: Option, #[serde(default)] pub numero: Option, #[serde(default)] pub motivo: Option, } impl ReciboLinha { fn fmt_v(&self) -> String { let v = self.retirada.or(self.valor).unwrap_or(0.0); format!("R$ {:.2}", v).replace('.', ",") } fn label(&self) -> String { self.desc .clone() .or(self.nome.clone()) .or(self.hora.clone().map(|h| format!("{}h", h))) .unwrap_or_default() } fn obs(&self) -> String { self.gerente .clone() .or(self.obs.clone()) .or(self.motivo.clone()) .unwrap_or_default() } } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ReciboData { pub loja: String, pub data: String, pub operador: String, pub turno: String, pub saldo_troco: f64, /// Valor contado no fechamento (dinheiro + cartões + receber) pub fechamento: f64, /// Saldo esperado = total operações − sangrias − despesas pub saldo_esperado: f64, /// Diferença = fechamento − saldo_esperado pub diferenca: f64, /// Total do sistema (troco + crédito + débito + alimentação + voucher + pixcnpj + vales + receber) #[serde(default)] pub sys_total: f64, /// Soma de todas operações creditadas (crédito + débito + alimentação + voucher + pixcnpj) — usado no BLACK section como TOTAL #[serde(default)] pub total_operado: f64, // Totais de cartões pub credito: f64, pub debito: f64, pub alimentacao: f64, pub voucher: f64, // Totais diversos (mantidos para compatibilidade com JSON antigo) #[serde(default)] pub sangrias: f64, #[serde(default)] pub despesas: f64, #[serde(default)] pub vales: f64, #[serde(default)] pub areceber: f64, #[serde(default)] pub pixcnpj: f64, #[serde(default)] pub cancelamentos: f64, // Arrays para linhas detalhadas (recebidos do frontend) #[serde(default)] pub sangrias_arr: Vec, #[serde(default)] pub despesas_arr: Vec, #[serde(default)] pub pixcnpj_arr: Vec, #[serde(default)] pub vales_arr: Vec, #[serde(default)] pub receber_arr: Vec, #[serde(default)] pub cancelamentos_arr: Vec, #[serde(default)] pub observacoes: Option, #[serde(default)] pub clientes: i32, #[serde(default)] pub frango: i32, } impl ReciboData { pub fn fmt_money(&self, v: f64) -> String { format!("R$ {:.2}", v).replace('.', ",") } /// Gera todos os bytes ESC/POS do recibo 80mm /// Layout: Header → SANGRIAS → DESPESAS → PIX CNPJ → VALES → A RECEBER → CANCELAMENTOS → CARTÕES → TROCO/Saldo Caixa → SALDO ESPERADO → DIFERENÇA pub fn to_escpos(&self) -> Vec { let mut out = Vec::new(); // Inicializa out.extend(escpos::INIT); // Cabeçalho out.extend(escpos::blank_lines(1)); out.extend(escpos::title(&self.loja)); out.extend(escpos::line_center("Fechamento de Caixa")); out.extend(escpos::blank_lines(1)); out.extend(escpos::divider()); // Info básica out.extend(escpos::kv("Data:", &self.data)); out.extend(escpos::kv("Operador:", &self.operador)); out.extend(escpos::kv("Turno:", &self.turno)); out.extend(escpos::divider()); // ── SANGRIAS ── if !self.sangrias_arr.is_empty() { out.extend(escpos::line_bold("SANGRIAS")); for linha in &self.sangrias_arr { if linha.retirada.unwrap_or(0.0) > 0.0 || linha.valor.unwrap_or(0.0) > 0.0 { let label = linha.label(); let obs = linha.obs(); out.extend(escpos::kv(&label, &linha.fmt_v())); if !obs.is_empty() { out.extend(escpos::line_center(&format!(" {}", obs))); } } } // Total sangrias let total_sangrias: f64 = self.sangrias_arr.iter() .map(|l| l.retirada.unwrap_or(0.0) + l.valor.unwrap_or(0.0)) .sum(); if total_sangrias > 0.0 { out.extend(escpos::kv("Total:", &self.fmt_money(total_sangrias))); } out.extend(escpos::divider()); } // ── DESPESAS ── if !self.despesas_arr.is_empty() { out.extend(escpos::line_bold("DESPESAS")); for linha in &self.despesas_arr { if linha.valor.unwrap_or(0.0) > 0.0 { let label = linha.label(); let obs = linha.obs(); out.extend(escpos::kv(&label, &linha.fmt_v())); if !obs.is_empty() { out.extend(escpos::line_center(&format!(" {}", obs))); } } } // Total despesas let total_despesas: f64 = self.despesas_arr.iter() .map(|l| l.valor.unwrap_or(0.0)) .sum(); out.extend(escpos::kv("Total:", &self.fmt_money(total_despesas))); out.extend(escpos::divider()); } // ── PIX CNPJ ── if !self.pixcnpj_arr.is_empty() { out.extend(escpos::line_bold("PIX CNPJ")); for linha in &self.pixcnpj_arr { if linha.valor.unwrap_or(0.0) > 0.0 { let label = linha.nome.clone().unwrap_or_else(|| "PIX CNPJ".to_string()); out.extend(escpos::kv(&label, &linha.fmt_v())); } } // Total PIX CNPJ let total_pixcnpj: f64 = self.pixcnpj_arr.iter() .map(|l| l.valor.unwrap_or(0.0)) .sum(); if total_pixcnpj > 0.0 { out.extend(escpos::kv("Total:", &self.fmt_money(total_pixcnpj))); } out.extend(escpos::divider()); } // ── VALES ── if !self.vales_arr.is_empty() { out.extend(escpos::line_bold("VALES")); for linha in &self.vales_arr { if linha.valor.unwrap_or(0.0) > 0.0 { let label = linha.label(); let obs = linha.obs(); out.extend(escpos::kv(&label, &linha.fmt_v())); if !obs.is_empty() { out.extend(escpos::line_center(&format!(" {}", obs))); } } } // Total vales let total_vales: f64 = self.vales_arr.iter() .map(|l| l.valor.unwrap_or(0.0)) .sum(); if total_vales > 0.0 { out.extend(escpos::kv("Total:", &self.fmt_money(total_vales))); } out.extend(escpos::divider()); } // ── A RECEBER ── if !self.receber_arr.is_empty() { out.extend(escpos::line_bold("A RECEBER")); for linha in &self.receber_arr { if linha.valor.unwrap_or(0.0) > 0.0 { let label = linha.label(); out.extend(escpos::kv(&label, &linha.fmt_v())); } } // Total a receber let total_receber: f64 = self.receber_arr.iter() .map(|l| l.valor.unwrap_or(0.0)) .sum(); if total_receber > 0.0 { out.extend(escpos::kv("Total:", &self.fmt_money(total_receber))); } out.extend(escpos::divider()); } // ── CANCELAMENTOS ── if !self.cancelamentos_arr.is_empty() { out.extend(escpos::line_bold("CANCELAMENTOS")); for linha in &self.cancelamentos_arr { if linha.numero.is_some() || linha.valor.unwrap_or(0.0) > 0.0 { let num = linha.numero.clone().unwrap_or_default(); out.extend(escpos::kv(&num, &linha.fmt_v())); if let Some(ref m) = linha.motivo { if !m.is_empty() { out.extend(escpos::line_center(&format!(" {}", m))); } } } } // Total cancelamentos let total_cancel: f64 = self.cancelamentos_arr.iter() .map(|l| l.valor.unwrap_or(0.0)) .sum(); if total_cancel > 0.0 { out.extend(escpos::kv("Total:", &self.fmt_money(total_cancel))); } out.extend(escpos::divider()); } // ── CARTÕES ── out.extend(escpos::line_bold("CARTOES")); out.extend(escpos::kv("Credito:", &self.fmt_money(self.credito))); out.extend(escpos::kv("Debito:", &self.fmt_money(self.debito))); out.extend(escpos::kv("Alimentacao:", &self.fmt_money(self.alimentacao))); out.extend(escpos::kv("PIX Cartao:", &self.fmt_money(self.voucher))); out.extend(escpos::kv("Cancelamentos:", &self.fmt_money(self.cancelamentos))); out.extend(escpos::divider()); // ── TROCO / SALDO DE CAIXA ── out.extend(escpos::line_bold("SALDO DE CAIXA")); out.extend(escpos::kv("Troco (+):", &self.fmt_money(self.saldo_troco))); out.extend(escpos::kv("Credito (+):", &self.fmt_money(self.credito))); out.extend(escpos::kv("Debito (+):", &self.fmt_money(self.debito))); out.extend(escpos::kv("Alimentacao (+):", &self.fmt_money(self.alimentacao))); out.extend(escpos::kv("PIX Cartao (+):", &self.fmt_money(self.voucher))); out.extend(escpos::kv("Vales (+):", &self.fmt_money(self.vales))); out.extend(escpos::kv("A Receber (+):", &self.fmt_money(self.areceber))); out.extend(escpos::kv("PIX CNPJ (+):", &self.fmt_money(self.pixcnpj))); out.extend(escpos::divider()); // ── TOTAL ── if self.sys_total > 0.0 { out.extend(escpos::line_bold("TOTAL")); out.extend(escpos::kv("Total:", &self.fmt_money(self.sys_total))); out.extend(escpos::divider()); } // ── SALDO ESPERADO ── out.extend(escpos::line_bold("SALDO ESPERADO")); out.extend(escpos::kv("Esperado:", &self.fmt_money(self.saldo_esperado))); out.extend(escpos::divider()); // ── DIFERENÇA ── // Diferença = total_fechamento - saldo_esperado - cancelamentos out.extend(escpos::line_bold("DIFERENCA")); let diff = self.fechamento - self.saldo_esperado - self.cancelamentos; let diff_str = if diff >= 0.0 { format!("+{}", self.fmt_money(diff)) } else { self.fmt_money(diff) }; out.extend(escpos::title(&diff_str)); // ── CONTADORES ── out.extend(escpos::line_bold("CONTADORES")); out.extend(escpos::kv("Clientes:", &self.clientes.to_string())); out.extend(escpos::kv("Frango Assado:", &self.frango.to_string())); out.extend(escpos::divider()); // ── OBSERVAÇÕES ── if let Some(ref obs) = self.observacoes { if !obs.trim().is_empty() { out.extend(escpos::line_bold("OBSERVACOES")); out.extend(escpos::line_center(obs.trim())); out.extend(escpos::divider()); } } // Rodapé out.extend(escpos::divider()); out.extend(escpos::line_center( &chrono::Local::now().format("%d/%m/%Y %H:%M").to_string(), )); out.extend(escpos::line_center("Obrigado!")); out.extend(escpos::blank_lines(4)); // Corta papel out.extend(escpos::FEED_CUT); out.extend(escpos::CUT); out } } // ── Printer Manager ───────────────────────────────────────────────────────── pub struct PrinterManager { device_path: Mutex>, /// Modo teste: grava bytes num arquivo ao invés de enviar pra USB test_mode: Mutex, } impl Default for PrinterManager { fn default() -> Self { Self { device_path: Mutex::new(None), test_mode: Mutex::new(false), } } } impl PrinterManager { /// Detecta impressora USB conectada pub fn detectar(&self) -> Result { #[cfg(windows)] { // Tenta encontrar porta USB virtual via WMI/registry // Common TM-T20 USB port names on Windows let candidates = [ r"\\.\USB001", // TM-T20 USB Printing Support r"\\.\USB002", r"\\.\GPR1", r"\\.\COM3", r"\\.\COM4", ]; for port in &candidates { if std::path::Path::new(port).exists() || self.test_device(port) { *self.device_path.lock().unwrap() = Some(port.to_string()); return Ok(port.to_string()); } } Err("Nenhuma impressora USB encontrada. Verifique o cabo e se a impressora está ligada.".into()) } #[cfg(not(windows))] { Err("Deteccao USB s-para apenas no Windows.".into()) } } #[cfg(windows)] fn test_device(&self, path: &str) -> bool { use std::process::Command; let out = Command::new("cmd") .args(["/C", &format!("type {} < nul 2>&1", path)]) .output(); out.map(|o| o.status.success()).unwrap_or(false) } pub fn configurar(&self, path: String) -> Result<(), String> { if path.is_empty() { return Err("Caminho vazio".into()); } *self.device_path.lock().unwrap() = Some(path.clone()); log::info!("Impressora configurada: {}", path); Ok(()) } /// Ativa modo teste: grava os bytes ESC/POS num arquivo em vez de imprimir pub fn set_test_mode(&self, enabled: bool) { *self.test_mode.lock().unwrap() = enabled; log::info!("Printer test mode: {}", enabled); } pub fn imprimir_bytes(&self, data: &[u8]) -> Result { // Modo teste: grava arquivo if *self.test_mode.lock().unwrap() { let test_file = std::env::temp_dir().join("fechamento_recibo_escpos.bin"); std::fs::write(&test_file, data) .map_err(|e| format!("Falha ao gravar arquivo de teste: {}", e))?; log::info!("Bytes ESC/POS gravados em: {:?}", test_file); return Ok(data.len()); } let path = self .device_path .lock() .unwrap() .clone() .ok_or_else(|| "Impressora nao configurada. Use o botao de detectar ou configure manualmente.".to_string())?; #[cfg(windows)] { self.windows_write(&path, data) } #[cfg(not(windows))] { let _ = data; Err("Impressao USB disponivel apenas no Windows.".into()) } } #[cfg(windows)] fn windows_write(&self, path: &str, data: &[u8]) -> Result { use std::io::Write; // Tenta via File primeiro (mais simples) { let file = std::fs::OpenOptions::new() .write(true) .create(false) .open(path); if let Ok(mut f) = file { let r = f.write_all(data); let _ = f.flush(); if r.is_ok() { return Ok(data.len()); } } } // Fallback: usa API Windows direta via std::process::Command + > arquivo // Isso绕过 problemas de locking do OpenOptions let tmp = std::env::temp_dir().join("escpos_tmp.bin"); std::fs::write(&tmp, data) .map_err(|e| format!("Falha ao criar arquivo temporario: {}", e))?; let status = std::process::Command::new("cmd") .args(["/C", &format!("copy /B {} {} /Y > nul 2>&1", tmp.display(), path)]) .status(); let _ = std::fs::remove_file(&tmp); if status.map(|s| s.success()).unwrap_or(false) { log::info!("Escritos {} bytes para {}", data.len(), path); Ok(data.len()) } else { Err(format!( "Falha ao enviar dados para {}. Verifique se a impressora USB está \ conectada e ligada. Tente configurar manualmente em Config > Impressora.", path )) } } } // ── Tauri Commands ─────────────────────────────────────────────────────────── static PRINTER: PrinterManager = PrinterManager { device_path: Mutex::new(None), test_mode: Mutex::new(false), }; #[tauri::command] pub fn detectar_impressora() -> Result { PRINTER.detectar() } #[tauri::command] pub fn configurar_impressora(path: String) -> Result<(), String> { PRINTER.configurar(path) } #[tauri::command] pub fn caminho_impressora() -> Option { PRINTER.device_path.lock().unwrap().clone() } #[tauri::command] pub fn imprimir_recibo(data: String) -> Result { let r: ReciboData = serde_json::from_str(&data).map_err(|e| format!("Erro ao analisar dados: {}", e))?; let bytes = r.to_escpos(); log::info!("Imprimindo recibo: {} bytes ESC/POS", bytes.len()); PRINTER.imprimir_bytes(&bytes) } #[tauri::command] pub fn teste_impressora() -> Result { let mut test = Vec::new(); test.extend(escpos::INIT); test.extend(escpos::blank_lines(1)); test.extend(escpos::title("TESTE DE IMPRESSORA")); test.extend(escpos::line_center("O Frangao")); test.extend(escpos::line_center(&chrono::Local::now().format("%d/%m/%Y %H:%M").to_string())); test.extend(escpos::blank_lines(2)); test.extend(escpos::FEED_CUT); test.extend(escpos::CUT); log::info!("Teste impressora: {} bytes", test.len()); PRINTER.imprimir_bytes(&test) } #[tauri::command] pub fn ativar_modo_teste_impressao() { PRINTER.set_test_mode(true); } /// Called by Tauri on plugin init (no-op for this plugin) pub fn init() { log::info!("Printer plugin ready. Use detectar_impressora() ou configure manualmente."); }