//! 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(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&self.to_string()) } } // ── Tipos ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct FechamentoEstado { pub id: Option, 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, pub despesas: Vec, pub pixcnpj: Vec, pub vales: Vec, pub areceber: Vec, pub cancelamentos: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct TableRow { pub retirada: Option, pub valor: Option, pub descricao: Option, pub motivo: Option, pub cliente: Option, #[serde(flatten)] pub extra: std::collections::HashMap, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct CartaoData { pub credito: Vec, pub debito: Vec, pub alimentacao: Vec, pub voucher: Vec, } #[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, } impl DbManager { pub fn new(app_dir: PathBuf) -> Result { 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 { 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, 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, 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::, _>>().map_err(StorageError::Sqlite) } /// Busca um fechamento específico por ID. pub fn buscar_por_id(&self, id: i64) -> Result, 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. #[allow(dead_code)] 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). #[allow(dead_code)] pub fn remover(&self, uuid: &str) -> Result { 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 { 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 { // 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 = estado .get("despesas") .and_then(|v| v.as_array()) .map(|arr| arr.to_vec()) .unwrap_or_default(); let sangrias: Vec = estado .get("sangrias") .and_then(|v| v.as_array()) .map(|arr| arr.to_vec()) .unwrap_or_default(); let pixcnpj: Vec = estado .get("pixcnpj") .and_then(|v| v.as_array()) .map(|arr| arr.to_vec()) .unwrap_or_default(); let vales: Vec = estado .get("vales") .and_then(|v| v.as_array()) .map(|arr| arr.to_vec()) .unwrap_or_default(); let receber: Vec = estado .get("receber") .and_then(|v| v.as_array()) .map(|arr| arr.to_vec()) .unwrap_or_default(); let cancelamentos: Vec = 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, 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, ) -> Result, 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, 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::>(), "total_sangrias": e.tabelas.sangrias.iter().filter_map(|r| r.valor).sum::(), "total_despesas": e.tabelas.despesas.iter().filter_map(|r| r.valor).sum::(), "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::>(), "observacoes": "", "sync_status": format!("{:?}", e.sync_status).to_lowercase(), }))), None => Ok(None), } } #[tauri::command] pub fn pendentes_sync(db: State<'_, DbManager>) -> Result { db.pendentes_sync() } pub fn init(app_dir: PathBuf) -> Result { DbManager::new(app_dir) }