Files
fechamento-caixa/src-tauri/src/plugins/storage.rs
T
root d623d11e23
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
fix: 5 problemas recorrentes
1. PIX CNPJ: linha separada no Saldo de Caixa + incluído na soma sys-total
2. Casas decimais: fmtNum sempre 2 casas (padrão dinheiro)
3. btn-anterior: agora busca do Supabase via sb_carregar_rascunho e repovoa todas as tabelas
4. Impressão: reimplementado usb_windows (WriteFile no Windows); adicionado modo teste (grava .bin)
5. Supabase: feedback de erro com console.error; storage salvar_agora persiste TODAS as tabelas (sangrias, pixcnpj, vales, cancelamentos)
2026-06-23 14:22:06 +00:00

612 lines
23 KiB
Rust

//! 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, Default)]
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, Default)]
#[serde(rename_all = "lowercase")]
pub enum SyncStatus {
#[default]
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 Commands ─────────────────────────────────────────────────────────
/// Salva fechamento com estrutura flexível ( campos simples do frontend JS )
#[tauri::command]
pub fn salvar_fechamento(
db: State<'_, DbManager>,
estado: serde_json::Value,
) -> Result<SalvarResult, StorageError> {
// Extrai campos simples do JSON flexível
let uuid = estado.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string();
let loja = estado.get("loja").and_then(|v| v.as_str()).unwrap_or("").to_string();
let data = estado.get("data").and_then(|v| v.as_str()).unwrap_or("").to_string();
let operador = estado.get("operador").and_then(|v| v.as_str()).unwrap_or("").to_string();
let turno = estado.get("turno").and_then(|v| v.as_str()).unwrap_or("").to_string();
let saldo_troco = estado.get("saldo_troco").and_then(|v| v.as_f64()).unwrap_or(0.0);
let fechamento = estado.get("fechamento").and_then(|v| v.as_f64()).unwrap_or(0.0);
let saldo_esperado = saldo_troco + fechamento;
// Constrói TableData a partir de todas as tabelas do frontend
let despesas: Vec<serde_json::Value> = estado
.get("despesas")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let sangrias: Vec<serde_json::Value> = estado
.get("sangrias")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let pixcnpj: Vec<serde_json::Value> = estado
.get("pixcnpj")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let vales: Vec<serde_json::Value> = estado
.get("vales")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let receber: Vec<serde_json::Value> = estado
.get("receber")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let cancelamentos: Vec<serde_json::Value> = estado
.get("cancelamentos")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let cartoes_json = estado.get("cartoes");
let to_table_row = |v: &serde_json::Value, _tipo: &str| -> TableRow {
TableRow {
descricao: v.get("desc").or_else(|| v.get("nome")).and_then(|s| s.as_str()).map(String::from),
retirada: v.get("retirada").and_then(|n| n.as_f64()),
valor: v.get("valor").and_then(|n| n.as_f64()),
motivo: v.get("motivo").and_then(|s| s.as_str()).map(String::from),
cliente: v.get("nome").and_then(|s| s.as_str()).map(String::from),
// hora, gerente, obs, numero vão para extra via flatten
extra: std::collections::HashMap::new(),
..Default::default()
}
};
let tabelas = TableData {
despesas: despesas.iter().map(|v| to_table_row(v, "despesas")).collect(),
sangrias: sangrias.iter().map(|v| to_table_row(v, "sangrias")).collect(),
pixcnpj: pixcnpj.iter().map(|v| to_table_row(v, "pixcnpj")).collect(),
vales: vales.iter().map(|v| to_table_row(v, "vales")).collect(),
areceber: receber.iter().map(|v| to_table_row(v, "receber")).collect(),
cancelamentos: cancelamentos.iter().map(|v| to_table_row(v, "cancelamentos")).collect(),
};
let cartoes = CartaoData {
credito: cartoes_json
.and_then(|c| c.get("credito"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
debito: cartoes_json
.and_then(|c| c.get("debito"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
alimentacao: cartoes_json
.and_then(|c| c.get("alimentacao"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
voucher: cartoes_json
.and_then(|c| c.get("pix"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
};
let estado_rust = FechamentoEstado {
id: estado.get("id_local").and_then(|v| v.as_i64()),
uuid,
loja,
data,
operador,
turno,
saldo_troco,
fechamento,
saldo_esperado,
tabelas,
cartoes,
sync_status: SyncStatus::Local,
..Default::default()
};
db.salvar(&estado_rust)
}
/// Carrega rascunho e devolve estrutura simples para o frontend
#[tauri::command]
pub fn carregar_rascunho(
db: State<'_, DbManager>,
loja: String,
data: String,
) -> Result<Option<serde_json::Value>, StorageError> {
match db.carregar_rascunho(&loja, &data)? {
Some(e) => {
// Simplifica para formato que o frontend espera
let total_sangrias: f64 = e.tabelas.sangrias.iter().filter_map(|r| r.valor).sum();
let total_despesas: f64 = e.tabelas.despesas.iter().filter_map(|r| r.valor).sum();
let total_receber: f64 = e.tabelas.areceber.iter().filter_map(|r| r.valor).sum();
let despesa_rows: Vec<_> = e.tabelas.despesas.iter().filter_map(|r| {
r.descricao.as_ref().map(|d| serde_json::json!({"desc": d, "valor": r.valor}))
}).collect();
let receber_rows: Vec<_> = e.tabelas.areceber.iter().filter_map(|r| {
r.descricao.as_ref().map(|d| serde_json::json!({"desc": d, "valor": r.valor}))
}).collect();
Ok(Some(serde_json::json!({
"id_local": e.id,
"uuid": e.uuid,
"loja": e.loja,
"data": e.data,
"operador": e.operador,
"turno": e.turno,
"saldo_troco": e.saldo_troco,
"fechamento": e.fechamento,
"saldo_esperado": e.saldo_esperado,
"diferenca": e.fechamento - e.saldo_esperado,
"despesas": despesa_rows,
"total_sangrias": total_sangrias,
"total_despesas": total_despesas,
"vales": 0.0,
"cartoes": {
"credito": e.cartoes.credito.iter().find_map(|r| r.valor).unwrap_or(0.0),
"debito": e.cartoes.debito.iter().find_map(|r| r.valor).unwrap_or(0.0),
"alimentacao": e.cartoes.alimentacao.iter().find_map(|r| r.valor).unwrap_or(0.0),
"voucher": e.cartoes.voucher.iter().find_map(|r| r.valor).unwrap_or(0.0),
"cancelamentos": 0.0,
},
"receber": receber_rows,
"observacoes": "",
"sync_status": "local",
})))
}
None => Ok(None),
}
}
#[tauri::command]
pub fn listar_fechamentos(
db: State<'_, DbManager>,
loja: String,
limite: Option<i64>,
) -> Result<Vec<serde_json::Value>, StorageError> {
let rows = db.listar(&loja, limite.unwrap_or(50))?;
Ok(rows.into_iter().map(|e| serde_json::json!({
"id": e.id,
"uuid": e.uuid,
"data": e.data,
"operador": e.operador,
"turno": e.turno,
"saldo_esperado": e.saldo_esperado,
"sync_status": format!("{:?}", e.sync_status).to_lowercase(),
})).collect())
}
#[tauri::command]
pub fn buscar_fechamento_por_id(
db: State<'_, DbManager>,
id: i64,
) -> Result<Option<serde_json::Value>, StorageError> {
match db.buscar_por_id(id)? {
Some(e) => Ok(Some(serde_json::json!({
"id_local": e.id,
"uuid": e.uuid,
"loja": e.loja,
"data": e.data,
"operador": e.operador,
"turno": e.turno,
"saldo_troco": e.saldo_troco,
"fechamento": e.fechamento,
"saldo_esperado": e.saldo_esperado,
"diferenca": e.fechamento - e.saldo_esperado,
"despesas": e.tabelas.despesas.iter().filter_map(|r| r.descricao.as_ref().map(|d| serde_json::json!({"desc": d, "valor": r.valor}))).collect::<Vec<_>>(),
"total_sangrias": e.tabelas.sangrias.iter().filter_map(|r| r.valor).sum::<f64>(),
"total_despesas": e.tabelas.despesas.iter().filter_map(|r| r.valor).sum::<f64>(),
"vales": 0.0,
"cartoes": {
"credito": e.cartoes.credito.iter().find_map(|r| r.valor).unwrap_or(0.0),
"debito": e.cartoes.debito.iter().find_map(|r| r.valor).unwrap_or(0.0),
"alimentacao": e.cartoes.alimentacao.iter().find_map(|r| r.valor).unwrap_or(0.0),
"voucher": e.cartoes.voucher.iter().find_map(|r| r.valor).unwrap_or(0.0),
"cancelamentos": 0.0,
},
"receber": e.tabelas.areceber.iter().filter_map(|r| r.descricao.as_ref().map(|d| serde_json::json!({"desc": d, "valor": r.valor}))).collect::<Vec<_>>(),
"observacoes": "",
"sync_status": format!("{:?}", e.sync_status).to_lowercase(),
}))),
None => Ok(None),
}
}
#[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)
}