Compare commits

...
4 Commits
Author SHA1 Message Date
Felipe 4c4f573cd1 Fix button colors. 2026-03-04 22:46:27 -03:00
Felipe 857fcd6a82 Fix SalvarLayoutConfig 2026-03-04 21:45:52 -03:00
Felipe 841dfadde5 update 2026-03-04 18:33:32 -03:00
Felipe 3af14ab957 Create AGENTS.md 2026-03-04 18:18:11 -03:00
18 changed files with 1165 additions and 1538 deletions
-353
View File
@@ -1,353 +0,0 @@
# FIX — Salvar Layout no Banco de Dados
**Data:** 04/03/2026
**Versão do PRD:** 1.7
**Status:** Pendente de Implementação
---
## Sumário dos Problemas
| # | Severidade | Arquivo(s) | Descrição |
|---|-----------|------------|-----------|
| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado |
| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations |
| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa |
| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco |
| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI |
| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas |
| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` |
| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite |
| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout |
---
## Problema 1 (Crítico) — `migration_v3` sem transação atômica
### Local
`src/infrastructure/sqlite/migrations.rs` — função `migration_v3`
### Situação Atual
```rust
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
)?;
Ok(())
}
```
`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial:
- `indice_documento_tipo` existe, `pos_documento_tipo` não existe.
- A versão no banco não é atualizada (o erro propaga antes).
- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"`**o app nunca mais inicializa sem intervenção manual**.
### Correção Necessária
Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**:
```rust
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
UPDATE schema_version SET versao = 3;
COMMIT;
")?;
Ok(())
}
```
> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`.
---
## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations
### Local
`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations`
### Situação Atual
O fluxo atual é:
1. Executa `migration_v1(conn)?`
2. Executa `migration_v2(conn)?`
3. Executa `migration_v3(conn)?`
4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...`
Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura.
### Correção Necessária
Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas.
Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão:
```rust
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE TABLE IF NOT EXISTS layouts ( ... );
INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1);
COMMIT;
")?;
Ok(())
}
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch("
BEGIN;
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
-- renomeia duplicatas existentes se houver
UPDATE schema_version SET versao = 2;
COMMIT;
")?;
Ok(())
}
```
---
## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado`
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout`
### Situação Atual
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
```
Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria:
- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado"
- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado"
- `"attempt to write a readonly database"` → exibido como "JSON malformado"
### Correção Necessária
1. Adicionar variante própria em `domain/errors.rs`:
```rust
pub enum ErroLayout {
// ... variantes existentes ...
ErroBanco(String), // erros genéricos de I/O do banco
NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE)
}
```
2. No use case, inspecionar o erro antes de mapear:
```rust
layout_repository::salvar(conn, layout)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante
} else {
ErroLayout::ErroBanco(msg)
}
})?;
```
3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`.
---
## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco
### Local
`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV)
### Situação Atual
O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle.
### Correção Necessária
Normalizar o delimitador para um token legível antes de salvar:
```rust
// Ao salvar:
let delim_str = match config.delimitador {
'\t' => "tab".to_string(),
c => c.to_string(),
};
// Ao ler:
let delimitador = match delim_str.as_str() {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
};
```
> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts.
---
## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI
### Local
`src/application/usecases/layouts.rs` — função `salvar_layout`
### Situação Atual
A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option<String>`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto.
### Correção Necessária
Expandir a validação no use case `salvar_layout`:
```rust
match layout {
Layout::Csv { config, .. } => {
// indice_numero e indice_serie são usize — sempre válidos se presentes
// nenhuma validação adicional necessária aqui
}
Layout::Xlsx { config, .. } => {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_numero".to_string()));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
}
```
---
## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)`
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `listar`
### Situação Atual
O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação.
### Correção Necessária
Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`:
```rust
// Em vez de:
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
// Usar:
let id: i64 = row.get("id")?;
let nome: String = row.get("nome")?;
```
O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível.
---
## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada
### Local
`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs`
### Situação Atual
No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima).
### Correção Necessária
No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID):
```rust
// Verificar conflito excluindo o próprio registro
if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? {
return Err(ErroLayout::NomeConflitante);
}
```
Adicionar função no repository:
```rust
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option<i64>) -> Result<bool> {
match id {
Some(id) => {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
params![nome, id],
|row| row.get(0),
)?;
Ok(count > 0)
}
None => layout_repository::existe_nome(conn, nome),
}
}
```
---
## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco
### Local
`src/infrastructure/sqlite/connection.rs`
### Situação Atual
```rust
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* trata como banco corrompido */ }
}
```
`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente.
### Correção Necessária
```rust
match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)),
Err(_) => { /* banco corrompido */ }
}
```
`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`.
---
## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto
### Local
`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar`
### Situação Atual
O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco.
### Correção Necessária
Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE:
```sql
-- UPDATE CSV: zerar campos XLSX
UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4,
indice_numero = ?5, indice_serie = ?6, indice_valor = ?7,
indice_data = ?8, indice_documento_tipo = ?9,
-- zerar campos XLSX:
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10
```
```sql
-- UPDATE XLSX: zerar campos CSV
UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
-- zerar campos CSV:
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
indice_data = NULL, indice_documento_tipo = NULL
WHERE id = ?8
```
---
## Ordem de Implementação Recomendada
1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro.
2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado.
3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos.
4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3.
5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash.
6. **Problema 6** (índices posicionais) — Refactor preventivo.
7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade.
8. **Problema 9** (zerar campos opostos) — Limpeza defensiva.
-504
View File
@@ -1,504 +0,0 @@
# Migração egui → iced: Estado e Referência
**Branch:** `change-ui`
**Versão iced:** 0.13.1
**Última atualização:** 04/03/2026
**Status:****COMPILANDO**`cargo build` passa sem erros (5 warnings não-bloqueantes, todos em camadas intocáveis)
---
## Estado Atual
### O que foi feito (100% completo)
| Arquivo | Status | Descrição |
|---|---|---|
| `Cargo.toml` | ✅ | `eframe`/`egui`/`image` removidos; `iced 0.13` + `tokio` adicionados |
| `src/main.rs` | ✅ | Reescrito com `iced::application(...)` |
| `src/ui/mod.rs` | ✅ | `pub mod app; pub mod components; pub mod message; pub mod screens;` |
| `src/ui/message.rs` | ✅ | Enum `Message` completo + enum `ResultadoPendente` |
| `src/ui/app.rs` | ✅ | Reescrito para iced (Elm architecture) — `App`, `update()`, `view()` |
| `src/ui/screens/mod.rs` | ✅ | Re-exports + `pub fn indice_para_letra(idx: usize) -> String` |
| `src/ui/screens/import.rs` | ✅ | Tela de importação — pick_list de layout usa `LayoutSelecionado` |
| `src/ui/screens/selecionar_aba.rs` | ✅ | Tela de seleção de aba XLSX |
| `src/ui/screens/configuracao_colunas.rs` | ✅ | Tela de configuração — pick_list de layout usa `LayoutSelecionado` |
| `src/ui/screens/resultado.rs` | ✅ | Tela de resultado — botão "Nova Análise" usa `Message::NovaAnalise` |
| `src/ui/screens/layouts.rs` | ✅ | Tela de gerenciamento de layouts |
| `src/ui/components/mod.rs` | ✅ | `pub mod modal; pub mod paginacao; pub mod tabela_preview;` |
| `src/ui/components/modal.rs` | ✅ | Overlay real com `stack!` + `mouse_area` |
| `src/ui/components/tabela_preview.rs` | ✅ | Tabela com scroll horizontal e cabeçalho estilo Excel |
| `src/ui/components/paginacao.rs` | ✅ | Controles reutilizáveis de paginação |
### O que NÃO foi alterado (intocado por design)
- `src/domain/` — entidades, serviços, erros
- `src/application/` — casos de uso
- `src/infrastructure/` — leitores CSV/XLSX, gerador PDF, SQLite
---
## Arquitetura da UI
### Padrão Elm (iced)
```
App (estado) ──→ view(&self) ──→ Element (widgets renderizados)
↑ ↓
│ usuário interage
│ ↓
└── update(&mut self, msg) ←── Message (enum)
retorna Task<Message>
```
- `view()` é **pura e somente leitura** — nunca muta estado
- Toda mutação acontece **exclusivamente** em `update()`
- Background tasks retornam via `Task::perform(async { ... }, Message::Variante)`
### Estrutura de arquivos
```
src/
├── main.rs
└── ui/
├── mod.rs
├── app.rs (struct App + update + view)
├── message.rs (enum Message + ResultadoPendente)
├── screens/
│ ├── mod.rs (re-exports + indice_para_letra)
│ ├── import.rs
│ ├── selecionar_aba.rs
│ ├── configuracao_colunas.rs
│ ├── resultado.rs
│ └── layouts.rs
└── components/
├── mod.rs
├── modal.rs
├── tabela_preview.rs
└── paginacao.rs
```
---
## Diferenças Críticas da API — iced 0.13
Estas diferenças causaram erros de compilação e **devem ser lembradas** em qualquer adição futura:
### 1. `Command` foi renomeado para `Task`
```rust
// ❌ NÃO EXISTE em iced 0.13
use iced::Command;
Command::none()
// ✅ CORRETO
use iced::Task;
Task::none()
Task::perform(future, mapper)
```
Todos os retornos de `update()` são `Task<Message>`.
### 2. `.align_items()` foi dividido
```rust
// ❌ NÃO EXISTE em iced 0.13
row![...].align_items(Alignment::Center)
// ✅ CORRETO — Row usa align_y, Column usa align_x
row![...].align_y(Alignment::Center)
column![...].align_x(Alignment::Center)
```
### 3. `.center_x()` / `.center_y()` exigem argumento `Length`
```rust
// ❌ NÃO COMPILA
container(...).center_x().center_y()
// ✅ CORRETO
container(...).center_x(Length::Fill).center_y(Length::Fill)
```
### 4. `iced::clipboard::write` retorna `Task<Message>`
```rust
// Pode ser retornado diretamente de update():
return iced::clipboard::write(texto);
```
### 5. Entry point usa API funcional, não trait `Application`
```rust
// main.rs
fn main() -> iced::Result {
iced::application("Título", App::update, App::view)
.window(iced::window::Settings { ... })
.run_with(App::new)
}
// App::new retorna (Self, Task<Message>)
pub fn new() -> (Self, Task<Message>) { ... }
```
### 6. Lifetimes em funções que retornam `Element`
Sempre usar `Element<'_, Message>` (não `Element<Message>`) em funções que recebem referências:
```rust
// ❌ Gera warning mismatched_lifetime_syntaxes
pub fn view(app: &App) -> Element<Message>
// ✅ CORRETO
pub fn view(app: &App) -> Element<'_, Message>
```
**Armadilha com Vec local:** criar `Vec<T>` local e passar `&vec` para função que retorna `Element<'a>` causa E0515. Solução: filtrar diretamente de dados que vivem no `&app`.
### 7. Borrow checker com `Arc<Mutex<Connection>>`
```rust
// ❌ Mantém borrow imutável de &self enquanto tenta &mut self
if let Some(conn_arc) = &self.conn {
let conn = conn_arc.lock().unwrap();
self.exibir_erro(...); // ERRO: borrow ativo
}
// ✅ Clonar o Arc primeiro (O(1), não clona a Connection)
let conn_arc = self.conn.as_ref().map(Arc::clone);
if let Some(conn_arc) = conn_arc {
let conn = conn_arc.lock().unwrap();
drop(conn); // liberar antes de &mut self
self.exibir_erro(...);
}
```
---
## Componentes Implementados
### `App` (src/ui/app.rs)
**Estado:**
```rust
pub struct App {
pub estado: EstadoApp,
pub conn: Option<Arc<Mutex<Connection>>>,
pub banco_foi_recriado: bool,
pub notas_importadas: Vec<Nota>,
pub caminho_arquivo: Option<PathBuf>, // ← populado para CSV e XLSX
pub nome_arquivo: String,
pub tipo_arquivo_atual: TipoArquivo,
pub layout_csv_atual: LayoutCsv,
pub layout_xlsx_atual: LayoutXlsx,
pub nome_layout_atual: String,
pub abas_xlsx: Vec<String>,
pub layouts_salvos: Vec<Layout>,
pub modal: Option<EstadoModal>,
pub avisos_importacao: Option<ResumoAvisos>,
pub pagina_faltantes: usize,
pub pagina_duplicatas: usize,
pub itens_por_pagina: usize,
pub preview_arquivo: Option<Vec<Vec<String>>>,
pub resultado_anterior: Option<ResultadoAnalise>,
}
```
**Máquina de estados (`EstadoApp`):**
```
Importando
→ ArquivoSelecionado (CSV) → ConfigurandoColunas
→ ArquivoSelecionado (XLSX) → SelecionandoAba → ConfigurandoColunas
ConfigurandoColunas
→ ExecutarImportacao → Analisando
Analisando
→ AnaliseCompleta (ok, sem excessivos) → ExibindoResultado
→ AnaliseCompleta (excessivos) → ConfirmandoIntervalo (+ modal)
ConfirmandoIntervalo
→ ModalConfirmado (ConfirmarExpansaoFaltantes) → Analisando → ExibindoResultado
→ ModalCancelado → ConfigurandoColunas
ExibindoResultado
→ ReanalisarArquivo → Analisando
→ IrParaConfiguracaoColunas → ConfigurandoColunas
→ NovaAnalise (modal) → Importando (após confirmação)
GerenciandoLayouts
→ IrParaImportacao → Importando
```
**Modal (`EstadoModal` / `AcaoModal`):**
```rust
pub enum EstadoModal {
Informacao { titulo: String, mensagem: String }, // definido, não usado ainda
Aviso { titulo: String, mensagem: String },
Erro { titulo: String, mensagem: String },
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
InputTexto { titulo: String, mensagem: String, texto: String, acao: AcaoModal },
}
pub enum AcaoModal {
ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64),
SobrescreverLayout(Layout),
ConfirmarNovaAnalise,
SalvarLayoutConfig,
}
```
**Comportamento do `ModalCancelado`:**
- Fecha o modal (`self.modal = None`)
- Se o estado for `ConfirmandoIntervalo`, **também** reseta para `ConfigurandoColunas`
### `Message` (src/ui/message.rs) — enum completo
```rust
pub enum Message {
// Inicialização
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
LayoutsRecarregados(Vec<Layout>),
// Navegação
IrParaImportacao, IrParaConfiguracaoColunas, IrParaLayouts, Voltar,
// Arquivo
SelecionarArquivo,
ArquivoSelecionado(PathBuf),
AbaSelecionada(String),
AbaxlsxCarregadas { caminho: PathBuf, abas: Vec<String>, layout_xlsx: LayoutXlsx, nome_layout: String },
XlsxErroAoCarregar(String),
// Background
AnaliseCompleta(ResultadoPendente),
// Config CSV
DelimitadorAlterado(char), EncodingAlterado(String), LinhaCabecalhoAlterada(usize),
IndiceNumeroAlterado(usize), IndiceSerieAlterado(usize),
IndiceValorToggle(bool), IndiceValorAlterado(usize),
IndiceDataToggle(bool), IndiceDataAlterado(usize),
IndiceDocTipoToggle(bool), IndiceDocTipoAlterado(usize),
// Config XLSX
AbaXlsxAlterada(String),
PosNumeroAlterada(String), PosSerieAlterada(String),
PosValorToggle(bool), PosValorAlterada(String),
PosDataToggle(bool), PosDataAlterada(String),
PosDocTipoToggle(bool), PosDocTipoAlterada(String),
// Análise
ExecutarImportacao, ReanalisarArquivo,
ConfirmarExpansaoFaltantes,
CancelarExpansao, // arm existe em update() mas nenhum widget o dispara diretamente
NovaAnalise, // abre modal de confirmação (AcaoModal::ConfirmarNovaAnalise)
// Resultado
PaginaFaltantesAlterada(usize), PaginaDuplicatasAlterada(usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie), CopiarDuplicatas(ChaveSerie),
ExportarPdf, PdfExportado(Result<PathBuf, String>),
// Layouts
LayoutSelecionado(i64), // aplica layout (chama aplicar_layout) + atualiza nome
SalvarLayout, // abre modal InputTexto
NomeLayoutAlterado(String),
ExcluirLayout(i64), ExclusaoConfirmada(i64),
ExportarLayoutJson(i64), ImportarLayoutJson, LayoutJsonImportado(String),
SobrescreverLayout(Layout),
// Modal
ModalTextoAlterado(String), ModalConfirmado, ModalCancelado,
Noop,
}
```
### `modal.rs` (src/ui/components/modal.rs)
Overlay real: `stack![conteudo, overlay_escuro]`. O `mouse_area` captura cliques fora da caixa e dispara `ModalCancelado`.
```rust
pub fn view_com_modal<'a>(
conteudo: Element<'a, Message>,
modal: &'a EstadoModal,
) -> Element<'a, Message>
```
Botões da caixa modal:
- Sempre: **"Fechar"** → `ModalCancelado`
- Se `com_confirmar`: **"Confirmar"** → `ModalConfirmado`
### `tabela_preview.rs` (src/ui/components/tabela_preview.rs)
Cabeçalho com letras estilo Excel (A, B, C...) + índice numérico. Scroll horizontal. Trunca células em 30 caracteres. Altura fixa de 160px.
### `paginacao.rs` (src/ui/components/paginacao.rs)
```rust
pub fn controles_paginacao(
pagina_atual: usize,
total_paginas: usize,
msg_anterior: Message,
msg_proximo: Message,
) -> Element<'static, Message>
```
Botões ◀ / ▶ desabilitados automaticamente na primeira/última página via `on_press_maybe`.
---
## Regras de Seleção de Layout (pick_list)
**Em todas as telas** que exibem um `pick_list` de layouts, o handler **deve** usar `LayoutSelecionado(id)`, não `NomeLayoutAlterado`:
```rust
pick_list(opcoes_layout, nome_sel, {
let layouts = app.layouts_salvos.clone();
move |nome: String| {
if let Some(id) = layouts.iter().find(|l| l.nome() == nome).and_then(|l| l.id()) {
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome) // fallback (nunca deve ocorrer com layouts do banco)
}
}
})
```
Isso vale para: `import.rs`, `configuracao_colunas.rs`. A diferença:
- `NomeLayoutAlterado` — só atualiza `self.nome_layout_atual` (string), **não preenche o formulário**
- `LayoutSelecionado(id)` — chama `aplicar_layout()`, que preenche `layout_csv_atual` ou `layout_xlsx_atual`
---
## Fluxo XLSX — Detalhe Crítico
### Onde `caminho_arquivo` é populado
Para **CSV**: em `processar_arquivo_selecionado()`, logo após detectar a extensão.
Para **XLSX**: o caminho fica dentro do estado `SelecionandoAba { caminho }` e é copiado para `self.caminho_arquivo` **somente quando o usuário seleciona uma aba** (handler `AbaSelecionada`).
```
ArquivoSelecionado(xlsx)
→ AbaxlsxCarregadas
→ aba já definida no layout e existe no arquivo?
Sim → caminho_arquivo setado aqui → disparar_importacao()
Não → estado = SelecionandoAba { caminho } ← caminho_arquivo ainda None aqui
→ AbaSelecionada(aba)
→ caminho_arquivo = Some(caminho) ← só aqui é setado
→ estado permanece SelecionandoAba
→ IrParaConfiguracaoColunas
→ estado = ConfigurandoColunas
→ tem_arquivo = true ✓
```
**Consequência:** nunca verificar `app.caminho_arquivo.is_some()` como condição de habilitação antes do usuário ter selecionado uma aba no fluxo XLSX.
---
## Background Tasks
Todas as operações pesadas usam `Task::perform`:
```rust
// Padrão para operações síncronas em background:
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
// código síncrono (rusqlite, calamine, etc.)
})
.await
.unwrap_or_else(|e| ResultadoPendente::Erro(e.to_string()))
},
Message::AnaliseCompleta,
)
```
| Operação | Message de retorno |
|---|---|
| Abrir banco + migrations + listar layouts | `BancoInicializado` |
| Listar layouts após salvar/excluir | `LayoutsRecarregados` |
| Listar abas de arquivo XLSX | `AbaxlsxCarregadas` / `XlsxErroAoCarregar` |
| Importar CSV/XLSX + analisar | `AnaliseCompleta` |
| Expandir análise com faltantes | `AnaliseCompleta` |
| Exportar PDF | `PdfExportado` |
| Exportar layout JSON | `Noop` (salva diretamente) |
| Importar layout JSON | `LayoutJsonImportado` |
**Diálogos de arquivo** usam `rfd::AsyncFileDialog` (não bloqueia):
```rust
Task::perform(
async {
rfd::AsyncFileDialog::new()
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
.pick_file()
.await
.map(|h| h.path().to_path_buf())
},
|r| r.map(Message::ArquivoSelecionado).unwrap_or(Message::Noop),
)
```
---
## Warnings Existentes (não-bloqueantes)
| Warning | Local | Situação |
|---|---|---|
| `field data is never read` | `domain/nota.rs` | Domínio intocável — ignorar |
| `fields minimo/maximo never read` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
| `method sem_inconsistencias never used` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
| `variant Informacao never constructed` | `ui/app.rs` | Mantido para uso futuro (modal informativo) |
| `variant CancelarExpansao never constructed` | `ui/message.rs` | O arm existe em `update()`, mas nenhum widget o dispara; o cancelamento do intervalo ocorre via `ModalCancelado` |
---
## O Que Falta Implementar
### Bugs/lacunas conhecidos
1. **`selecionar_aba.rs` — botões sem diferenciação visual entre aba selecionada e não selecionada** — ambos os branches do `if selecionada` são idênticos (sem estilo diferente). A aba ativa deveria ter destaque visual.
2. **`selecionar_aba.rs` — layout não é aplicável nessa tela** — se o usuário chegou aqui sem ter selecionado um layout previamente, não há como selecionar um layout antes de avançar para ConfigurandoColunas. Considerar adicionar um `pick_list` de layouts na tela `SelecionandoAba`.
3. **Sem tema dark/light** — usa o tema padrão do sistema. Para adicionar:
```rust
// Em main.rs: encadear .theme(|app, _| app.tema.clone())
// Em App: campo pub tema: iced::Theme
// Mensagem: Message::TemaAlterado(iced::Theme)
```
4. **Sem drag-and-drop de arquivos** — importação tem apenas o botão de seleção. Verificar suporte nativo em iced 0.13 antes de implementar.
5. **Sem filtro/busca nos resultados** — sem `text_input` para filtrar `faltantes_por_serie`.
6. **`EstadoModal::Informacao` nunca é usado** — definido mas sem chamadas. Remover ou usar.
### Funcionalidades futuras (Fase 6 do plano original)
- Histórico de análises (nova tela + schema SQLite v4)
- Gráficos de completude por série
- Exportação para Excel/CSV
---
## Como Rodar
```bash
# Verificar sem compilar binário (rápido)
cargo check
# Compilar (primeira vez ~1 min por baixar iced + wgpu)
cargo build
# Executar em modo debug
cargo run
# Build de release (otimizado, sem console no Windows)
cargo build --release
```
---
## Referências
- [iced 0.13 changelog](https://github.com/iced-rs/iced/blob/master/CHANGELOG.md)
- [iced exemplos oficiais](https://github.com/iced-rs/iced/tree/master/examples)
- [rfd AsyncFileDialog](https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.html)
- [iced::clipboard::write](https://docs.rs/iced/0.13.1/iced/clipboard/fn.write.html)
-217
View File
@@ -1,217 +0,0 @@
# Roteiro de Desenvolvimento — Comparador de Notas
**Gerado em:** 04/03/2026
**Baseado em:** PRD v1.7 + análise estática do código atual
---
## 1. Warnings do Compilador
São 7 warnings ativos (`cargo build`). Nenhum é crítico, mas todos devem ser eliminados para manter o código limpo.
### W-01 — `Nota.data` nunca lida
**Arquivo:** `src/domain/entities/nota.rs:17`
**Causa:** O campo `data: Option<NaiveDate>` é armazenado na struct `Nota`, mas nenhum código de negócio ou de UI o consome atualmente.
**Contexto PRD:** O campo Data é definido no modelo de dados (seção 5) como opcional. O PRD diz que é "exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo."
**Solução:** Usar o campo `data` na geração do PDF (`pdf_generator.rs`), exibindo a data da nota nas seções de faltantes ou duplicatas quando disponível. Isso resolve o warning e implementa o requisito do PRD.
---
### W-02 — `IntervaloSerie.minimo` e `IntervaloSerie.maximo` nunca lidos
**Arquivo:** `src/domain/entities/resultado_analise.rs:23-24`
**Causa:** Os campos `minimo: u64` e `maximo: u64` existem na struct `IntervaloSerie`, mas nenhum código os consome após o cálculo.
**Contexto PRD:** RF04 define que o intervalo de faltantes é avaliado entre o menor e o maior número encontrado. O aviso de confirmação (RF04 — Proteção contra intervalos anormalmente grandes) exibe o intervalo calculado ao usuário.
**Solução:** Exibir `minimo` e `maximo` na mensagem de confirmação em `app.rs` quando o intervalo exceder 10.000. Exemplo: `"Série 001 / NFE: intervalo de 999.996 faltantes detectado (de 1 a 1.000.000)"`. O PRD especifica exatamente esse formato de aviso.
---
### W-03 — `ResultadoAnalise::sem_inconsistencias` nunca usada
**Arquivo:** `src/domain/entities/resultado_analise.rs:51`
**Causa:** O método `pub fn sem_inconsistencias()` está definido mas nunca chamado.
**Contexto PRD:** Nenhuma funcionalidade específica é mapeada diretamente para este método.
**Solução (opção A — remover):** Remover o método se não houver uso planejado próximo. É código morto.
**Solução (opção B — usar):** Usar o método na tela de resultado (`resultado.rs`) para exibir um badge "Sem inconsistências" ou mensagem de sucesso no topo quando `sem_inconsistencias()` for `true`. Melhora a UX e elimina o warning.
---
### W-04 — `EstadoModal::Informacao` nunca construída
**Arquivo:** `src/ui/app.rs:39`
**Causa:** A variante `Informacao { titulo, mensagem }` existe no enum `EstadoModal` mas nenhum código a instancia. Existe `Aviso` e `Erro` para as demais situações.
**Solução:** Remover a variante `Informacao` do enum se não houver distinção visual planejada entre "Informação" e "Aviso". Alternativamente, usá-la onde hoje se usa `Aviso` para casos puramente informativos (sem cor amarela de alerta). A variante mais simples é a remoção.
---
### W-05 — `Message::CancelarExpansao` nunca construída
**Arquivo:** `src/ui/message.rs:76`
**Causa:** A variante `CancelarExpansao` existe no enum `Message` e é tratada em `update()` (muda o estado para `ConfigurandoColunas`), mas nenhuma tela emite essa mensagem. O cancelamento do intervalo excessivo é feito via `Message::ModalCancelado`.
**Solução:** Remover `Message::CancelarExpansao` do enum. O comportamento de cancelar a expansão já está implementado em `Message::ModalCancelado` (`app.rs:629-636`).
---
### W-06 — `theme::cabecalho_tabela` nunca usada
**Arquivo:** `src/ui/theme.rs:140`
**Causa:** A função de estilo `cabecalho_tabela` está definida mas nenhuma tela a usa.
**Contexto:** O componente `tabela_preview.rs` usa estilo inline para o cabeçalho em vez desta função.
**Solução:** Aplicar `t::cabecalho_tabela` no cabeçalho da tabela de preview em `tabela_preview.rs`, substituindo o estilo inline atual. Isso centraliza o estilo e elimina o warning.
---
### W-07 — `theme::badge_sucesso` nunca usada
**Arquivo:** `src/ui/theme.rs:149`
**Causa:** A função de estilo `badge_sucesso` está definida mas nenhuma tela a usa. Os badges de sucesso são aplicados via `t::badge_aviso` (amarelo) ou texto colorido com `t::SUCCESS`.
**Contexto PRD:** A tela de resultado exibe indicador de completude por série. Quando 100% completo (sem faltantes), poderia exibir um badge verde.
**Solução (opção A — usar):** Exibir badge `t::badge_sucesso` na tela de resultado quando uma série não tem faltantes, em vez de apenas texto verde. Melhora a distinção visual.
**Solução (opção B — remover):** Remover se não houver uso planejado.
---
## 2. Funcionalidades Faltantes (vs. PRD v1.7)
### F-01 — Campo `data` no PDF (RF07.1)
**Status:** Não implementado
**PRD:** Seção 5 e RF07.1 — "Data de emissão da nota. Exibida como informação adicional no relatório PDF."
**Situação atual:** O campo `data` é lido do arquivo (`csv_reader.rs`, `xlsx_reader.rs`) e armazenado em `Nota.data`, mas `pdf_generator.rs` não o utiliza em nenhuma seção.
**O que falta:** Exibir a data de cada nota nas seções de faltantes e/ou duplicatas do PDF quando disponível. Por exemplo, no detalhamento de duplicatas: `"NF 42 / Série 001 — 3 ocorrências (última: 15/01/2025)"`.
---
### F-02 — Mensagem de confirmação com intervalo exato (RF04)
**Status:** Parcialmente implementado
**PRD:** RF04 — "Exibir aviso informando o intervalo calculado (ex: 'Série 001 / NFE: intervalo de 999.996 faltantes detectado')"
**Situação atual:** O aviso de confirmação em `app.rs:930-948` exibe apenas `"Série X: intervalo de N faltantes detectado"`, mas `minimo` e `maximo` de `IntervaloSerie` não são incluídos na mensagem. Os campos existem mas não são usados (W-02 acima).
**O que falta:** Incluir `minimo` e `maximo` na mensagem de confirmação para que o usuário veja o intervalo completo.
---
### F-03 — Limite de tamanho de arquivo 50 MB (RF01.3)
**Status:** Não implementado
**PRD:** RF01.3 — "O sistema deve recusar arquivos maiores que 50 MB e exibir mensagem de erro ao usuário."
**Situação atual:** Nenhuma verificação de tamanho de arquivo existe nos readers (`csv_reader.rs`, `xlsx_reader.rs`) nem em `processar_arquivo_selecionado` (`app.rs`).
**O que falta:** Verificar `std::fs::metadata(caminho)?.len()` antes de processar. Se > 50 MB, retornar `ErroArquivo::TamanhoExcedido` (o tipo já está definido em `domain/errors.rs`) e exibir modal de erro.
---
### F-04 — Arquivo XLSX corrompido: limpar estado (RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.2 — "Se o arquivo não puder ser lido, exibir mensagem de erro em modal e limpar o arquivo carregado; o estado anterior é descartado."
**Situação atual:** `Message::XlsxErroAoCarregar` exibe o erro em modal, mas não limpa `self.caminho_arquivo` nem `self.nome_arquivo` nem `self.preview_arquivo`. O estado anterior do arquivo permanece em memória.
**O que falta:** No handler de `Message::XlsxErroAoCarregar`, zerar `self.caminho_arquivo = None`, `self.nome_arquivo = String::new()`, `self.preview_arquivo = None` e `self.notas_importadas.clear()` antes de exibir o erro.
---
### F-05 — Relatório de linhas malformadas ao usuário (RF01.1 / RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.1/RF01.2 — "o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação"
**Situação atual:** `ResumoAvisos` agrega contagens, e o modal de avisos exibe resumos do tipo "32 linhas descartadas por malformação". Porém, **os números de linha específicos** (ex: "linhas 15, 42, 103 descartadas") não são rastreados nem exibidos.
**O que falta:** Avaliar se o PRD exige listagem de números de linha individuais (o texto diz "quais linhas foram descartadas"). A interpretação atual (contagens por categoria) pode ser suficiente, mas merece revisão explícita com o product owner. Se linhas individuais forem necessárias, `ResumoAvisos` precisa armazenar `Vec<usize>` por categoria.
---
### F-06 — Validação de campos duplicados no mapeamento (RF02.3)
**Status:** Não implementado
**PRD:** RF02 — Tratamento de Erros de Configuração: "Dois campos mapeados para o mesmo índice/posição → Bloquear e exibir erro de validação imediatamente, antes de executar a análise"
**Situação atual:** `executar_importacao_sync` delega a validação ao reader, mas não há verificação explícita de índices/posições duplicados antes do disparo da análise. Se o usuário mapear, por exemplo, Numero e Serie para o mesmo índice CSV, os dados serão importados incorretamente sem aviso.
**O que falta:** Adicionar validação em `disparar_importacao` (ou no use case `importar_arquivo`) que verifique se algum índice (CSV) ou posição (XLSX) aparece em mais de um campo mapeado e retorne erro descritivo antes de iniciar o processamento.
---
### F-07 — Índice/posição inválido: identificar qual campo (RF02)
**Status:** Parcialmente implementado
**PRD:** RF02 — "Índice/posição configurado não existe no arquivo importado → Exibir erro ao usuário identificando qual campo está inválido"
**Situação atual:** Os readers retornam erros quando um índice não existe, mas a mensagem de erro pode não identificar claramente qual campo (Numero, Serie, Valor etc.) causou o problema.
**O que falta:** Garantir que as mensagens de erro de importação identifiquem o campo problemático pelo nome lógico (ex: "Campo 'Numero': índice 5 não existe — o arquivo tem 4 colunas").
---
### F-08 — Spinner/indicador visual durante análise (RNF03 / RNF04)
**Status:** Minimamente implementado
**PRD:** RNF03 — "A análise é executada em uma thread separada para não bloquear a interface gráfica"
**Situação atual:** O estado `EstadoApp::Analisando` exibe apenas dois textos estáticos ("Analisando..." e "Aguarde...") sem nenhum indicador de progresso animado.
**O que falta:** Implementar um spinner ou progress bar indeterminado na tela de análise para dar feedback visual ao usuário de que o processamento está ativo. O iced suporta animações via `Subscription` com `time::every`.
---
### F-09 — Paginação independente por série (RF07)
**Status:** Não implementado (limitação funcional)
**PRD:** RF07 — paginação para listas longas
**Situação atual:** `app.pagina_faltantes` e `app.pagina_duplicatas` são contadores globais únicos, compartilhados entre todas as séries. Ao navegar para a página 2 de faltantes da Série 001, a paginação também afeta a Série 002 se ambas aparecerem na mesma tela.
**O que falta:** Tornar a paginação independente por `ChaveSerie`, usando um `HashMap<ChaveSerie, usize>` no estado da aplicação em vez de um único `usize` global. Isso requer mudanças em `App`, em `Message` e na tela `resultado.rs`.
---
### F-10 — Exportação do resultado em CSV (seção 4.2 / seção 14)
**Status:** Não incluído no MVP (seção 4.2)
**PRD:** Listado como "Não Incluído (MVP)" na seção 4.2 e como evolução futura na seção 14.
**Nota:** Não é um requisito do MVP. Documentado aqui apenas para rastreabilidade.
---
## 3. Resumo por Prioridade
| ID | Tipo | Arquivo(s) afetado(s) | Esforço estimado |
|----|------|-----------------------|-----------------|
| W-01 | Warning → usar campo `data` | `pdf_generator.rs` | Baixo |
| W-02 | Warning → usar `minimo`/`maximo` | `app.rs` | Baixo |
| W-03 | Warning → remover ou usar | `resultado_analise.rs`, `resultado.rs` | Baixo |
| W-04 | Warning → remover variante | `app.rs` | Baixo |
| W-05 | Warning → remover variante | `message.rs`, `app.rs` | Baixo |
| W-06 | Warning → aplicar estilo | `tabela_preview.rs` | Baixo |
| W-07 | Warning → usar ou remover | `resultado.rs` ou `theme.rs` | Baixo |
| F-01 | Funcionalidade faltante | `pdf_generator.rs` | Baixo |
| F-02 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-03 | Funcionalidade faltante | `csv_reader.rs`, `xlsx_reader.rs`, `app.rs` | Médio |
| F-04 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-05 | Revisão de requisito | `domain/errors.rs`, readers | Médio |
| F-06 | Funcionalidade faltante | `app.rs` ou `importar_arquivo.rs` | Médio |
| F-07 | Funcionalidade incompleta | `csv_reader.rs`, `xlsx_reader.rs` | Médio |
| F-08 | UX faltante | `app.rs` | Médio |
| F-09 | Limitação funcional | `app.rs`, `message.rs`, `resultado.rs` | Alto |
| F-10 | Fora do MVP | — | — |
---
## 4. Ordem de Execução Sugerida
### Fase 1 — Warnings (todos baixo esforço, fazem parte da limpeza)
1. W-05: Remover `Message::CancelarExpansao`
2. W-04: Remover `EstadoModal::Informacao`
3. W-03: Decidir entre remover `sem_inconsistencias` ou usá-la em `resultado.rs`
4. W-07: Decidir entre remover `badge_sucesso` ou usá-la em `resultado.rs`
5. W-06: Aplicar `t::cabecalho_tabela` em `tabela_preview.rs`
6. W-02 + F-02: Usar `minimo`/`maximo` na mensagem de confirmação de intervalo
7. W-01 + F-01: Usar `data` no PDF
### Fase 2 — Bugs e requisitos críticos
8. F-04: Limpar estado ao falhar leitura XLSX
9. F-03: Validar limite de 50 MB
10. F-06: Validar campos duplicados no mapeamento
11. F-07: Mensagens de erro com nome do campo
### Fase 3 — Melhorias de UX
12. F-08: Spinner animado na tela de análise
13. F-09: Paginação independente por série
14. F-05: Revisar requisito de rastreamento de linhas individuais
---
*Fim do roteiro.*
-291
View File
@@ -1,291 +0,0 @@
# UI Redesign — Comparador de Notas
**Data:** 04/03/2026
**Branch:** `change-ui`
**Base:** iced 0.13.1 (Elm architecture)
**Referência visual:** `ui-ideia/mockup.html` + `ui-ideia/design_tokens.json`
---
## 1. Objetivo
Aplicar o visual do mockup (tema dark navy, cards, badges coloridos, progress bars) a todas as telas da aplicação, mantendo a lógica de negócio e a arquitetura Elm intocadas.
---
## 2. Design Tokens (mapeados para Rust)
### Paleta de cores
| Token | Hex | Uso |
|------------------------|-----------|---------------------------------------|
| `BG` | `#0F172A` | Fundo geral da janela |
| `SURFACE` | `#1E293B` | Cards / containers primários |
| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela|
| `BORDER` | `#334155` | Bordas de inputs e cards |
| `TEXT` | `#F1F5F9` | Texto principal |
| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders, texto muted |
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
| `PRIMARY` | `#3B82F6` | Botões primários, links, step ativo |
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
| `SUCCESS` | `#22C55E` | Badge OK, progress bar ≥ 90% |
| `WARNING` | `#F59E0B` | Badge Faltante, progress bar 6089% |
| `ERROR` | `#EF4444` | Badge Duplicada, progress bar < 60% |
| `OVERLAY` | rgba(0,0,0,0.6) | Fundo do modal |
### Espaçamentos
| Token | px |
|-------|----|
| `XS` | 4 |
| `SM` | 8 |
| `MD` | 12 |
| `LG` | 16 |
| `XL` | 24 |
| `XXL` | 32 |
### Border radius
| Token | px |
|--------|----|
| `SM` | 4 |
| `MD` | 6 |
| `LG` | 8 |
---
## 3. Arquivo de tema: `src/ui/theme.rs`
Módulo responsável por expor:
- `PALETA`: constantes `Color` para todas as cores acima
- Funções de estilo para `container::Style`, `button::Style`, `text_input::Style`, `progress_bar::Style`
- Nenhuma lógica de negócio — apenas aparência
### Estratégia de tema iced
```rust
// main.rs — encadear .theme()
iced::application(...)
.theme(|_app, _| tema_dark())
.run_with(App::new)
// theme.rs
pub fn tema_dark() -> iced::Theme {
iced::Theme::custom("dark".to_string(), iced::theme::Palette {
background: hex("#0F172A"),
text: hex("#F1F5F9"),
primary: hex("#3B82F6"),
success: hex("#22C55E"),
danger: hex("#EF4444"),
})
}
```
Widgets que precisam de aparência customizada além da paleta (cards, badges) recebem closure `.style(|theme| ...)` inline ou via função helper em `theme.rs`.
---
## 4. Componentes visuais novos / modificados
### 4.1 Card container
Container com background `SURFACE`, borda `BORDER` 1px, radius `LG` (8px), padding `LG` (16px).
```rust
// theme.rs
pub fn card(theme: &iced::Theme) -> container::Style { ... }
pub fn card_secondary(theme: &iced::Theme) -> container::Style { ... }
```
### 4.2 Botão primary
Background `PRIMARY`, texto `TEXT`, radius `MD` (6px), sem borda.
Hover: background `PRIMARY_HOVER`.
### 4.3 Botão secondary / ghost
Background `SURFACE_2`, texto `TEXT`, radius `MD`.
### 4.4 Botão danger
Background semi-transparente `ERROR` (20% alpha), texto `ERROR`, radius `MD`.
### 4.5 Badge de status (inline)
Container com padding `[2, 8]`, radius `SM` (4px), background 20% alpha da cor semântica.
Implementado como `container(text(...).size(12))` com style closure.
| Status | Cor texto | Background alpha |
|------------|------------|-----------------|
| OK | `SUCCESS` | 20% |
| Faltante | `WARNING` | 20% |
| Duplicada | `ERROR` | 20% |
### 4.6 Progress bar por série
Componente `progress_bar` nativo do iced com style closure que escolhe cor baseada no percentual:
- ≥ 90% → `SUCCESS`
- 6089% → `WARNING`
- < 60% → `ERROR`
Background da trilha: `#111827` (mais escuro que SURFACE).
### 4.7 Stat cards (tela de resultado)
Row de 3 cards com número grande colorido + label. Componente reutilizável `stat_card(valor, label, cor)`.
### 4.8 Breadcrumb
Row no topo da janela (exceto Layouts e Analisando). Steps separados por ``. Background `SURFACE`, padding `[8, 16]`, borda inferior 1px `BORDER`.
| Estado do step | Cor | Tamanho |
|---------------|-------------|---------|
| Ativo | `PRIMARY` | 14px |
| Concluído | `TEXT_SECONDARY` | 13px |
| Futuro | `TEXT_MUTED` | 13px |
---
## 5. Telas — mudanças por arquivo
### 5.1 `screens/import.rs`
**Atual:** Coluna plana com título, row de arquivo e row de layout.
**Novo:**
- Card central (max-width 600px) centrado na tela
- Área de drop zone estilizada com borda tracejada `BORDER`, radius `LG`, padding `XL`
- Ícone `📂` grande + texto instrucional
- Nome do arquivo selecionado com truncamento
- Seção de layout com separador visual
- Botão primary "▶ Configurar Colunas" ocupando largura do card
### 5.2 `screens/selecionar_aba.rs`
**Atual:** Lista de botões idênticos (bug de highlight).
**Novo:**
- Card com lista de abas scrollável
- Aba selecionada: background `PRIMARY` (20% alpha), texto `PRIMARY`, borda `PRIMARY`
- Aba não selecionada: background `SURFACE_2`, texto `TEXT`
- Preview abaixo da lista em card separado
### 5.3 `screens/configuracao_colunas.rs`
**Atual:** Coluna plana de inputs.
**Novo:**
- Seção de configuração em card `SURFACE`
- Labels com `TEXT_SECONDARY`, inputs com fundo `BG`, borda `BORDER`
- Campos opcionais: checkbox com estilo consistente + input inline
- Erros em card com borda `ERROR` (20% alpha)
- Botões na barra inferior fixada: "Voltar" (ghost), "Analisar" (primary), "Reanalisar" (secondary), "Salvar" (ghost)
### 5.4 `screens/resultado.rs`
**Atual:** Coluna de texto puro.
**Novo (alinhado ao mockup):**
1. **Header row:** título + botões de ação à direita
2. **Stat cards row:** 3 cards — "Notas Faltantes" (azul), "Duplicadas" (vermelho), "Total R$" (texto branco)
3. **Card de completude por série:** para cada série, label + progress bar colorida por threshold
4. **Controle de itens/página:** botões com highlight no ativo
5. **Seções faltantes/duplicatas:** cabeçalho de série em row com badge de contagem + botão Copiar; itens em lista
### 5.5 `screens/layouts.rs`
**Atual:** Coluna plana.
**Novo:**
- Seções CSV e XLSX em cards separados
- Cada layout numa row com hover highlight
- Botões de ação menores (ícone + texto compacto)
- Linha de importar JSON no rodapé do card
### 5.6 `components/modal.rs`
**Atual:** Box com estilo do tema padrão.
**Novo:**
- Background `SURFACE`, borda `BORDER`, radius `LG`
- Título `TEXT` 18px, mensagem `TEXT_SECONDARY` 14px
- Separador entre conteúdo e botões
- Botão "Fechar" ghost, "Confirmar" primary
- Tipos Erro/Aviso com ícone + cor no título
### 5.7 `components/tabela_preview.rs`
**Atual:** Monospace puro.
**Novo:**
- Cabeçalho com background `SURFACE_2`, texto `TEXT_SECONDARY`
- Células com background `SURFACE`, texto `TEXT`, fonte monospace
- Borda inferior `BORDER` nas células
### 5.8 `components/paginacao.rs`
**Atual:** Row simples de botões.
**Novo:**
- Botões ◀/▶ com estilo ghost
- "Página X / Y" em `TEXT_SECONDARY`
---
## 6. Arquivos a criar/modificar
| Arquivo | Ação |
|---------|------|
| `src/main.rs` | Adicionar `.theme(...)` |
| `src/ui/mod.rs` | Adicionar `pub mod theme;` |
| `src/ui/theme.rs` | **Criar** — paleta + helpers de estilo |
| `src/ui/app.rs` | Breadcrumb novo estilo + tela Analisando centralizada |
| `src/ui/screens/import.rs` | Reescrever |
| `src/ui/screens/selecionar_aba.rs` | Reescrever (corrigir bug highlight) |
| `src/ui/screens/configuracao_colunas.rs` | Reescrever |
| `src/ui/screens/resultado.rs` | Reescrever |
| `src/ui/screens/layouts.rs` | Reescrever |
| `src/ui/components/modal.rs` | Reescrever |
| `src/ui/components/tabela_preview.rs` | Reescrever |
| `src/ui/components/paginacao.rs` | Reescrever |
---
## 7. Limitações do iced 0.13 e workarounds
| Limitação CSS | Workaround iced |
|---------------|----------------|
| `box-shadow` | Cor de borda ou sem sombra (aceitar diferença) |
| `rgba(r,g,b,0.2)` | `Color { r, g, b, a: 0.2 }` com valores 0.01.0 |
| Font Inter | Usa fonte do sistema (system-ui) — sem mudança necessária |
| `display: flex; gap` | `row![...].spacing(N)` |
| `border-bottom` nas células | `container` com border bottom via `border.width` fracional não suportado — usar separador visual alternativo |
---
## 8. Ordem de implementação
1. `theme.rs` — paleta e helpers (base para tudo)
2. `main.rs` — ativar tema
3. `modal.rs` — usado por todas as telas
4. `resultado.rs` — tela principal do mockup
5. `import.rs`
6. `selecionar_aba.rs`
7. `configuracao_colunas.rs`
8. `layouts.rs`
9. `app.rs` — breadcrumb + Analisando
10. `tabela_preview.rs` + `paginacao.rs`
11. Compilar e corrigir
---
## 9. Notas de compatibilidade iced 0.13
- `button::Style` inclui `background`, `text_color`, `border: Border { color, width, radius }`, `shadow`
- `container::Style` inclui `background`, `text_color`, `border`, `shadow`
- `progress_bar::Style` inclui `background` e `bar`
- `text_input::Style` inclui `background`, `border`, `icon`, `placeholder`, `value`, `selection`
- Closures de estilo recebem `&Theme` e retornam o `Style` concreto do widget
- `iced::Border` aceita `radius: iced::border::Radius` — usar `N.into()` para uniform radius
@@ -2,40 +2,6 @@
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Alta Prioridade
### F-01 — Exportar Resultado em CSV.
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
- `faltantes.csv`: `serie,documento_tipo,numero`
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
**Escopo técnico:**
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
- Botão na barra de ações da tela `resultado.rs`
- Dependência `csv` já está no `Cargo.toml`
**Esforço estimado:** Médio (23h)
**Impacto:** Alto — elimina retrabalho manual
**Observações:**
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementado)
**Status:** Já implementado. Ambos `csv_reader.rs` e `xlsx_reader.rs` verificam o tamanho do arquivo
antes de qualquer leitura via constante `LIMITE_BYTES = 50 MB`, retornando `ErroArquivo::TamanhoExcedido`
se excedido — exibido como modal de erro pela UI.
**Esforço estimado:** Baixo (30min) — *concluído*
**Impacto:** Médio — evita travamentos inesperados
---
## Média Prioridade
@@ -59,43 +25,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
---
### F-04 — Busca por Número na Tela de Resultado (Util)
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
- está **faltante** (aparece na lista de faltantes)
- está **duplicada** (aparece na lista de duplicatas)
- está **presente** (está nas notas importadas)
- **não encontrada** (fora do intervalo conhecido)
**Escopo técnico:**
- Campo `filtro_numero: String` no `App`
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
- Banner de resultado no topo da tela
**Esforço estimado:** Médio (23h)
**Impacto:** Médio — uso diário em auditorias pontuais
---
### F-05 — Recarregar Arquivo Sem Reconfigurar (Implementado)
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
**Escopo técnico:**
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
- Botão na barra de ações da tela `resultado.rs`
**Esforço estimado:** Baixo (1h)
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
---
### F-06 — Auto-detecção de Delimitador CSV (Util)
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
@@ -152,11 +81,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|---|---|---|---|---|---|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
| F-04 | Busca por número no resultado | Médio | Médio | Média |
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
+205
View File
@@ -0,0 +1,205 @@
# Application — AGENTS.md
Camada de aplicação do projeto `comparador-notas`. Orquestra os serviços de domínio
e a infraestrutura para expor casos de uso coesos à camada de UI (Tauri/frontend).
Não contém regras de negócio próprias; delega ao `domain` e ao `infrastructure`.
---
## Estrutura dos arquivos
```
src/application/
├── mod.rs
└── usecases/
├── mod.rs
├── executar_analise.rs # Análise de sequência e duplicatas
├── exportar_pdf.rs # Exportação de relatório PDF
├── importar_arquivo.rs # Importação de CSV e XLSX → Vec<Nota>
└── layouts.rs # CRUD e import/export JSON de layouts
```
---
## usecases/mod.rs
Re-exporta os quatro módulos de casos de uso:
```rust
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;
```
---
## executar_analise.rs
Orquestra a análise em **duas etapas** para lidar com grandes intervalos de
faltantes sem travar a UI (regra RF04).
### Etapa 1 — `pre_analisar(notas) -> ResultadoPreAnalise`
1. Agrupa as notas por `ChaveSerie` (`serie` + `documento_tipo`).
2. Para cada grupo:
- Acumula soma total e por série.
- Chama `calcular_intervalo` (sem materializar faltantes).
3. Detecta duplicidades via `duplicidades_por_serie`.
4. Retorna `ResultadoPreAnalise` com intervalos, duplicadas e somas.
### Verificação intermediária — `series_com_intervalo_excessivo(pre) -> Vec<(ChaveSerie, IntervaloSerie)>`
Filtra as séries cujo `contagem_faltantes > LIMITE_FALTANTES` (10.000).
O caller (UI) deve exibir confirmação ao usuário se a lista não for vazia.
### Etapa 2 — `expandir_analise(pre, notas) -> ResultadoAnalise`
Materializa a lista completa de faltantes via `detectar_faltantes` e combina
com os dados já calculados na pré-análise (duplicadas, somas, totais).
### Fluxo de uso
```
pre_analisar(notas)
└─ ResultadoPreAnalise
series_com_intervalo_excessivo(&pre)
├─ [] → chamar expandir_analise diretamente
└─ [...] → exibir diálogo de confirmação na UI
└─ confirmado → expandir_analise(pre, notas)
expandir_analise(pre, notas)
└─ ResultadoAnalise (com faltantes materializados)
```
---
## exportar_pdf.rs
### `exportar_pdf(gerador, resultado, notas, nome_arquivo, nome_layout, caminho_saida) -> Result<(), String>`
Caso de uso simples que:
1. Constrói `MetadadosRelatorio` com `nome_arquivo`, `nome_layout` e timestamp
`Local::now()`.
2. Delega a geração para `gerador.gerar(...)` via a trait abstrata `PdfGenerator`.
A dependência em `&dyn PdfGenerator` (e não em `GenpdfGenerator` diretamente)
mantém o use case desacoplado da implementação concreta e facilita testes.
---
## importar_arquivo.rs
Converte arquivos brutos (CSV ou XLSX) em `Vec<Nota>` prontas para análise,
acumulando avisos não-fatais em `ResumoAvisos`.
### Tipos de saída
```rust
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
pub struct InfoXlsx {
pub abas: Vec<String>,
}
```
### `listar_abas_xlsx(caminho) -> Result<InfoXlsx, ErroArquivo>`
Delega para `xlsx_reader::listar_abas`. Retorna `InfoXlsx` com os nomes das
abas para que a UI permita ao usuário selecionar a aba correta.
### `importar_csv(caminho, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo>`
1. Chama `csv_reader::ler_csv` com os parâmetros do layout.
2. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `importar_xlsx(caminho, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo>`
1. Converte `pos_numero` e `pos_serie` (LetraLinha) para coordenadas.
2. Determina `linha_inicio` como o mínimo entre as linhas das duas posições.
3. Chama `xlsx_reader::ler_xlsx`.
4. Converte posições de todos os campos mapeados para índices de coluna (base 0).
5. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `mapear_linhas_para_notas(...)` (privada)
Função central de mapeamento. Para cada linha:
1. **Validação de índices** (na primeira linha disponível):
- Campos obrigatórios (`Numero`, `Serie`): retorna `Err` se o índice não existe.
- Campos opcionais (`Valor`, `Data`, `Tipo de Documento`): retorna `Err` se
configurado com índice fora dos limites.
2. **Numero**: tenta `parse::<u64>` direto; se falhar, extrai apenas dígitos.
Rejeita zero. Incrementa `avisos.numeros_invalidos` e pula a linha se inválido.
3. **Serie**: valida via `domain::entities::serie::validar_serie`. Pula linha se inválida.
4. **Valor** (opcional): faz `parse_valor`; se inválido, registra em
`avisos.valores_invalidos` e usa `None` (não descarta a linha).
5. **Data** (opcional): tenta `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. `None`
se nenhum formato casar (não gera aviso).
6. **Tipo de Documento** (opcional): qualquer string não vazia.
### `parse_numero(s) -> Result<u64, String>` (privada)
- Tenta `s.parse::<u64>()` diretamente.
- Se falhar, extrai apenas dígitos ASCII e tenta novamente.
- Rejeita zero em ambos os casos.
### `parse_data(s) -> Option<NaiveDate>` (privada)
Tenta os formatos `%d/%m/%Y`, `%Y-%m-%d` e `%d-%m-%Y` nessa ordem.
---
## layouts.rs
CRUD de layouts sobre o banco SQLite e import/export em JSON.
### `salvar_layout(conn, layout) -> Result<i64, ErroLayout>`
- Valida que `nome` não está vazio.
- Se `layout.id()` é `Some` → chama `layout_repository::atualizar` e retorna o id.
- Se `None` → verifica conflito de nome via `existe_nome`; se existir, retorna
`ErroLayout::NomeConflitante`; caso contrário, insere e retorna o novo id.
### `listar_layouts(conn) -> Result<Vec<Layout>, String>`
Delega para `layout_repository::listar`. Retorna todos os layouts ordenados
por nome.
### `excluir_layout(conn, id) -> Result<(), String>`
Delega para `layout_repository::excluir`.
### `exportar_layout_json(layout) -> Result<(String, String), String>`
1. Converte `Layout` para `LayoutJson` via `From<&Layout>`.
2. Serializa com `serde_json::to_string_pretty`.
3. Retorna `(conteúdo_json, nome_arquivo_sugerido)` onde o nome é `"{nome}.json"`.
### `importar_layout_json(conn, json, sobrescrever_se_existir, novo_nome) -> Result<i64, ErroLayout>`
1. Desserializa `json` para `LayoutJson`.
2. Converte para `Layout` via `TryFrom` (valida campos obrigatórios).
3. Aplica `novo_nome` se fornecido (mutação direta no enum).
4. Verifica conflito de nome:
- Se existe e `sobrescrever_se_existir == true`: busca o id existente,
injeta no layout e chama `atualizar`.
- Se existe e `false`: retorna `ErroLayout::NomeConflitante`.
5. Se não existe: chama `layout_repository::salvar`.
---
## Dependencias de outros módulos
```
application::usecases
├─ domain::entities::{chave_serie, nota, resultado_analise, layout, serie}
├─ domain::services::{detector_duplicidade, detector_sequencia, parser_monetario}
├─ domain::errors::{ErroArquivo, ErroLayout, ResumoAvisos}
└─ infrastructure::{csv_reader, xlsx_reader, pdf_generator, sqlite::layout_repository}
```
+41 -8
View File
@@ -5,6 +5,17 @@ use crate::domain::{
use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection;
/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome
/// de erros genéricos de banco.
fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante(nome.to_string())
} else {
ErroLayout::ErroBanco(msg)
}
}
/// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
@@ -14,19 +25,41 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayo
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
}
// Validar campos XLSX obrigatórios
if let Layout::Xlsx { config, .. } = layout {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
if let Some(id) = layout.id() {
// Verificar conflito de nome com outro layout (excluindo o próprio)
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
.and_then(|conflito| {
if conflito {
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
} else {
Ok(())
}
})?;
layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id)
} else {
let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe {
return Err(ErroLayout::NomeConflitante(nome));
}
layout_repository::salvar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
}
}
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome
let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe {
if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
let id_existente = layouts_existentes
.iter()
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
layout_repository::atualizar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
.map_err(|e| mapear_erro_banco(e, &nome_atual))?;
return Ok(id);
}
} else {
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
}
// Inserir novo
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
}
+304
View File
@@ -0,0 +1,304 @@
# Domain — AGENTS.md
Camada de domínio do projeto `comparador-notas`. Contém as entidades, serviços
de negócio e erros tipados. Não possui dependências de infraestrutura — todo
acesso a banco, arquivos ou UI é responsabilidade das camadas superiores.
---
## Estrutura dos arquivos
```
src/domain/
├── mod.rs
├── errors.rs # Enums de erro tipados
├── entities/
│ ├── mod.rs
│ ├── chave_serie.rs # Chave composta (serie + documento_tipo)
│ ├── layout.rs # Entidades de configuração de layout CSV/XLSX
│ ├── nota.rs # Entidade Nota Fiscal
│ ├── resultado_analise.rs # Resultados de análise (pré e completo)
│ └── serie.rs # Validação de série
└── services/
├── mod.rs
├── detector_duplicidade.rs # Detecção de notas duplicadas
├── detector_sequencia.rs # Detecção de notas faltantes na sequência
└── parser_monetario.rs # Parsing e formatação de valores monetários
```
---
## errors.rs
Erros tipados com a crate `thiserror`. Cada domínio tem seu próprio enum.
### `ErroSerie`
| Variante | Mensagem |
|---|---|
| `Invalida(String)` | Série inválida: deve conter de 1 a 3 dígitos numéricos |
| `Vazia` | Série vazia |
### `ErroValor`
| Variante | Mensagem |
|---|---|
| `Negativo(String)` | Valor negativo não é permitido |
| `NaoNumerico(String)` | Valor não numérico |
### `ErroLayout`
| Variante | Mensagem |
|---|---|
| `CampoObrigatorioAusente(String)` | Campo obrigatório ausente |
| `JsonMalformado(String)` | JSON malformado |
| `NomeConflitante(String)` | Layout com esse nome já existe |
### `ErroArquivo`
| Variante | Mensagem |
|---|---|
| `TamanhoExcedido(u64)` | Arquivo > 50 MB |
| `Corrompido(String)` | Arquivo corrompido ou ilegível |
| `ErroLeitura(String)` | Erro genérico de leitura |
### `ResumoAvisos`
Estrutura de avisos não-fatais acumulados durante a importação de um arquivo.
```rust
pub struct ResumoAvisos {
pub linhas_malformadas: usize,
pub numeros_invalidos: usize,
pub series_invalidas: usize,
pub valores_invalidos: usize,
pub detalhes: Vec<String>, // mensagens individuais por linha
}
```
- `tem_avisos()``true` se qualquer contador > 0
- `linhas_para_exibir()``Vec<String>` com resumo para exibição em modal
---
## entities/
### `chave_serie.rs` — `ChaveSerie`
Chave composta que identifica um grupo de notas fiscais. Combina série com
tipo de documento (`NFE`, `NFCE`, etc.).
```rust
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
```
- Deriva `Hash`, `Eq`, `Ord` — usada como chave em `HashMap` e para ordenação.
- `new(serie, documento_tipo)` — construtor.
- `label()` — formata para exibição:
- `Some(tipo)``"001 / NFE"`
- `None``"001"`
### `nota.rs` — `Nota`
Entidade central. `(numero, serie, documento_tipo)` é o identificador único.
```rust
pub struct Nota {
pub numero: u64,
pub serie: String,
pub documento_tipo: Option<String>, // None quando não mapeado
pub valor: Option<Decimal>,
pub data: Option<NaiveDate>, // usado no PDF, não em regras
}
```
### `serie.rs` — `validar_serie`
```rust
pub fn validar_serie(s: &str) -> Result<String, ErroSerie>
```
Valida via regex `^[0-9]{1,3}$` (1 a 3 dígitos numéricos). Faz trim antes
de validar. Usa `OnceLock` para compilar o regex uma única vez.
### `resultado_analise.rs`
Dois tipos de resultado que modelam o fluxo de análise em duas etapas:
#### `ResultadoPreAnalise`
Resultado intermediário — calculado sem expandir a lista completa de faltantes.
Usado para verificar se algum intervalo excede 10.000 registros (RF04) antes
de pedir confirmação ao usuário.
```rust
pub struct ResultadoPreAnalise {
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
#### `IntervaloSerie`
```rust
pub struct IntervaloSerie {
pub minimo: u64,
pub maximo: u64,
pub contagem_faltantes: u64,
}
```
- `excede_limite(limite)``contagem_faltantes > limite`
#### `ResultadoAnalise`
Resultado completo com a lista materializada de faltantes.
```rust
pub struct ResultadoAnalise {
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>, // ordenados crescentemente
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
Métodos auxiliares:
- `sem_inconsistencias()``true` se não há faltantes nem duplicatas
- `total_faltantes()` → soma do tamanho de todas as listas de faltantes
- `total_duplicatas()` → soma do número de grupos de duplicatas
### `layout.rs`
Entidades de configuração de layout de arquivo.
#### `TipoArquivo`
```rust
pub enum TipoArquivo { Csv, Xlsx }
```
#### `LayoutCsv`
| Campo | Tipo | Descrição |
|---|---|---|
| `delimitador` | `char` | `','`, `';'` ou `'\t'` |
| `encoding` | `String` | `"utf-8"` ou `"windows-1252"` |
| `linha_cabecalho` | `usize` | Linha do cabeçalho (base 1). `0` = sem cabeçalho |
| `indice_numero` | `usize` | Índice da coluna Numero (base 0) |
| `indice_serie` | `usize` | Índice da coluna Serie (base 0) |
| `indice_valor` | `Option<usize>` | Índice da coluna Valor (base 0), opcional |
| `indice_data` | `Option<usize>` | Índice da coluna Data (base 0), opcional |
| `indice_documento_tipo` | `Option<usize>` | Índice da coluna Tipo Documento (base 0), opcional |
#### `LayoutXlsx`
| Campo | Tipo | Descrição |
|---|---|---|
| `aba` | `String` | Nome da aba a processar |
| `pos_numero` | `String` | Posição LetraLinha (ex: `"D3"`) |
| `pos_serie` | `String` | Posição LetraLinha (ex: `"B3"`) |
| `pos_valor` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_data` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_documento_tipo` | `Option<String>` | Posição LetraLinha, opcional |
#### `Layout` (enum)
```rust
pub enum Layout {
Csv { id: Option<i64>, nome: String, config: LayoutCsv },
Xlsx { id: Option<i64>, nome: String, config: LayoutXlsx },
}
```
Métodos: `id()`, `nome()`, `tipo()`.
#### `LayoutJson` (serialização)
Representação `serde` com tag `"tipo"` para importação/exportação em JSON.
Implementa `TryFrom<LayoutJson> for Layout` (valida campos obrigatórios) e
`From<&Layout> for LayoutJson`.
---
## services/
### `detector_duplicidade.rs`
#### `detectar_duplicidades(notas) -> HashMap<(u64, String, Option<String>), usize>`
Conta ocorrências de cada `(numero, serie, documento_tipo)`. Retém apenas
grupos com mais de uma ocorrência.
#### `duplicidades_por_serie(notas) -> HashMap<ChaveSerie, Vec<(u64, usize)>>`
Agrupa o resultado de `detectar_duplicidades` por `ChaveSerie`. Cada vetor
é ordenado por `numero` crescente.
Regras:
- O mesmo número em séries diferentes **não** é duplicata.
- O mesmo número com `documento_tipo` diferente **não** é duplicata.
- O mesmo número com mesma série e mesmo `documento_tipo` **é** duplicata.
---
### `detector_sequencia.rs`
#### Constante
```rust
pub const LIMITE_FALTANTES: u64 = 10_000;
```
#### `calcular_intervalo(notas) -> Option<IntervaloSerie>`
Calcula `minimo`, `maximo` e `contagem_faltantes` de forma incremental
(usando `windows(2)`) **sem materializar** a lista de faltantes. Deduplica
números antes do cálculo para não contar duplicatas como faltantes.
#### `detectar_faltantes(notas) -> Vec<u64>`
Materializa a lista completa de faltantes em ordem crescente. Deve ser
chamado apenas após confirmação do usuário quando `calcular_intervalo`
indica que o intervalo excede `LIMITE_FALTANTES`.
#### `agrupar_contiguos(faltantes) -> Vec<(u64, u64)>`
Recebe uma lista **já ordenada** de números faltantes e retorna intervalos
contíguos como pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
Exemplo: `[1, 2, 3, 5, 8, 9]``[(1, 3), (5, 5), (8, 9)]`
---
### `parser_monetario.rs`
#### `parse_valor(input) -> Result<Decimal, ErroValor>`
Faz o parsing de uma string monetária suportando formatos brasileiro e americano.
Rejeita valores negativos.
Algoritmo (RF06):
| Condição | Regra |
|---|---|
| Contém ponto **e** vírgula | Último separador é o decimal |
| Apenas ponto, 3 dígitos após | Separador de milhar (`1.234``1234`) |
| Apenas ponto, outros casos | Decimal (`1000.00`) |
| Apenas vírgula, 3 dígitos após | Separador de milhar (`1,234``1234`) |
| Apenas vírgula, outros casos | Decimal (`1000,00``1000.00`) |
| Sem separador | Número inteiro |
Aceita prefixo `R$` (case-insensitive).
#### `formatar_valor_br(valor) -> String`
Formata `Decimal` para exibição brasileira com 2 casas decimais e pontos de
milhar. Ex: `1234567.89``"1.234.567,89"`.
+2
View File
@@ -24,6 +24,8 @@ pub enum ErroLayout {
JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
}
#[derive(Debug, Error, Clone)]
+207
View File
@@ -0,0 +1,207 @@
# Infrastructure — AGENTS.md
Camada de infraestrutura do projeto `comparador-notas`. Responsável por toda I/O
concreta: leitura de arquivos (CSV e XLSX), geração de PDF e persistência SQLite.
Não contém regras de negócio; depende do `domain` para tipos e erros.
---
## Estrutura dos arquivos
```
src/infrastructure/
├── mod.rs # Re-exporta os submódulos públicos
├── csv_reader.rs # Leitura e preview de arquivos CSV
├── xlsx_reader.rs # Leitura e preview de arquivos XLSX/XLS
├── pdf_generator.rs # Trait abstrata + implementação concreta de geração de PDF
└── sqlite/ # Submódulo de persistência (ver sqlite/AGENTS.md)
```
---
## mod.rs
Re-exporta os quatro submódulos:
```rust
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
```
---
## csv_reader.rs
Leitura de arquivos CSV com suporte a múltiplos encodings e delimitadores.
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### `ResultadoCsv`
```rust
pub struct ResultadoCsv {
pub linhas: Vec<Vec<String>>, // dados sem o cabeçalho
pub avisos: ResumoAvisos,
}
```
### `ler_csv(caminho, delimitador, encoding, linha_cabecalho) -> Result<ResultadoCsv, ErroArquivo>`
Fluxo:
1. Verifica tamanho do arquivo — retorna `ErroArquivo::TamanhoExcedido` se > 50 MB.
2. Lê os bytes brutos com `std::fs::read`.
3. Decodifica o conteúdo:
- `"windows-1252"`, `"latin-1"`, `"iso-8859-1"``encoding_rs::WINDOWS_1252`
- qualquer outro → `String::from_utf8` (UTF-8)
4. Constrói um `csv::ReaderBuilder` com `flexible(true)` e `has_headers(false)`.
5. Itera sobre todos os registros:
- Pula linhas até e incluindo `linha_cabecalho` (quando > 0).
- Ignora linhas completamente em branco.
- Registra erros de parse em `avisos.linhas_malformadas`.
6. Retorna `ResultadoCsv` com as linhas de dados e os avisos.
### `preview_csv(caminho, delimitador, encoding, n) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras `n` linhas brutas (sem pular cabeçalho). Usado exclusivamente
para pré-visualização na UI. Não verifica tamanho do arquivo.
---
## xlsx_reader.rs
Leitura de arquivos XLSX (e XLS por magic bytes) com suporte a coordenadas
no formato `LetraLinha` (ex: `"B3"`).
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### Tipos auxiliares
```rust
pub struct Coordenada {
pub coluna: u32, // base 0
pub linha: u32, // base 1
}
```
### `listar_abas(caminho) -> Result<Vec<String>, ErroArquivo>`
Abre o workbook via `calamine::open_workbook_auto` (detecção por magic bytes)
e retorna os nomes das abas.
### `ler_xlsx(caminho, nome_aba, linha_inicio) -> Result<ResultadoXlsx, ErroArquivo>`
1. Verifica tamanho (50 MB).
2. Abre workbook e seleciona a aba pelo nome.
3. Itera sobre as linhas a partir de `linha_inicio - 1` (base 0 internamente).
4. Converte cada célula para `String` via `celula_para_string` (ver abaixo).
5. Ignora linhas completamente em branco.
6. Retorna `ResultadoXlsx { linhas, avisos }`.
### `preview_xlsx(caminho, nome_aba) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras 5 linhas brutas da aba, a partir da linha 1. Usado para
pré-visualização na UI.
### `parsear_letra_linha(s) -> Option<Coordenada>`
Converte notação Excel (`"B3"`) para `Coordenada { coluna: 1, linha: 3 }`:
- Normaliza para maiúsculas e faz trim.
- Divide entre letras e dígitos.
- Converte letras para índice de coluna base 0:
`A=0, B=1, ..., Z=25, AA=26, ...`
- Retorna `None` para notações inválidas (vazio, só números, zero, etc.).
### `celula_para_string(cell) -> String` (privada)
| Tipo calamine | Conversão |
|---|---|
| `Empty` | `""` |
| `String(s)` | `s.clone()` |
| `Float(f)` | sem `.0` quando `f.fract() == 0.0` |
| `Int(i)` | `i.to_string()` |
| `Bool(b)` | `b.to_string()` |
| `DateTime` / `DateTimeIso` / `DurationIso` | representação string |
| `Error(_)` | `""` |
---
## pdf_generator.rs
Geração de relatórios PDF com fontes embutidas no binário.
### Fontes embutidas
```rust
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
```
Liberation Sans (~402 KB/variante) é usada em vez de Arial do sistema (~993 KB),
eliminando dependência externa e reduzindo o tamanho dos PDFs.
### `PdfGenerator` (trait pública)
```rust
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
```
Permite que o use case `exportar_pdf` dependa da abstração, não da crate `genpdf`.
### `GenpdfGenerator` (implementação concreta)
Implementa `PdfGenerator` usando a crate `genpdf`. Estrutura do PDF gerado:
1. **Título** — "Relatório de Análise de Notas Fiscais" (bold, 16pt)
2. **Metadados** — nome do arquivo, layout (se presente) e data/hora de geração
3. **Totais** — soma total e por série (`ChaveSerie.label()`)
4. **Notas Faltantes por Série** — lista agrupada em intervalos contíguos
(ex: `100104 (5 notas)` em vez de `100, 101, 102, 103, 104`)
5. **Duplicatas por Série** — grupo por número com contagem e data da última ocorrência
### `MetadadosRelatorio`
```rust
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
```
### `carregar_fonte_familia()` (privada)
Constrói `fonts::FontFamily` com os 4 slots exigidos por `genpdf`. Como o
relatório não usa itálico, `italic` e `bold_italic` reutilizam os dados de
`regular` e `bold` respectivamente.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `csv` | Parser de arquivos CSV com suporte a delimitadores e modo flexível |
| `encoding_rs` | Decodificação Windows-1252 / Latin-1 |
| `calamine` | Leitura de XLSX/XLS com detecção automática por magic bytes |
| `genpdf` | Geração de PDF com layout de parágrafos e decorador de página |
| `chrono` | `DateTime<Local>` para timestamp do relatório |
+212
View File
@@ -0,0 +1,212 @@
# SQLite Infrastructure — AGENTS.md
Visão geral da camada de persistência SQLite do projeto `comparador-notas`.
---
## Estrutura dos arquivos
```
src/infrastructure/sqlite/
├── mod.rs # Re-exporta os módulos públicos
├── connection.rs # Abertura e validação da conexão
├── migrations.rs # Controle de versão do schema
└── layout_repository.rs # CRUD da entidade Layout
```
---
## mod.rs
Ponto de entrada do módulo. Apenas re-exporta os três submódulos:
```rust
pub mod connection;
pub mod layout_repository;
pub mod migrations;
```
---
## connection.rs
Responsável por localizar, abrir e validar o arquivo SQLite.
### Caminho do banco
`caminho_banco()` resolve o diretório de configuração do sistema operacional via
`dirs::config_dir()` e retorna:
```
<config_dir>/comparador-notas/config.db
```
Exemplos por SO:
- **Linux**: `~/.config/comparador-notas/config.db`
- **macOS**: `~/Library/Application Support/comparador-notas/config.db`
- **Windows**: `%APPDATA%\comparador-notas\config.db`
### Abertura da conexão — `abrir_banco()`
Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
`Result<(Connection, bool), String>`, onde o `bool` indica se o banco foi
**recriado** (era corrompido).
### Lógica de recuperação de corrupção — `abrir_banco_no_caminho(path)`
1. Cria o diretório pai caso não exista (`create_dir_all`).
2. Se o arquivo já existe, tenta abri-lo com `Connection::open`.
3. Executa `SELECT 1;` como teste de sanidade.
- Sucesso → retorna a conexão com flag `false` (não recriado).
- Falha (corrupção ou erro de abertura) → renomeia o arquivo para
`config.db.bak` e segue para a criação de um banco novo.
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8).
---
## migrations.rs
Controla a evolução incremental do schema via uma tabela interna de versão.
### Tabela de controle
```sql
CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);
```
Armazena apenas uma linha com a versão atual do schema.
### `aplicar_migrations(conn)`
Fluxo:
1. Garante que `schema_version` existe.
2. Lê a versão atual (padrão `0` caso a tabela esteja vazia).
3. Executa sequencialmente as migrations pendentes:
- `versao_atual < 1``migration_v1`
- `versao_atual < 2``migration_v2`
- `versao_atual < 3``migration_v3`
4. Persiste a nova versão na tabela `schema_version`:
- `versao_atual == 0``INSERT INTO schema_version (versao) VALUES (3)`
- `versao_atual > 0 && versao_atual < 3``UPDATE schema_version SET versao = 3`
> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2).
### Histórico de migrations
| Versão | Descrição |
|--------|-----------|
| **v1** | Cria a tabela `layouts` com campos para CSV e XLSX. |
| **v2** | Renomeia layouts com nomes duplicados (sufixo `(id)`) e cria índice único `idx_layouts_nome` em `layouts.nome`. |
| **v3** | Adiciona colunas `indice_documento_tipo` (INTEGER) e `pos_documento_tipo` (TEXT) na tabela `layouts`. |
### Schema final da tabela `layouts`
```sql
CREATE TABLE layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
indice_documento_tipo INTEGER, -- adicionado em v3
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT,
pos_documento_tipo TEXT -- adicionado em v3
);
CREATE UNIQUE INDEX idx_layouts_nome ON layouts (nome); -- adicionado em v2
```
---
## layout_repository.rs
Implementa as operações CRUD sobre a entidade `Layout` (enum com variantes
`Layout::Csv` e `Layout::Xlsx`).
### Funções públicas
#### `salvar(conn, layout) -> Result<i64>`
Insere um novo layout e retorna o `rowid` gerado.
- `Layout::Csv` → preenche colunas CSV; colunas XLSX ficam `NULL`.
- `Layout::Xlsx` → preenche colunas XLSX; colunas CSV ficam `NULL`.
#### `atualizar(conn, layout) -> Result<()>`
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
`id` for `None`.
- `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
- `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco.
> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9).
#### `listar(conn) -> Result<Vec<Layout>>`
Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
- `tipo == "csv"` → constrói `Layout::Csv` mapeando as colunas de índice.
- `tipo == "xlsx"` → constrói `Layout::Xlsx` mapeando as colunas de posição.
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6).
#### `excluir(conn, id) -> Result<()>`
Remove o registro com o `id` informado via `DELETE`.
#### `existe_nome(conn, nome) -> Result<bool>`
Conta registros com o nome fornecido; retorna `true` se `COUNT(*) > 0`.
Usado para validar unicidade antes de salvar.
---
## Fluxo de inicialização
```
abrir_banco()
└─> abrir_banco_no_caminho(caminho)
├─ cria diretório se necessário
├─ testa banco existente (SELECT 1)
│ ├─ OK → retorna (conn, false)
│ └─ ERR → renomeia para .bak, cria banco novo → (conn, true)
└─ banco novo → retorna (conn, true)
aplicar_migrations(conn)
├─ cria schema_version se necessário
├─ lê versão atual
├─ executa migrations pendentes (v1 → v2 → v3)
└─ grava versão final (INSERT se versao=0, UPDATE se versao<3)
```
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `rusqlite` | Driver SQLite embutido (sem servidor externo) |
| `dirs` | Resolve `config_dir()` conforme o SO |
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
if caminho.exists() {
match Connection::open(caminho) {
Ok(conn) => {
// Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") {
// Testar integridade estrutural do banco
match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)),
Err(_e) => {
// Banco corrompido
+61 -22
View File
@@ -1,6 +1,22 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result};
/// Converte o char delimitador para string legível no banco.
fn delim_para_str(c: char) -> String {
match c {
'\t' => "tab".to_string(),
c => c.to_string(),
}
}
/// Converte a string armazenada no banco de volta para char delimitador.
fn str_para_delim(s: &str) -> char {
match s {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
}
}
/// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout {
@@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
config.delimitador.to_string(),
delim_para_str(config.delimitador),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
@@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
}
/// Atualiza um layout existente no banco.
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout
.id()
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10",
params![
nome,
config.delimitador.to_string(),
delim_para_str(config.delimitador),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
indice_data = NULL, indice_documento_tipo = NULL
WHERE id = ?8",
params![
nome,
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
let id: i64 = row.get("id")?;
let nome: String = row.get("nome")?;
let tipo: String = row.get("tipo")?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
let delim_str: String = row.get("delimitador")?;
let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize),
encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row
.get::<_, Option<i64>>("indice_valor")?
.map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>("indice_data")?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>("indice_documento_tipo")?
.map(|v| v as usize),
},
})
} else {
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
aba: row.get("aba")?,
pos_numero: row.get("pos_numero")?,
pos_serie: row.get("pos_serie")?,
pos_valor: row.get("pos_valor")?,
pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get("pos_documento_tipo")?,
},
})
}
@@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
)?;
Ok(count > 0)
}
/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado.
/// Usado para validar conflito de nome ao renomear um layout existente.
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
params![nome, id],
|row| row.get(0),
)?;
Ok(count > 0)
}
+36 -42
View File
@@ -1,8 +1,5 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
@@ -29,26 +26,17 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
migration_v3(conn)?;
}
if versao_atual == 0 {
conn.execute(
"INSERT INTO schema_version (versao) VALUES (?1);",
[VERSAO_SCHEMA_ATUAL],
)?;
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
conn.execute(
"UPDATE schema_version SET versao = ?1;",
[VERSAO_SCHEMA_ATUAL],
)?;
}
Ok(())
}
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
"BEGIN;
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
UPDATE schema_version SET versao = 3;
COMMIT;",
)?;
Ok(())
}
@@ -59,12 +47,15 @@ fn migration_v3(conn: &Connection) -> Result<()> {
/// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch(
"UPDATE layouts
SET nome = nome || ' (' || id || ')'
WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);",
"BEGIN;
UPDATE layouts
SET nome = nome || ' (' || id || ')'
WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?;
Ok(())
}
@@ -72,27 +63,30 @@ fn migration_v2(conn: &Connection) -> Result<()> {
/// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
"BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);",
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?;
Ok(())
}
+17 -3
View File
@@ -81,11 +81,19 @@ literais fora de `theme.rs`.**
**Botões:**
- `t::btn_primary` — azul sólido, ação principal
- `t::btn_secondary` — SURFACE_2, ação secundária
- `t::btn_ghost` — transparente com borda, ação terciária
- `t::btn_secondary` — SURFACE_2 com borda `TEXT_SECONDARY` 55%, ação secundária
- `t::btn_ghost` — transparente com borda `TEXT_SECONDARY` 55%, ação terciária
- `t::btn_danger` — vermelho semitransparente, exclusão
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
- `t::btn_breadcrumb_visitado` — botão sem fundo para passos anteriores do breadcrumb
### Contraste de estados de botão
`btn_secondary` e `btn_ghost` usam borda visível (`TEXT_SECONDARY` a 55% de opacidade)
no estado normal para se destacar de containers com fundo `SURFACE_2`. O estado
`Disabled` reduz a borda para 20% de opacidade, criando contraste claro entre ativo e
inativo. **Nunca remova a borda desses estilos** — sem ela os botões se perdem no fundo.
**Inputs:**
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
@@ -214,7 +222,7 @@ Chamado em `app.rs` quando `self.modal.is_some()`.
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
- A altura da área de scroll está fixada em `Fixed(160.0)`intencional.
- A altura da área de scroll está fixada em `Fixed(200.0)`acomoda 5 linhas + cabeçalho + scrollbar horizontal.
- Scroll horizontal via `scrollable::Direction::Horizontal`.
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
@@ -244,6 +252,12 @@ diretamente de dentro de uma tela.**
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
exceto `GerenciandoLayouts` e `Analisando`.
### Comportamento de navegação do breadcrumb
- **Passo ativo** — destaque azul com fundo semitransparente (`PRIMARY` a 15%).
- **Passos anteriores** — `button` com `t::btn_breadcrumb_visitado` e `on_press` com a mensagem de navegação correspondente (`Message::IrParaImportacao` para passo 1, `Message::IrParaConfiguracaoColunas` para passo 2). O hover revela fundo sutil + borda, sinalizando interatividade.
- **Passos futuros** — `container` com texto `TEXT_MUTED`, sem interação (não é possível pular à frente).
---
## 7. Adicionando uma nova tela
+26 -14
View File
@@ -14,7 +14,7 @@ use crate::domain::{
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use crate::ui::message::{Message, ResultadoPendente};
use crate::ui::theme as t;
use iced::widget::{column, container, text, Row};
use iced::widget::{button, column, container, text, Row};
use iced::{Alignment, Element, Length, Task};
use rusqlite::Connection;
use std::path::PathBuf;
@@ -614,9 +614,11 @@ impl App {
let modal = self.modal.take();
if let Some(estado) = modal {
match estado {
EstadoModal::Confirmacao { acao, .. }
| EstadoModal::InputTexto { acao, .. } => {
return self.executar_acao_modal(acao);
EstadoModal::Confirmacao { acao, .. } => {
return self.executar_acao_modal(acao, None);
}
EstadoModal::InputTexto { acao, texto, .. } => {
return self.executar_acao_modal(acao, Some(texto));
}
_ => {}
}
@@ -786,6 +788,12 @@ impl App {
let mut items: Vec<Element<'_, Message>> = Vec::new();
for (i, (label, n)) in passos.iter().enumerate() {
let msg_nav = match n {
1 => Some(Message::IrParaImportacao),
2 => Some(Message::IrParaConfiguracaoColunas),
_ => None,
};
let elem: Element<'_, Message> = if *n == passo_ativo {
container(text(*label).size(13).color(t::PRIMARY))
.padding([4, 10])
@@ -796,9 +804,19 @@ impl App {
})
.into()
} else if *n < passo_ativo {
text(*label).size(13).color(t::TEXT_SECONDARY).into()
// Passo já visitado — botão clicável
let mut btn = button(text(*label).size(13))
.padding([4, 10])
.style(t::btn_breadcrumb_visitado);
if let Some(msg) = msg_nav {
btn = btn.on_press(msg);
}
btn.into()
} else {
text(*label).size(13).color(t::TEXT_MUTED).into()
// Passo futuro — ainda não disponível
container(text(*label).size(13).color(t::TEXT_MUTED))
.padding([4, 10])
.into()
};
items.push(elem);
if i < passos.len() - 1 {
@@ -991,7 +1009,7 @@ impl App {
}
}
fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task<Message> {
fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option<String>) -> Task<Message> {
match acao {
AcaoModal::ConfirmarExpansaoFaltantes => {
self.update(Message::ConfirmarExpansaoFaltantes)
@@ -1009,13 +1027,7 @@ impl App {
Task::none()
}
AcaoModal::SalvarLayoutConfig => {
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal {
texto.clone()
} else {
String::new()
};
self.modal = None;
let nome = texto.trim().to_string();
let nome = texto_input.unwrap_or_default().trim().to_string();
if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio.");
return Task::none();
+1 -1
View File
@@ -82,7 +82,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(),
))
.height(Length::Fixed(160.0)),
.height(Length::Fixed(200.0)),
)
.style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE.into()),
+51 -5
View File
@@ -288,8 +288,12 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
background: Some(SURFACE_2.into()),
text_color: TEXT,
border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(),
..Default::default()
},
..Default::default()
};
@@ -304,6 +308,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
}
.into(),
),
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base
},
button::Status::Disabled => button::Style {
@@ -315,6 +323,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
.into(),
),
text_color: Color { a: 0.4, ..TEXT },
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base
},
_ => {
@@ -324,13 +336,16 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Botão ghost (sem fundo, borda sutil)
/// Botão ghost (sem fundo, borda visível)
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(Color::TRANSPARENT.into()),
text_color: TEXT_SECONDARY,
text_color: TEXT,
border: Border {
color: BORDER,
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(),
},
@@ -340,12 +355,16 @@ pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
text_color: TEXT,
border: Border {
color: PRIMARY,
..base.border
},
..base
},
button::Status::Disabled => button::Style {
text_color: TEXT_MUTED,
border: Border {
color: Color { a: 0.3, ..BORDER },
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base
@@ -381,6 +400,33 @@ pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
}
}
/// Botão de passo anterior no breadcrumb (clicável, mas sem destaque)
pub fn btn_breadcrumb_visitado(_theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: None,
text_color: TEXT_SECONDARY,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..TEXT }.into()),
text_color: TEXT,
border: Border {
color: Color { a: 0.15, ..TEXT },
width: 1.0,
radius: 4.0.into(),
},
..base
},
_ => base,
}
}
/// Botão de aba selecionada (tela selecionar_aba)
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {