//! 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; /// Remove acentos de uma string (ex: "União" → "Uniao") fn sem_acento(s: &str) -> String { s.replace("ã", "a") .replace("á", "a") .replace("à", "a") .replace("â", "a") .replace("é", "e") .replace("è", "e") .replace("ê", "e") .replace("í", "i") .replace("ì", "i") .replace("î", "i") .replace("õ", "o") .replace("ó", "o") .replace("ò", "o") .replace("ô", "o") .replace("ú", "u") .replace("ù", "u") .replace("û", "u") .replace("ç", "c") .replace("ñ", "n") } #[derive(Error, Debug)] #[allow(dead_code)] 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(&self, serializer: S) -> Result 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 { // Normaliza loja e turno para minúsculo (CHECK constraints do Postgres) let loja = estado .get("loja") .and_then(|v| v.as_str()) .map(|s| sem_acento(&s.to_lowercase()).replace(" ", "_")) .unwrap_or_else(|| "uniao".to_string()); let data = estado.get("data").and_then(|v| v.as_str()).unwrap_or(""); let operador = estado.get("operador").and_then(|v| v.as_str()).unwrap_or(""); let id_fechamento = format!("{}_{}_{}", loja, data, operador) .to_lowercase() .replace(" ", "_"); let turno = estado .get("turno") .and_then(|v| v.as_str()) .map(|s| sem_acento(&s.to_lowercase())) .unwrap_or_else(|| "manha".to_string()); let payload = serde_json::json!({ "id_fechamento": id_fechamento, "loja": loja, "data": data, "operador": operador, "turno": turno, "saldo_troco": estado.get("saldo_troco").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_credito": estado.get("credito").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_debito": estado.get("debito").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_alimentacao": estado.get("alimentacao").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_vales": estado.get("vales").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_areceber": estado.get("areceber").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_pixcnpj": estado.get("pixcnpj").and_then(|v| v.as_f64()).unwrap_or(0.0), "fechamento_dinheiro": estado.get("fd").and_then(|v| v.as_f64()).unwrap_or(0.0), "fechamento_cartoes": estado.get("fc").and_then(|v| v.as_f64()).unwrap_or(0.0), "fechamento_areceber": estado.get("fr").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_sangrias": estado.get("total_sangrias").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_despesas": estado.get("total_despesas").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_cancelamentos": estado.get("total_cancelamentos").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_vales": estado.get("total_vales").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_pixcnpj": estado.get("total_pixcnpj").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_areceber": estado.get("total_areceber").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_cartao_credito": estado.get("total_cartao_credito").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_cartao_debito": estado.get("total_cartao_debito").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_cartao_alimentacao": estado.get("total_cartao_alimentacao").and_then(|v| v.as_f64()).unwrap_or(0.0), "total_cartao_pix": estado.get("total_cartao_pix").and_then(|v| v.as_f64()).unwrap_or(0.0), "saldo_esperado": estado.get("saldo_esperado").and_then(|v| v.as_f64()).unwrap_or(0.0), "dados": estado, "observacoes": estado.get("observacoes").and_then(|v| v.as_str()).unwrap_or(""), "clientes": estado.get("clientes").and_then(|v| v.as_i64()).unwrap_or(0), "frango": estado.get("frango").and_then(|v| v.as_i64()).unwrap_or(0), "vendas": estado.get("vendas").and_then(|v| v.as_f64()).unwrap_or(0.0), }); // Primeiro tenta INSERT (upsert via Prefer) let resp = self .http .post(&format!("{}/rest/v1/fechamentos_web", self.base_url)) .headers(self.headers(true)) .header("Prefer", "resolution=merge-duplicates") .json(&payload) .send()?; let status = resp.status(); if status.is_success() || status.as_u16() == 409 { Ok(serde_json::json!({ "ok": true, "id_fechamento": id_fechamento })) } else { let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } /// Busca o rascunho mais recente para loja+data. pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result, 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 = 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 ))) } } /// Busca um fechamento pelo campo "id" (UUID externo). pub fn buscar_por_uuid(&self, uuid: &str) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?id=eq.{}&select=*", self.base_url, uuid ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = 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 ))) } } /// Busca um fechamento pelo id_fechamento (ex: uniao_2026-06-24_silanea). pub fn buscar_por_id_fechamento(&self, id_fechamento: &str) -> Result, SupabaseError> { let url = format!( "{}/rest/v1/fechamentos_web?id_fechamento=eq.{}&select=*", self.base_url, urlencoding::encode(id_fechamento) ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = 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 ))) } } /// Atualiza campos específicos de um fechamento pelo id numérico (pk) ou UUID. pub fn atualizar_fechamento(&self, id: Option, uuid: &str, updates: &serde_json::Value) -> Result { // Se temos id numerico, usa id=eq.; senao usa id=eq.UUID let url = if let Some(num) = id { format!("{}/rest/v1/fechamentos_web?id=eq.{}", self.base_url, num) } else { format!("{}/rest/v1/fechamentos_web?id=eq.{}", self.base_url, uuid) }; let resp = self .http .patch(&url) .headers(self.headers(true)) .json(updates) .send()?; if resp.status().is_success() { Ok(serde_json::json!({ "ok": true, "id": id.unwrap_or(0) })) } else { let status = resp.status(); let body = resp.text().unwrap_or_default(); Err(SupabaseError::Api(format!( "HTTP {}: {}", status.as_u16(), body ))) } } pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result, SupabaseError> { let loja_normalized = sem_acento(&loja.to_lowercase()); let url = format!( "{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,id_fechamento,data,operador,turno,saldo_troco,saldo_esperado,fechamento_dinheiro,fechamento_cartoes,fechamento_areceber,diferenca,status_diferenca,observacoes,atualizado_em", self.base_url, urlencoding::encode(&loja_normalized), limite, ); let resp = self .http .get(&url) .headers(self.headers(false)) .send()?; if resp.status().is_success() { let arr: Vec = 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, 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 = 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 { 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, 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 = 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 ))) } } /// Tenta forçar PostgREST a recarregar schema com novas colunas (vendas, edited_at, edited_by). /// Estratégia: INSERT com novas colunas → se PGRST204, espera 1.5s → retry. /// Fire-and-forget — falha não bloqueia. pub fn run_migration(&self) { let payload = serde_json::json!({ "id_fechamento": "_mig_probe_1900", "loja": "_probe_", "data": "1900-01-01", "operador": "_probe_", "vendas": 0, "edited_at": null, "edited_by": null }); let r1 = self.http .post(&format!("{}/rest/v1/fechamentos_web", self.base_url)) .headers(self.headers(true)) .header("Prefer", "resolution=merge-duplicates") .json(&payload) .send(); if let Ok(resp) = r1 { if resp.status().as_u16() == 400 { std::thread::sleep(std::time::Duration::from_millis(1500)); let _ = self.http .post(&format!("{}/rest/v1/fechamentos_web", self.base_url)) .headers(self.headers(true)) .header("Prefer", "resolution=merge-duplicates") .json(&serde_json::json!({ "id_fechamento": "_mig_probe_1900", "vendas": 0, "edited_at": null, "edited_by": null })) .send(); } } // Limpa probe let _ = self.http .delete(&format!("{}/rest/v1/fechamentos_web?id_fechamento=eq._mig_probe_1900", self.base_url)) .headers(self.headers(true)) .send(); } /// 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 { sb.salvar_fechamento(&estado) } #[tauri::command] pub fn sb_carregar_rascunho( sb: State<'_, SupabaseClient>, loja: String, data: String, ) -> Result, SupabaseError> { sb.carregar_rascunho(&loja, &data) } #[tauri::command] pub fn sb_listar_recentes( sb: State<'_, SupabaseClient>, loja: String, limite: Option, ) -> Result, SupabaseError> { sb.listar_recentes(&loja, limite.unwrap_or(30)) } #[tauri::command] pub fn sb_buscar_por_id( sb: State<'_, SupabaseClient>, id: i64, ) -> Result, SupabaseError> { sb.buscar_por_id(id) } #[tauri::command] pub fn sb_buscar_por_uuid( sb: State<'_, SupabaseClient>, uuid: String, ) -> Result, SupabaseError> { sb.buscar_por_uuid(&uuid) } /// Busca um fechamento pelo id_fechamento (ex: uniao_2026-06-24_silanea). #[tauri::command] pub fn sb_buscar_por_id_fechamento( sb: State<'_, SupabaseClient>, id_fechamento: String, ) -> Result, SupabaseError> { sb.buscar_por_id_fechamento(&id_fechamento) } #[tauri::command] pub fn sb_atualizar_fechamento( sb: State<'_, SupabaseClient>, id: String, updates: serde_json::Value, ) -> Result { // Tenta primeiro como i64 (id numerico), depois como String (UUID) let id_i64 = id.parse::().ok(); let id_str = &id; sb.atualizar_fechamento(id_i64, id_str, &updates) } #[tauri::command] pub fn sb_salvar_listas( sb: State<'_, SupabaseClient>, loja: String, listas: serde_json::Value, ) -> Result { sb.salvar_listas(&loja, &listas) } #[tauri::command] pub fn sb_carregar_listas( sb: State<'_, SupabaseClient>, loja: String, ) -> Result, 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 } }