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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user