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