Compare commits
124 Commits
v1.2.7
...
v2.0.4-alianca
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c036b916f | |||
| c58f01d51b | |||
| acb90f093d | |||
| 6eb08b6bd4 | |||
| 5a0126417c | |||
| 733519e1d3 | |||
| 6440b262cc | |||
| 420e104df7 | |||
| b04e5acfd6 | |||
| 65c444ad09 | |||
| 2bfbf29ccb | |||
| cbe2c22440 | |||
| 1dc278dbbd | |||
| c2c9a29247 | |||
| 659d9779b3 | |||
| 5c9cb1905b | |||
| a2d3dcc366 | |||
| 12e3d081a7 | |||
| ad967bf0a1 | |||
| d39cc7518b | |||
| 52bec03988 | |||
| 9639048bf7 | |||
| b1585af3f9 | |||
| 155782f50a | |||
| 16972f4734 | |||
| d3d5c4fbca | |||
| 5f1d9a4e2f | |||
| cc45ed9df7 | |||
| 378a589572 | |||
| 50b0ba4498 | |||
| 7154901ce3 | |||
| 8b24a06496 | |||
| 3058908b37 | |||
| 9b1d45ae50 | |||
| fbed0e57c2 | |||
| 362a76961b | |||
| 7a3a4b221b | |||
| 94bb8840ea | |||
| dba7eb3856 | |||
| 7b3b7a9f1d | |||
| 5641de21f8 | |||
| d77ad55fbd | |||
| 4a5cae8f2f | |||
| 313c740470 | |||
| d21cb0a98d | |||
| 16bee1eacb | |||
| c8016f076d | |||
| b1a9f94231 | |||
| 42aadcca2c | |||
| a8319d96a6 | |||
| bf878545b3 | |||
| 22cda312cf | |||
| d112ad061e | |||
| be917f8fb7 | |||
| cb48afeb9c | |||
| 2b8ff711ed | |||
| 9a4ccabd1b | |||
| 32728eec37 | |||
| 905ca253d7 | |||
| d89b79a3f8 | |||
| c4dda7e2e5 | |||
| 662f3681a5 | |||
| a56d7188f2 | |||
| 7e1ee6bc04 | |||
| 6a8881d41d | |||
| 3b3eb1a4cc | |||
| c7109b1017 | |||
| f45d1e8170 | |||
| 8ea5ba771a | |||
| 84578fe43d | |||
| 3e94789cb0 | |||
| 292d0b85d9 | |||
| a8edab029d | |||
| cbaef8772d | |||
| 80740afbf0 | |||
| 7134c85bb7 | |||
| 4a157d27cb | |||
| a0f2f91131 | |||
| 15f141c191 | |||
| 96a84ec194 | |||
| 7d0c416248 | |||
| 60ec60bf29 | |||
| 6d9e28a6f2 | |||
| ecb9ce3926 | |||
| de5d5aaafb | |||
| a11db54ffc | |||
| 674caf2b7a | |||
| bdfd54e0cf | |||
| 9c64c06ed9 | |||
| 3cf1864af9 | |||
| aa147885a8 | |||
| 00fa17fa52 | |||
| 5e6f8b09a9 | |||
| 9ac4eb7042 | |||
| 2b8fa98ed7 | |||
| 77e927fef0 | |||
| 3f6b2ae6e4 | |||
| 35dcd27dda | |||
| bd867e8419 | |||
| da861ef964 | |||
| 8e642f2e0c | |||
| 7473db6b1f | |||
| 13489d58cf | |||
| 5ad8c1f395 | |||
| 103cd31e50 | |||
| 06783de4e2 | |||
| 11abac8daf | |||
| fea957dfb9 | |||
| 130b2b6da7 | |||
| b47d7766ad | |||
| 78fa778197 | |||
| 5e4340adca | |||
| c01133f39c | |||
| a6b295a6d0 | |||
| f850831e98 | |||
| e70f31e00f | |||
| 79e1dbd645 | |||
| cce8f3df60 | |||
| 318d44e934 | |||
| bfbcd89be9 | |||
| 1848c1aa35 | |||
| 9176b764fd | |||
| 5cab38a27c | |||
| 5b1f3a2691 |
+13
@@ -7,3 +7,16 @@ src-tauri/gen/
|
||||
*.msi
|
||||
*.dmg
|
||||
.DS_Store
|
||||
|
||||
# Binarios compilados
|
||||
*.exe
|
||||
*.tar.gz
|
||||
*.zip
|
||||
fechamento-caixa.exe
|
||||
fechamento-caixa-latest.tar.gz
|
||||
WebView2Loader.dll
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/*.exe
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/*.dll
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Integração Frontend ↔ Tauri (Rust) ↔ Supabase
|
||||
|
||||
## Visão geral da comunicação
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
|
||||
│ JavaScript │ invoke │ Tauri/Rust │ HTTP │ Supabase │
|
||||
│ (index.html) │────────▶│ commands │────────▶│ (cloud) │
|
||||
│ │◀────────│ │◀────────│ │
|
||||
└─────────────────┘ response└─────────────────┘ response└──────────────────┘
|
||||
```
|
||||
|
||||
O frontend **nunca faz requisições HTTP direto** para o Supabase. Toda comunicação passa pelos comandos Tauri (Rust).
|
||||
|
||||
---
|
||||
|
||||
## Comandos Tauri disponíveis
|
||||
|
||||
### App
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `get_loja` | Retorna a loja configurada |
|
||||
| `get_loja_fixa` | Retorna `"Uniao"`, `"Alianca"` ou `null` (se build fixa) |
|
||||
| `set_loja(loja)` | Define a loja (bloqueado se build fixa) |
|
||||
| `get_config` | Retorna config completo `{ loja, pdv_nome, operador_default, ... }` |
|
||||
| `set_config(cfg)` | Salva config |
|
||||
|
||||
### Storage (SQLite local)
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `salvar_fechamento(estado)` | Salva estado completo no SQLite local |
|
||||
| `carregar_rascunho(loja, data)` | Retorna último rascunho para loja+data |
|
||||
| `listar_fechamentos(loja, limite)` | Lista fechamentos de uma loja |
|
||||
| `buscar_fechamento_por_id(id)` | Busca por ID local |
|
||||
| `pendentes_sync` | Retorna fechamentos que ainda não foram syncados |
|
||||
|
||||
### Supabase
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `sb_salvar_fechamento(estado)` | Upsert no Supabase via service_role JWT |
|
||||
| `sb_carregar_rascunho(loja, data)` | Busca rascunho mais recente |
|
||||
| `sb_listar_recentes(loja, limite)` | Lista fechamentos recentes |
|
||||
| `sb_buscar_por_id(id)` | Busca por ID |
|
||||
| `sb_buscar_por_uuid(uuid)` | Busca por UUID |
|
||||
| `sb_buscar_por_id_fechamento(id_fechamento)` | Busca por chave natural |
|
||||
| `sb_atualizar_fechamento(id_fechamento, updates)` | PATCH campos específicos |
|
||||
| `sb_salvar_listas(loja, listas)` | Salva operadores/gerentes customizados |
|
||||
| `sb_carregar_listas(loja)` | Carrega listas salvas |
|
||||
| `sb_online` | Retorna `true`/`false` se Supabase está acessível |
|
||||
|
||||
### Printer
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `detectar_impressora` | Detecta EPSON TM-T20 USB |
|
||||
| `configurar_impressora(caminho)` | Define caminho da impressora |
|
||||
| `caminho_impressora` | Retorna caminho configurado |
|
||||
| `imprimir_recibo(html)` | Envia HTML puro para impressora |
|
||||
| `teste_impressora` | Imprime página de teste |
|
||||
| `ativar_modo_teste_impressao` | Ativa modo teste (salva PDF em vez de imprimir) |
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de salvar um fechamento
|
||||
|
||||
```javascript
|
||||
// No frontend (index.html), quando operador clica "Salvar":
|
||||
async function salvarFechamento() {
|
||||
const estado = buildEstado(); // coleta todos os campos do formulário
|
||||
|
||||
// 1. Salva localmente (sempre funciona offline)
|
||||
await invoke('salvar_fechamento', { estado });
|
||||
|
||||
// 2. Tenta sync com Supabase
|
||||
try {
|
||||
await invoke('sb_salvar_fechamento', { estado });
|
||||
toast('✅ Salvo e sincronizado');
|
||||
} catch(e) {
|
||||
// Supabase offline — fica pendente no SQLite
|
||||
toast('💾 Salvo localmente (sync pendente)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Como o Rust conversa com o Supabase
|
||||
|
||||
Em `supabase.rs`, o plugin usa `reqwest` (HTTP client) com:
|
||||
|
||||
```rust
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- **`use_service = true`:** para INSERT/UPDATE (salvar fechamento)
|
||||
- **`use_service = false`:** para GET (listar, buscar)
|
||||
|
||||
---
|
||||
|
||||
## Upsert — salvar sem duplicar
|
||||
|
||||
O Supabase não tem UPSERT nativo via REST. O plugin usa um truque:
|
||||
|
||||
```http
|
||||
POST /fechamentos_web HTTP/1.1
|
||||
Prefer: resolution=merge-duplicates
|
||||
```
|
||||
|
||||
Se o `id_fechamento` já existe, o PostgREST faz um UPDATE em vez de INSERT. Se não existe, faz INSERT.
|
||||
|
||||
---
|
||||
|
||||
## UUID vs id_fechamento
|
||||
|
||||
| Campo | O que é | Quem gera |
|
||||
|-------|---------|-----------|
|
||||
| `id` | ID sequencial do PostgreSQL | Supabase (auto) |
|
||||
| `uuid` | UUID único do registro local | Frontend JS (`crypto.randomUUID()`) |
|
||||
| `id_fechamento` | Chave natural: `{loja}_{data}_{operador}` | Frontend JS (construído na hora) |
|
||||
|
||||
O `id_fechamento` é a chave de negócio. O `uuid` é para rastrear registros entre local e cloud.
|
||||
|
||||
---
|
||||
|
||||
## Offline first — como funciona
|
||||
|
||||
1. **Operador/edita** → `salvar_fechamento` → SQLite (sempre funciona)
|
||||
2. **Operador/edita** → `sb_salvar_fechamento` → Supabase (só se online)
|
||||
3. **Se offline:** o fechamento fica no SQLite com `sync_status = "local"`
|
||||
4. **Quando conecta:** `pendentes_sync` retorna os pendentes → sync automático
|
||||
|
||||
O sync automático é controlado por `sync_automatico` na config da app.
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Builds — Como gerar fc-uniao.exe, fc-alianca.exe, fc-ambos.exe
|
||||
|
||||
## Conceito
|
||||
|
||||
O mesmo código gera 3 binários diferentes. A diferença entre eles é apenas o valor da variável de ambiente `DOBRADO_LOJA` passado na hora da compilação Rust.
|
||||
|
||||
- **Build fixa (União ou Aliança):** o operador **não consegue trocar a loja** no formulário
|
||||
- **Build livre (Ambós):** o operador escolhe entre União e Aliança no dropdown
|
||||
|
||||
---
|
||||
|
||||
## Mecanismo: `option_env!` em Rust
|
||||
|
||||
Em `src-tauri/src/plugins/app.rs`:
|
||||
|
||||
```rust
|
||||
/// DOBRADO_LOJA é uma variável de ambiente lida em TEMPO DE COMPILAÇÃO.
|
||||
/// Se não definida, retorna None.
|
||||
pub const LOJA_FIXA: Option<&'static str> = option_env!("DOBRADO_LOJA");
|
||||
```
|
||||
|
||||
O `option_env!` é uma macro built-in do Rust que avalia expressões const em tempo de compilação. Se `DOBRADO_LOJA` estiver definida como env var, `LOJA_FIXA` contém o valor. Se não, `None`.
|
||||
|
||||
### Por que compile-time?
|
||||
|
||||
Se fosse runtime (`std::env::var`), o valor estaría hardcoded no binário da mesma forma, mas compile-time é mais limpo porque o Rust elimina código morto (dead code elimination). Se `LOJA_FIXA` é `None`, o Rust pode optimizar o código de "loja fixa" para fora do binário final.
|
||||
|
||||
---
|
||||
|
||||
## Comportamento por build
|
||||
|
||||
| Build | `DOBRADO_LOJA` | Seletor loja | `set_loja` | `get_loja_fixa` |
|
||||
|-------|----------------|-------------|-------------|-----------------|
|
||||
| União | `Uniao` | **Escondido** | Bloqueado com erro | `"Uniao"` |
|
||||
| Aliança | `Alianca` | **Escondido** | Bloqueado com erro | `"Alianca"` |
|
||||
| Ambos | _(não definido)_ | Visível + funcional | Permitido | `null` |
|
||||
|
||||
---
|
||||
|
||||
## Como buildar (Linux → Windows cross-compile)
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
```bash
|
||||
# Instalar target cross-compile
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
|
||||
# Ou via apt (Ubuntu/Debian)
|
||||
sudo apt install mingw-w64
|
||||
```
|
||||
|
||||
### Comandos
|
||||
|
||||
```bash
|
||||
cd src-tauri/
|
||||
|
||||
# ── UNIÃO ─────────────────────────────────────────────
|
||||
DOBRADO_LOJA=Uniao \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# Binário gerado:
|
||||
# src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe
|
||||
|
||||
# ── ALIANÇA ──────────────────────────────────────────
|
||||
DOBRADO_LOJA=Alianca \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# ── AMBOS ────────────────────────────────────────────
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
# (sem DOBRADO_LOJA → loja livre)
|
||||
```
|
||||
|
||||
### Local do binário
|
||||
|
||||
```
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe
|
||||
```
|
||||
|
||||
Renomear para `fc-uniao.exe`, `fc-alianca.exe`, `fc-ambos.exe` antes de distribuir.
|
||||
|
||||
### ⚠️ Distribuição: WebView2Loader.dll
|
||||
|
||||
O binário **precisa da `WebView2Loader.dll` na mesma pasta** para funcionar. Esta DLL é o loader do WebView2 (o navegador embutido do Tauri).
|
||||
|
||||
```
|
||||
Pasta de distribuição (cada build):
|
||||
├── fc-uniao.exe
|
||||
├── WebView2Loader.dll ← copiar junto!
|
||||
```
|
||||
|
||||
A DLL está em:
|
||||
```
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll
|
||||
```
|
||||
|
||||
Para copiar junto ao buildar:
|
||||
```bash
|
||||
DLL="src-tauri/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll"
|
||||
cp ${DLL} /tmp/fc-uniao/
|
||||
cp ${DLL} /tmp/fc-alianca/
|
||||
cp ${DLL} /tmp/fc-ambos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que acontece no frontend quando a loja é fixa
|
||||
|
||||
Em `src/index.html`, `DOMContentLoaded`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const fixa = await window.__TAURI__.core.invoke('get_loja_fixa');
|
||||
if (fixa) {
|
||||
// Build fixa: esconde o <select id="f-loja">
|
||||
const sel = document.getElementById('f-loja');
|
||||
sel.style.display = 'none';
|
||||
sel.disabled = true;
|
||||
|
||||
// Esconde o label "Loja"
|
||||
const label = document.querySelector('.meta-label');
|
||||
if (label && label.textContent.trim() === 'Loja') {
|
||||
label.style.display = 'none';
|
||||
}
|
||||
|
||||
estado.loja = fixa; // define sem precisar do select
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('get_loja_fixa não disponível:', e);
|
||||
}
|
||||
// ... resto do init
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que acontece no Rust quando a loja é fixa
|
||||
|
||||
```rust
|
||||
// set_loja retorna erro se a build tem loja fixa
|
||||
#[tauri::command]
|
||||
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
|
||||
if LOJA_FIXA.is_some() {
|
||||
return Err("Loja fixa em tempo de build — não é possível trocar.".to_string());
|
||||
}
|
||||
state.config.lock().unwrap().loja = loja;
|
||||
state.save()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testar localmente (dev mode)
|
||||
|
||||
```bash
|
||||
cd src-tauri/
|
||||
|
||||
# Com loja fixa (dev)
|
||||
DOBRADO_LOJA=Uniao cargo tauri dev
|
||||
|
||||
# Com loja livre (dev)
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Criar release no Gitea
|
||||
|
||||
```bash
|
||||
# 1. Buildar as 3 versões e copiar para /tmp/
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-uniao.exe
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-alianca.exe
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-ambos.exe
|
||||
|
||||
# 2. Criar releases via Gitea API
|
||||
GITEA_TOKEN="seu_token_aqui"
|
||||
REPO="filipe/fechamento-caixa"
|
||||
|
||||
for build in uniao alianca ambos; do
|
||||
TAG="v2.0.0-${build}"
|
||||
FILE="/tmp/fc-${build}.exe"
|
||||
|
||||
# Criar release
|
||||
RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"Build ${build}\"}" \
|
||||
"https://git.ofrangao.com.br/api/v1/repos/${REPO}/releases")
|
||||
ID=$(echo $RESP | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
# Upload asset
|
||||
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @${FILE} \
|
||||
"https://git.ofrangao.com.br/api/v1/repos/${REPO}/releases/${ID}/assets?name=fc-${build}.exe"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquivos que mudam entre builds
|
||||
|
||||
**Nenhum arquivo fonte muda.** O binário é identico exceto pelo valor de `LOJA_FIXA` hardcoded.
|
||||
|
||||
Para verificar o valor hardcoded no binário:
|
||||
```bash
|
||||
strings fc-uniao.exe | grep -E "Uniao|Alianca"
|
||||
```
|
||||
|
||||
O binário `fc-uniao.exe` contém a string `Uniao` no lugar de `LOJA_FIXA`, enquanto `fc-ambos.exe` não contém nada (ou contém `None` ou não tem referência).
|
||||
@@ -0,0 +1,192 @@
|
||||
# Fechamento de Caixa — O Frangão
|
||||
|
||||
## O que é
|
||||
|
||||
App desktop nativo para registrar o fechamento de caixa diário das lojas **União** e **Aliança** do O Frangão. Cada fechamento registra: saldo em dinheiro, cartões de crédito/débito/alimentação/PIX, vales, sangrias, despesas, PIX CNPJ, e itens a receber.
|
||||
|
||||
## Ideia central
|
||||
|
||||
O app é um **formulário inteligente** que:
|
||||
|
||||
1. Captura todos os dados de fechamento de caixa (dinheiro, cartões, despesas, etc.)
|
||||
2. Salva localmente em **SQLite** (funciona offline)
|
||||
3. Envia automaticamente para o **Supabase** (cloud) quando há conexão
|
||||
4. Gera um **recibo térmico 80mm** para impressão
|
||||
|
||||
O objetivo é substituir planilhas manuais e webhooks frágeis, dando uma fonte única de verdade para os dados de caixa.
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura geral
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||
│ HTML/JS │────▶│ Tauri (Rust) │────▶│ SQLite (local) │
|
||||
│ (frontend) │◀────│ plugins: │◀────│ fechamentos.db │
|
||||
│ │ │ app, storage, │ │ - funciona offline │
|
||||
│ - formulário │ │ supabase, │ │ - sync automático │
|
||||
│ - impressão │ │ printer │ └────────────────────┘
|
||||
│ - estado JS │ └────────┬─────────┘ │
|
||||
└─────────────────┘ │ ▼
|
||||
│ ┌────────────────────┐
|
||||
│ │ Supabase Cloud │
|
||||
│ │ (supabase. │
|
||||
└─────────────▶│ ofrangao.com.br) │
|
||||
│ - PostgreSQL │
|
||||
│ - RLS + JWT auth │
|
||||
│ - API REST │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack técnica
|
||||
|
||||
| Camada | Tecnologia |
|
||||
|--------|-----------|
|
||||
| Frontend | HTML5 + CSS + JavaScript vanilla (single-file `src/index.html`) |
|
||||
| Runtime | [Tauri 2](https://tauri.app/) (Rust + WebView2) |
|
||||
| Dados local | SQLite via `rusqlite` |
|
||||
| Dados cloud | [Supabase](https://supabase.com/) — PostgreSQL + REST API |
|
||||
| Impressão | HTML → `window.print()` com CSS @media print (80mm) |
|
||||
| Build | Cross-compile Linux → Windows (`x86_64-pc-windows-gnu`) |
|
||||
|
||||
---
|
||||
|
||||
## Repositório
|
||||
|
||||
```
|
||||
https://git.ofrangao.com.br/filipe/fechamento-caixa
|
||||
```
|
||||
|
||||
### Estrutura de arquivos
|
||||
|
||||
```
|
||||
fechamento-caixa/
|
||||
├── src/
|
||||
│ └── index.html ← Frontend completo (HTML + CSS + JS)
|
||||
├── src-tauri/
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs ← Entry point + plugin initialization
|
||||
│ │ └── plugins/
|
||||
│ │ ├── app.rs ← Config da app (loja, PDV, sync)
|
||||
│ │ ├── storage.rs ← SQLite local
|
||||
│ │ ├── supabase.rs ← Sync com Supabase
|
||||
│ │ ├── printer.rs ← Impressão
|
||||
│ │ └── escpos.rs ← Helpers ESCPOS
|
||||
│ └── tauri.conf.json ← Config Tauri (janela, bundle, etc.)
|
||||
├── SPEC.md ← Este arquivo
|
||||
├── SUPABASE_SCHEMA.md ← Schema do banco de dados
|
||||
├── API_INTEGRATION.md ← Como o frontend se comunica com Supabase
|
||||
└── BUILS_FARM.md ← Como gerar fc-uniao.exe, fc-alianca.exe, fc-ambos.exe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Builds — 3 versões do app
|
||||
|
||||
O mesmo código gera 3 binários diferentes conforme a variável de ambiente `DOBRADO_LOJA`:
|
||||
|
||||
## Downloads
|
||||
|
||||
| Binário | Loja | Download |
|
||||
|---------|------|----------|
|
||||
| `fc-uniao.exe` | **Travada: UNIÃO** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-uniao/fc-uniao.exe) |
|
||||
| `fc-alianca.exe` | **Travada: ALIANÇA** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-alianca/fc-alianca.exe) |
|
||||
| `fc-ambos.exe` | **Seletor livre** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-ambos/fc-ambos.exe) |
|
||||
|
||||
Quando a loja é fixa, o seletor de loja no formulário é **escondido via JavaScript** e tentativas de mudar via API retornam erro.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de um fechamento
|
||||
|
||||
```
|
||||
1. Operador abre o app
|
||||
↓
|
||||
2. Seleciona Data, Operador, Turno, Loja (se não for fixa)
|
||||
↓
|
||||
3. Preenche os campos:
|
||||
- Saldo de Troy
|
||||
- Cartões (crédito, débito, alimentação, PIX) — um valor por vez
|
||||
- Despesas (descrição + valor)
|
||||
- Sangrias (hora + gerente + valor)
|
||||
- PIX CNPJ (nome + valor)
|
||||
- Vales (nome + observação + valor)
|
||||
- A Receber (nome + valor)
|
||||
- Cancelamentos (número + motivo + valor)
|
||||
↓
|
||||
4. App calcula:
|
||||
TOTAL SISTEMA = soma de todos os campos acima
|
||||
DIFERENÇA = TOTAL SISTEMA − Saldo Esperado − Cancelamentos
|
||||
↓
|
||||
5. Operador clica "💾 Salvar / Sincronizar"
|
||||
→ Salva em SQLite local
|
||||
→ Envia para Supabase (se online)
|
||||
↓
|
||||
6. Operador clica "🖨️ Imprimir"
|
||||
→ Abre preview do recibo 80mm
|
||||
→ window.print() para impressora térmica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tauri — o runtime desktop
|
||||
|
||||
O app roda como **Tauri 2**, um runtime que embute um navegador WebView2 (Windows) dentro de um processo Rust.
|
||||
|
||||
### O que o Tauri fornece
|
||||
|
||||
| Recurso | Como é usado |
|
||||
|---------|-------------|
|
||||
| **Janela nativa** | A janela do app com barra de título, controls de redimensionar, ícone |
|
||||
| **Sistema de arquivos** | Lê/escreve `config.json` e `fechamento.db` em `%LOCALAPPDATA%/FechamentoCaixa/` |
|
||||
| **Invocação de comandos Rust** | Frontend JS chama `invoke('nome_comando', args)` → Rust executa e retorna |
|
||||
| **HTTP client** | Plugin `supabase.rs` usa `reqwest` para falar com o Supabase |
|
||||
| **Shell** | Abre diálogo de impressoras, executa `cmd /C copy` para impressão |
|
||||
| **Diálogos nativos** | `tauri_plugin_dialog` para escolher arquivos, pastas, impressora |
|
||||
|
||||
### Comandos Tauri (Rust → JS)
|
||||
|
||||
O frontend **nunca acessa** Supabase, SQLite ou impressora diretamente. Tudo passa por `invoke`:
|
||||
|
||||
```javascript
|
||||
// Salvar fechamento (SQLite + Supabase)
|
||||
await invoke('salvar_fechamento', { estado });
|
||||
|
||||
// Sync com Supabase
|
||||
await invoke('sb_salvar_fechamento', { estado });
|
||||
|
||||
// Detectar impressora
|
||||
await invoke('detectar_impressora');
|
||||
|
||||
// Imprimir
|
||||
await invoke('imprimir_recibo', { html: reciboHTML });
|
||||
```
|
||||
|
||||
### Estrutura dos plugins Rust
|
||||
|
||||
```
|
||||
src-tauri/src/plugins/
|
||||
├── app.rs — Config da app (loja, operador default, sync)
|
||||
├── storage.rs — SQLite local (fonte primária offline)
|
||||
├── supabase.rs — Cliente HTTP para Supabase (service_role JWT)
|
||||
├── printer.rs — Detecção EPSON TM-T20, fallback para window.print()
|
||||
└── escpos.rs — Helpers ESCPOS (alinhamento, negrito, corte)
|
||||
```
|
||||
|
||||
### Onde os dados ficam
|
||||
|
||||
| Dado | Local |
|
||||
|------|-------|
|
||||
| Config (SUPABASE keys, loja default) | `%LOCALAPPDATA%/FechamentoCaixa/config.json` |
|
||||
| Banco SQLite (fechamentos) | `%LOCALAPPDATA%/FechamentoCaixa/fechamento.db` |
|
||||
| Rascunho auto-save | `%LOCALAPPDATA%/FechamentoCaixa/fechamento_rascunho.db` |
|
||||
|
||||
---
|
||||
|
||||
## Autores & Contexto
|
||||
|
||||
- **Desenvolvedor:** Filipe.Tavares
|
||||
- **Dono das lojas:** O Frangão — União e Aliança
|
||||
- **Objetivo:** Substituir planilhas manuais e webhooks n8n por um sistema de fechamento de caixa confiável e offline-first
|
||||
@@ -0,0 +1,142 @@
|
||||
# Schema do Supabase — Fechamento de Caixa
|
||||
|
||||
## Servidor
|
||||
|
||||
- **URL:** `https://supabase.ofrangao.com.br`
|
||||
- **Instância:** O Frangão — PostgreSQL hospedado
|
||||
|
||||
---
|
||||
|
||||
## Tabela principal: `fechamentos_web`
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.fechamentos_web (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_fechamento TEXT UNIQUE NOT NULL, -- chave natural: {loja}_{data}_{operador}
|
||||
loja TEXT NOT NULL, -- "Uniao" | "Alianca"
|
||||
data DATE NOT NULL, -- data do fechamento (YYYY-MM-DD)
|
||||
operador TEXT NOT NULL, -- nome do operador
|
||||
turno TEXT NOT NULL, -- "manha" | "tarde"
|
||||
observacoes TEXT DEFAULT 'rascunho', -- "rascunho" | "finalizado"
|
||||
|
||||
-- Totais de fechamento (campos escalares)
|
||||
fechamento_dinheiro REAL DEFAULT 0, -- dinheiro no caixa
|
||||
fechamento_cartoes REAL DEFAULT 0, -- total cartões
|
||||
fechamento_receber REAL DEFAULT 0, -- a receber
|
||||
fechamento REAL DEFAULT 0, -- TOTAL: dinheiro + cartões + receber
|
||||
|
||||
-- Totais do sistema (valores lidos do sistema PAF-ECF)
|
||||
sys_total REAL DEFAULT 0, -- soma: troco+crédito+débito+alimentação+vales+receber+pixCartao+pixCnpj
|
||||
saldo_esperado REAL DEFAULT 0, -- quanto deveria ter (informado pelo operador)
|
||||
|
||||
-- Controle
|
||||
criado_em TIMESTAMPTZ DEFAULT NOW(),
|
||||
atualizado_em TIMESTAMPTZ DEFAULT NOW(),
|
||||
enviado_por TEXT,
|
||||
ip_origem TEXT,
|
||||
user_agent TEXT,
|
||||
|
||||
-- Dados variáveis em formato livre (JSONB)
|
||||
dados JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
```
|
||||
|
||||
### Comentários sobre colunas
|
||||
|
||||
| Coluna | O que guarda |
|
||||
|--------|-------------|
|
||||
| `id_fechamento` | Chave natural única. Ex: `uniao_2026-06-19_filipe`. Formato: `{loja}_{data}_{operador}` em minúsculas, espaços → `_` |
|
||||
| `sys_total` | Total calculado pelo sistema — é a soma de **todos** os campos de cartão + vales + despesas + sangrias + etc |
|
||||
| `saldo_esperado` | Valor informado pelo operador — o quanto ele espera ter em caixa |
|
||||
| `dados` | JSONB com todos os arrays: `cartoes_credito[]`, `cartoes_debito[]`, `cartoes_alimentacao[]`, `cartoes_pix[]`, `despesas[]`, `sangrias[]`, `pixcnpj[]`, `vales[]`, `receber[]`, `cancelamentos[]` |
|
||||
| `observacoes` | `rascunho` enquanto.edita, `finalizado` quando o operador confirma |
|
||||
|
||||
---
|
||||
|
||||
## Fórmula de diferença
|
||||
|
||||
```
|
||||
DIFERENÇA = sys_total − saldo_esperado − total_cancelamentos
|
||||
```
|
||||
|
||||
Se `DIFERENÇA = 0` → ✅ OK (caixa fecha perfeito)
|
||||
Se `DIFERENÇA > 0` → ⚠️ Sobrou dinheiro
|
||||
Se `DIFERENÇA < 0` → 🔴 Faltou dinheiro
|
||||
|
||||
---
|
||||
|
||||
## Colunas dentro do JSONB `dados`
|
||||
|
||||
```json
|
||||
{
|
||||
"cartoes_credito": [152.00, 123.50, ...],
|
||||
"cartoes_debito": [15.45, 20.00, ...],
|
||||
"cartoes_alimentacao": [12.55, ...],
|
||||
"cartoes_pix": [112.50, ...],
|
||||
"despesas": [{"desc": "luz", "valor": 150.00}, ...],
|
||||
"sangrias": [{"hora": "14:30", "gerente": "João", "retirada": 50.00, "valor": 50.00}, ...],
|
||||
"pixcnpj": [{"nome": "João Silva", "valor": 80.00}, ...],
|
||||
"vales": [{"nome": "Maria", "obs": "vale transporte", "valor": 30.00}, ...],
|
||||
"receber": [{"nome": "Pedro", "valor": 45.00}, ...],
|
||||
"cancelamentos": [{"numero": "001", "motivo": "erro digitação", "valor": 25.00}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
A tabela usa RLS com duas políticas:
|
||||
|
||||
1. **Anon (chave pública):** pode ler registros
|
||||
2. **Service role:** pode ler + escrever registros (usado pelo app com JWT service_role)
|
||||
|
||||
### Chave anon (pública)
|
||||
Usada para consultasreadonly pelo frontend.
|
||||
|
||||
### Chave service_role
|
||||
Usada pelo app Tauri para INSERT/UPDATE via plugin `supabase.rs`. Esta chave **nunca deve ser exposta publicamente** — está embedada no binário compilado.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints da API REST
|
||||
|
||||
Base: `https://supabase.ofrangao.com.br/rest/v1/`
|
||||
|
||||
| Método | Endpoint | Uso |
|
||||
|--------|----------|-----|
|
||||
| `GET` | `/fechamentos_web?loja=eq.uniao&data=eq.2026-06-19&select=*` | Lista fechamentos |
|
||||
| `GET` | `/fechamentos_web?id_fechamento=eq.uniao_2026-06-19_filipe` | Busca por chave |
|
||||
| `POST` | `/fechamentos_web` | Cria/atualiza (upsert via `Prefer: resolution=merge-duplicates`) |
|
||||
| `PATCH` | `/fechamentos_web?id_fechamento=eq.uniao_2026-06-19_filipe` | Atualiza campos |
|
||||
|
||||
---
|
||||
|
||||
## Header de autenticação
|
||||
|
||||
```http
|
||||
Authorization: Bearer {SUPABASE_SERVICE_ROLE_JWT}
|
||||
apikey: {SUPABASE_ANON_KEY}
|
||||
Content-Type: application/json
|
||||
Prefer: resolution=merge-duplicates (para upsert no POST)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supabase — Configuração do projeto
|
||||
|
||||
- **Projeto:** O Frangão
|
||||
- **Host:** `https://supabase.ofrangao.com.br`
|
||||
- **Banco:** PostgreSQL 15+
|
||||
- **API:** REST via PostgREST
|
||||
- **Auth:** JWT (anon key + service role key)
|
||||
|
||||
O app busca a service_role key via `config.json` local (em `%LOCALAPPDATA%/FechamentoCaixa/config.json`). O fallback é a chave embedada no binário.
|
||||
|
||||
```json
|
||||
// config.json (opcional — sobrepõe a embedada)
|
||||
{
|
||||
"SUPABASE_ANON_KEY": "sb_publishable_...",
|
||||
"SUPABASE_SERVICE_KEY": "sb_secret_..."
|
||||
}
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
-35
@@ -900,14 +900,12 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-shell",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -3281,19 +3279,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -4070,25 +4055,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
@@ -4399,12 +4370,6 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -15,8 +15,6 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde_yaml = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
log = "0.4"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for Fechamento de Caixa",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-confirm",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-open",
|
||||
"fs:default",
|
||||
"fs:allow-app-read",
|
||||
"fs:allow-app-write",
|
||||
"fs:allow-appdata-read",
|
||||
"fs:allow-appdata-write",
|
||||
"fs:allow-appconfig-read",
|
||||
"fs:allow-appconfig-write",
|
||||
"fs:allow-applocaldata-read",
|
||||
"fs:allow-applocaldata-write"
|
||||
]
|
||||
}
|
||||
+32
-18
@@ -19,7 +19,7 @@ fn data_dir_path() -> PathBuf {
|
||||
.join("FechamentoCaixa")
|
||||
}
|
||||
|
||||
fn load_config() -> (String, String) {
|
||||
fn load_config() -> Result<(String, String), String> {
|
||||
let cfg_path = data_dir_path().join("config.json");
|
||||
if let Ok(content) = fs::read_to_string(&cfg_path) {
|
||||
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
@@ -32,15 +32,17 @@ fn load_config() -> (String, String) {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !anon.is_empty() && !service.is_empty() {
|
||||
return (anon, service);
|
||||
return Ok((anon, service));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: tenta variáveis de ambiente
|
||||
(
|
||||
std::env::var("SUPABASE_ANON_KEY").unwrap_or_default(),
|
||||
std::env::var("SUPABASE_SERVICE_KEY").unwrap_or_default(),
|
||||
)
|
||||
Err(format!(
|
||||
"SUPABASE_ANON_KEY e SUPABASE_SERVICE_KEY nao encontradas em config.json.\n\
|
||||
Crie o arquivo {:?} com o seguinte formato:\n\
|
||||
{{\"SUPABASE_ANON_KEY\": \"sua_chave_anon\", \"SUPABASE_SERVICE_KEY\": \"sua_chave_service\"}}\n\
|
||||
Gere as chaves em: https://supabase.ofrangao.com.br/project/default/settings/api",
|
||||
cfg_path
|
||||
))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -55,17 +57,22 @@ fn main() {
|
||||
fs::create_dir_all(&data_dir).expect("não foi possível criar diretório de dados");
|
||||
log::info!("Data dir: {:?}", data_dir);
|
||||
|
||||
// Carrega chaves do Supabase
|
||||
let (anon_key, service_key) = load_config();
|
||||
if anon_key.is_empty() || service_key.is_empty() {
|
||||
log::warn!("SUPABASE_ANON_KEY ou SUPABASE_SERVICE_KEY não encontradas!");
|
||||
log::warn!("Cole-as em: {:?}", data_dir.join("config.json"));
|
||||
log::warn!("Formato: {{\"SUPABASE_ANON_KEY\": \"...\", \"SUPABASE_SERVICE_KEY\": \"...\"}}");
|
||||
} else {
|
||||
log::info!("Supabase: keys carregadas (anon={}..., service={}...)",
|
||||
&anon_key[..8.min(anon_key.len())],
|
||||
&service_key[..8.min(service_key.len())]);
|
||||
}
|
||||
// Carrega chaves do Supabase — opcional. App abre mesmo sem config (modo offline-only).
|
||||
// O usuário pode configurar depois via modal ⚙️.
|
||||
let (anon_key, service_key) = match load_config() {
|
||||
Ok(keys) => {
|
||||
log::info!("Supabase: keys carregadas (anon={}..., service={}...)",
|
||||
&keys.0[..8.min(keys.0.len())],
|
||||
&keys.1[..8.min(keys.1.len())]);
|
||||
keys
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Supabase: configuracao ausente — app abrindo em modo offline-only.");
|
||||
log::warn!("Motivo: {}", e);
|
||||
log::warn!("Use o botao Configurar no app pra definir as chaves.");
|
||||
(String::new(), String::new())
|
||||
}
|
||||
};
|
||||
|
||||
// Inicializa plugins
|
||||
let storage = storage::init(data_dir.clone())
|
||||
@@ -96,6 +103,9 @@ fn main() {
|
||||
plugins::supabase::sb_carregar_rascunho,
|
||||
plugins::supabase::sb_listar_recentes,
|
||||
plugins::supabase::sb_buscar_por_id,
|
||||
plugins::supabase::sb_buscar_por_uuid,
|
||||
plugins::supabase::sb_buscar_por_id_fechamento,
|
||||
plugins::supabase::sb_atualizar_fechamento,
|
||||
plugins::supabase::sb_salvar_listas,
|
||||
plugins::supabase::sb_carregar_listas,
|
||||
plugins::supabase::sb_online,
|
||||
@@ -108,9 +118,13 @@ fn main() {
|
||||
plugins::printer::ativar_modo_teste_impressao,
|
||||
// App
|
||||
plugins::app::get_loja,
|
||||
plugins::app::get_loja_fixa,
|
||||
plugins::app::set_loja,
|
||||
plugins::app::get_config,
|
||||
plugins::app::set_config,
|
||||
// Supabase config (gerenciado pela UI)
|
||||
plugins::app::ler_config_supabase,
|
||||
plugins::app::salvar_config_supabase,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("erro ao inicializar Tauri");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Plugin de configuração da aplicação (loja, preferências)
|
||||
//! Persistido em config.json ao lado do banco SQLite.
|
||||
//!
|
||||
//! LOJA_FIXA: definido em tempo de build via env DOBRADO_LOJA (Uniao | Alianca).
|
||||
//! Quando definido, a loja é travada e o seletor do formulário fica desabilitado.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -7,6 +10,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
/// Indica se a loja foi fixada em tempo de build.
|
||||
pub const LOJA_FIXA: Option<&'static str> = option_env!("DOBRADO_LOJA");
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub loja: String,
|
||||
@@ -15,17 +21,28 @@ pub struct AppConfig {
|
||||
pub turno_default: String,
|
||||
pub sync_automatico: bool,
|
||||
pub auto_open_printer: bool,
|
||||
/// Verdadeiro quando a build tem loja fixa (não permite trocar no formulário)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub loja_fixa: Option<bool>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Loja padrão: o env DOBRADO_LOJA se existir, senão "Uniao"
|
||||
pub fn default_loja() -> String {
|
||||
LOJA_FIXA.unwrap_or("Uniao").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
loja: "Uniao".to_string(),
|
||||
loja: Self::default_loja(),
|
||||
pdv_nome: "PDV-01".to_string(),
|
||||
operador_default: String::new(),
|
||||
turno_default: String::new(),
|
||||
sync_automatico: true,
|
||||
auto_open_printer: false,
|
||||
loja_fixa: LOJA_FIXA.map(|_| true),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,8 +82,17 @@ pub fn get_loja(state: State<'_, AppState>) -> String {
|
||||
state.config.lock().unwrap().loja.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_loja_fixa() -> Option<String> {
|
||||
LOJA_FIXA.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
|
||||
// Se loja_fixa está ativa, não permite alterar
|
||||
if LOJA_FIXA.is_some() {
|
||||
return Err("Loja fixa em tempo de build — não é possível trocar.".to_string());
|
||||
}
|
||||
state.config.lock().unwrap().loja = loja;
|
||||
state.save()
|
||||
}
|
||||
@@ -85,3 +111,87 @@ pub fn set_config(state: State<'_, AppState>, cfg: AppConfig) -> Result<(), Stri
|
||||
pub fn init(data_dir: PathBuf) -> AppState {
|
||||
AppState::new(data_dir)
|
||||
}
|
||||
|
||||
/// Lê o config.json cru e devolve as chaves do Supabase.
|
||||
/// Retorna masked:true se as chaves existirem (sem expor os valores completos).
|
||||
/// Retorna masked:false se o arquivo não existir ou estiver vazio.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SupabaseConfigStatus {
|
||||
pub tem_config: bool,
|
||||
pub anon_masked: String,
|
||||
pub service_masked: String,
|
||||
pub config_path: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn ler_config_supabase() -> SupabaseConfigStatus {
|
||||
let config_path = crate::plugins::app::data_dir_for_status()
|
||||
.join("config.json");
|
||||
|
||||
let (anon, service) = match fs::read_to_string(&config_path) {
|
||||
Ok(content) => {
|
||||
let cfg: serde_json::Value = serde_json::from_str(&content).unwrap_or_default();
|
||||
(
|
||||
cfg.get("SUPABASE_ANON_KEY").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
cfg.get("SUPABASE_SERVICE_KEY").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
)
|
||||
}
|
||||
Err(_) => (String::new(), String::new()),
|
||||
};
|
||||
|
||||
SupabaseConfigStatus {
|
||||
tem_config: !anon.is_empty() && !service.is_empty(),
|
||||
anon_masked: mask_key(&anon),
|
||||
service_masked: mask_key(&service),
|
||||
config_path: config_path.to_string_lossy().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn mask_key(key: &str) -> String {
|
||||
if key.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if key.len() <= 12 {
|
||||
return format!("{}...", &key[..4.min(key.len())]);
|
||||
}
|
||||
format!("{}...{}", &key[..6], &key[key.len()-4..])
|
||||
}
|
||||
|
||||
/// Salva (ou atualiza) as chaves do Supabase no config.json.
|
||||
/// Cria o diretório se necessário. Mantém os outros campos do config.json intactos.
|
||||
#[tauri::command]
|
||||
pub fn salvar_config_supabase(anon_key: String, service_key: String) -> Result<SupabaseConfigStatus, String> {
|
||||
if anon_key.trim().is_empty() || service_key.trim().is_empty() {
|
||||
return Err("As duas chaves são obrigatórias.".to_string());
|
||||
}
|
||||
|
||||
let config_path = crate::plugins::app::data_dir_for_status().join("config.json");
|
||||
if let Some(parent) = config_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Falha ao criar diretório: {}", e))?;
|
||||
}
|
||||
|
||||
// Lê config existente pra preservar outros campos (loja, pdv_nome, etc.)
|
||||
let mut cfg: serde_json::Value = fs::read_to_string(&config_path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
cfg["SUPABASE_ANON_KEY"] = serde_json::Value::String(anon_key.trim().to_string());
|
||||
cfg["SUPABASE_SERVICE_KEY"] = serde_json::Value::String(service_key.trim().to_string());
|
||||
|
||||
let json = serde_json::to_string_pretty(&cfg)
|
||||
.map_err(|e| format!("Falha ao serializar config: {}", e))?;
|
||||
fs::write(&config_path, json).map_err(|e| format!("Falha ao salvar config: {}", e))?;
|
||||
|
||||
log::info!("Supabase config salva em {:?}", config_path);
|
||||
|
||||
Ok(ler_config_supabase())
|
||||
}
|
||||
|
||||
/// Helper pra obter o data_dir sem precisar importar de main.rs.
|
||||
/// Mantém o mesmo cálculo de `main::data_dir_path()`.
|
||||
pub fn data_dir_for_status() -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("FechamentoCaixa")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub const LF: &[u8] = b"\x0A";
|
||||
/// Cortar papel (parcial)
|
||||
pub const CUT: &[u8] = b"\x1D\x56\x01";
|
||||
/// Cortar papel (total)
|
||||
#[allow(dead_code)]
|
||||
pub const CUT_FULL: &[u8] = b"\x1D\x56\x00";
|
||||
/// Alimentar 3 linhas antes de cortar
|
||||
pub const FEED_CUT: &[u8] = b"\x1B\x64\x03";
|
||||
@@ -17,6 +18,7 @@ pub const BOLD_OFF: &[u8] = b"\x1B\x45\x00";
|
||||
/// Alinhamento
|
||||
pub const ALIGN_LEFT: &[u8] = b"\x1B\x61\x00";
|
||||
pub const ALIGN_CENTER: &[u8] = b"\x1B\x61\x01";
|
||||
#[allow(dead_code)]
|
||||
pub const ALIGN_RIGHT: &[u8] = b"\x1B\x61\x02";
|
||||
/// Fonte normal / dupla
|
||||
pub const FONT_NORMAL: &[u8] = b"\x1B\x21\x00";
|
||||
@@ -38,6 +40,7 @@ pub fn title(text: &str) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// Monta linha normal (alinhada à esquerda)
|
||||
#[allow(dead_code)]
|
||||
pub fn line(text: &str) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(ALIGN_LEFT);
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::sync::Mutex;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum PrinterError {
|
||||
#[error("Impressora não encontrada. Verifique se está conectada via USB e ligada.")]
|
||||
NotFound,
|
||||
@@ -30,6 +31,50 @@ use crate::plugins::escpos;
|
||||
|
||||
// ── Struct de dados do recibo ────────────────────────────────────────────────
|
||||
|
||||
/// Linha de uma tabela no recibo (despesas, sangrias, vales, etc.)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ReciboLinha {
|
||||
#[serde(default)]
|
||||
pub desc: Option<String>,
|
||||
#[serde(rename = "hora", default)]
|
||||
pub hora: Option<String>,
|
||||
#[serde(rename = "nome", default)]
|
||||
pub nome: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retirada: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub valor: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub obs: Option<String>,
|
||||
#[serde(default)]
|
||||
pub gerente: Option<String>,
|
||||
#[serde(default)]
|
||||
pub numero: Option<String>,
|
||||
#[serde(default)]
|
||||
pub motivo: Option<String>,
|
||||
}
|
||||
|
||||
impl ReciboLinha {
|
||||
fn fmt_v(&self) -> String {
|
||||
let v = self.retirada.or(self.valor).unwrap_or(0.0);
|
||||
format!("R$ {:.2}", v).replace('.', ",")
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
self.desc
|
||||
.clone()
|
||||
.or(self.nome.clone())
|
||||
.or(self.hora.clone().map(|h| format!("{}h", h)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn obs(&self) -> String {
|
||||
self.gerente
|
||||
.clone()
|
||||
.or(self.obs.clone())
|
||||
.or(self.motivo.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ReciboData {
|
||||
pub loja: String,
|
||||
@@ -37,19 +82,55 @@ pub struct ReciboData {
|
||||
pub operador: String,
|
||||
pub turno: String,
|
||||
pub saldo_troco: f64,
|
||||
/// Valor contado no fechamento (dinheiro + cartões + receber)
|
||||
pub fechamento: f64,
|
||||
/// Saldo esperado = total operações − sangrias − despesas
|
||||
pub saldo_esperado: f64,
|
||||
/// Diferença = fechamento − saldo_esperado
|
||||
pub diferenca: f64,
|
||||
pub sangrias: f64,
|
||||
pub despesas: f64,
|
||||
pub vales: f64,
|
||||
pub areceber: f64,
|
||||
/// Total do sistema (troco + crédito + débito + alimentação + voucher + pixcnpj + vales + receber)
|
||||
#[serde(default)]
|
||||
pub sys_total: f64,
|
||||
/// Soma de todas operações creditadas (crédito + débito + alimentação + voucher + pixcnpj) — usado no BLACK section como TOTAL
|
||||
#[serde(default)]
|
||||
pub total_operado: f64,
|
||||
// Totais de cartões
|
||||
pub credito: f64,
|
||||
pub debito: f64,
|
||||
pub alimentacao: f64,
|
||||
pub voucher: f64,
|
||||
// Totais diversos (mantidos para compatibilidade com JSON antigo)
|
||||
#[serde(default)]
|
||||
pub sangrias: f64,
|
||||
#[serde(default)]
|
||||
pub despesas: f64,
|
||||
#[serde(default)]
|
||||
pub vales: f64,
|
||||
#[serde(default)]
|
||||
pub areceber: f64,
|
||||
#[serde(default)]
|
||||
pub pixcnpj: f64,
|
||||
#[serde(default)]
|
||||
pub cancelamentos: f64,
|
||||
// Arrays para linhas detalhadas (recebidos do frontend)
|
||||
#[serde(default)]
|
||||
pub sangrias_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub despesas_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub pixcnpj_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub vales_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub receber_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub cancelamentos_arr: Vec<ReciboLinha>,
|
||||
#[serde(default)]
|
||||
pub observacoes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub clientes: i32,
|
||||
#[serde(default)]
|
||||
pub frango: i32,
|
||||
}
|
||||
|
||||
impl ReciboData {
|
||||
@@ -58,6 +139,7 @@ impl ReciboData {
|
||||
}
|
||||
|
||||
/// Gera todos os bytes ESC/POS do recibo 80mm
|
||||
/// Layout: Header → SANGRIAS → DESPESAS → PIX CNPJ → VALES → A RECEBER → CANCELAMENTOS → CARTÕES → TROCO/Saldo Caixa → SALDO ESPERADO → DIFERENÇA
|
||||
pub fn to_escpos(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -77,40 +159,194 @@ impl ReciboData {
|
||||
out.extend(escpos::kv("Turno:", &self.turno));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// Entradas
|
||||
out.extend(escpos::line_bold("ENTRADAS"));
|
||||
out.extend(escpos::kv("Saldo Troco:", &self.fmt_money(self.saldo_troco)));
|
||||
out.extend(escpos::kv("Fechamento:", &self.fmt_money(self.fechamento)));
|
||||
out.extend(escpos::kv("Saldo Esperado:", &self.fmt_money(self.saldo_esperado)));
|
||||
out.extend(escpos::divider());
|
||||
// ── SANGRIAS ──
|
||||
if !self.sangrias_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("SANGRIAS"));
|
||||
for linha in &self.sangrias_arr {
|
||||
if linha.retirada.unwrap_or(0.0) > 0.0 || linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let label = linha.label();
|
||||
let obs = linha.obs();
|
||||
out.extend(escpos::kv(&label, &linha.fmt_v()));
|
||||
if !obs.is_empty() {
|
||||
out.extend(escpos::line_center(&format!(" {}", obs)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Total sangrias
|
||||
let total_sangrias: f64 = self.sangrias_arr.iter()
|
||||
.map(|l| l.retirada.unwrap_or(0.0) + l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
if total_sangrias > 0.0 {
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_sangrias)));
|
||||
}
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// Débitos
|
||||
out.extend(escpos::line_bold("DEBITOS"));
|
||||
out.extend(escpos::kv("Sangrias:", &self.fmt_money(self.sangrias)));
|
||||
out.extend(escpos::kv("Despesas:", &self.fmt_money(self.despesas)));
|
||||
out.extend(escpos::kv("Vales:", &self.fmt_money(self.vales)));
|
||||
out.extend(escpos::kv("A Receber:", &self.fmt_money(self.areceber)));
|
||||
out.extend(escpos::kv("PIX CNPJ:", &self.fmt_money(self.pixcnpj)));
|
||||
out.extend(escpos::divider());
|
||||
// ── DESPESAS ──
|
||||
if !self.despesas_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("DESPESAS"));
|
||||
for linha in &self.despesas_arr {
|
||||
if linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let label = linha.label();
|
||||
let obs = linha.obs();
|
||||
out.extend(escpos::kv(&label, &linha.fmt_v()));
|
||||
if !obs.is_empty() {
|
||||
out.extend(escpos::line_center(&format!(" {}", obs)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Total despesas
|
||||
let total_despesas: f64 = self.despesas_arr.iter()
|
||||
.map(|l| l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_despesas)));
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// Cartões
|
||||
out.extend(escpos::line_bold("CARTÕES"));
|
||||
// ── PIX CNPJ ──
|
||||
if !self.pixcnpj_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("PIX CNPJ"));
|
||||
for linha in &self.pixcnpj_arr {
|
||||
if linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let label = linha.nome.clone().unwrap_or_else(|| "PIX CNPJ".to_string());
|
||||
out.extend(escpos::kv(&label, &linha.fmt_v()));
|
||||
}
|
||||
}
|
||||
// Total PIX CNPJ
|
||||
let total_pixcnpj: f64 = self.pixcnpj_arr.iter()
|
||||
.map(|l| l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
if total_pixcnpj > 0.0 {
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_pixcnpj)));
|
||||
}
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// ── VALES ──
|
||||
if !self.vales_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("VALES"));
|
||||
for linha in &self.vales_arr {
|
||||
if linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let label = linha.label();
|
||||
let obs = linha.obs();
|
||||
out.extend(escpos::kv(&label, &linha.fmt_v()));
|
||||
if !obs.is_empty() {
|
||||
out.extend(escpos::line_center(&format!(" {}", obs)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Total vales
|
||||
let total_vales: f64 = self.vales_arr.iter()
|
||||
.map(|l| l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
if total_vales > 0.0 {
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_vales)));
|
||||
}
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// ── A RECEBER ──
|
||||
if !self.receber_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("A RECEBER"));
|
||||
for linha in &self.receber_arr {
|
||||
if linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let label = linha.label();
|
||||
out.extend(escpos::kv(&label, &linha.fmt_v()));
|
||||
}
|
||||
}
|
||||
// Total a receber
|
||||
let total_receber: f64 = self.receber_arr.iter()
|
||||
.map(|l| l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
if total_receber > 0.0 {
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_receber)));
|
||||
}
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// ── CANCELAMENTOS ──
|
||||
if !self.cancelamentos_arr.is_empty() {
|
||||
out.extend(escpos::line_bold("CANCELAMENTOS"));
|
||||
for linha in &self.cancelamentos_arr {
|
||||
if linha.numero.is_some() || linha.valor.unwrap_or(0.0) > 0.0 {
|
||||
let num = linha.numero.clone().unwrap_or_default();
|
||||
out.extend(escpos::kv(&num, &linha.fmt_v()));
|
||||
if let Some(ref m) = linha.motivo {
|
||||
if !m.is_empty() {
|
||||
out.extend(escpos::line_center(&format!(" {}", m)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Total cancelamentos
|
||||
let total_cancel: f64 = self.cancelamentos_arr.iter()
|
||||
.map(|l| l.valor.unwrap_or(0.0))
|
||||
.sum();
|
||||
if total_cancel > 0.0 {
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(total_cancel)));
|
||||
}
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// ── CARTÕES ──
|
||||
out.extend(escpos::line_bold("CARTOES"));
|
||||
out.extend(escpos::kv("Credito:", &self.fmt_money(self.credito)));
|
||||
out.extend(escpos::kv("Debito:", &self.fmt_money(self.debito)));
|
||||
out.extend(escpos::kv("Alimentacao:", &self.fmt_money(self.alimentacao)));
|
||||
out.extend(escpos::kv("Voucher:", &self.fmt_money(self.voucher)));
|
||||
out.extend(escpos::kv("PIX Cartao:", &self.fmt_money(self.voucher)));
|
||||
out.extend(escpos::kv("Cancelamentos:", &self.fmt_money(self.cancelamentos)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// Diferença
|
||||
let diff_str = if self.diferenca >= 0.0 {
|
||||
format!("+{}", self.fmt_money(self.diferenca))
|
||||
// ── TROCO / SALDO DE CAIXA ──
|
||||
out.extend(escpos::line_bold("SALDO DE CAIXA"));
|
||||
out.extend(escpos::kv("Troco (+):", &self.fmt_money(self.saldo_troco)));
|
||||
out.extend(escpos::kv("Credito (+):", &self.fmt_money(self.credito)));
|
||||
out.extend(escpos::kv("Debito (+):", &self.fmt_money(self.debito)));
|
||||
out.extend(escpos::kv("Alimentacao (+):", &self.fmt_money(self.alimentacao)));
|
||||
out.extend(escpos::kv("PIX Cartao (+):", &self.fmt_money(self.voucher)));
|
||||
out.extend(escpos::kv("Vales (+):", &self.fmt_money(self.vales)));
|
||||
out.extend(escpos::kv("A Receber (+):", &self.fmt_money(self.areceber)));
|
||||
out.extend(escpos::kv("PIX CNPJ (+):", &self.fmt_money(self.pixcnpj)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// ── TOTAL ──
|
||||
if self.sys_total > 0.0 {
|
||||
out.extend(escpos::line_bold("TOTAL"));
|
||||
out.extend(escpos::kv("Total:", &self.fmt_money(self.sys_total)));
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
|
||||
// ── SALDO ESPERADO ──
|
||||
out.extend(escpos::line_bold("SALDO ESPERADO"));
|
||||
out.extend(escpos::kv("Esperado:", &self.fmt_money(self.saldo_esperado)));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// ── DIFERENÇA ──
|
||||
// Usa o valor calculado pelo frontend (self.diferenca).
|
||||
// O frontend já subtrai saldo_esperado e cancelamentos de sys_total.
|
||||
out.extend(escpos::line_bold("DIFERENCA"));
|
||||
let diff = self.diferenca;
|
||||
let diff_str = if diff >= 0.0 {
|
||||
format!("+{}", self.fmt_money(diff))
|
||||
} else {
|
||||
self.fmt_money(self.diferenca)
|
||||
self.fmt_money(diff)
|
||||
};
|
||||
out.extend(escpos::line_bold("DIFERENCA:"));
|
||||
out.extend(escpos::title(&diff_str));
|
||||
out.extend(escpos::blank_lines(2));
|
||||
|
||||
// ── CONTADORES ──
|
||||
out.extend(escpos::line_bold("CONTADORES"));
|
||||
out.extend(escpos::kv("Clientes:", &self.clientes.to_string()));
|
||||
out.extend(escpos::kv("Frango Assado:", &self.frango.to_string()));
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// ── OBSERVAÇÕES ──
|
||||
if let Some(ref obs) = self.observacoes {
|
||||
if !obs.trim().is_empty() {
|
||||
out.extend(escpos::line_bold("OBSERVACOES"));
|
||||
out.extend(escpos::line_center(obs.trim()));
|
||||
out.extend(escpos::divider());
|
||||
}
|
||||
}
|
||||
|
||||
// Rodapé
|
||||
out.extend(escpos::divider());
|
||||
@@ -122,6 +358,7 @@ impl ReciboData {
|
||||
|
||||
// Corta papel
|
||||
out.extend(escpos::FEED_CUT);
|
||||
out.extend(escpos::CUT);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
@@ -335,6 +335,7 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -346,6 +347,7 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// Remove um registro (para debugging).
|
||||
#[allow(dead_code)]
|
||||
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])?;
|
||||
@@ -508,7 +510,7 @@ pub fn carregar_rascunho(
|
||||
// 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 _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();
|
||||
|
||||
@@ -7,7 +7,31 @@ 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),
|
||||
@@ -68,58 +92,76 @@ impl SupabaseClient {
|
||||
|
||||
/// 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")
|
||||
// Normaliza loja e turno para minúsculo (CHECK constraints do Postgres)
|
||||
let loja = estado
|
||||
.get("loja")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
.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!({
|
||||
"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"],
|
||||
"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": "rascunho",
|
||||
"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),
|
||||
});
|
||||
|
||||
let _url = format!(
|
||||
"{}/rest/v1/fechamentos_web?uuid=eq.{}&select=id",
|
||||
self.base_url, uuid
|
||||
);
|
||||
|
||||
// Upsert via POST com Prefer: resolution=merge-duplicates
|
||||
// Primeiro tenta INSERT (upsert via Prefer)
|
||||
let resp = self
|
||||
.http
|
||||
.post(&format!("{}/rest/v1/rpc/upsert_fechamento", self.base_url))
|
||||
.post(&format!("{}/rest/v1/fechamentos_web", 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, "uuid": uuid }))
|
||||
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();
|
||||
// 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
|
||||
)))
|
||||
}
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +194,127 @@ impl SupabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lista fechamentos recentes de uma loja.
|
||||
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, SupabaseError> {
|
||||
/// Busca um fechamento pelo campo "id" (UUID externo).
|
||||
pub fn buscar_por_uuid(&self, uuid: &str) -> Result<Option<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",
|
||||
"{}/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<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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Busca um fechamento pelo id_fechamento (ex: uniao_2026-06-24_silanea).
|
||||
pub fn buscar_por_id_fechamento(&self, id_fechamento: &str) -> Result<Option<Value>, 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<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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Atualiza campos específicos de um fechamento pelo id_fechamento.
|
||||
pub fn atualizar_fechamento(&self, id_fechamento: &str, updates: &serde_json::Value) -> Result<Value, SupabaseError> {
|
||||
// Campos que o frontend envia mas NÃO existem como colunas na tabela.
|
||||
// Arrays e objetos são guardados no JSONB `dados` — não em colunas escalar.
|
||||
static BLOCKED: &[&str] = &[
|
||||
"id", "uuid", "criado_em", "atualizado_em", "enviado_por",
|
||||
"ip_origem", "user_agent", "id_fechamento",
|
||||
// arrays e objetos que existem dentro do JSONB `dados`
|
||||
"vales", "despesas", "sangrias", "cancelamentos",
|
||||
"pixcnpj", "receber",
|
||||
"cartoes_credito", "cartoes_debito",
|
||||
"cartoes_alimentacao", "cartoes_pix",
|
||||
];
|
||||
|
||||
let map = updates.as_object().cloned().unwrap_or_default();
|
||||
|
||||
// Remove campos bloqueados
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = map
|
||||
.into_iter()
|
||||
.filter(|(k, _)| !BLOCKED.contains(&k.as_str()))
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return Err(SupabaseError::Api("Nenhum campo para atualizar".into()));
|
||||
}
|
||||
|
||||
let url = format!(
|
||||
"{}/rest/v1/fechamentos_web?id_fechamento=eq.{}",
|
||||
self.base_url,
|
||||
urlencoding::encode(loja),
|
||||
urlencoding::encode(id_fechamento)
|
||||
);
|
||||
|
||||
log::info!("[PATCH] url={} body={:#?}", url, filtered);
|
||||
|
||||
let resp = self
|
||||
.http
|
||||
.patch(&url)
|
||||
.headers(self.headers(true))
|
||||
.header("Prefer", "return=representation")
|
||||
.json(&filtered)
|
||||
.send()?;
|
||||
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
// Ler a resposta real do Supabase (retorna o registro atualizado)
|
||||
if let Ok(ret) = resp.json::<serde_json::Value>() {
|
||||
log::info!("[PATCH] sucesso — resposta: {}", ret);
|
||||
} else {
|
||||
log::warn!("[PATCH] sucesso mas não conseguiu ler resposta");
|
||||
}
|
||||
Ok(serde_json::json!({ "ok": true, "id_fechamento": id_fechamento }))
|
||||
} else {
|
||||
let body = resp.text().unwrap_or_default();
|
||||
log::error!("[PATCH] HTTP {} body: {}", status.as_u16(), body);
|
||||
Err(SupabaseError::Api(format!(
|
||||
"HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body
|
||||
)))
|
||||
}
|
||||
}
|
||||
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, 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_credito,saldo_debito,saldo_alimentacao,saldo_vales,saldo_areceber,saldo_esperado,total_pixcnpj,total_cartao_credito,total_cartao_debito,total_cartao_alimentacao,total_cartao_pix,total_cancelamentos,fechamento_dinheiro,fechamento_cartoes,fechamento_areceber,diferenca,status_diferenca,observacoes,atualizado_em,dados",
|
||||
self.base_url,
|
||||
urlencoding::encode(&loja_normalized),
|
||||
limite,
|
||||
);
|
||||
|
||||
@@ -273,6 +430,48 @@ impl SupabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -321,6 +520,32 @@ pub fn sb_buscar_por_id(
|
||||
sb.buscar_por_id(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_buscar_por_uuid(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
uuid: String,
|
||||
) -> Result<Option<serde_json::Value>, 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<Option<serde_json::Value>, SupabaseError> {
|
||||
sb.buscar_por_id_fechamento(&id_fechamento)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "snake_case")]
|
||||
pub fn sb_atualizar_fechamento(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
id_fechamento: String,
|
||||
updates: serde_json::Value,
|
||||
) -> Result<serde_json::Value, SupabaseError> {
|
||||
sb.atualizar_fechamento(&id_fechamento, &updates)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn sb_salvar_listas(
|
||||
sb: State<'_, SupabaseClient>,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Fechamento de Caixa — O Frangão",
|
||||
"title": "Fechamento de Caixa \u2014 O Frang\u00e3o",
|
||||
"width": 1100,
|
||||
"height": 780,
|
||||
"minWidth": 900,
|
||||
@@ -22,13 +22,18 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"capabilities": [
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"withGlobalTauri": true
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis"],
|
||||
"targets": [
|
||||
"nsis"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -37,7 +42,9 @@
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": { "type": "embedBootstrapper" }
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
"../src/*": "./"
|
||||
|
||||
+2416
-290
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user