Files
fechamento-caixa/src-tauri/src/plugins/printer.rs
T
root 662f3681a5
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
Revert "fix: fórmula diferença = sys_total - saldo_esperado - cancelamentos (todas as 3 functions de print); aumenta fonte print para 17px + ajustes de padding/margens"
This reverts commit a56d7188f2.
2026-06-30 22:26:52 +00:00

562 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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<String>,
#[serde(rename = "hora", default)]
pub hora: Option<String>,
#[serde(rename = "nome", default)]
pub nome: Option<String>,
#[serde(default)]
pub retirada: Option<f64>,
#[serde(default)]
pub valor: Option<f64>,
#[serde(default)]
pub obs: Option<String>,
#[serde(default)]
pub gerente: Option<String>,
#[serde(default)]
pub numero: Option<String>,
#[serde(default)]
pub motivo: Option<String>,
}
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<ReciboLinha>,
#[serde(default)]
pub despesas_arr: Vec<ReciboLinha>,
#[serde(default)]
pub pixcnpj_arr: Vec<ReciboLinha>,
#[serde(default)]
pub vales_arr: Vec<ReciboLinha>,
#[serde(default)]
pub receber_arr: Vec<ReciboLinha>,
#[serde(default)]
pub cancelamentos_arr: Vec<ReciboLinha>,
#[serde(default)]
pub observacoes: Option<String>,
#[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<u8> {
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<Option<String>>,
/// Modo teste: grava bytes num arquivo ao invés de enviar pra USB
test_mode: Mutex<bool>,
}
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<String, String> {
#[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<usize, String> {
// 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<usize, String> {
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<String, String> {
PRINTER.detectar()
}
#[tauri::command]
pub fn configurar_impressora(path: String) -> Result<(), String> {
PRINTER.configurar(path)
}
#[tauri::command]
pub fn caminho_impressora() -> Option<String> {
PRINTER.device_path.lock().unwrap().clone()
}
#[tauri::command]
pub fn imprimir_recibo(data: String) -> Result<usize, String> {
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<usize, String> {
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.");
}