562 lines
20 KiB
Rust
562 lines
20 KiB
Rust
//! 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.");
|
||
}
|