Fechamento de Caixa - Tauri 2 initial commit
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

- 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:
root
2026-06-20 13:58:34 +00:00
parent b06462a92d
commit a5cff433a6
20 changed files with 8313 additions and 0 deletions
+409
View File
@@ -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)
}