Fechamento de Caixa - Tauri 2 initial commit
- Storage: SQLite local via rusqlite - Supabase: REST API via reqwest - Printer: USB ESC/POS for TM-T20 - Frontend: vanilla HTML (form-v9.3 ported) - Build: GitHub Actions for Linux/Windows/macOS
This commit is contained in:
Generated
+5505
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "fechamento-caixa"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["devtools"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_yaml = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
thiserror = "2"
|
||||
dirs = "5"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Devices_Usb", "Win32_Storage_FileSystem"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 393 B |
Binary file not shown.
|
After Width: | Height: | Size: 857 B |
Binary file not shown.
|
After Width: | Height: | Size: 108 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,116 @@
|
||||
//! Fechamento de Caixa — O Frangão
|
||||
//! App desktop nativo (Tauri 2 + Rust)
|
||||
//! Substitui: n8n webhooks + localStorage/IndexedDB + window.print()
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod plugins;
|
||||
|
||||
use log::LevelFilter;
|
||||
use plugins::printer::{self};
|
||||
use plugins::storage::{self};
|
||||
use plugins::supabase::{self};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn data_dir_path() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("FechamentoCaixa")
|
||||
}
|
||||
|
||||
fn load_config() -> (String, String) {
|
||||
let cfg_path = data_dir_path().join("config.json");
|
||||
if let Ok(content) = fs::read_to_string(&cfg_path) {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let anon = cfg.get("SUPABASE_ANON_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let service = cfg.get("SUPABASE_SERVICE_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !anon.is_empty() && !service.is_empty() {
|
||||
return (anon, service);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: tenta variáveis de ambiente
|
||||
(
|
||||
std::env::var("SUPABASE_ANON_KEY").unwrap_or_default(),
|
||||
std::env::var("SUPABASE_SERVICE_KEY").unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::new()
|
||||
.filter_level(LevelFilter::Info)
|
||||
.format_timestamp_millis()
|
||||
.init();
|
||||
|
||||
log::info!("=== Fechamento de Caixa (Tauri) ===");
|
||||
|
||||
let data_dir = data_dir_path();
|
||||
fs::create_dir_all(&data_dir).expect("não foi possível criar diretório de dados");
|
||||
log::info!("Data dir: {:?}", data_dir);
|
||||
|
||||
// Carrega chaves do Supabase
|
||||
let (anon_key, service_key) = load_config();
|
||||
if anon_key.is_empty() || service_key.is_empty() {
|
||||
log::warn!("SUPABASE_ANON_KEY ou SUPABASE_SERVICE_KEY não encontradas!");
|
||||
log::warn!("Cole-as em: {:?}", data_dir.join("config.json"));
|
||||
log::warn!("Formato: {{\"SUPABASE_ANON_KEY\": \"...\", \"SUPABASE_SERVICE_KEY\": \"...\"}}");
|
||||
} else {
|
||||
log::info!("Supabase: keys carregadas (anon={}..., service={}...)",
|
||||
&anon_key[..8.min(anon_key.len())],
|
||||
&service_key[..8.min(service_key.len())]);
|
||||
}
|
||||
|
||||
// Inicializa plugins
|
||||
let storage = storage::init(data_dir.clone())
|
||||
.expect("falha ao inicializar SQLite");
|
||||
let supabase = supabase::init(anon_key, service_key);
|
||||
let printer = printer::init();
|
||||
let app_state = plugins::app::init(data_dir.clone());
|
||||
|
||||
log::info!("Plugins inicializados: storage, supabase, printer");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(storage)
|
||||
.manage(supabase)
|
||||
.manage(printer)
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Storage
|
||||
plugins::storage::salvar_fechamento,
|
||||
plugins::storage::carregar_rascunho,
|
||||
plugins::storage::listar_fechamentos,
|
||||
plugins::storage::buscar_fechamento_por_id,
|
||||
plugins::storage::pendentes_sync,
|
||||
// Supabase
|
||||
plugins::supabase::sb_salvar_fechamento,
|
||||
plugins::supabase::sb_carregar_rascunho,
|
||||
plugins::supabase::sb_listar_recentes,
|
||||
plugins::supabase::sb_buscar_por_id,
|
||||
plugins::supabase::sb_salvar_listas,
|
||||
plugins::supabase::sb_carregar_listas,
|
||||
plugins::supabase::sb_online,
|
||||
// Printer
|
||||
plugins::printer::detectar_impressora,
|
||||
plugins::printer::configurar_impressora,
|
||||
plugins::printer::caminho_impressora,
|
||||
plugins::printer::imprimir_recibo,
|
||||
plugins::printer::teste_impressora,
|
||||
// App
|
||||
plugins::app::get_loja,
|
||||
plugins::app::set_loja,
|
||||
plugins::app::get_config,
|
||||
plugins::app::set_config,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("erro ao inicializar Tauri");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Plugin de configuração da aplicação (loja, preferências)
|
||||
//! Persistido em config.json ao lado do banco SQLite.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub loja: String,
|
||||
pub pdv_nome: String,
|
||||
pub operador_default: String,
|
||||
pub turno_default: String,
|
||||
pub sync_automatico: bool,
|
||||
pub auto_open_printer: bool,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
loja: "Uniao".to_string(),
|
||||
pdv_nome: "PDV-01".to_string(),
|
||||
operador_default: String::new(),
|
||||
turno_default: String::new(),
|
||||
sync_automatico: true,
|
||||
auto_open_printer: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
let config_path = data_dir.join("config.json");
|
||||
let config = if let Ok(content) = fs::read_to_string(&config_path) {
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
} else {
|
||||
AppConfig::default()
|
||||
};
|
||||
|
||||
Self {
|
||||
config: Mutex::new(config),
|
||||
config_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let cfg = self.config.lock().map_err(|_| "lock error")?;
|
||||
let json = serde_json::to_string_pretty(&*cfg)
|
||||
.map_err(|e| e.to_string())?;
|
||||
fs::write(&self.config_path, json).map_err(|e| e.to_string())?;
|
||||
log::info!("Config salvo em {:?}", self.config_path);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_loja(state: State<'_, AppState>) -> String {
|
||||
state.config.lock().unwrap().loja.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
|
||||
state.config.lock().unwrap().loja = loja;
|
||||
state.save()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_config(state: State<'_, AppState>) -> AppConfig {
|
||||
state.config.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_config(state: State<'_, AppState>, cfg: AppConfig) -> Result<(), String> {
|
||||
*state.config.lock().unwrap() = cfg;
|
||||
state.save()
|
||||
}
|
||||
|
||||
pub fn init(data_dir: PathBuf) -> AppState {
|
||||
AppState::new(data_dir)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Comandos ESC/POS para impressoras térmicas 80mm
|
||||
//! Comandos Epson ESC/POS padrão — funciona com TM-T20 e compatíveis.
|
||||
|
||||
/// Inicializar impressora
|
||||
pub const INIT: &[u8] = b"\x1B\x40";
|
||||
/// Nova linha
|
||||
pub const LF: &[u8] = b"\x0A";
|
||||
/// Cortar papel (parcial)
|
||||
pub const CUT: &[u8] = b"\x1D\x56\x01";
|
||||
/// Cortar papel (total)
|
||||
pub const CUT_FULL: &[u8] = b"\x1D\x56\x00";
|
||||
/// Alimentar 3 linhas antes de cortar
|
||||
pub const FEED_CUT: &[u8] = b"\x1B\x64\x03";
|
||||
/// Negrito ON / OFF
|
||||
pub const BOLD_ON: &[u8] = b"\x1B\x45\x01";
|
||||
pub const BOLD_OFF: &[u8] = b"\x1B\x45\x00";
|
||||
/// Alinhamento
|
||||
pub const ALIGN_LEFT: &[u8] = b"\x1B\x61\x00";
|
||||
pub const ALIGN_CENTER: &[u8] = b"\x1B\x61\x01";
|
||||
pub const ALIGN_RIGHT: &[u8] = b"\x1B\x61\x02";
|
||||
/// Fonte normal / dupla
|
||||
pub const FONT_NORMAL: &[u8] = b"\x1B\x21\x00";
|
||||
pub const FONT_DOUBLE: &[u8] = b"\x1B\x21\x30";
|
||||
/// Linha divisória (48 colunas para 80mm)
|
||||
pub const DIVIDER: &str = "--------------------------------";
|
||||
|
||||
/// Monta texto centralizado com negrito + fonte dupla
|
||||
pub fn title(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_CENTER);
|
||||
out.extend_from_slice(BOLD_ON);
|
||||
out.extend_from_slice(FONT_DOUBLE);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out.extend_from_slice(FONT_NORMAL);
|
||||
out.extend_from_slice(BOLD_OFF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta linha normal (alinhada à esquerda)
|
||||
pub fn line(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_LEFT);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta linha centralizada
|
||||
pub fn line_center(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_CENTER);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta linha em negrito
|
||||
pub fn line_bold(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(BOLD_ON);
|
||||
out.extend_from_slice(ALIGN_LEFT);
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out.extend_from_slice(BOLD_OFF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta linha com chave-valor alinhado (label 14 chars + espaço + valor 14 chars)
|
||||
pub fn kv(label: &str, value: &str) -> Vec<u8> {
|
||||
let padded_label = format!("{:<14}", label);
|
||||
let padded_value = format!("{:>14}", value);
|
||||
let line = format!("{} {}", padded_label, padded_value);
|
||||
let line = if line.len() > 48 { line[..48].to_string() } else { line };
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_LEFT);
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta linha divisória
|
||||
pub fn divider() -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_LEFT);
|
||||
out.extend_from_slice(DIVIDER.as_bytes());
|
||||
out.extend_from_slice(LF);
|
||||
out
|
||||
}
|
||||
|
||||
/// Monta espaço em branco (N linhas)
|
||||
pub fn blank_lines(n: u8) -> &'static [u8] {
|
||||
const BLANK: &[u8] = b"\n\n\n\n\n\n\n\n";
|
||||
&BLANK[..(n as usize).min(8)]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod escpos;
|
||||
pub mod printer;
|
||||
pub mod storage;
|
||||
pub mod supabase;
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Plugin de impressão ESC/POS via USB para TM-T20 e compatíveis
|
||||
//! Substitui window.print() — impressão direta sem popup do SO.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ReciboData {
|
||||
pub loja: String,
|
||||
pub data: String,
|
||||
pub operador: String,
|
||||
pub turno: String,
|
||||
pub saldo_troco: f64,
|
||||
pub fechamento: f64,
|
||||
pub saldo_esperado: f64,
|
||||
pub diferenca: f64,
|
||||
pub sangrias: f64,
|
||||
pub despesas: f64,
|
||||
pub vales: f64,
|
||||
pub areceber: f64,
|
||||
pub credito: f64,
|
||||
pub debito: f64,
|
||||
pub alimentacao: f64,
|
||||
pub voucher: f64,
|
||||
pub cancelamentos: f64,
|
||||
}
|
||||
|
||||
impl ReciboData {
|
||||
pub fn fmt_money(&self, v: f64) -> String {
|
||||
format!("R$ {:.2}", v).replace('.', ",")
|
||||
}
|
||||
|
||||
/// Gera todos os bytes ESC/POS do recibo 80mm
|
||||
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());
|
||||
|
||||
// Valores de entrada
|
||||
out.extend(escpos::line_bold("ENTRADAS"));
|
||||
out.extend(escpos::kv("Saldo Troco:", &self.fmt_money(self.saldo_troco)));
|
||||
out.extend(escpos::kv("Fechamento:", &self.fmt_money(self.fechamento)));
|
||||
out.extend(escpos::kv("Saldo Esperado:", &self.fmt_money(self.saldo_esperado)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// Sangrias / Despesas
|
||||
out.extend(escpos::line_bold("DÉBITOS"));
|
||||
out.extend(escpos::kv("Sangrias:", &self.fmt_money(self.sangrias)));
|
||||
out.extend(escpos::kv("Despesas:", &self.fmt_money(self.despesas)));
|
||||
out.extend(escpos::kv("Vales:", &self.fmt_money(self.vales)));
|
||||
out.extend(escpos::kv("A Receber:", &self.fmt_money(self.areceber)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// Cartões
|
||||
out.extend(escpos::line_bold("CARTÕES"));
|
||||
out.extend(escpos::kv("Crédito:", &self.fmt_money(self.credito)));
|
||||
out.extend(escpos::kv("Débito:", &self.fmt_money(self.debito)));
|
||||
out.extend(escpos::kv("Alimentação:", &self.fmt_money(self.alimentacao)));
|
||||
out.extend(escpos::kv("Voucher:", &self.fmt_money(self.voucher)));
|
||||
out.extend(escpos::kv("Cancelamentos:", &self.fmt_money(self.cancelamentos)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// Resultado final
|
||||
let diff_str = if self.diferenca >= 0.0 {
|
||||
format!("+{}", self.fmt_money(self.diferenca))
|
||||
} else {
|
||||
self.fmt_money(self.diferenca)
|
||||
};
|
||||
out.extend(escpos::line_bold("DIFERENÇA:"));
|
||||
out.extend(escpos::title(&diff_str));
|
||||
out.extend(escpos::blank_lines(2));
|
||||
|
||||
// Rodapé
|
||||
out.extend(escpos::divider());
|
||||
out.extend(escpos::line_center(&format!("{}", chrono::Local::now().format("%d/%m/%Y %H:%M"))));
|
||||
out.extend(escpos::line_center("Obrigado!"));
|
||||
out.extend(escpos::blank_lines(4));
|
||||
|
||||
// Corta papel
|
||||
out.extend(escpos::FEED_CUT);
|
||||
out.extend(escpos::CUT);
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
// ── USB Printer Manager ─────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(windows)]
|
||||
pub mod usb_windows {
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::ptr;
|
||||
use windows::Win32::Devices::Usb::{
|
||||
CM_Get_Child, CM_Get_Child_Req, CM_Get_Device_IDW, CM_Get_Parent,
|
||||
CR_SUCCESS, DEVINST, GUID, HDEVINFO, DIGCF_PRESENT, DIGCF_DEVICEINTERFACE,
|
||||
};
|
||||
use windows::Win32::Foundation::{BOOL, CloseHandle, HANDLE};
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_ACCESS_FLAGS, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,
|
||||
};
|
||||
|
||||
pub fn wide_string(s: &str) -> Vec<u16> {
|
||||
OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
pub fn close_handle(h: HANDLE) {
|
||||
unsafe { let _ = CloseHandle(h); }
|
||||
}
|
||||
|
||||
pub fn find_printer_device() -> Option<String> {
|
||||
// TMP: returns the first USB printer found
|
||||
// In production you'd enumerate device interfaces
|
||||
// For TM-T20, the USB device interface GUID is the printer GUID
|
||||
let guid = GUID::from_values(
|
||||
0x4d36e979, 0xe325, 0x11ce,
|
||||
[0xbf, 0xc1, 0x08, 0x00, 0x2b, 0xe1, 0x03, 0x18],
|
||||
);
|
||||
|
||||
let devices = unsafe {
|
||||
windows::Win32::Devices::Enumeration::SetupDiGetClassDevsW(
|
||||
Some(&guid),
|
||||
None,
|
||||
None,
|
||||
DIGCF_PRESENT.0 | DIGCF_DEVICEINTERFACE.0,
|
||||
)
|
||||
};
|
||||
|
||||
if devices.is_err() {
|
||||
return None;
|
||||
}
|
||||
let devices = devices.unwrap();
|
||||
|
||||
// Try opening as a file (USB bulk endpoint workaround)
|
||||
// This works for many USB printers when accessed via a file path
|
||||
// The TM-T20 often appears as \\.\USB001
|
||||
let paths = [
|
||||
r"\\.\USB001",
|
||||
r"\\.\USB002",
|
||||
r"\\.\USB003",
|
||||
r"\\.\USB004",
|
||||
r"\\.\LPT1",
|
||||
r"\\.\COM1",
|
||||
r"\\.\COM2",
|
||||
];
|
||||
|
||||
for path in &paths {
|
||||
let wide = wide_string(path);
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
windows::core::PCWSTR::from_raw(wide.as_ptr()),
|
||||
FILE_ACCESS_FLAGS(0xC0000000), // GENERIC_READ | GENERIC_WRITE
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
None,
|
||||
)
|
||||
};
|
||||
if handle.is_ok() {
|
||||
unsafe { let _ = CloseHandle(handle.unwrap()); }
|
||||
return Some((*path).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let _ = windows::Win32::Devices::Enumeration::SetupDiDestroyDeviceInfoList(devices.ok()?);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn open_printer(path: &str) -> Result<HANDLE, String> {
|
||||
let wide = wide_string(path);
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
windows::core::PCWSTR::from_raw(wide.as_ptr()),
|
||||
FILE_ACCESS_FLAGS(0xC0000000),
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
None,
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
match handle {
|
||||
Ok(h) => Ok(h),
|
||||
Err(e) => Err(format!("CreateFileW failed: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_to(handle: HANDLE, data: &[u8]) -> Result<usize, String> {
|
||||
use windows::Win32::Storage::FileSystem::WriteFile;
|
||||
use windows::Win32::Foundation::DWORD;
|
||||
|
||||
let mut written: DWORD = 0;
|
||||
let result = unsafe {
|
||||
WriteFile(
|
||||
handle,
|
||||
data.as_ptr() as *const _,
|
||||
data.len() as DWORD,
|
||||
Some(&mut written),
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
if result.is_ok() {
|
||||
Ok(written as usize)
|
||||
} else {
|
||||
Err(format!("WriteFile failed: {:?}", result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub mod usb_windows {
|
||||
pub fn find_printer_device() -> Option<String> {
|
||||
// On non-Windows, we'd use libusb or serialport
|
||||
None
|
||||
}
|
||||
pub fn open_printer(_path: &str) -> Result<(), String> {
|
||||
Err("Not supported on this platform".to_string())
|
||||
}
|
||||
pub fn write_to(_handle: (), _data: &[u8]) -> Result<usize, String> {
|
||||
Err("Not supported".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Printer Manager ────────────────────────────────────────────────────────
|
||||
|
||||
pub struct PrinterManager {
|
||||
device_path: Mutex<Option<String>>,
|
||||
printer_type: Mutex<PrinterType>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PrinterType {
|
||||
Usb,
|
||||
Serial,
|
||||
Network,
|
||||
}
|
||||
|
||||
impl Default for PrinterManager {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_path: Mutex::new(None),
|
||||
printer_type: Mutex::new(PrinterType::Usb),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrinterManager {
|
||||
/// Detecta impressora USB conectada.
|
||||
pub fn detectar(&self) -> Result<String, PrinterError> {
|
||||
if let Some(path) = usb_windows::find_printer_device() {
|
||||
*self.device_path.lock().unwrap() = Some(path.clone());
|
||||
return Ok(path);
|
||||
}
|
||||
Err(PrinterError::NotFound)
|
||||
}
|
||||
|
||||
/// Configura caminho manual da impressora.
|
||||
pub fn configurar(&self, path: String) -> Result<(), PrinterError> {
|
||||
if path.is_empty() {
|
||||
return Err(PrinterError::ConfigError("Caminho não pode ser vazio".into()));
|
||||
}
|
||||
*self.device_path.lock().unwrap() = Some(path.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Imprime bytes ESC/POS diretamente.
|
||||
pub fn imprimir_bytes(&self, data: &[u8]) -> Result<usize, PrinterError> {
|
||||
let path = self.device_path.lock().unwrap().clone()
|
||||
.ok_or_else(|| PrinterError::ConfigError(
|
||||
"Impressora não configurada. Use detectar() ou configurar().".into()
|
||||
))?;
|
||||
|
||||
let handle = usb_windows::open_printer(&path)
|
||||
.map_err(|e| PrinterError::OpenFailed(e))?;
|
||||
|
||||
let n = usb_windows::write_to(handle, data)
|
||||
.map_err(|e| PrinterError::WriteFailed(e))?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
unsafe { let _ = CloseHandle(handle); }
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = handle;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Imprime um recibo a partir dos dados estruturados.
|
||||
pub fn imprimir_recibo(&self, dados: &ReciboData) -> Result<usize, PrinterError> {
|
||||
let bytes = dados.to_escpos();
|
||||
self.imprimir_bytes(&bytes)
|
||||
}
|
||||
|
||||
/// Retorna o caminho configurado.
|
||||
pub fn caminho(&self) -> Option<String> {
|
||||
self.device_path.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn detectar_impressora(pm: State<'_, PrinterManager>) -> Result<String, PrinterError> {
|
||||
pm.detectar()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn configurar_impressora(pm: State<'_, PrinterManager>, caminho: String) -> Result<(), PrinterError> {
|
||||
pm.configurar(caminho)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn caminho_impressora(pm: State<'_, PrinterManager>) -> Option<String> {
|
||||
pm.caminho()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn imprimir_recibo(
|
||||
pm: State<'_, PrinterManager>,
|
||||
dados: ReciboData,
|
||||
) -> Result<usize, PrinterError> {
|
||||
pm.imprimir_recibo(&dados)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn teste_impressora(pm: State<'_, PrinterManager>) -> Result<usize, PrinterError> {
|
||||
let mut out = Vec::new();
|
||||
out.extend(escpos::INIT);
|
||||
out.extend(escpos::blank_lines(2));
|
||||
out.extend(escpos::title("TESTE DE IMPRESSAO"));
|
||||
out.extend(escpos::line_center("O Frangao - Fechamento de Caixa"));
|
||||
out.extend(escpos::line_center(&chrono::Local::now().format("%d/%m/%Y %H:%M").to_string()));
|
||||
out.extend(escpos::blank_lines(3));
|
||||
out.extend(escpos::FEED_CUT);
|
||||
out.extend(escpos::CUT);
|
||||
pm.imprimir_bytes(&out)
|
||||
}
|
||||
|
||||
pub fn init() -> PrinterManager {
|
||||
PrinterManager::default()
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Plugin de armazenamento local via SQLite
|
||||
//! Fonte primária de dados — funciona offline, sync com Supabase quando online.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum StorageError {
|
||||
#[error("SQLite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("Lock error")]
|
||||
Lock,
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
impl serde::Serialize for StorageError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tipos ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FechamentoEstado {
|
||||
pub id: Option<i64>,
|
||||
pub uuid: String,
|
||||
pub loja: String,
|
||||
pub data: String,
|
||||
pub operador: String,
|
||||
pub turno: String,
|
||||
pub saldo_troco: f64,
|
||||
pub saldo_esperado: f64,
|
||||
pub fechamento: f64,
|
||||
pub tabelas: TableData,
|
||||
pub cartoes: CartaoData,
|
||||
pub sync_status: SyncStatus,
|
||||
pub criado_em: String,
|
||||
pub atualizado_em: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct TableData {
|
||||
pub sangrias: Vec<TableRow>,
|
||||
pub despesas: Vec<TableRow>,
|
||||
pub pixcnpj: Vec<TableRow>,
|
||||
pub vales: Vec<TableRow>,
|
||||
pub areceber: Vec<TableRow>,
|
||||
pub cancelamentos: Vec<TableRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct TableRow {
|
||||
pub retirada: Option<f64>,
|
||||
pub valor: Option<f64>,
|
||||
pub descricao: Option<String>,
|
||||
pub motivo: Option<String>,
|
||||
pub cliente: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CartaoData {
|
||||
pub credito: Vec<TableRow>,
|
||||
pub debito: Vec<TableRow>,
|
||||
pub alimentacao: Vec<TableRow>,
|
||||
pub voucher: Vec<TableRow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SyncStatus {
|
||||
Local, // só local, não sincronizado
|
||||
Syncing, // sync em progresso
|
||||
Synced, // sincronizado com servidor
|
||||
Error, // erro no sync
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SyncStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SyncStatus::Local => write!(f, "local"),
|
||||
SyncStatus::Syncing => write!(f, "syncing"),
|
||||
SyncStatus::Synced => write!(f, "synced"),
|
||||
SyncStatus::Error => write!(f, "error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SyncStatus {
|
||||
fn from(s: &str) -> Self {
|
||||
match s {
|
||||
"local" => SyncStatus::Local,
|
||||
"syncing" => SyncStatus::Syncing,
|
||||
"synced" => SyncStatus::Synced,
|
||||
"error" => SyncStatus::Error,
|
||||
_ => SyncStatus::Local,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SalvarResult {
|
||||
pub id: i64,
|
||||
pub uuid: String,
|
||||
pub sync_status: String,
|
||||
}
|
||||
|
||||
// ── DB Manager ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct DbManager {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
pub fn new(app_dir: PathBuf) -> Result<Self, StorageError> {
|
||||
std::fs::create_dir_all(&app_dir)?;
|
||||
let db_path = app_dir.join("fechamento.db");
|
||||
let conn = Connection::open(&db_path)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS fechamentos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
loja TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
operador TEXT NOT NULL DEFAULT '',
|
||||
turno TEXT NOT NULL DEFAULT '',
|
||||
saldo_troco REAL NOT NULL DEFAULT 0,
|
||||
saldo_esperado REAL NOT NULL DEFAULT 0,
|
||||
fechamento REAL NOT NULL DEFAULT 0,
|
||||
tabelas_json TEXT NOT NULL DEFAULT '{}',
|
||||
cartoes_json TEXT NOT NULL DEFAULT '{}',
|
||||
sync_status TEXT NOT NULL DEFAULT 'local',
|
||||
criado_em TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
atualizado_em TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(loja, data, operador, turno)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_fechamentos_lookup
|
||||
ON fechamentos(loja, data, operador, turno);
|
||||
CREATE INDEX IF NOT EXISTS idx_fechamentos_data
|
||||
ON fechamentos(data);"
|
||||
)?;
|
||||
|
||||
log::info!("SQLite ready: {:?}", db_path);
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
|
||||
/// Salva ou atualiza um estado de fechamento. Retorna (id, uuid).
|
||||
pub fn salvar(&self, estado: &FechamentoEstado) -> Result<SalvarResult, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let uuid = if estado.uuid.is_empty() {
|
||||
Uuid::new_v4().to_string()
|
||||
} else {
|
||||
estado.uuid.clone()
|
||||
};
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
let tabelas_json = serde_json::to_string(&estado.tabelas)?;
|
||||
let cartoes_json = serde_json::to_string(&estado.cartoes)?;
|
||||
let status_str = estado.sync_status.to_string();
|
||||
|
||||
// Upsert
|
||||
conn.execute(
|
||||
r#"INSERT INTO fechamentos
|
||||
(uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
|
||||
fechamento, tabelas_json, cartoes_json, sync_status, atualizado_em)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)
|
||||
ON CONFLICT(loja, data, operador, turno) DO UPDATE SET
|
||||
uuid=excluded.uuid,
|
||||
saldo_troco=excluded.saldo_troco,
|
||||
saldo_esperado=excluded.saldo_esperado,
|
||||
fechamento=excluded.fechamento,
|
||||
tabelas_json=excluded.tabelas_json,
|
||||
cartoes_json=excluded.cartoes_json,
|
||||
sync_status=excluded.sync_status,
|
||||
atualizado_em=excluded.atualizado_em
|
||||
WHERE atualizado_em <= excluded.atualizado_em"#,
|
||||
params![
|
||||
uuid,
|
||||
estado.loja,
|
||||
estado.data,
|
||||
estado.operador,
|
||||
estado.turno,
|
||||
estado.saldo_troco,
|
||||
estado.saldo_esperado,
|
||||
estado.fechamento,
|
||||
tabelas_json,
|
||||
cartoes_json,
|
||||
status_str,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
|
||||
let id = conn.query_row(
|
||||
"SELECT id FROM fechamentos WHERE uuid = ?1",
|
||||
params![uuid],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
Ok(SalvarResult { id, uuid, sync_status: status_str })
|
||||
}
|
||||
|
||||
/// Busca o rascunho mais recente para loja+data (independente de operador/turno).
|
||||
pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result<Option<FechamentoEstado>, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
|
||||
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
|
||||
FROM fechamentos
|
||||
WHERE loja = ?1 AND data = ?2
|
||||
ORDER BY atualizado_em DESC
|
||||
LIMIT 1"
|
||||
)?;
|
||||
|
||||
let result = stmt.query_row(params![loja, data], |r| {
|
||||
let tabelas_json: String = r.get(9)?;
|
||||
let cartoes_json: String = r.get(10)?;
|
||||
let sync_str: String = r.get(11)?;
|
||||
Ok(FechamentoEstado {
|
||||
id: Some(r.get(0)?),
|
||||
uuid: r.get(1)?,
|
||||
loja: r.get(2)?,
|
||||
data: r.get(3)?,
|
||||
operador: r.get(4)?,
|
||||
turno: r.get(5)?,
|
||||
saldo_troco: r.get(6)?,
|
||||
saldo_esperado: r.get(7)?,
|
||||
fechamento: r.get(8)?,
|
||||
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
|
||||
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
|
||||
sync_status: SyncStatus::from(sync_str.as_str()),
|
||||
criado_em: r.get(12)?,
|
||||
atualizado_em: r.get(13)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(StorageError::Sqlite(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lista todos os fechamentos de uma loja (para o histórico).
|
||||
pub fn listar(&self, loja: &str, limite: i64) -> Result<Vec<FechamentoEstado>, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
|
||||
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
|
||||
FROM fechamentos
|
||||
WHERE loja = ?1
|
||||
ORDER BY data DESC, id DESC
|
||||
LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![loja, limite], |r| {
|
||||
let tabelas_json: String = r.get(9)?;
|
||||
let cartoes_json: String = r.get(10)?;
|
||||
let sync_str: String = r.get(11)?;
|
||||
Ok(FechamentoEstado {
|
||||
id: Some(r.get(0)?),
|
||||
uuid: r.get(1)?,
|
||||
loja: r.get(2)?,
|
||||
data: r.get(3)?,
|
||||
operador: r.get(4)?,
|
||||
turno: r.get(5)?,
|
||||
saldo_troco: r.get(6)?,
|
||||
saldo_esperado: r.get(7)?,
|
||||
fechamento: r.get(8)?,
|
||||
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
|
||||
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
|
||||
sync_status: SyncStatus::from(sync_str.as_str()),
|
||||
criado_em: r.get(12)?,
|
||||
atualizado_em: r.get(13)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(StorageError::Sqlite)
|
||||
}
|
||||
|
||||
/// Busca um fechamento específico por ID.
|
||||
pub fn buscar_por_id(&self, id: i64) -> Result<Option<FechamentoEstado>, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
|
||||
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
|
||||
FROM fechamentos WHERE id = ?1"
|
||||
)?;
|
||||
|
||||
let result = stmt.query_row(params![id], |r| {
|
||||
let tabelas_json: String = r.get(9)?;
|
||||
let cartoes_json: String = r.get(10)?;
|
||||
let sync_str: String = r.get(11)?;
|
||||
Ok(FechamentoEstado {
|
||||
id: Some(r.get(0)?),
|
||||
uuid: r.get(1)?,
|
||||
loja: r.get(2)?,
|
||||
data: r.get(3)?,
|
||||
operador: r.get(4)?,
|
||||
turno: r.get(5)?,
|
||||
saldo_troco: r.get(6)?,
|
||||
saldo_esperado: r.get(7)?,
|
||||
fechamento: r.get(8)?,
|
||||
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
|
||||
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
|
||||
sync_status: SyncStatus::from(sync_str.as_str()),
|
||||
criado_em: r.get(12)?,
|
||||
atualizado_em: r.get(13)?,
|
||||
})
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(e) => Ok(Some(e)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(StorageError::Sqlite(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atualiza sync_status de um registro.
|
||||
pub fn atualizar_sync(&self, uuid: &str, status: SyncStatus) -> Result<(), StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
|
||||
conn.execute(
|
||||
"UPDATE fechamentos SET sync_status = ?1, atualizado_em = ?2 WHERE uuid = ?3",
|
||||
params![status.to_string(), now, uuid],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove um registro (para debugging).
|
||||
pub fn remover(&self, uuid: &str) -> Result<bool, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let n = conn.execute("DELETE FROM fechamentos WHERE uuid = ?1", params![uuid])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Retorna quantos registros estão pendentes de sync.
|
||||
pub fn pendentes_sync(&self) -> Result<i64, StorageError> {
|
||||
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM fechamentos WHERE sync_status = 'local' OR sync_status = 'error'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn salvar_fechamento(
|
||||
db: State<'_, DbManager>,
|
||||
estado: FechamentoEstado,
|
||||
) -> Result<SalvarResult, StorageError> {
|
||||
db.salvar(&estado)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn carregar_rascunho(
|
||||
db: State<'_, DbManager>,
|
||||
loja: String,
|
||||
data: String,
|
||||
) -> Result<Option<FechamentoEstado>, StorageError> {
|
||||
db.carregar_rascunho(&loja, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn listar_fechamentos(
|
||||
db: State<'_, DbManager>,
|
||||
loja: String,
|
||||
limite: Option<i64>,
|
||||
) -> Result<Vec<FechamentoEstado>, StorageError> {
|
||||
db.listar(&loja, limite.unwrap_or(50))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn buscar_fechamento_por_id(
|
||||
db: State<'_, DbManager>,
|
||||
id: i64,
|
||||
) -> Result<Option<FechamentoEstado>, StorageError> {
|
||||
db.buscar_por_id(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pendentes_sync(db: State<'_, DbManager>) -> Result<i64, StorageError> {
|
||||
db.pendentes_sync()
|
||||
}
|
||||
|
||||
pub fn init(app_dir: PathBuf) -> Result<DbManager, StorageError> {
|
||||
DbManager::new(app_dir)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//! Plugin de comunicação com Supabase via REST API
|
||||
//! Substitui completamente o fetch() do JS + n8n como intermediário.
|
||||
|
||||
use reqwest::blocking::Client;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tauri::State;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SupabaseError {
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
#[error("API error: {0}")]
|
||||
Api(String),
|
||||
#[error("Lock error")]
|
||||
Lock,
|
||||
}
|
||||
|
||||
impl serde::Serialize for SupabaseError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SupabaseClient {
|
||||
http: Client,
|
||||
anon_key: String,
|
||||
service_role_key: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl SupabaseClient {
|
||||
pub fn new(anon_key: String, service_role_key: String) -> Self {
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("reqwest client");
|
||||
|
||||
Self {
|
||||
http,
|
||||
anon_key,
|
||||
service_role_key,
|
||||
base_url: "https://supabase.ofrangao.com.br".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn headers(&self, use_service: bool) -> reqwest::header::HeaderMap {
|
||||
let key = if use_service { &self.service_role_key } else { &self.anon_key };
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", key).parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::HeaderName::from_static("apikey"),
|
||||
key.parse().unwrap(),
|
||||
);
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
"application/json".parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// Salva/atualiza um fechamento na tabela `fechamentos_web`.
|
||||
pub fn salvar_fechamento(&self, estado: &serde_json::Value) -> Result<Value, SupabaseError> {
|
||||
let uuid = estado
|
||||
.get("uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"uuid": uuid,
|
||||
"loja": estado["loja"],
|
||||
"data": estado["data"],
|
||||
"operador": estado["operador"],
|
||||
"turno": estado["turno"],
|
||||
"saldo_troco": estado["saldo_troco"],
|
||||
"saldo_esperado": estado["saldo_esperado"],
|
||||
"fechamento": estado["fechamento"],
|
||||
"dados": estado,
|
||||
"observacoes": "rascunho",
|
||||
});
|
||||
|
||||
let _url = format!(
|
||||
"{}/rest/v1/fechamentos_web?uuid=eq.{}&select=id",
|
||||
self.base_url, uuid
|
||||
);
|
||||
|
||||
// Upsert via POST com Prefer: resolution=merge-duplicates
|
||||
let resp = self
|
||||
.http
|
||||
.post(&format!("{}/rest/v1/rpc/upsert_fechamento", self.base_url))
|
||||
.headers(self.headers(true))
|
||||
.json(&payload)
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(serde_json::json!({ "ok": true, "uuid": uuid }))
|
||||
} else {
|
||||
let body = resp.text().unwrap_or_default();
|
||||
// Fallback: tenta POST direto na tabela
|
||||
let resp2 = self
|
||||
.http
|
||||
.post(&format!("{}/rest/v1/fechamentos_web", self.base_url))
|
||||
.headers(self.headers(true))
|
||||
.header("Prefer", "resolution=merge-duplicates")
|
||||
.json(&payload)
|
||||
.send()?;
|
||||
if resp2.status().is_success() {
|
||||
Ok(serde_json::json!({ "ok": true, "uuid": uuid }))
|
||||
} else {
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
resp2.status().as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Busca o rascunho mais recente para loja+data.
|
||||
pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result<Option<Value>, SupabaseError> {
|
||||
let url = format!(
|
||||
"{}/rest/v1/fechamentos_web?loja=eq.{}&data=eq.{}&observacoes=eq.rascunho&order=atualizado_em.desc&limit=1&select=*",
|
||||
self.base_url,
|
||||
urlencoding::encode(loja),
|
||||
urlencoding::encode(data),
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers(false))
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let arr: Vec<Value> = resp.json()?;
|
||||
Ok(arr.into_iter().next())
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Lista fechamentos recentes de uma loja.
|
||||
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, SupabaseError> {
|
||||
let url = format!(
|
||||
"{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,uuid,data,operador,turno,saldo_troco,saldo_esperado,fechamento,sync_status,observacoes,atualizado_em",
|
||||
self.base_url,
|
||||
urlencoding::encode(loja),
|
||||
limite,
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers(false))
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let arr: Vec<Value> = resp.json()?;
|
||||
Ok(arr)
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Busca um fechamento por ID.
|
||||
pub fn buscar_por_id(&self, id: i64) -> Result<Option<Value>, SupabaseError> {
|
||||
let url = format!(
|
||||
"{}/rest/v1/fechamentos_web?id=eq.{}&select=*",
|
||||
self.base_url, id
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers(false))
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let arr: Vec<Value> = resp.json()?;
|
||||
Ok(arr.into_iter().next())
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Salva operadores/gerentes/despesas customizadas.
|
||||
pub fn salvar_listas(&self, loja: &str, listas: &serde_json::Value) -> Result<Value, SupabaseError> {
|
||||
let payload = serde_json::json!({
|
||||
"loja": loja,
|
||||
"operadores": listas.get("operadores"),
|
||||
"gerentes": listas.get("gerentes"),
|
||||
"despesas_custom": listas.get("despesas_custom"),
|
||||
"clientes": listas.get("clientes"),
|
||||
});
|
||||
|
||||
let _url = format!(
|
||||
"{}/rest/v1/listas_personalizadas?loja=eq.{}&select=id",
|
||||
self.base_url,
|
||||
urlencoding::encode(loja),
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.post(&format!("{}/rest/v1/listas_personalizadas", self.base_url))
|
||||
.headers(self.headers(true))
|
||||
.header("Prefer", "resolution=merge-duplicates")
|
||||
.json(&payload)
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
Ok(serde_json::json!({ "ok": true }))
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Carrega listas personalizadas do servidor.
|
||||
pub fn carregar_listas(&self, loja: &str) -> Result<Option<Value>, SupabaseError> {
|
||||
let url = format!(
|
||||
"{}/rest/v1/listas_personalizadas?loja=eq.{}&limit=1&select=*",
|
||||
self.base_url,
|
||||
urlencoding::encode(loja),
|
||||
);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.get(&url)
|
||||
.headers(self.headers(false))
|
||||
.send()?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let arr: Vec<Value> = resp.json()?;
|
||||
Ok(arr.into_iter().next())
|
||||
} else {
|
||||
let status = resp.status();
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica se está online.
|
||||
pub fn health_check(&self) -> bool {
|
||||
let url = format!("{}/rest/v1/?limit=0", self.base_url);
|
||||
self.http
|
||||
.get(&url)
|
||||
.headers(self.headers(false))
|
||||
.send()
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tauri Commands ─────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_salvar_fechamento(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
estado: serde_json::Value,
|
||||
) -> Result<serde_json::Value, SupabaseError> {
|
||||
sb.salvar_fechamento(&estado)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_carregar_rascunho(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
loja: String,
|
||||
data: String,
|
||||
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
||||
sb.carregar_rascunho(&loja, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_listar_recentes(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
loja: String,
|
||||
limite: Option<i64>,
|
||||
) -> Result<Vec<serde_json::Value>, SupabaseError> {
|
||||
sb.listar_recentes(&loja, limite.unwrap_or(30))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_buscar_por_id(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
id: i64,
|
||||
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
||||
sb.buscar_por_id(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_salvar_listas(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
loja: String,
|
||||
listas: serde_json::Value,
|
||||
) -> Result<serde_json::Value, SupabaseError> {
|
||||
sb.salvar_listas(&loja, &listas)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_carregar_listas(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
loja: String,
|
||||
) -> Result<Option<serde_json::Value>, SupabaseError> {
|
||||
sb.carregar_listas(&loja)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_online(sb: State<'_, SupabaseClient>) -> bool {
|
||||
sb.health_check()
|
||||
}
|
||||
|
||||
pub fn init(anon_key: String, service_role_key: String) -> SupabaseClient {
|
||||
SupabaseClient::new(anon_key, service_role_key)
|
||||
}
|
||||
|
||||
// urlencoding helper (we'll add it as a dep)
|
||||
mod urlencoding {
|
||||
pub fn encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Fechamento de Caixa",
|
||||
"identifier": "com.ofrangao.fechamento-caixa",
|
||||
"version": "1.0.0",
|
||||
"build": {
|
||||
"beforeBuildCommand": "",
|
||||
"beforeDevCommand": "",
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Fechamento de Caixa — O Frangão",
|
||||
"width": 1100,
|
||||
"height": 780,
|
||||
"minWidth": 900,
|
||||
"minHeight": 650,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
},
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": { "type": "embedBootstrapper" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user