Compare commits

..
25 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
FelipeCN 06eca335db update. 2026-03-04 17:27:02 -03:00
FelipeCN 6a5d941a00 Add roteiro para corrigir warnings. 2026-03-04 13:48:51 -03:00
FelipeCN 5745a78a5a Update .gitignore 2026-03-04 13:39:15 -03:00
Felipe 1f4ebb4686 Drag and Drop 2026-03-04 12:35:36 -03:00
Felipe 007f6c818e Fix icons and fonts 2026-03-04 12:25:08 -03:00
Felipe d06110240f Corrected: Responsive design for low-resolution monitors. 2026-03-04 12:12:23 -03:00
FelipeCN ad09c53a5d feat: implement UI redesign for Comparador de Notas
- Introduced a new theme for the application based on dark navy aesthetics.
- Created design tokens for colors, spacings, and border radii.
- Developed a theme module in Rust to manage styles for various UI components.
- Updated multiple screens and components to align with the new design, including cards, buttons, badges, and progress bars.
- Enhanced user experience with improved layouts and visual elements across the application.
2026-03-04 10:58:54 -03:00
FelipeCN 8d00cfc4d7 Refactor UI components for layout management and results display
- Replaced the `renderizar` function in `layouts.rs` with a new `view` function using Iced for a more modern UI approach.
- Introduced a new `view_secao_layouts` function to handle the display of saved layouts.
- Updated the `resultado.rs` file to use Iced for rendering the results screen, including buttons for actions and pagination controls.
- Created a new `selecionar_aba.rs` file for the selection of XLSX sheet tabs, implementing a preview feature.
- Removed old rendering functions and replaced them with Iced components for better performance and maintainability.
- Added design tokens in `design_tokens.json` for consistent styling across the application.
- Created a mockup HTML file to visualize the UI design.
2026-03-04 10:33:40 -03:00
FelipeCN 2d1d29ce3d Create a MIGRATION_ICED.md 2026-03-04 09:43:43 -03:00
FelipeCN 12b8501ffb feat: adiciona novas dependências, implementa reimportação de arquivos e melhorias na interface 2026-03-04 09:19:47 -03:00
Felipe 2a21138bf8 feat: implement reimport and analysis functionality for XLSX files
- Added `reimportar_e_analisar` method to `App` struct to handle reimporting and analyzing files.
- Preserved previous analysis results to restore in case of errors or empty imports.
- Updated UI to include a button for reanalyzing the current file.
- Enhanced file selection logic to allow direct processing if a compatible preset is selected.
- Refactored import logic to streamline the analysis process for both CSV and XLSX files.
- Improved user feedback with appropriate error and success messages during file operations.
2026-03-03 23:28:55 -03:00
FelipeCN e7d72c7e01 F-03 está 40% implementado. O preview raw existe e funciona bem. O que
seria novo é sobrepor ao preview existente uma segunda linha de
"interpretação" — indicando qual coluna mapeada seria o número, qual
seria a série, e se o valor parsearia com sucesso.
2026-03-03 17:04:38 -03:00
FelipeCN b9052e073f Implementado (3 features)
**Agrupamento de faltantes contíguos** (`detector_sequencia.rs`)
- Nova função pública `agrupar_contiguos(&[u64]) -> Vec<(u64, u64)>` com
  4 testes
- Na tela de resultado, faltantes agora aparecem como `• 100–104 (5
  notas)` em vez de 5 linhas separadas

**Estatísticas de completude** (`resultado.rs`)
- Cada série exibe: `Série 001 — 10 faltante(s) — 990/1000 notas (99.0%
  completo):`

**Copiar para clipboard** (`resultado.rs`)
- Botão `📋 Copiar` ao lado de cada série nos faltantes — copia todos os
  números (não só a página atual)
- Botão `📋 Copiar` nas duplicatas — copia no formato `1234 (3x), 5678
  (2x)`

Total: **42 → 46 testes**, todos passando.
2026-03-03 16:41:32 -03:00
FelipeCN 2c96e99ccc update. 2026-03-03 13:51:26 -03:00
Felipe a5605de6a3 feat: adiciona análise e proposta de correção para duplicidade de layouts ao salvar 2026-03-03 12:37:16 -03:00
Felipe 0354ece935 refactor: organiza imports e simplifica funções em diversos arquivos 2026-03-03 11:59:50 -03:00
FelipeCN 2c08f237e9 feat: adiciona suporte para análise em background e processamento de resultados pendentes 2026-03-03 10:16:50 -03:00
FelipeCN dedb6a72c2 feat: atualiza a renderização da tabela de pré-visualização para exibir letras do Excel como cabeçalho 2026-03-03 09:27:22 -03:00
FelipeCN 41c75e5ed1 feat: adiciona pré-visualização de arquivos CSV e XLSX na interface 2026-03-03 09:25:12 -03:00
FelipeCN 0f728b9684 feat: adiciona modal para salvar layout com campo de texto e botão na tela de configuração de colunas 2026-03-03 09:19:23 -03:00
FelipeCN b2b91cda55 feat: adiciona nova ação de confirmação para iniciar nova análise e implementa breadcrumb na interface 2026-03-03 08:48:19 -03:00
54 changed files with 7535 additions and 2827 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(cargo build)",
"Bash(xargs grep -l \"tipo\\\\|invoice\\\\|tipo.*documento\")",
"Bash(xargs wc -l)",
"Bash(xargs grep -l \"grupo\\\\|grouping\\\\|agrupa\")",
"Bash(cargo metadata --no-deps --format-version 1)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''name''], p[''version'']\\) for p in d[''packages'']]\")",
"WebFetch(domain:github.com)",
"WebFetch(domain:api.github.com)",
"Bash(ls D:/PROJECTS/comparador-notas/*.md)"
]
}
}
+1
View File
@@ -1 +1,2 @@
/target /target
nul
+10
View File
@@ -0,0 +1,10 @@
{
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db",
"name": "comparador-notas"
}
]
}
Generated
+1236 -694
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
eframe = "0.31" iced = { version = "0.13", features = ["tokio", "image"] }
egui = "0.31" tokio = { version = "1", features = ["full"] }
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
csv = "1.3" csv = "1.3"
calamine = "0.26" calamine = "0.26"
@@ -20,3 +20,6 @@ dirs = "5"
thiserror = "2" thiserror = "2"
regex = "1" regex = "1"
rfd = "0.15" rfd = "0.15"
[build-dependencies]
winres = "0.1"
-429
View File
@@ -1,429 +0,0 @@
# Roteiro de Implementação — Comparador de Notas
**Versão:** 1.0
**Data:** 02/03/2026
**Baseado no PRD:** v1.6
---
## Fase 0 — Fundação do Projeto
**Objetivo:** Estrutura compilável com todas as dependências.
### 0.1 Configurar `Cargo.toml`
Adicionar todas as dependências:
```toml
[dependencies]
eframe = "0.31"
egui = "0.31"
rusqlite = { version = "0.32", features = ["bundled"] }
csv = "1.3"
calamine = "0.26"
rust_decimal = { version = "1.36", features = ["serde"] }
rust_decimal_macros = "1.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
genpdf = "0.2"
chrono = { version = "0.4", features = ["serde"] }
encoding_rs = "0.8"
dirs = "5"
thiserror = "2"
```
### 0.2 Criar estrutura de pastas completa
Conforme definido na seção 9.1 do PRD:
```
src/
main.rs
ui/
mod.rs
app.rs
screens/
import.rs
configuracao_colunas.rs
layouts.rs
resultado.rs
application/
mod.rs
usecases/
importar_arquivo.rs
executar_analise.rs
exportar_pdf.rs
domain/
mod.rs
errors.rs
entities/
nota.rs
serie.rs
layout.rs
resultado_analise.rs
services/
detector_sequencia.rs
detector_duplicidade.rs
parser_monetario.rs
infrastructure/
mod.rs
csv_reader.rs
xlsx_reader.rs
pdf_generator.rs
sqlite/
mod.rs
connection.rs
migrations.rs
layout_repository.rs
```
### 0.3 Criar todos os `mod.rs` com declarações vazias
Garantir que o projeto compila antes de começar a implementar.
---
## Fase 1 — Camada Domain (núcleo puro)
**Dependência:** Fase 0 concluída.
**Restrição:** Zero dependência de egui, rusqlite, calamine, csv ou genpdf. Apenas Rust puro + `rust_decimal` e `chrono`.
### 1.1 `domain/errors.rs`
Definir tipos de erro com `thiserror`:
- `ErroNumero` (Zero, NaoNumerico)
- `ErroSerie` (Invalida, Vazia)
- `ErroValor` (Negativo, NaoNumerico)
- `ErroLayout` (CampoObrigatorioAusente, JsonMalformado, NomeConflitante)
### 1.2 `domain/entities/nota.rs`
```rust
pub struct Nota {
pub numero: u64,
pub serie: String,
pub valor: Option<Decimal>,
pub data: Option<NaiveDate>,
}
```
### 1.3 `domain/entities/serie.rs`
- Validação da regex `[0-9]{1,3}`
- Função `validar_serie(s: &str) -> Result<String, ErroSerie>`
### 1.4 `domain/entities/layout.rs`
- `enum TipoArquivo { Csv, Xlsx }`
- `struct LayoutCsv` com todos os campos da seção 10.4 do PRD
- `struct LayoutXlsx` com todos os campos da seção 10.4 do PRD
- `enum Layout { Csv(LayoutCsv), Xlsx(LayoutXlsx) }`
### 1.5 `domain/entities/resultado_analise.rs`
```rust
pub struct ResultadoPreAnalise {
// min, max, contagem_faltantes por série
pub intervalos_por_serie: HashMap<String, (u64, u64, usize)>,
}
pub struct ResultadoAnalise {
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
}
```
### 1.6 `domain/services/parser_monetario.rs`
Implementar exatamente o algoritmo da seção RF06:
| Regra | Condição | Comportamento |
|-------|----------|---------------|
| 1 | Contém ponto **e** vírgula | Último separador é o decimal |
| 2a | Apenas um separador + exatamente 2 dígitos após | Separador decimal |
| 2b | Apenas um separador + exatamente 3 dígitos após | Separador de milhar |
| 2c | Apenas um separador + outros casos | Separador decimal |
| 3 | Sem separador | Número inteiro |
- Rejeitar valores negativos (precedidos de `-`)
- Armazenar como `rust_decimal::Decimal`
> **Prioridade:** Testar exaustivamente com todos os exemplos da tabela do PRD antes de avançar.
### 1.7 `domain/services/detector_sequencia.rs`
- Recebe `Vec<&Nota>` de uma série
- Ordena por `numero` crescente
- Percorre **incrementalmente** (sem lista intermediária)
- Retorna `Vec<u64>` de faltantes
- Respeitar o limite de 10.000 registros faltantes (RFC04): retornar `ResultadoPreAnalise` antes de expandir
### 1.8 `domain/services/detector_duplicidade.rs`
- Recebe `Vec<&Nota>`
- Retorna `HashMap<(u64, String), usize>` com contagem por grupo
- Filtra apenas grupos com contagem > 1
---
## Fase 2 — Camada Infrastructure
**Dependência:** Fase 1 concluída.
### 2.1 `infrastructure/sqlite/connection.rs`
- Determinar caminho do banco conforme SO via `dirs::config_dir()`:
- Linux: `~/.config/comparador-notas/config.db`
- Windows: `%APPDATA%\comparador-notas\config.db`
- macOS: `~/Library/Application Support/comparador-notas/config.db`
- Criar diretório automaticamente se não existir
- Abrir conexão SQLite
- Tratamento de banco corrompido: renomear para `config.db.bak`, recriar banco vazio
### 2.2 `infrastructure/sqlite/migrations.rs`
- Tabela `schema_version` para controle de versão do schema
- Migration v1: criar tabela `layouts` com todos os campos da seção 10.4 do PRD
- Aplicar migrations automaticamente na inicialização
### 2.3 `infrastructure/sqlite/layout_repository.rs`
```rust
fn salvar(layout: &Layout) -> Result<i64>
fn listar() -> Result<Vec<Layout>>
fn buscar_por_id(id: i64) -> Result<Option<Layout>>
fn excluir(id: i64) -> Result<()>
fn existe_nome(nome: &str) -> Result<bool>
```
### 2.4 `infrastructure/csv_reader.rs`
- Suporte a delimitadores: `,` `;` `\t`
- Suporte a encoding: UTF-8 e Windows-1252 (via `encoding_rs`)
- Respeitar linha de cabeçalho configurável (base 1)
- Ignorar linhas em branco silenciosamente
- Coletar linhas malformadas para relatório consolidado
- Validar limite de 50 MB antes de ler
- Retornar `Vec<Vec<String>>` (linhas × colunas)
### 2.5 `infrastructure/xlsx_reader.rs`
- `listar_abas(path) -> Vec<String>` — chamado imediatamente após seleção do arquivo
- Ler dados da aba selecionada a partir da posição `LetraLinha`
- Converter notação `LetraLinha` (ex: `B3`) para `(col_idx, row_idx)`
- Ignorar linhas em branco silenciosamente
- Validar limite de 50 MB
- Tratar arquivo corrompido retornando erro tipado
### 2.6 `infrastructure/pdf_generator.rs`
- Implementar trait `PdfGenerator` definida em `application/`
- Gerar PDF com `genpdf` contendo:
- Metadados: nome do arquivo importado, data/hora de geração, nome do layout
- Faltantes agrupados por série
- Duplicatas agrupadas por série
- Totais por série e total geral
---
## Fase 3 — Camada Application
**Dependência:** Fases 1 e 2 concluídas.
### 3.1 `application/usecases/importar_arquivo.rs`
1. Validar tamanho do arquivo (recusar > 50 MB)
2. Detectar tipo (CSV ou XLSX)
3. Para XLSX: chamar `listar_abas()` e retornar lista para UI fazer a seleção
4. Chamar leitor adequado com configurações do layout
5. Mapear colunas e construir `Vec<Nota>`
6. Aplicar validações: regex de série, numero zero, valores inválidos
7. Retornar notas válidas + relatório de avisos consolidado (RF06/RNF06)
### 3.2 `application/usecases/executar_analise.rs`
1. Receber `Vec<Nota>`
2. Chamar `parser_monetario` para cada valor
3. Agrupar por série
4. Chamar `detector_sequencia` por série → gerar `ResultadoPreAnalise`
5. Se algum intervalo > 10.000: retornar `ResultadoPreAnalise` para UI solicitar confirmação
6. Após confirmação: expandir faltantes e montar `ResultadoAnalise`
7. Chamar `detector_duplicidade`
8. Calcular somas com `Decimal` (nunca `f64`)
9. Retornar `ResultadoAnalise`
### 3.3 `application/usecases/exportar_pdf.rs`
- Depende da trait `PdfGenerator` (não de `genpdf` diretamente)
- Recebe `ResultadoAnalise` + metadados do arquivo e layout
- Delega geração para a implementação concreta em infrastructure
### 3.4 Gerenciamento de layouts
- `salvar_layout` — valida campos obrigatórios antes de persistir
- `listar_layouts` — lista separada por tipo (CSV / XLSX)
- `carregar_layout` — busca por id
- `excluir_layout` — remove do banco
- `exportar_layout_json` — serializa com `serde_json`, sugere nome do arquivo
- `importar_layout_json` — desserializa + valida campos obrigatórios + verifica conflito de nome
---
## Fase 4 — Camada UI (egui/eframe)
**Dependência:** Fase 3 concluída.
**Regra:** Nenhuma regra de negócio, parsing ou cálculo dentro da UI.
### 4.1 `ui/app.rs` — Estrutura principal
```rust
enum EstadoApp {
Importando,
ConfigurandoColunas,
SelecionandoAba(Vec<String>), // lista de abas XLSX
Analisando,
ConfirmandoIntervalo(ResultadoPreAnalise),
ExibindoResultado(ResultadoAnalise),
GerenciandoLayouts,
}
```
- Gerenciamento de modais/popups bloqueantes (RNF06)
- Struct `App` com estado compartilhado e referência ao banco SQLite
### 4.2 `ui/screens/import.rs`
- Botão "Selecionar arquivo" com diálogo nativo
- Exibir nome do arquivo selecionado
- Para XLSX: exibir dropdown de seleção de aba **imediatamente** após seleção do arquivo
- Dropdown de layouts salvos para carregar configuração existente
- Botão "Gerenciar Layouts" → navegar para `layouts.rs`
### 4.3 `ui/screens/configuracao_colunas.rs`
- Para CSV: campos numéricos para índice de cada coluna (base 0) + delimitador + encoding + linha cabeçalho
- Para XLSX: campos de texto para posição `LetraLinha` de cada coluna
- Validação em tempo real: índices duplicados, campos obrigatórios ausentes
- Bloqueio do botão "Executar Análise" quando configuração inválida
- Botão "Executar Análise"
### 4.4 `ui/screens/layouts.rs`
- Listar layouts salvos separados por tipo (CSV / XLSX)
- Campo de nome + botão "Salvar layout atual"
- Botão "Excluir" com modal de confirmação
- Botão "Exportar para JSON" → diálogo de salvar com nome sugerido baseado no nome do layout
- Botão "Importar de JSON" → diálogo de abertura
- Modal de conflito de nome ao importar: "Sobrescrever" ou "Salvar com novo nome"
### 4.5 `ui/screens/resultado.rs`
- Seções: **Faltantes** | **Duplicatas** | **Totais**
- Agrupamento por série em todas as seções
- Paginação com dropdown: 50 / 100 / 200 / 1000 itens por página
- Valores monetários formatados em pt-BR (ex: `1.234,56`)
- Números de notas sem zeros à esquerda (ex: `0001` exibido como `1`)
- Botão "Exportar PDF" → chamar use case `exportar_pdf`
### 4.6 Modais globais (RNF06)
| Modal | Trigger |
|-------|---------|
| Erro genérico | Qualquer erro com mensagem + botão OK |
| Aviso consolidado | Final de importação com lista de categorias de problema |
| Confirmação de intervalo | Faltantes > 10.000 por série |
| Confirmação de exclusão | Excluir layout |
| Conflito de nome | Importar JSON com nome existente |
| Banco corrompido | Inicialização com `config.db` inválido |
---
## Fase 5 — Testes
**Dependência:** Fases 14 concluídas (testes unitários podem ser escritos junto com cada fase).
### 5.1 Testes unitários — Domain
- `parser_monetario`: todos os casos da tabela do PRD + casos extremos
- `detector_sequencia`: sequência completa, com faltantes, série com um único registro, intervalos > 10.000
- `detector_duplicidade`: sem duplicatas, com duplicatas, múltiplas séries
- Validação de série (regex `[0-9]{1,3}`): válidos e inválidos
- Validação de numero zero: deve ser descartado
### 5.2 Testes de integração — Infrastructure
- Leitura CSV: UTF-8, Windows-1252, diferentes delimitadores, linhas malformadas, arquivo > 50 MB
- Leitura XLSX: aba correta, posição `LetraLinha`, arquivo corrompido, limite de tamanho
- SQLite: migrations, CRUD de layouts, banco corrompido → recriação
### 5.3 Testes de integração — Application
- Fluxo completo CSV → análise → resultado
- Fluxo completo XLSX → análise → resultado
- Importação de layout JSON: válido, malformado, conflito de nome com sobrescrita, conflito com renomeação
- Intervalo > 10.000: verificar que `ResultadoPreAnalise` é retornado antes da expansão
### 5.4 Testes manuais de UI
- Todas as mensagens em PT-BR
- Formatação monetária correta (pt-BR)
- Paginação funcionando
- Geração do PDF com metadados corretos
- Fluxo XLSX: aba exibida imediatamente após seleção do arquivo
---
## Fase 6 — Polimento e Empacotamento
### 6.1 Strings e idioma
- Revisar todas as mensagens de erro/aviso para PT-BR (RNF05)
- Garantir que nenhuma string de erro interna vaze para a UI como texto bruto
### 6.2 Performance
- Validar com arquivo de 100.000 registros (RNF03)
- Confirmar que detecção de faltantes é incremental (sem lista intermediária antes da confirmação)
- Medir tempo de importação e análise
### 6.3 Build multiplataforma
- Linux: validar caminho `~/.config/comparador-notas/config.db`
- Windows: validar caminho `%APPDATA%\comparador-notas\config.db`
- macOS: validar caminho `~/Library/Application Support/comparador-notas/config.db`
### 6.4 Empacotamento
- Configurar ícone da aplicação
- Build de release otimizado: `cargo build --release`
- Testar executável standalone (sem instalação, sem dependências externas)
---
## Ordem de Execução Recomendada
```
Fase 0 → Fase 1 (com testes unitários) → Fase 2.1/2.2/2.3 → Fase 2.4/2.5
→ Fase 3.1/3.2 → Fase 4.1/4.2/4.3/4.5 → Fase 2.6/3.3 → Fase 3.4/4.4
→ Fase 5 → Fase 6
```
> O núcleo de domínio deve estar **totalmente testado** antes de qualquer integração. O `parser_monetario` e o `detector_sequencia` são os componentes mais críticos do sistema.
---
## Dependências entre Componentes
```
domain/errors.rs
└─ domain/entities/ (nota, serie, layout, resultado_analise)
└─ domain/services/ (parser_monetario, detector_sequencia, detector_duplicidade)
└─ application/usecases/ (importar_arquivo, executar_analise, exportar_pdf)
└─ infrastructure/ (csv_reader, xlsx_reader, sqlite/, pdf_generator)
└─ ui/ (app, screens/)
```
Nenhuma camada pode importar de uma camada acima dela na hierarquia.
+248 -113
View File
@@ -1,8 +1,8 @@
# PRD — Comparador de Notas # PRD — Comparador de Notas
**Versão:** 1.6 **Versão:** 1.7
**Data:** 02/03/2026 **Data:** 03/03/2026
**Status:** Planejamento **Status:** Implementado (MVP)
--- ---
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Notas faltantes * Notas faltantes
* Notas duplicadas * Notas duplicadas
* Soma total dos valores * Soma total dos valores
* Agrupamento por série * Agrupamento por série e tipo de documento
## 3.2 Objetivos Secundários ## 3.2 Objetivos Secundários
@@ -75,15 +75,21 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Detecção de quebras de sequência * Detecção de quebras de sequência
* Detecção de duplicidades * Detecção de duplicidades
* Soma de valores * Soma de valores
* Agrupamento por série * Agrupamento por série e tipo de documento
* Pré-visualização das primeiras linhas do arquivo na tela de configuração
* Relatório visual com paginação * Relatório visual com paginação
* Exportação de relatório para PDF * Exibição de faltantes agrupados em intervalos contíguos (ex: `1050 (41 notas)`)
* Indicador de completude por série (ex: `48/50 notas — 96,0% completo`)
* Botão de cópia rápida de listas de faltantes/duplicatas para área de transferência
* Exportação de relatório para PDF (fontes Liberation Sans embutidas no binário)
* Salvar layouts personalizados (exclusivos por tipo de arquivo) * Salvar layouts personalizados (exclusivos por tipo de arquivo)
* Carregar layouts salvos * Carregar layouts salvos
* Selecionar layout por menu dropdown * Selecionar layout por menu dropdown
* Excluir layouts * Excluir layouts
* Exportar layout para JSON * Exportar layout para JSON
* Importar layout de JSON * Importar layout de JSON
* Reanalisar arquivo sem reconfiguração (reimporta o mesmo arquivo com o layout atual)
* Análise em background thread (UI não bloqueia durante importação e análise)
## 4.2 Não Incluído (MVP) ## 4.2 Não Incluído (MVP)
@@ -91,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Integração com banco de dados externo * Integração com banco de dados externo
* Multiusuário * Multiusuário
* Acesso remoto * Acesso remoto
* Exportação de resultado em CSV
--- ---
@@ -99,15 +106,16 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
O sistema trabalhará com os seguintes campos lógicos: O sistema trabalhará com os seguintes campos lógicos:
| Campo | Obrigatório | Descrição | | Campo | Obrigatório | Descrição |
| ------ | ----------- | ------------------------------------------------------------------------- | | --------------- | ----------- | ------------------------------------------------------------------------- |
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único. Armazenado internamente como `u64`. | | Numero | Sim | Número incremental da nota. Armazenado internamente como `u64`. |
| Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `[0-9]{1,3}`, ex: `1`, `01`, `001`). Separa lotes de emissão. | | Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `^[0-9]{1,3}$`, ex: `1`, `01`, `001`). Separa lotes de emissão. |
| Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). | | Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). |
| Data | Não | Data de emissão da nota. Quando mapeado, exibido como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. | | Data | Não | Data de emissão da nota. Exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. Formatos aceitos: `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. |
| TipoDocumento | Não | Tipo do documento (ex: `NFE`, `NFCE`). Quando mapeado, compõe a chave de agrupamento junto com a Série. Qualquer string não vazia é aceita. |
Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX. Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
> **Identificador único de uma nota:** combinação de `Numero + Serie`. Duplicidade e sequência são sempre avaliadas dentro da mesma série. > **Identificador único de uma nota:** combinação de `Numero + Serie + TipoDocumento`. Duplicidade e sequência são sempre avaliadas dentro do mesmo grupo `(Serie, TipoDocumento)`. Quando `TipoDocumento` não é mapeado, o agrupamento é feito somente por `Serie` (retrocompatível).
--- ---
@@ -126,7 +134,7 @@ O sistema deve permitir importar arquivos:
| ----------------- | ----------------------------------------------------------------------------- | | ----------------- | ----------------------------------------------------------------------------- |
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação | | Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário | | Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.) | | Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.). 0 = sem cabeçalho |
| Linhas em branco | Devem ser ignoradas silenciosamente | | Linhas em branco | Devem ser ignoradas silenciosamente |
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação | | Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
@@ -160,16 +168,21 @@ O usuário define, via **índice numérico** (posição da coluna, base 0), qual
* Qual índice representa a série (obrigatório) * Qual índice representa a série (obrigatório)
* Qual índice representa o valor (opcional) * Qual índice representa o valor (opcional)
* Qual índice representa a data (opcional) * Qual índice representa a data (opcional)
* Qual índice representa o tipo de documento (opcional)
### RF02.2 — Mapeamento XLSX (letra + linha) ### RF02.2 — Mapeamento XLSX (letra + linha)
O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha: O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha:
* Ex: `B3` indica coluna B a partir da linha 3 * Ex: `B3` indica coluna B a partir da linha 3
* Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional) e Data (opcional) * Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional), Data (opcional) e TipoDocumento (opcional)
> A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior. > A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior.
### RF02.3 — Pré-visualização do Arquivo
A tela de configuração de colunas exibe as primeiras 5 linhas do arquivo com cabeçalho em notação de letras (A, B, C, ... com índice base-0 entre parênteses). A pré-visualização é atualizada automaticamente ao mudar o delimitador ou a aba selecionada.
### Comportamento de Memória ### Comportamento de Memória
Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória. Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória.
@@ -207,7 +220,7 @@ Cada layout é exclusivo de um tipo de arquivo: **CSV** ou **XLSX**. Um layout C
Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções: Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções:
* Sobrescrever o layout existente * Sobrescrever o layout existente
* Salvar com novo nome * Cancelar a importação
### Erros na Importação de Layout JSON ### Erros na Importação de Layout JSON
@@ -227,14 +240,14 @@ Não há limite no número de layouts que podem ser armazenados.
O sistema deve: O sistema deve:
* Ordenar os registros por número dentro de cada série * Ordenar os registros por número dentro de cada grupo `(Serie, TipoDocumento)`
* Detectar números faltantes na sequência * Detectar números faltantes na sequência
### Regra de Detecção ### Regra de Detecção
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada série. Qualquer número ausente nesse intervalo é considerado faltante. A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada grupo. Qualquer número ausente nesse intervalo é considerado faltante.
Exemplo: série 001 contém os números `0001, 0002, 0003, 0005``0004` está faltando. Exemplo: série 001 / NFE contém os números `0001, 0002, 0003, 0005``0004` está faltando.
### Tratamento de Valores Não Numéricos ### Tratamento de Valores Não Numéricos
@@ -251,26 +264,30 @@ Registros com o campo Numero igual a `0` devem ser descartados e reportados ao u
### Escopo da Detecção ### Escopo da Detecção
Faltantes são detectados **por série**. Cada série possui sua própria sequência independente. Faltantes são detectados **por grupo `(Serie, TipoDocumento)`**. Cada grupo possui sua própria sequência independente.
### Série com Apenas um Registro ### Série com Apenas um Registro
Se uma série contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. A série é processada e exibida normalmente. Se um grupo contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. O grupo é processado e exibido normalmente.
### Ordenação ### Ordenação
A ordenação dos registros dentro de cada série é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada. A ordenação dos registros dentro de cada grupo é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada.
### Exibição do Campo Numero ### Exibição do Campo Numero
O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação. O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação.
### Agrupamento de Faltantes Contíguos
Na tela de resultado, faltantes consecutivos são exibidos agrupados em intervalos (ex: `1050 (41 notas)`) para facilitar a leitura. Faltantes isolados são exibidos individualmente (ex: `• 75`).
### Proteção contra Intervalos Anormalmente Grandes ### Proteção contra Intervalos Anormalmente Grandes
Se o intervalo de faltantes de qualquer série exceder **10.000 registros**, o sistema deve: Se o intervalo de faltantes de qualquer grupo exceder **10.000 registros**, o sistema deve:
1. Interromper o processamento dessa série 1. Interromper o processamento desse grupo
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: intervalo de 999.996 faltantes detectado") 2. Exibir aviso informando o intervalo calculado (ex: "Série 001 / NFE: intervalo de 999.996 faltantes detectado")
3. Solicitar confirmação do usuário antes de continuar 3. Solicitar confirmação do usuário antes de continuar
Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação. Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação.
@@ -279,7 +296,7 @@ Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam
### Tratamento de Série com Valor Inválido ### Tratamento de Série com Valor Inválido
A Série é válida se e somente se corresponder à regex `[0-9]{1,3}` após remoção de espaços. A Série é válida se e somente se corresponder à regex `^[0-9]{1,3}$` após remoção de espaços.
Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável: Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável:
@@ -294,11 +311,11 @@ Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
O sistema deve identificar registros duplicados. O sistema deve identificar registros duplicados.
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie** no arquivo importado. Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie + TipoDocumento** no arquivo importado.
### Exibição das Duplicatas ### Exibição das Duplicatas
A lista de duplicatas exibe o identificador (Numero + Serie) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 — 3 ocorrências`. As ocorrências individuais não são listadas separadamente. A lista de duplicatas exibe o identificador (`Numero + Serie + TipoDocumento`) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 / NFE — 3 ocorrências`. As ocorrências individuais não são listadas separadamente.
### Impacto na Soma de Valores ### Impacto na Soma de Valores
@@ -311,12 +328,14 @@ Todas as ocorrências de registros duplicados são incluídas na soma de valores
O sistema deve calcular: O sistema deve calcular:
* Soma total * Soma total
* Soma por série * Soma por grupo `(Serie, TipoDocumento)`
### Formato de Valor Aceito ### Formato de Valor Aceito
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo. O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
O parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
#### Algoritmo de Parsing Monetário #### Algoritmo de Parsing Monetário
**Regra 1 — Contém ambos ponto e vírgula:** **Regra 1 — Contém ambos ponto e vírgula:**
@@ -348,7 +367,7 @@ Valores negativos (precedidos de `-`) devem ser rejeitados e reportados ao usuá
Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário. Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário.
> **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por série devem ser calculadas inteiramente em `Decimal`. > **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por grupo devem ser calculadas inteiramente em `Decimal`.
### Formato de Exibição de Valores ### Formato de Exibição de Valores
@@ -360,15 +379,17 @@ Todos os valores monetários são exibidos com **2 casas decimais fixas** no for
O sistema deve exibir: O sistema deve exibir:
* Lista de notas faltantes agrupadas por série * Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
* Lista de duplicadas agrupadas por série * Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
* Totais (soma total e soma por série) * Lista de duplicadas agrupadas por `(Serie, TipoDocumento)`
* Totais (soma total e soma por grupo, com contagem de notas por grupo)
* Botão de cópia rápida de listas para a área de transferência
### Organização dos Resultados ### Organização dos Resultados
| Aspecto | Comportamento | | Aspecto | Comportamento |
| ------------- | ----------------------------------------------------- | | ------------- | ----------------------------------------------------- |
| Agrupamento | Resultados sempre agrupados por série | | Agrupamento | Resultados sempre agrupados por `(Serie, TipoDocumento)`. Quando TipoDocumento não está mapeado, o label do grupo exibe apenas a Série |
| Listas longas | Paginação — o usuário navega entre páginas de resultados | | Listas longas | Paginação — o usuário navega entre páginas de resultados |
| Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 | | Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 |
@@ -376,15 +397,21 @@ O sistema deve exibir:
O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`. O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`.
As fontes do PDF (Liberation Sans) são embutidas no binário em tempo de compilação, eliminando dependência de fontes instaladas no sistema operacional.
O PDF deve conter: O PDF deve conter:
* Notas faltantes por série * Notas faltantes por grupo `(Serie, TipoDocumento)`
* Duplicatas por série * Duplicatas por grupo
* Totais por série e total geral * Totais por grupo e total geral
**Metadados do relatório:** **Metadados do relatório:**
* Nome do arquivo importado * Nome do arquivo importado
* Data e hora da geração * Data e hora da geração
* Nome do layout utilizado * Nome do layout utilizado (se houver)
### RF07.2 — Reanalisar Arquivo
O sistema deve permitir reimportar o mesmo arquivo do disco com o layout atual e executar a análise novamente, sem nenhuma interação adicional. A operação é executada em background thread para não bloquear a interface.
--- ---
@@ -424,6 +451,8 @@ O sistema deve suportar no mínimo:
100.000 registros por arquivo 100.000 registros por arquivo
A análise é executada em uma thread separada (background) para não bloquear a interface gráfica durante o processamento.
> **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo. > **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo.
--- ---
@@ -448,9 +477,10 @@ Todas as mensagens de erro e aviso devem ser exibidas em **modal/popup bloqueant
Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltiplas linhas malformadas, múltiplos registros descartados), esses avisos devem ser **consolidados em um único modal**, exibindo um resumo ao final da operação. Exemplo de conteúdo consolidado: Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltiplas linhas malformadas, múltiplos registros descartados), esses avisos devem ser **consolidados em um único modal**, exibindo um resumo ao final da operação. Exemplo de conteúdo consolidado:
* "32 linhas descartadas por mal formação" * "32 linhas descartadas por malformação"
* "12 valores de Numero inválidos convertidos ou descartados" * "12 valores de Numero inválidos convertidos ou descartados"
* "5 registros com Série inválida descartados" * "5 registros com Série inválida descartados"
* "3 valores monetários inválidos descartados"
Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação. Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação.
@@ -462,9 +492,16 @@ Fluxo principal:
1. Usuário abre o sistema 1. Usuário abre o sistema
2. Usuário importa planilha 2. Usuário importa planilha
3. Usuário configura colunas 3. (Para XLSX) Usuário seleciona aba
4. Usuário executa análise 4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
5. Sistema exibe resultado 5. Usuário executa análise (processamento em background)
6. Sistema exibe resultado
Fluxo alternativo — layout salvo:
1. Usuário abre o sistema
2. Usuário seleciona layout no dropdown
3. Usuário importa planilha → análise é disparada automaticamente
--- ---
@@ -485,6 +522,7 @@ src/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ app.rs │ ├─ app.rs
│ ├─ screens/ │ ├─ screens/
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
│ │ ├─ import.rs │ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs │ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs │ │ ├─ layouts.rs
@@ -493,20 +531,25 @@ src/
├─ application/ ├─ application/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ usecases/ │ ├─ usecases/
│ │ ├─ mod.rs
│ │ ├─ importar_arquivo.rs │ │ ├─ importar_arquivo.rs
│ │ ├─ executar_analise.rs │ │ ├─ executar_analise.rs
│ │ ├─ exportar_pdf.rs │ │ ├─ exportar_pdf.rs
│ │ ├─ layouts.rs
├─ domain/ ├─ domain/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ errors.rs │ ├─ errors.rs
│ ├─ entities/ │ ├─ entities/
│ │ ├─ mod.rs
│ │ ├─ nota.rs │ │ ├─ nota.rs
│ │ ├─ serie.rs │ │ ├─ serie.rs
│ │ ├─ chave_serie.rs
│ │ ├─ layout.rs │ │ ├─ layout.rs
│ │ ├─ resultado_analise.rs │ │ ├─ resultado_analise.rs
│ │ │ │
│ ├─ services/ │ ├─ services/
│ │ ├─ mod.rs
│ │ ├─ detector_sequencia.rs │ │ ├─ detector_sequencia.rs
│ │ ├─ detector_duplicidade.rs │ │ ├─ detector_duplicidade.rs
│ │ ├─ parser_monetario.rs │ │ ├─ parser_monetario.rs
@@ -533,47 +576,77 @@ Contém toda a lógica de negócio real.
**Não pode depender de:** **Não pode depender de:**
* egui * egui / eframe
* rusqlite * rusqlite
* calamine * calamine
* csv * csv
* genpdf * genpdf
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`). Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`) e utilitários de erros (`thiserror`, `regex`).
#### Entidades #### Entidades
**`Nota`** **`Nota`**
| Campo | Tipo | | Campo | Tipo |
| ------ | ----------------- | | -------------- | ------------------- |
| numero | `u64` | | numero | `u64` |
| serie | `String` | | serie | `String` |
| documento_tipo | `Option<String>` |
| valor | `Option<Decimal>` | | valor | `Option<Decimal>` |
| data | `Option<NaiveDate>` | | data | `Option<NaiveDate>` |
**`ChaveSerie`**
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
| Campo | Tipo |
| -------------- | ---------------- |
| serie | `String` |
| documento_tipo | `Option<String>` |
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
**`ResultadoPreAnalise`**
Resultado intermediário, antes de materializar os faltantes.
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
**`ResultadoAnalise`** **`ResultadoAnalise`**
| Campo | Tipo | | Campo | Tipo |
| -------------------- | -------------------------------------- | | -------------------- | ---------------------------------------- |
| faltantes_por_serie | `HashMap<String, Vec<u64>>` | | faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` | | duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` | | soma_total | `Decimal` |
| soma_por_serie | `HashMap<String, Decimal>` | | soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
> **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas. > **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas.
#### Services #### Services
`detector_sequencia` — recebe `Vec<Nota>` agrupadas por rie, retorna faltantes. `detector_sequencia` — recebe `Vec<&Nota>` agrupadas por `ChaveSerie`, retorna faltantes. Funções:
- `calcular_intervalo` — retorna `IntervaloSerie` sem materializar a lista completa
- `detectar_faltantes` — materializa a lista completa após confirmação
- `agrupar_contiguos` — agrupa uma lista ordenada de faltantes em pares `(inicio, fim)` para exibição compacta
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie)`. `detector_duplicidade` — retorna mapa de contagem por `(numero, serie, documento_tipo)`.
`parser_monetario` — implementa exatamente o algoritmo definido no RF06. `parser_monetario` — implementa exatamente o algoritmo definido no RF06. Também expõe `formatar_valor_br` para exibição no formato `1.234,56`.
#### Erros #### Erros
`domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`). Nenhuma camada deve propagar `String` livre como erro de domínio. `domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`, `ErroLayout::NomeConflitante`, `ErroArquivo::TamanhoExcedido`). Nenhuma camada deve propagar `String` livre como erro de domínio.
`ResumoAvisos` consolida contagens de linhas malformadas, números inválidos, séries inválidas e valores inválidos para exibição em um único modal ao final da importação.
--- ---
@@ -585,15 +658,25 @@ Conhece o domain. O domain não conhece o application.
**`executar_analise.rs`** **`executar_analise.rs`**
1. Recebe dados crus Expõe três funções:
2. Chama `parser_monetario` 1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
3. Chama `detector_sequencia` 2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
4. Chama `detector_duplicidade` 3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
5. Monta `ResultadoAnalise`
**`exportar_pdf.rs`** **`exportar_pdf.rs`**
Depende de uma trait abstrata (`PdfGenerator`) definida no próprio módulo application. A implementação concreta fica em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`. Depende da trait abstrata `PdfGenerator` definida em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`.
**`importar_arquivo.rs`**
Expõe:
- `importar_csv(caminho, config)` — lê CSV e mapeia para notas
- `importar_xlsx(caminho, config)` — lê XLSX e mapeia para notas
- `listar_abas_xlsx(caminho)` — lista abas antes de configurar
**`layouts.rs`**
Expõe operações de CRUD e import/export de layouts: `salvar_layout`, `listar_layouts`, `excluir_layout`, `exportar_layout_json`, `importar_layout_json`.
--- ---
@@ -603,19 +686,15 @@ Implementa leitores, persistência e geração de arquivos.
| Arquivo | Responsabilidade | | Arquivo | Responsabilidade |
| -------------------------------- | ----------------------------------------- | | -------------------------------- | ----------------------------------------- |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` | | `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` | | `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
| `pdf_generator.rs` | Geração de PDF via `genpdf` | | `pdf_generator.rs` | Trait `PdfGenerator` + implementação `GenpdfGenerator` via `genpdf`; fontes Liberation Sans embutidas no binário |
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite | | `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite; tratamento de banco corrompido |
| `sqlite/migrations.rs` | Aplicação de migrations de schema | | `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` | | `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
Nada de infrastructure sobe para domain. Nada de infrastructure sobe para domain.
#### Por que `layout_repository.rs` dentro de `sqlite/`
Manter o repositório dentro de `sqlite/` concentra todos os artefatos SQLite em um único módulo. Se futuramente o sistema armazenar histórico de análises ou configurações do usuário (RF14/10.8), novos repositórios são adicionados no mesmo lugar sem dispersão.
--- ---
### UI (interface) ### UI (interface)
@@ -624,16 +703,31 @@ Apenas coleta input, chama use cases e renderiza resultado.
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI. Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
#### App (estado global)
`app.rs` contém o estado global da aplicação (`App`), o enum `EstadoApp`, os tipos `Modal`/`TipoModal`/`AcaoModal`, e a lógica de processamento de resultados assíncronos via `mpsc::channel`.
**Estados da aplicação:**
| Estado | Descrição |
| ----------------------- | --------- |
| `Importando` | Tela inicial: seleção de arquivo e layout |
| `SelecionandoAba` | Aguardando seleção de aba XLSX |
| `ConfigurandoColunas` | Mapeamento de colunas com pré-visualização |
| `Analisando` | Análise em execução em background thread |
| `ConfirmandoIntervalo` | Aguardando confirmação do usuário para expandir faltantes |
| `ExibindoResultado` | Resultado pronto para exibição |
| `GerenciandoLayouts` | Gerenciamento de layouts salvos |
#### Screens #### Screens
| Arquivo | Responsabilidade | | Arquivo | Responsabilidade |
| ------------------------- | ----------------------------------------------------- | | ------------------------- | ----------------------------------------------------- |
| `import.rs` | Seleção de arquivo e configurações de importação | | `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) | | `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) | | `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
| `resultado.rs` | Exibição de resultados com paginação (RF07) | | `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir, exportar/importar JSON (RF08) |
| `resultado.rs` | Exibição de resultados com paginação, intervalos contíguos, indicador de completude, botões copiar/reanalisar/exportar PDF (RF07) |
> `configuracao_colunas.rs` e `layouts.rs` são mantidos separados porque tratam de responsabilidades distintas do RF02 e RF08, evitando que uma única screen acumule lógica de mapeamento de colunas e gerenciamento de persistência.
--- ---
@@ -646,26 +740,34 @@ Infrastructure entra apenas quando necessário.
Exemplo real: Exemplo real:
1. UI chama `importar_arquivo` 1. UI chama `executar_importacao` (thread separada)
2. Infrastructure lê CSV/XLSX 2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota` 3. Application transforma registros em entidades `Nota`
4. Domain executa análise 4. Domain executa pré-análise (`pre_analisar`)
5. Application retorna `ResultadoAnalise` 5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
6. UI renderiza 6. Application retorna `ResultadoAnalise` via `mpsc::channel`
7. UI renderiza
--- ---
## 9.4 Stack Tecnológica ## 9.4 Stack Tecnológica
| Camada | Tecnologia | Status | | Camada | Tecnologia | Versão |
| ---------------- | ------------------ | -------------- | | ---------------- | ------------------ | ------- |
| Linguagem | Rust | Definido | | Linguagem | Rust | edition 2024 |
| Framework de UI | egui | Definido | | Framework de UI | egui + eframe | 0.31 |
| SQLite | rusqlite | Definido | | Diálogos nativos | rfd | 0.15 |
| Leitura de CSV | csv | Definido | | SQLite | rusqlite (bundled) | 0.32 |
| Leitura de XLSX | calamine | Definido | | Leitura de CSV | csv | 1.3 |
| Decimal fixo | rust_decimal | Definido | | Leitura de XLSX | calamine | 0.26 |
| Geração de PDF | genpdf | Definido | | Decimal fixo | rust_decimal | 1.36 |
| Geração de PDF | genpdf | 0.2 |
| Datas | chrono | 0.4 |
| Encoding | encoding_rs | 0.8 |
| Caminhos de dados| dirs | 5 |
| Erros tipados | thiserror | 2 |
| Regex | regex | 1 |
| Serialização | serde + serde_json | 1 |
--- ---
@@ -723,6 +825,14 @@ Os layouts salvos anteriormente serão perdidos neste cenário. O arquivo `confi
O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco. O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco.
**Versão atual do schema: 3**
| Versão | Alteração |
| ------ | --------- |
| 1 | Criação da tabela `layouts` |
| 2 | Índice único em `layouts.nome`; renomeia duplicatas com sufixo `(id)` |
| 3 | Adição das colunas `indice_documento_tipo` (CSV) e `pos_documento_tipo` (XLSX) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
## 10.3 Dados Armazenados ## 10.3 Dados Armazenados
@@ -741,31 +851,33 @@ Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configuraç
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| ---------- | ------- | -------------------------------------- | | ---------- | ------- | -------------------------------------- |
| id | inteiro | Identificador único | | id | inteiro | Identificador único (auto-incremento) |
| nome | texto | Nome do layout | | nome | texto | Nome do layout (único no banco) |
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` | | tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
**Campos exclusivos de layouts CSV:** **Campos exclusivos de layouts CSV:**
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| ------------------ | ------ | -------------------------------------------------- | | ---------------------- | ------- | ---------------------------------------------------------- |
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) | | delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) | | encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1) | | linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho |
| indice_numero | inteiro | Índice da coluna Numero (base 0) | | indice_numero | inteiro | Índice da coluna Numero (base 0) |
| indice_serie | inteiro | Índice da coluna Serie (base 0) | | indice_serie | inteiro | Índice da coluna Serie (base 0) |
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) | | indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) | | indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
**Campos exclusivos de layouts XLSX:** **Campos exclusivos de layouts XLSX:**
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| -------------- | ------ | ---------------------------------------------------------------- | | ------------------ | ------ | ----------------------------------------------------------------- |
| aba | texto | Nome ou índice da aba a ser processada | | aba | texto | Nome da aba a ser processada |
| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) | | pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) |
| pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) | | pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) |
| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) | | pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) |
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) | | pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -785,15 +897,19 @@ Excluir layout
## 10.6 Interface do Usuário ## 10.6 Interface do Usuário
Os layouts salvos devem ser exibidos em um menu dropdown. Os layouts salvos devem ser exibidos em um menu dropdown, filtrado pelo tipo de arquivo atual (CSV ou XLSX).
O usuário deve poder: O usuário deve poder:
Selecionar layout existente Selecionar layout existente
Criar novo layout Criar novo layout (via modal com campo de texto ou via tela de gerenciamento)
Excluir layout Excluir layout (com confirmação)
Exportar layout para JSON
Importar layout de JSON (com tratamento de conflito de nome)
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -835,15 +951,16 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
```json ```json
{ {
"nome": "Layout Padrão CSV",
"tipo": "csv", "tipo": "csv",
"nome": "Layout Padrão CSV",
"delimitador": ";", "delimitador": ";",
"encoding": "utf-8", "encoding": "utf-8",
"linha_cabecalho": 1, "linha_cabecalho": 1,
"indice_numero": 3, "indice_numero": 3,
"indice_serie": 1, "indice_serie": 1,
"indice_valor": 5, "indice_valor": 5,
"indice_data": null "indice_data": null,
"indice_documento_tipo": null
} }
``` ```
@@ -851,16 +968,19 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
```json ```json
{ {
"nome": "Layout Padrão XLSX",
"tipo": "xlsx", "tipo": "xlsx",
"nome": "Layout Padrão XLSX",
"aba": "Plan1", "aba": "Plan1",
"pos_numero": "D3", "pos_numero": "D3",
"pos_serie": "B3", "pos_serie": "B3",
"pos_valor": "F3", "pos_valor": "F3",
"pos_data": null "pos_data": null,
"pos_documento_tipo": null
} }
``` ```
> **Nota:** o campo `tipo` é usado como tag de discriminante pelo `serde` (`#[serde(tag = "tipo")]`). O campo `indice_documento_tipo` / `pos_documento_tipo` usa `#[serde(default)]` para retrocompatibilidade com arquivos JSON exportados antes da versão 1.7.
--- ---
# 12. Critérios de Aceite # 12. Critérios de Aceite
@@ -869,14 +989,17 @@ O sistema será considerado funcional quando:
* Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho * Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
* Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha` * Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
* Detectar notas faltantes por série corretamente * Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
* Detectar duplicatas por série corretamente * Detectar duplicatas por grupo corretamente
* Calcular soma total e soma por série corretamente * Calcular soma total e soma por grupo corretamente
* Exibir resultados agrupados por série com paginação * Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exibir indicador de completude por grupo
* Exportar relatório de resultados para PDF * Exportar relatório de resultados para PDF
* Salvar, carregar, selecionar e excluir layouts * Salvar, carregar, selecionar e excluir layouts
* Exportar e importar layouts via JSON * Exportar e importar layouts via JSON
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por série * Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por grupo
* Executar análise em background sem bloquear a interface
* Exibir pré-visualização das primeiras 5 linhas do arquivo na tela de configuração
--- ---
@@ -885,25 +1008,29 @@ O sistema será considerado funcional quando:
O MVP inclui o escopo completo descrito neste PRD: O MVP inclui o escopo completo descrito neste PRD:
* Importar CSV e XLSX * Importar CSV e XLSX
* Mapear colunas Numero, Serie, Valor e Data * Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
* Detectar notas faltantes por série * Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
* Detectar duplicatas por série * Detectar duplicatas por grupo
* Calcular soma total e por série * Calcular soma total e por grupo
* Exibir resultados agrupados por série com paginação * Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exportar relatório para PDF * Exportar relatório para PDF
* Gerenciar layouts (salvar, carregar, excluir) * Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
* Exportar e importar layouts via JSON * Reanalisar arquivo sem reconfiguração
--- ---
# 14. Evoluções Futuras # 14. Evoluções Futuras
Possíveis melhorias: Possíveis melhorias (ver `FEATURES_BACKLOG.md` para detalhes):
* Exportação de resultado em CSV (`faltantes.csv`, `duplicatas.csv`)
* Busca por número na tela de resultado
* Auto-detecção de delimitador CSV
* Auto-detecção de encoding CSV
* Agrupamento de faltantes como intervalos no PDF
* Integração com ERP * Integração com ERP
* Histórico de análises * Histórico de análises
* Automação de importação (monitorar pasta) * Automação de importação (monitorar pasta)
* Exportação para CSV
* Multiusuário * Multiusuário
--- ---
@@ -918,8 +1045,16 @@ Série
Agrupador independente de sequência. Agrupador independente de sequência.
TipoDocumento
Subtipo de documento dentro de uma série (ex: NFE, NFCE). Quando mapeado, compõe a chave de agrupamento junto com a Série.
ChaveSerie
Chave composta `(Serie, TipoDocumento)` que identifica um grupo de notas para fins de detecção de sequência, duplicidade e cálculo de somas.
Sequência Sequência
Ordem numérica crescente sem lacunas. Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
--- ---
-113
View File
@@ -1,113 +0,0 @@
# Sugestões de Melhoria — Fluxo da Interface
**Data:** 02/03/2026
---
## 1. Indicador de progresso das etapas
O fluxo possui 34 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
```
① Arquivo ② Colunas ③ Resultado
```
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
---
## 2. Unificar importação e configuração de colunas em um único painel
Atualmente o caminho é:
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
São 3 ações separadas para chegar à análise.
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
---
## 3. Pré-visualização das primeiras linhas do arquivo
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
**Sugestão:** Exibir as primeiras 35 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
---
## 4. Confirmação ao clicar em "Nova Análise"
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
**Sugestão:** Exibir modal de confirmação com a mensagem:
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
Botões: **Confirmar** | **Cancelar**
---
## 5. Reorganizar seções do resultado
A ordem atual das seções em `resultado.rs` é:
```
Totais → Faltantes → Duplicatas
```
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
**Sugestão:** Inverter para:
```
Faltantes → Duplicatas → Totais
```
Isso coloca a informação mais relevante no topo da tela.
---
## 6. Seletor de layout também na tela de configuração de colunas
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
---
## 7. Botão "Salvar como layout..." na tela de configuração
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
---
## 8. Feedback visual durante a análise
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
```
⏳ Analisando... aguarde.
```
Mesmo sem progresso percentual, já elimina a percepção de travamento.
---
## Resumo de Impacto
| # | Sugestão | Impacto UX | Esforço estimado |
|---|----------|------------|-----------------|
| 1 | Breadcrumb de etapas | Médio | Baixo |
| 2 | Unificar importação + configuração | Alto | Médio |
| 3 | Pré-visualização do arquivo | Alto | Médio |
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
| 5 | Reordenar seções do resultado | Médio | Baixo |
| 6 | Seletor de layout em configuração | Médio | Baixo |
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
| 8 | Feedback durante análise | Alto | Baixo |
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
let mut res = winres::WindowsResource::new();
res.set_icon("icon.ico");
res.compile().unwrap();
}
}
+86
View File
@@ -0,0 +1,86 @@
# Backlog de Features — Comparador de Notas
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Média Prioridade
### F-03 — Preview de Colunas Antes da Análise (Isso já existe?)
**Problema:** Erros de mapeamento de colunas (índice errado, encoding incorreto) só são descobertos depois da importação, que pode falhar silenciosamente com "0 notas válidas".
**Solução:** Na tela de configuração de colunas, exibir uma tabela das primeiras 5 linhas mostrando como os campos serão interpretados, com highlight em células que falhariam o parse.
**Escopo técnico:**
- Função `pre_visualizar(caminho, layout, n_linhas: usize) -> Vec<LinhaPreview>`
- `LinhaPreview`: `{ numero: Result<u64>, serie: Result<String>, valor: Result<Decimal>, ... }`
- Widget de tabela em `configuracao_colunas.rs`
- Atualizar preview ao mudar qualquer campo de configuração (via `changed()`)
**Esforço estimado:** Alto (46h)
**Impacto:** Alto — reduz drasticamente "por que não importou nada?"
**Observações:**
Verificar se já existe no codigo, pois na tela de configuração do Layout ele exibe as 5 primeiras linhas do arquivo.
---
### 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.
**Solução:** Ao selecionar o arquivo CSV, amostrar as primeiras 5 linhas e contar ocorrências de cada delimitador candidato. Sugerir o mais frequente como padrão, mantendo a ComboBox editável.
**Escopo técnico:**
- Função `detectar_delimitador(primeiras_linhas: &[&str]) -> char`
- Chamar na transição para `ConfigurandoColunas` quando `TipoArquivo::Csv`
- Pré-selecionar na ComboBox sem impedir mudança manual
**Esforço estimado:** Médio (2h)
**Impacto:** Baixo-Médio — reduz um passo de configuração na maioria dos casos
---
## Baixa Prioridade
### F-07 — Auto-detecção de Encoding CSV (Util)
**Problema:** Arquivos gerados por sistemas legados brasileiros frequentemente usam `windows-1252`, mas o padrão selecionado é `utf-8`. O usuário descobre o problema somente ao ver caracteres estranhos nos dados.
**Solução:** Tentar ler as primeiras linhas em `utf-8`; se houver erro de decodificação, sugerir automaticamente `windows-1252` com aviso visual na tela de configuração.
**Escopo técnico:**
- Função `detectar_encoding(caminho: &Path) -> String`
- Usar `encoding_rs` (já dependência) para tentar decode e verificar `had_errors`
- Aviso na tela de configuração: "Encoding detectado: windows-1252"
**Esforço estimado:** Médio (2h)
**Impacto:** Baixo — problema raro, mas confuso quando ocorre
---
### F-09 — Paginação Automática de Faltantes no PDF (Util)
**Problema:** PDFs com muitos faltantes (ex: 5.000 números) geram um único bloco de texto muito longo, difícil de ler e imprimir.
**Solução:** No `pdf_generator.rs`, ao adicionar a lista de faltantes, agrupar em intervalos contíguos (usando a função `agrupar_contiguos` já implementada) e formatar como `100104 (5 notas)`. Reduz drasticamente o tamanho do PDF e melhora legibilidade.
**Escopo técnico:**
- Importar `agrupar_contiguos` em `pdf_generator.rs`
- Substituir o `join(", ")` atual pelo formato de intervalos
- Sem novas dependências
**Esforço estimado:** Baixo (30min)
**Impacto:** Baixo-Médio — melhora legibilidade de PDFs com muitos faltantes
> **Nota:** Esta feature é especialmente fácil porque a lógica de agrupamento já existe — é só aplicar no PDF.
---
## Resumo
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|---|---|---|---|---|---|
| F-03 | Preview de colunas antes da análise | Alto | Alto | 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 |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+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}
```
+65 -36
View File
@@ -1,5 +1,6 @@
use crate::domain::{ use crate::domain::{
entities::{ entities::{
chave_serie::ChaveSerie,
nota::Nota, nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
}, },
@@ -15,30 +16,31 @@ use std::collections::HashMap;
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e, /// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`. /// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise { pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série // Agrupar por (serie, documento_tipo)
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new(); let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas { for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota); let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
} }
let mut intervalos_por_serie = HashMap::new(); let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO; let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new(); let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new(); let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie { for (chave, notas_grupo) in &por_chave {
// Somar valores // Somar valores
for nota in notas_serie.iter() { for nota in notas_grupo.iter() {
if let Some(v) = nota.valor { if let Some(v) = nota.valor {
soma_total += v; soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v; *soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
} }
} }
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len(); *total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes // Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) { if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(serie.clone(), intervalo); intervalos_por_serie.insert(chave.clone(), intervalo);
} }
} }
@@ -53,31 +55,32 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
} }
} }
/// Verifica se alguma série excede o limite de faltantes. /// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação. /// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
pub fn series_com_intervalo_excessivo( pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise, pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> { ) -> Vec<(ChaveSerie, IntervaloSerie)> {
pre.intervalos_por_serie pre.intervalos_por_serie
.iter() .iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes)) .map(|(chave, iv)| (chave.clone(), iv.clone()))
.collect() .collect()
} }
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes. /// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite). /// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise { pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new(); let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas { for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota); let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
} }
let mut faltantes_por_serie = HashMap::new(); let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie { for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_serie); let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(serie.clone(), faltantes); faltantes_por_serie.insert(chave.clone(), faltantes);
} }
ResultadoAnalise { ResultadoAnalise {
@@ -89,21 +92,30 @@ pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAn
} }
} }
/// Executa análise completa sem verificar limites (use case simplificado).
/// Útil quando o caller já confirmou ou sabe que não há intervalos excessivos.
pub fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec; use rust_decimal_macros::dec;
/// Executa análise completa sem verificar limites (atalho para testes).
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota { fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, None) Nota::new(numero, serie.to_string(), None, valor, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(
numero,
serie.to_string(),
Some(tipo.to_string()),
None,
None,
)
} }
#[test] #[test]
@@ -114,7 +126,8 @@ mod tests {
nota(4, "001", Some(dec!(50.00))), nota(4, "001", Some(dec!(50.00))),
]; ];
let resultado = executar_analise(&notas); let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]); let chave = ChaveSerie::new("001".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]);
assert_eq!(resultado.soma_total, dec!(350.00)); assert_eq!(resultado.soma_total, dec!(350.00));
} }
@@ -127,19 +140,35 @@ mod tests {
nota(2, "002", None), nota(2, "002", None),
]; ];
let resultado = executar_analise(&notas); let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]); let chave001 = ChaveSerie::new("001".to_string(), None);
assert!(resultado.faltantes_por_serie["002"].is_empty()); let chave002 = ChaveSerie::new("002".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
} }
#[test] #[test]
fn pre_analise_detecta_intervalo_excessivo() { fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![ let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
nota(1, "001", None),
nota(20_000, "001", None),
];
let pre = pre_analisar(&notas); let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre); let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1); assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, "001"); assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
}
#[test]
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
// NFE série 001: 1, 3 → faltante 2
// NFCE série 001: 2, 5 → faltante 3 e 4
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(3, "001", "NFE"),
nota_com_tipo(2, "001", "NFCE"),
nota_com_tipo(5, "001", "NFCE"),
];
let resultado = executar_analise(&notas);
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
} }
} }
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator}; use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local; use chrono::Local;
@@ -9,6 +10,7 @@ use std::path::Path;
pub fn exportar_pdf( pub fn exportar_pdf(
gerador: &dyn PdfGenerator, gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
nome_arquivo: &str, nome_arquivo: &str,
nome_layout: Option<&str>, nome_layout: Option<&str>,
caminho_saida: &Path, caminho_saida: &Path,
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
gerado_em: Local::now(), gerado_em: Local::now(),
}; };
gerador.gerar(resultado, &meta, caminho_saida) gerador.gerar(resultado, notas, &meta, caminho_saida)
} }
+82 -19
View File
@@ -1,4 +1,4 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use crate::domain::entities::serie::validar_serie; use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos}; use crate::domain::errors::{ErroArquivo, ResumoAvisos};
@@ -24,7 +24,10 @@ pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
} }
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados. /// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_csv(
caminho: &Path,
config: &LayoutCsv,
) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv( let resultado = csv_reader::ler_csv(
caminho, caminho,
config.delimitador as u8, config.delimitador as u8,
@@ -37,26 +40,31 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
&resultado.linhas, &resultado.linhas,
config.indice_numero, config.indice_numero,
config.indice_serie, config.indice_serie,
config.indice_documento_tipo,
config.indice_valor, config.indice_valor,
config.indice_data, config.indice_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados. /// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_xlsx(
caminho: &Path,
config: &LayoutXlsx,
) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado // Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = { let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero) let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
.ok_or_else(|| ErroArquivo::ErroLeitura(format!( ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'", config.pos_numero "Posição de Numero inválida: '{}'",
)))?; config.pos_numero
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie) ))
.ok_or_else(|| ErroArquivo::ErroLeitura(format!( })?;
"Posição de Serie inválida: '{}'", config.pos_serie let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie).ok_or_else(|| {
)))?; ErroArquivo::ErroLeitura(format!("Posição de Serie inválida: '{}'", config.pos_serie))
})?;
coord_num.linha.min(coord_ser.linha) coord_num.linha.min(coord_ser.linha)
}; };
@@ -79,32 +87,71 @@ pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImp
.as_ref() .as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p)) .and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize); .map(|c| c.coluna as usize);
let col_documento_tipo = config
.pos_documento_tipo
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos; let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas( let notas = mapear_linhas_para_notas(
&resultado.linhas, &resultado.linhas,
col_numero, col_numero,
col_serie, col_serie,
col_documento_tipo,
col_valor, col_valor,
col_data, col_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais, /// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos. /// aplicando validações e coletando avisos.
///
/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de
/// colunas da primeira linha, identificando qual campo está inválido.
fn mapear_linhas_para_notas( fn mapear_linhas_para_notas(
linhas: &[Vec<String>], linhas: &[Vec<String>],
idx_numero: usize, idx_numero: usize,
idx_serie: usize, idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>, idx_valor: Option<usize>,
idx_data: Option<usize>, idx_data: Option<usize>,
avisos: &mut ResumoAvisos, avisos: &mut ResumoAvisos,
) -> Vec<Nota> { ) -> Result<Vec<Nota>, ErroArquivo> {
let mut notas = Vec::new(); let mut notas = Vec::new();
// Verificar se os índices existem na primeira linha disponível
if let Some(primeira_linha) = linhas.first() {
let total_colunas = primeira_linha.len();
let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)];
for (nome, idx) in obrigatorios {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
let opcionais: &[(&str, Option<usize>)] = &[
("Valor", idx_valor),
("Data", idx_data),
("Tipo de Documento", idx_documento_tipo),
];
for (nome, maybe_idx) in opcionais {
if let Some(idx) = maybe_idx {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
}
}
for (i, linha) in linhas.iter().enumerate() { for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1; let linha_num = i + 1;
@@ -113,7 +160,9 @@ fn mapear_linhas_para_notas(
Some(s) if !s.trim().is_empty() => s.trim().to_string(), Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => { _ => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Numero ausente", linha_num));
continue; continue;
} }
}; };
@@ -123,7 +172,9 @@ fn mapear_linhas_para_notas(
Ok(n) => n, Ok(n) => n,
Err(msg) => { Err(msg) => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg)); avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue; continue;
} }
}; };
@@ -133,7 +184,9 @@ fn mapear_linhas_para_notas(
Some(s) => s.trim().to_string(), Some(s) => s.trim().to_string(),
None => { None => {
avisos.series_invalidas += 1; avisos.series_invalidas += 1;
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Serie ausente", linha_num));
continue; continue;
} }
}; };
@@ -182,10 +235,20 @@ fn mapear_linhas_para_notas(
None None
}; };
notas.push(Nota::new(numero, serie, valor, data)); // Extrair tipo de documento (opcional) — qualquer string não vazia
let documento_tipo = if let Some(idx) = idx_documento_tipo {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
}
} else {
None
};
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
} }
notas Ok(notas)
} }
/// Faz o parsing de um número, tentando extrair dígitos se necessário. /// Faz o parsing de um número, tentando extrair dígitos se necessário.
+52 -18
View File
@@ -2,24 +2,64 @@ use crate::domain::{
entities::layout::{Layout, LayoutJson}, entities::layout::{Layout, LayoutJson},
errors::ErroLayout, errors::ErroLayout,
}; };
use crate::infrastructure::sqlite::{layout_repository, migrations}; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; 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. /// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Se o layout já tem um id, atualiza. Caso contrário, insere.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String> { /// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayout> {
// Validar campos obrigatórios // Validar campos obrigatórios
if layout.nome().trim().is_empty() { if layout.nome().trim().is_empty() {
return Err("Nome do layout não pode ser vazio".to_string()); 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() { 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) layout_repository::atualizar(conn, layout)
.map_err(|e| e.to_string())?; .map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id) Ok(id)
} else { } else {
layout_repository::salvar(conn, layout) let nome = layout.nome().to_string();
.map_err(|e| e.to_string()) let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe {
return Err(ErroLayout::NomeConflitante(nome));
}
layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
} }
} }
@@ -28,11 +68,6 @@ pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
layout_repository::listar(conn).map_err(|e| e.to_string()) layout_repository::listar(conn).map_err(|e| e.to_string())
} }
/// Carrega um layout pelo id.
pub fn carregar_layout(conn: &Connection, id: i64) -> Result<Option<Layout>, String> {
layout_repository::buscar_por_id(conn, id).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id. /// Exclui um layout pelo id.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> { pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string()) layout_repository::excluir(conn, id).map_err(|e| e.to_string())
@@ -61,8 +96,8 @@ pub fn importar_layout_json(
novo_nome: Option<&str>, novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> { ) -> Result<i64, ErroLayout> {
// Deserializar // Deserializar
let json_repr: LayoutJson = serde_json::from_str(json) let json_repr: LayoutJson =
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?; let mut layout = Layout::try_from(json_repr)?;
@@ -77,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome // Verificar conflito de nome
let nome_atual = layout.nome().to_string(); let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual) 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 existe {
if sobrescrever_se_existir { if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever // Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn) 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 let id_existente = layouts_existentes
.iter() .iter()
@@ -96,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *i = Some(id), Layout::Xlsx { id: i, .. } => *i = Some(id),
} }
layout_repository::atualizar(conn, &layout) 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); return Ok(id);
} }
} else { } else {
@@ -105,6 +140,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // Inserir novo
layout_repository::salvar(conn, &layout) layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
} }
+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"`.
+26
View File
@@ -0,0 +1,26 @@
/// Chave composta que identifica um grupo de notas fiscais.
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
/// somente por série (retrocompatível).
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
impl ChaveSerie {
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
Self {
serie,
documento_tipo,
}
}
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
pub fn label(&self) -> String {
match &self.documento_tipo {
Some(tipo) => format!("{} / {}", self.serie, tipo),
None => self.serie.clone(),
}
}
}
+16
View File
@@ -34,6 +34,8 @@ pub struct LayoutCsv {
pub indice_valor: Option<usize>, pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado) /// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>, pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
} }
impl Default for LayoutCsv { impl Default for LayoutCsv {
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
indice_serie: 1, indice_serie: 1,
indice_valor: None, indice_valor: None,
indice_data: None, indice_data: None,
indice_documento_tipo: None,
} }
} }
} }
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
pub pos_valor: Option<String>, pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado) /// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>, pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
} }
impl Default for LayoutXlsx { impl Default for LayoutXlsx {
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
pos_serie: String::new(), pos_serie: String::new(),
pos_valor: None, pos_valor: None,
pos_data: None, pos_data: None,
pos_documento_tipo: None,
} }
} }
} }
@@ -129,6 +135,8 @@ pub enum LayoutJson {
indice_serie: usize, indice_serie: usize,
indice_valor: Option<usize>, indice_valor: Option<usize>,
indice_data: Option<usize>, indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
}, },
Xlsx { Xlsx {
nome: String, nome: String,
@@ -137,6 +145,8 @@ pub enum LayoutJson {
pos_serie: String, pos_serie: String,
pos_valor: Option<String>, pos_valor: Option<String>,
pos_data: Option<String>, pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
}, },
} }
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie, indice_serie,
indice_valor, indice_valor,
indice_data, indice_data,
indice_documento_tipo,
} => { } => {
if nome.trim().is_empty() { if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie, indice_serie,
indice_valor, indice_valor,
indice_data, indice_data,
indice_documento_tipo,
}, },
}) })
} }
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie, pos_serie,
pos_valor, pos_valor,
pos_data, pos_data,
pos_documento_tipo,
} => { } => {
if nome.trim().is_empty() { if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie, pos_serie,
pos_valor, pos_valor,
pos_data, pos_data,
pos_documento_tipo,
}, },
}) })
} }
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
indice_serie: config.indice_serie, indice_serie: config.indice_serie,
indice_valor: config.indice_valor, indice_valor: config.indice_valor,
indice_data: config.indice_data, indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
}, },
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx { Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(), nome: nome.clone(),
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
pos_serie: config.pos_serie.clone(), pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(), pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(), pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
}, },
} }
} }
+1
View File
@@ -1,3 +1,4 @@
pub mod chave_serie;
pub mod layout; pub mod layout;
pub mod nota; pub mod nota;
pub mod resultado_analise; pub mod resultado_analise;
+5 -1
View File
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
use rust_decimal::Decimal; use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos. /// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie` é o identificador único de cada nota. /// `numero + serie + documento_tipo` é o identificador único de cada nota.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Nota { pub struct Nota {
/// Número incremental da nota. Armazenado como u64. /// Número incremental da nota. Armazenado como u64.
pub numero: u64, pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1". /// Série da nota (13 dígitos numéricos). Ex: "001", "1".
pub serie: String, pub serie: String,
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
pub documento_tipo: Option<String>,
/// Valor monetário da nota (opcional). /// Valor monetário da nota (opcional).
pub valor: Option<Decimal>, pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras). /// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
@@ -19,12 +21,14 @@ impl Nota {
pub fn new( pub fn new(
numero: u64, numero: u64,
serie: String, serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>, valor: Option<Decimal>,
data: Option<NaiveDate>, data: Option<NaiveDate>,
) -> Self { ) -> Self {
Self { Self {
numero, numero,
serie, serie,
documento_tipo,
valor, valor,
data, data,
} }
+15 -14
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::collections::HashMap; use std::collections::HashMap;
@@ -5,15 +6,15 @@ use std::collections::HashMap;
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04). /// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResultadoPreAnalise { pub struct ResultadoPreAnalise {
/// Mapa série → (minimo, maximo, contagem_faltantes) /// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>, pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes) /// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>, pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas /// Somas já calculadas
pub soma_total: Decimal, pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>, pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por série /// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<String, usize>, pub total_por_serie: HashMap<ChaveSerie, usize>,
} }
/// Intervalo de sequência de uma série. /// Intervalo de sequência de uma série.
@@ -33,16 +34,16 @@ impl IntervaloSerie {
/// Resultado completo da análise, com a lista materializada de faltantes. /// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResultadoAnalise { pub struct ResultadoAnalise {
/// Números faltantes por série (ordenados crescentemente) /// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>, pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)] /// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>, pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores /// Soma total de todos os valores
pub soma_total: Decimal, pub soma_total: Decimal,
/// Soma por série /// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<String, Decimal>, pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por série /// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<String, usize>, pub total_por_serie: HashMap<ChaveSerie, usize>,
} }
impl ResultadoAnalise { impl ResultadoAnalise {
+2 -50
View File
@@ -1,13 +1,5 @@
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroNumero {
#[error("Número zero não é válido para uma nota fiscal")]
Zero,
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
pub enum ErroSerie { pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")] #[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
@@ -32,6 +24,8 @@ pub enum ErroLayout {
JsonMalformado(String), JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")] #[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String), NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
} }
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
@@ -40,52 +34,10 @@ pub enum ErroArquivo {
TamanhoExcedido(u64), TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")] #[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String), Corrompido(String),
#[error("Formato não suportado: {0}")]
FormatoNaoSuportado(String),
#[error("Erro de leitura: {0}")] #[error("Erro de leitura: {0}")]
ErroLeitura(String), ErroLeitura(String),
} }
#[derive(Debug, Error)]
pub enum ErroAnalise {
#[error("Intervalo de faltantes muito grande para a série '{serie}': {intervalo} registros")]
IntervaloMuitoGrande { serie: String, intervalo: u64 },
#[error("Campo obrigatório não mapeado: {0}")]
CampoObrigatorioNaoMapeado(String),
#[error("Índice de coluna inválido: campo '{campo}', índice {indice}")]
IndiceInvalido { campo: String, indice: usize },
#[error("Dois campos mapeados para o mesmo índice: {0}")]
IndicesDuplicados(String),
}
/// Aviso coletado durante a importação, para ser exibido de forma consolidada ao usuário.
#[derive(Debug, Clone)]
pub struct AvisoImportacao {
pub categoria: CategoriaAviso,
pub mensagem: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CategoriaAviso {
LinhaMalformada,
NumeroInvalido,
SerieInvalida,
ValorInvalido,
RegistroDescartado,
}
impl std::fmt::Display for CategoriaAviso {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CategoriaAviso::LinhaMalformada => write!(f, "Linhas malformadas"),
CategoriaAviso::NumeroInvalido => write!(f, "Valores de Numero inválidos"),
CategoriaAviso::SerieInvalida => write!(f, "Registros com Série inválida"),
CategoriaAviso::ValorInvalido => write!(f, "Valores monetários inválidos"),
CategoriaAviso::RegistroDescartado => write!(f, "Registros descartados"),
}
}
}
/// Resumo consolidado de avisos para exibição em um único modal. /// Resumo consolidado de avisos para exibição em um único modal.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct ResumoAvisos { pub struct ResumoAvisos {
+53 -19
View File
@@ -1,16 +1,17 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use std::collections::HashMap; use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas. /// Detecta registros duplicados em uma lista de notas.
/// ///
/// Retorna um mapa (numero, serie) → contagem de ocorrências, /// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência. /// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> { pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new(); let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas { for nota in notas {
*contagem *contagem
.entry((nota.numero, nota.serie.clone())) .entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1; .or_insert(0) += 1;
} }
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
contagem contagem
} }
/// Agrupa as duplicidades por série. /// Agrupa as duplicidades por (serie, documento_tipo).
/// ///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente. /// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> { pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas); let raw = detectar_duplicidades(notas);
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new(); let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie), contagem) in raw { for ((numero, serie, documento_tipo), contagem) in raw {
result.entry(serie).or_default().push((numero, contagem)); let chave = ChaveSerie::new(serie, documento_tipo);
result.entry(chave).or_default().push((numero, contagem));
} }
// Ordenar por numero dentro de cada série // Ordenar por numero dentro de cada grupo
for lista in result.values_mut() { for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num); lista.sort_by_key(|(num, _)| *num);
} }
@@ -44,7 +46,17 @@ mod tests {
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota { fn nota(numero: u64, serie: &str) -> Nota {
Nota::new(numero, serie.to_string(), None, None) Nota::new(numero, serie.to_string(), None, None, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(
numero,
serie.to_string(),
Some(tipo.to_string()),
None,
None,
)
} }
#[test] #[test]
@@ -58,7 +70,7 @@ mod tests {
fn com_duplicata_simples() { fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")]; let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2)); assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1); assert_eq!(dup.len(), 1);
} }
@@ -66,17 +78,39 @@ mod tests {
fn duplicata_multiplas_ocorrencias() { fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")]; let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3)); assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
} }
#[test] #[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() { fn mesmo_numero_series_diferentes_nao_e_duplicata() {
// Número 1 em séries diferentes não é duplicata
let notas = vec![nota(1, "001"), nota(1, "002")]; let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty()); assert!(dup.is_empty());
} }
#[test]
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(1, "001", "NFCE"),
];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(1, "001", "NFE"),
];
let dup = detectar_duplicidades(&notas);
assert_eq!(
dup.get(&(1, "001".to_string(), Some("NFE".to_string()))),
Some(&2)
);
}
#[test] #[test]
fn agrupamento_por_serie() { fn agrupamento_por_serie() {
let notas = vec![ let notas = vec![
@@ -88,13 +122,13 @@ mod tests {
nota(2, "001"), nota(2, "001"),
]; ];
let por_serie = duplicidades_por_serie(&notas); let por_serie = duplicidades_por_serie(&notas);
// Série 001 deve ter notas 1 e 2 duplicadas let chave001 = ChaveSerie::new("001".to_string(), None);
let serie001 = por_serie.get("001").unwrap(); let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2); assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2)); assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2)); assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada let serie002 = por_serie.get(&chave002).unwrap();
let serie002 = por_serie.get("002").unwrap();
assert_eq!(serie002.len(), 1); assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2)); assert_eq!(serie002[0], (1, 2));
} }
+50 -1
View File
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
faltantes faltantes
} }
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
///
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
///
/// # Exemplo
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
if faltantes.is_empty() {
return Vec::new();
}
let mut grupos = Vec::new();
let mut inicio = faltantes[0];
let mut anterior = faltantes[0];
for &num in &faltantes[1..] {
if num != anterior + 1 {
grupos.push((inicio, anterior));
inicio = num;
}
anterior = num;
}
grupos.push((inicio, anterior));
grupos
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -74,7 +100,7 @@ mod tests {
use rust_decimal::Decimal; use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota { fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None) Nota::new(numero, "001".to_string(), None, None, None)
} }
#[test] #[test]
@@ -136,4 +162,27 @@ mod tests {
let refs: Vec<&Nota> = notas.iter().collect(); let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new()); assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
} }
#[test]
fn agrupar_vazio() {
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
}
#[test]
fn agrupar_isolados() {
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
}
#[test]
fn agrupar_um_intervalo() {
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
}
#[test]
fn agrupar_misto() {
assert_eq!(
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
vec![(1, 3), (5, 5), (8, 9)]
);
}
} }
+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 |
+40 -8
View File
@@ -26,15 +26,14 @@ pub fn ler_csv(
linha_cabecalho: usize, linha_cabecalho: usize,
) -> Result<ResultadoCsv, ErroArquivo> { ) -> Result<ResultadoCsv, ErroArquivo> {
// Verificar tamanho // Verificar tamanho
let metadata = std::fs::metadata(caminho) let metadata =
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?; std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES { if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len())); return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
} }
// Ler conteúdo bruto // Ler conteúdo bruto
let bytes = std::fs::read(caminho) let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
// Decodificar encoding // Decodificar encoding
let conteudo = match encoding.to_lowercase().as_str() { let conteudo = match encoding.to_lowercase().as_str() {
@@ -42,10 +41,8 @@ pub fn ler_csv(
let (decoded, _, _) = WINDOWS_1252.decode(&bytes); let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned() decoded.into_owned()
} }
_ => { _ => String::from_utf8(bytes)
String::from_utf8(bytes) .map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
}
}; };
let mut avisos = ResumoAvisos::default(); let mut avisos = ResumoAvisos::default();
@@ -88,3 +85,38 @@ pub fn ler_csv(
avisos, avisos,
}) })
} }
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
n: usize,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
let linhas = reader
.records()
.take(n)
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
Ok(linhas)
}
+100 -67
View File
@@ -1,12 +1,22 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::detector_sequencia::agrupar_contiguos;
use crate::domain::services::parser_monetario::formatar_valor_br; use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use genpdf::{ use genpdf::{
elements::{Break, Paragraph}, elements::{Break, Paragraph},
fonts, style, Document, Element, SimplePageDecorator, fonts, style, Document, SimplePageDecorator,
}; };
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
// Fontes embutidas no binário em tempo de compilação.
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
/// Metadados do relatório. /// Metadados do relatório.
pub struct MetadadosRelatorio { pub struct MetadadosRelatorio {
pub nome_arquivo: String, pub nome_arquivo: String,
@@ -21,6 +31,7 @@ pub trait PdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String>; ) -> Result<(), String>;
@@ -33,12 +44,21 @@ impl PdfGenerator for GenpdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans) // Construir mapa (chave_serie, numero) → data para uso na seção de duplicatas
let font_family = carregar_fonte_sistema() let datas: HashMap<(ChaveSerie, u64), String> = notas
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?; .iter()
.filter_map(|n| {
n.data.map(|d| {
let chave = ChaveSerie::new(n.serie.clone(), n.documento_tipo.clone());
((chave, n.numero), d.format("%d/%m/%Y").to_string())
})
})
.collect();
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family); let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas"); doc.set_title("Relatório — Comparador de Notas");
@@ -49,12 +69,10 @@ impl PdfGenerator for GenpdfGenerator {
doc.set_page_decorator(decorator); doc.set_page_decorator(decorator);
// Título // Título
doc.push( doc.push(Paragraph::new("").styled_string(
Paragraph::new("").styled_string(
"Relatório de Análise de Notas Fiscais", "Relatório de Análise de Notas Fiscais",
style::Style::new().bold().with_font_size(16), style::Style::new().bold().with_font_size(16),
), ));
);
doc.push(Break::new(1)); doc.push(Break::new(1));
// Metadados // Metadados
@@ -68,24 +86,26 @@ impl PdfGenerator for GenpdfGenerator {
))); )));
doc.push(Break::new(1)); doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais // Totais
doc.push( doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)), Paragraph::new("")
.styled_string("Totais", style::Style::new().bold().with_font_size(14)),
); );
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
"Total Geral: R$ {}", "Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total) formatar_valor_br(&resultado.soma_total)
))); )));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect(); for chave in &chaves_ordenadas {
series_ordenadas.sort(); let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}", " Série {}: {} nota(s) — R$ {}",
serie, chave.label(),
total, total,
formatar_valor_br(soma) formatar_valor_br(soma)
))); )));
@@ -94,53 +114,86 @@ impl PdfGenerator for GenpdfGenerator {
doc.push(Break::new(1)); doc.push(Break::new(1));
// Notas Faltantes // Notas Faltantes
doc.push( doc.push(Paragraph::new("").styled_string(
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)), "Notas Faltantes por Série",
); style::Style::new().bold().with_font_size(14),
));
for serie in &series_ordenadas { // Use faltantes keys for this section (may differ from soma keys if no values)
let faltantes = match resultado.faltantes_por_serie.get(*serie) { let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves_faltantes.sort();
for chave in &chaves_faltantes {
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
Some(f) if !f.is_empty() => f, Some(f) if !f.is_empty() => f,
_ => { _ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie))); doc.push(Paragraph::new(format!(
" Série {}: nenhuma faltante",
chave.label()
)));
continue; continue;
} }
}; };
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)", " Série {}: {} faltante(s)",
serie, chave.label(),
faltantes.len() faltantes.len()
))); )));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect(); // Agrupar faltantes contíguos em intervalos (ex: 100104 em vez de 100, 101, 102, 103, 104)
doc.push(Paragraph::new(format!(" {}", numeros.join(", ")))); let grupos = agrupar_contiguos(faltantes);
let descricao: Vec<String> = grupos
.iter()
.map(|(ini, fim)| {
if ini == fim {
ini.to_string()
} else {
format!("{ini}{fim} ({} notas)", fim - ini + 1)
}
})
.collect();
doc.push(Paragraph::new(format!(" {}", descricao.join(", "))));
} }
doc.push(Break::new(1)); doc.push(Break::new(1));
// Duplicatas // Duplicatas
doc.push( doc.push(Paragraph::new("").styled_string(
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)), "Duplicatas por Série",
); style::Style::new().bold().with_font_size(14),
));
for serie in &series_ordenadas { let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) { chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
Some(d) if !d.is_empty() => d, Some(d) if !d.is_empty() => d,
_ => { _ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie))); doc.push(Paragraph::new(format!(
" Série {}: nenhuma duplicata",
chave.label()
)));
continue; continue;
} }
}; };
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)", " Série {}: {} grupo(s) duplicado(s)",
serie, chave.label(),
duplicatas.len() duplicatas.len()
))); )));
for (numero, count) in duplicatas { for (numero, count) in duplicatas {
let data_str = datas
.get(&((*chave).clone(), *numero))
.map(|d| format!(" (última: {})", d))
.unwrap_or_default();
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências", " NF {} / Série {}{} ocorrências{}",
numero, serie, count numero,
chave.label(),
count,
data_str,
))); )));
} }
} }
@@ -153,41 +206,21 @@ impl PdfGenerator for GenpdfGenerator {
} }
} }
/// Tenta carregar fontes DejaVu Sans do sistema. /// Constrói a família de fontes a partir dos bytes embutidos no binário.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> { /// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
// Caminhos comuns no Linux, Windows e macOS /// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
let candidatos_regular = [ fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
"/usr/share/fonts/TTF/DejaVuSans.ttf", .map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
"C:\\Windows\\Fonts\\arial.ttf", let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
"/Library/Fonts/Arial.ttf", .map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
// italic e bold_italic reusam os dados de regular/bold respectivamente.
Ok(fonts::FontFamily { Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(), italic: regular.clone(),
bold_italic: regular, bold_italic: bold.clone(),
regular,
bold,
}) })
} }
+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 |
+3 -5
View File
@@ -1,8 +1,6 @@
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// Determina o caminho do banco de dados conforme o sistema operacional. /// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf { pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
@@ -30,10 +28,10 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
if caminho.exists() { if caminho.exists() {
match Connection::open(caminho) { match Connection::open(caminho) {
Ok(conn) => { Ok(conn) => {
// Testar se o banco funciona com uma query simples // Testar integridade estrutural do banco
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
drop(conn); drop(conn);
let bak = caminho.with_extension("db.bak"); let bak = caminho.with_extension("db.bak");
+71 -76
View File
@@ -1,6 +1,22 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result}; 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. /// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> { pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout { match layout {
@@ -8,17 +24,19 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
conn.execute( conn.execute(
"INSERT INTO layouts "INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho, (nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data) indice_numero, indice_serie, indice_valor, indice_data,
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)", indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
config.indice_serie as i64, config.indice_serie as i64,
config.indice_valor.map(|v| v as i64), config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64), config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
], ],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
@@ -26,8 +44,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
Layout::Xlsx { nome, config, .. } => { Layout::Xlsx { nome, config, .. } => {
conn.execute( conn.execute(
"INSERT INTO layouts "INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data) (nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)", pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
nome, nome,
config.aba, config.aba,
@@ -35,6 +54,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.pos_serie, config.pos_serie,
config.pos_valor, config.pos_valor,
config.pos_data, config.pos_data,
config.pos_documento_tipo,
], ],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
@@ -43,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
} }
/// Atualiza um layout existente no banco. /// 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<()> { pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout let id = layout
.id() .id()
@@ -54,17 +75,20 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8 indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
WHERE id = ?9", aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
config.indice_serie as i64, config.indice_serie as i64,
config.indice_valor.map(|v| v as i64), config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64), config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id, id,
], ],
)?; )?;
@@ -73,8 +97,11 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute( conn.execute(
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6 pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
WHERE id = ?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![ params![
nome, nome,
config.aba, config.aba,
@@ -82,6 +109,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.pos_serie, config.pos_serie,
config.pos_valor, config.pos_valor,
config.pos_data, config.pos_data,
config.pos_documento_tipo,
id, id,
], ],
)?; )?;
@@ -96,33 +124,37 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
"SELECT id, nome, tipo, "SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data, indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC", FROM layouts ORDER BY nome ASC",
)?; )?;
let layouts: Result<Vec<Layout>> = stmt let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| { .query_map([], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get("id")?;
let nome: String = row.get(1)?; let nome: String = row.get("nome")?;
let tipo: String = row.get(2)?; let tipo: String = row.get("tipo")?;
if tipo == "csv" { if tipo == "csv" {
let delim_str: String = row.get(3)?; let delim_str: String = row.get("delimitador")?;
let delimitador = delim_str.chars().next().unwrap_or(';'); let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv { Ok(Layout::Csv {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutCsv { config: LayoutCsv {
delimitador, delimitador,
encoding: row.get(4)?, encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>(5)? as usize, linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>(6)? as usize, indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>(7)? as usize, indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row indice_valor: row
.get::<_, Option<i64>>(8)? .get::<_, Option<i64>>("indice_valor")?
.map(|v| v as usize), .map(|v| v as usize),
indice_data: row indice_data: row
.get::<_, Option<i64>>(9)? .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), .map(|v| v as usize),
}, },
}) })
@@ -131,11 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutXlsx { config: LayoutXlsx {
aba: row.get(10)?, aba: row.get("aba")?,
pos_numero: row.get(11)?, pos_numero: row.get("pos_numero")?,
pos_serie: row.get(12)?, pos_serie: row.get("pos_serie")?,
pos_valor: row.get(13)?, pos_valor: row.get("pos_valor")?,
pos_data: row.get(14)?, pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get("pos_documento_tipo")?,
}, },
}) })
} }
@@ -145,55 +178,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
layouts layouts
} }
/// Busca um layout pelo id.
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
FROM layouts WHERE id = ?1",
)?;
let mut results = stmt.query_map([id], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
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),
},
})
} else {
Ok(Layout::Xlsx {
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)?,
},
})
}
})?;
results.next().transpose()
}
/// Remove um layout pelo id. /// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> { pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?; conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
@@ -209,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
)?; )?;
Ok(count > 0) 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)
}
+44 -14
View File
@@ -1,8 +1,5 @@
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 1;
/// Aplica todas as migrations necessárias para atualizar o banco /// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente. /// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> { pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
@@ -14,29 +11,60 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
)?; )?;
let versao_atual: i64 = conn let versao_atual: i64 = conn
.query_row( .query_row("SELECT versao FROM schema_version LIMIT 1;", [], |row| {
"SELECT versao FROM schema_version LIMIT 1;", row.get(0)
[], })
|row| row.get(0),
)
.unwrap_or(0); .unwrap_or(0);
if versao_atual < 1 { if versao_atual < 1 {
migration_v1(conn)?; migration_v1(conn)?;
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
} else {
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
} }
if versao_atual < 2 {
migration_v2(conn)?;
}
if versao_atual < 3 {
migration_v3(conn)?;
} }
Ok(()) Ok(())
} }
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
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(())
}
/// Migration v2: adicionar índice único em layouts.nome.
///
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
/// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch(
"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(())
}
/// Migration v1: criar tabela de layouts. /// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> { fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts ( "BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
@@ -56,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
pos_serie TEXT, pos_serie TEXT,
pos_valor TEXT, pos_valor TEXT,
pos_data TEXT pos_data TEXT
);", );
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
+55 -30
View File
@@ -24,8 +24,8 @@ pub struct ResultadoXlsx {
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> { pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?; verificar_tamanho(caminho)?;
let workbook = open_workbook_auto(caminho) let workbook =
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec()) Ok(workbook.sheet_names().to_vec())
} }
@@ -45,14 +45,14 @@ pub fn ler_xlsx(
) -> Result<ResultadoXlsx, ErroArquivo> { ) -> Result<ResultadoXlsx, ErroArquivo> {
verificar_tamanho(caminho)?; verificar_tamanho(caminho)?;
let mut workbook = open_workbook_auto(caminho) let mut workbook =
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba) .worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; .map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let mut avisos = ResumoAvisos::default(); let avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new(); let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize; let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
continue; continue;
} }
let campos: Vec<String> = row let campos: Vec<String> = row.iter().map(celula_para_string).collect();
.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect();
// Ignorar linhas completamente em branco // Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) { if campos.iter().all(|s: &String| s.trim().is_empty()) {
@@ -97,6 +75,29 @@ pub fn ler_xlsx(
Ok(ResultadoXlsx { linhas, avisos }) Ok(ResultadoXlsx { linhas, avisos })
} }
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook =
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let linhas = range
.rows()
.take(5)
.map(|row| {
row.iter().map(celula_para_string).collect()
})
.collect();
Ok(linhas)
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1). /// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
/// ///
/// Retorna `None` se a notação for inválida. /// Retorna `None` se a notação for inválida.
@@ -134,9 +135,33 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
Some(Coordenada { coluna, linha }) Some(Coordenada { coluna, linha })
} }
/// Converte uma célula calamine em String, usando representação legível.
/// Inteiros sem parte decimal são formatados sem ".0"; erros e células vazias
/// retornam string vazia.
fn celula_para_string(cell: &calamine::Data) -> String {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
}
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> { fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata = std::fs::metadata(caminho) let metadata =
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?; std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES { if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len())); return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
} }
+11 -17
View File
@@ -1,3 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod application; mod application;
mod domain; mod domain;
mod infrastructure; mod infrastructure;
@@ -5,22 +7,14 @@ mod ui;
use ui::app::App; use ui::app::App;
fn main() -> eframe::Result { fn main() -> iced::Result {
let native_options = eframe::NativeOptions { iced::application("Comparador de Notas", App::update, App::view)
viewport: egui::ViewportBuilder::default() .subscription(App::subscription)
.with_title("Comparador de Notas") .theme(|_app| ui::theme::tema_dark())
.with_inner_size([1024.0, 768.0]) .window(iced::window::Settings {
.with_min_inner_size([800.0, 600.0]), size: iced::Size::new(1024.0, 768.0),
min_size: Some(iced::Size::new(800.0, 600.0)),
..Default::default() ..Default::default()
}; })
.run_with(App::new)
eframe::run_native(
"Comparador de Notas",
native_options,
Box::new(|_cc| {
let mut app = App::default();
app.inicializar();
Ok(Box::new(app))
}),
)
} }
+338
View File
@@ -0,0 +1,338 @@
# AGENTS.md — Guia de desenvolvimento da UI
Este documento descreve como a interface do **Comparador de Notas** é estruturada,
quais padrões devem ser seguidos e o que não fazer. Leia antes de criar ou modificar
qualquer arquivo em `src/ui/`.
---
## 1. Arquitetura da UI
O projeto usa **iced 0.13** com arquitetura Elm (Model / Update / View).
```
src/ui/
├── app.rs Estado global (App), update, view raiz, breadcrumb
├── message.rs Enum Message — todos os eventos da UI
├── mod.rs Re-exporta submódulos
├── theme.rs Paleta de cores, estilos de widgets (sem lógica)
├── components/
│ ├── modal.rs Overlay de modal bloqueante
│ ├── paginacao.rs Controles de paginação (◀ / ▶)
│ └── tabela_preview.rs Tabela de pré-visualização do arquivo importado
└── screens/
├── import.rs Tela 1 — seleção de arquivo e layout
├── selecionar_aba.rs Tela 1.5 — seleção de aba XLSX
├── configuracao_colunas.rs Tela 2 — mapeamento de colunas
├── resultado.rs Tela 3 — resultado da análise
└── layouts.rs Tela lateral — gerenciamento de layouts
```
### Responsabilidades por camada
| Camada | Responsabilidade |
|--------|-----------------|
| `app.rs` | Estado global, `update()`, `view()` raiz, roteamento entre telas, tarefas assíncronas |
| `screens/` | Renderização de cada tela: coleta inputs, monta widgets, emite `Message` |
| `components/` | Widgets reutilizáveis sem estado próprio (recebem dados por parâmetro) |
| `theme.rs` | Apenas estilos visuais. Sem lógica de negócio. Sem `Message`. |
| `message.rs` | Todos os eventos possíveis da UI. Nenhuma lógica aqui. |
**Regra:** nenhuma lógica de domínio (parsing, validação de sequência, cálculos) pode
estar em `src/ui/`. A UI apenas chama use cases de `src/application/`.
---
## 2. Tema e paleta de cores
Todas as cores estão em `theme.rs` como constantes `Color`. **Nunca use valores RGB
literais fora de `theme.rs`.**
### Paleta
| Constante | Hex | Uso |
|-----------|-----|-----|
| `BG` | `#0F172A` | Fundo geral da janela |
| `SURFACE` | `#1E293B` | Cards 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 |
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
| `PRIMARY` | `#3B82F6` | Botões primários, step ativo |
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
| `SUCCESS` | `#22C55E` | Badge OK, barra ≥ 90% |
| `WARNING` | `#F59E0B` | Badge faltante, barra 6089% |
| `DANGER` | `#EF4444` | Badge duplicada, barra < 60% |
| `TRACK_BG` | `#111827` | Trilha da progress bar |
### Helpers de estilo disponíveis em `theme.rs`
**Containers:**
- `t::fundo` — fundo geral da janela
- `t::card` — card principal (SURFACE + borda + radius 8)
- `t::card_secondary` — card secundário (SURFACE_2 + borda + radius 6)
- `t::cabecalho_tabela` — cabeçalho de tabela sem borda
- `t::badge_sucesso` / `t::badge_aviso` / `t::badge_perigo` — badges coloridos
- `t::area_erro` — área de validação com fundo vermelho sutil
- `t::stat_card` — card de estatística (igual a `t::card`)
- `t::separador` — linha divisória fina
- `t::breadcrumb_bg` — fundo da barra de breadcrumb
**Botões:**
- `t::btn_primary` — azul sólido, ação principal
- `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
**Progress bar:**
- `t::progress_bar_por_percentual(f32)` — retorna closure com cor por threshold:
- ≥ 0.90 → SUCCESS, 0.600.89 → WARNING, < 0.60 → DANGER
---
## 3. Regras de layout e responsividade
### Princípio geral
A janela tem tamanho mínimo de **800×600**. Todo layout deve funcionar bem nessa
dimensão e escalar corretamente ao aumentar.
### O que usar
| Situação | Valor correto |
|----------|--------------|
| Widget que deve preencher o espaço disponível | `Length::Fill` |
| Label ao lado de input em linha | `Length::FillPortion(3)` (label) + input com tamanho fixo pequeno ou `Fill` |
| Input numérico curto (índice, posição) | `Length::Fixed(90.0)` ou `Length::Fixed(110.0)` |
| Input de texto longo (nome, aba) | `Length::Fill` |
| Pick list de opções | `Length::Fill` |
| Modal/card centralizado com largura máxima | `.max_width(N)` + `Length::Fill` |
| Botões em linha que podem quebrar | `.wrap()` no `row![]` |
| Elemento que deve ter tamanho mínimo sem crescer | `Length::Shrink` |
| Células de tabela com scroll horizontal | `Length::Fixed(100.0)` mínimo |
### O que **não** fazer
- **Não use `Length::Fixed` em labels de formulário.** Labels devem usar
`FillPortion` para se adaptar ao espaço disponível.
- **Não use `Length::Fixed` em pick lists ou text inputs de texto livre.**
Use `Fill` para que se adaptem à largura do container pai.
- **Não coloque valores maiores que `max_width` em modais.** Use `.max_width(N)`
em vez de `Fixed(N)` para que o modal encolha em janelas menores.
- **Não deixe telas sem `scrollable`.** Toda tela com conteúdo vertical deve ser
envolvida em `scrollable()` para evitar clipping em janelas pequenas.
- **Não use `row![]` com muitos itens sem `.wrap()`.** Botões de ação e grupos
de controles devem usar `.wrap()` para quebrar linha quando não couberem.
### Padrão de campo de formulário
```rust
// Linha de campo: label proporcional + input
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
row![
text(label)
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::FillPortion(3)), // proporcional, não fixo
input, // input define seu próprio tamanho
]
.spacing(10)
.align_y(Alignment::Center)
.into()
}
```
### Padrão de campo opcional com checkbox
```rust
// Quando ativo: checkbox (FillPortion) + input
// Quando inativo: só o checkbox
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("...", &val)
.width(Length::Fixed(90.0)), // input numérico curto
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
}
```
---
## 4. Estrutura de uma tela (screen)
Toda tela segue o mesmo padrão de função pública `view`:
```rust
pub fn view(app: &App) -> Element<'_, Message> {
// 1. Montar seções/componentes individuais
let secao_x = ...;
let secao_y = ...;
// 2. Combinar em coluna principal
let conteudo = column![secao_x, secao_y]
.spacing(14)
.padding([20, 24])
.width(Length::Fill);
// 3. Envolver em scrollable + container de fundo
container(scrollable(conteudo))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
```
**Toda tela deve ter `scrollable` e `container` com `t::fundo` na raiz.**
---
## 5. Componentes reutilizáveis
### `modal::view_com_modal(conteudo, modal)`
Envolve qualquer `Element` com um overlay de modal bloqueante.
Chamado em `app.rs` quando `self.modal.is_some()`.
- Modal tem `max_width(420)` + `Length::Fill` para ser responsivo.
- Tipos disponíveis: `Informacao`, `Aviso`, `Erro`, `Confirmacao`, `InputTexto`.
- Disparar modal: usar os helpers em `app.rs` (`exibir_erro`, `exibir_aviso`, `exibir_confirmacao`).
### `tabela_preview::tabela_preview(linhas)`
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(200.0)` — acomoda 5 linhas + cabeçalho + scrollbar horizontal.
- Scroll horizontal via `scrollable::Direction::Horizontal`.
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
Row de botões ◀ / "Página X / Y" / ▶.
Emite as mensagens passadas como parâmetro.
---
## 6. Roteamento entre telas
O roteamento é feito pelo enum `EstadoApp` em `app.rs`:
| Estado | Tela renderizada |
|--------|-----------------|
| `Importando` | `screens/import.rs` |
| `SelecionandoAba` | `screens/selecionar_aba.rs` |
| `ConfigurandoColunas` | `screens/configuracao_colunas.rs` |
| `ConfirmandoIntervalo` | `screens/configuracao_colunas.rs` (mesmo view) |
| `ExibindoResultado(r)` | `screens/resultado.rs` |
| `GerenciandoLayouts` | `screens/layouts.rs` |
| `Analisando` | Spinner inline em `app.rs` |
Transições são sempre via `Message``update()`. **Nunca altere `self.estado`
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
1. Crie `src/ui/screens/minha_tela.rs` com função `pub fn view(app: &App) -> Element<'_, Message>`.
2. Adicione `pub mod minha_tela;` em `src/ui/screens/mod.rs`.
3. Adicione a variante correspondente em `EstadoApp` (`app.rs`).
4. Adicione o arm no `match &self.estado` em `app.view()` (`app.rs`).
5. Adicione as mensagens necessárias em `message.rs`.
6. Trate as mensagens no `update()` de `app.rs`.
---
## 8. Adicionando um novo componente
1. Crie `src/ui/components/meu_componente.rs`.
2. Adicione `pub mod meu_componente;` em `src/ui/components/mod.rs`.
3. O componente deve ser uma função pura: recebe dados por parâmetro, retorna `Element<'_, Message>`.
4. Sem estado interno, sem `self`, sem acesso ao banco.
---
## 9. Adicionando novos estilos ao tema
- Sempre adicione em `theme.rs`.
- Siga o padrão dos helpers existentes: função que recebe `&Theme` e retorna o `Style` do widget.
- Para cores com alpha: use `Color { a: 0.N, ..CONSTANTE }` em vez de valores RGB manuais.
- Nomeie helpers de container como `nome_do_contexto`, botões como `btn_nome`, inputs como `input_nome`.
---
## 10. Mensagens e estado assíncrono
- Operações bloqueantes (I/O, análise) são sempre executadas em `Task::perform` com
`tokio::task::spawn_blocking`.
- O resultado retorna para `update()` via `Message`.
- O estado `EstadoApp::Analisando` é usado enquanto a operação está em background.
- `ResultadoPendente` é o tipo intermediário entre a thread de análise e a UI.
**Não bloquear a thread principal da UI.** Qualquer operação lenta deve usar `Task`.
---
## 11. Drag-and-drop de arquivos
O iced 0.13 expõe eventos de janela para drag-and-drop via `iced::window::Event`.
O projeto os captura através de `App::subscription()` registrado em `main.rs`.
### Eventos capturados
| Evento iced | Mensagem emitida | Efeito |
|---|---|---|
| `window::Event::FileDropped(path)` | `Message::ArquivoSolto(path)` | Processa o arquivo como se fosse selecionado via botão |
| `window::Event::FileHovered(_)` | `Message::ArquivoEmHover` | Liga `App.arquivo_em_hover = true` |
| `window::Event::FilesHoveredLeft` | `Message::ArquivoHoverSaiu` | Liga `App.arquivo_em_hover = false` |
### Subscription
`App::subscription()` em `app.rs` usa `iced::event::listen_with` para filtrar apenas
os três eventos acima. Está registrado em `main.rs` via `.subscription(App::subscription)`.
### Estado de hover
O campo `App.arquivo_em_hover: bool` é `true` enquanto um arquivo está sendo arrastado
sobre a janela. A tela `screens/import.rs` usa esse campo para alterar visualmente a
`drop_zone`: borda mais brilhante (`a: 0.9`) e mais espessa (`width: 2.0`) durante o hover.
### Fluxo de processamento
`Message::ArquivoSolto` chama `self.processar_arquivo_selecionado(caminho)` — o mesmo
método chamado pelo botão de seleção de arquivo. O comportamento é idêntico: validação
de extensão, leitura de abas (XLSX) ou preview (CSV), e transição de estado.
### Como estender
Para adicionar suporte a drag-and-drop em outras telas (ex: importar layout JSON por
drag), basta verificar `App.estado` dentro do arm `Message::ArquivoSolto` antes de
chamar `processar_arquivo_selecionado`, e desviar conforme necessário.
+1240 -200
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub mod modal;
pub mod paginacao;
pub mod tabela_preview;
+143
View File
@@ -0,0 +1,143 @@
use crate::ui::app::EstadoModal;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, mouse_area, row, stack, text, text_input};
use iced::{Alignment, Color, Element, Length};
/// Envolve o conteúdo principal com uma camada de modal por cima.
/// O overlay escuro bloqueia cliques no conteúdo de baixo.
pub fn view_com_modal<'a>(
conteudo: Element<'a, Message>,
modal: &'a EstadoModal,
) -> Element<'a, Message> {
let overlay = mouse_area(
container(view_modal(modal))
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()),
..Default::default()
})
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.on_press(Message::ModalCancelado);
stack![conteudo, overlay].into()
}
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal {
EstadoModal::Aviso { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
}
EstadoModal::Erro { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Erro, None, false)
}
EstadoModal::Confirmacao {
titulo, mensagem, ..
} => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true),
EstadoModal::InputTexto {
titulo,
mensagem,
texto,
..
} => caixa_modal(
titulo,
mensagem,
TipoModal::Info,
Some(texto.as_str()),
true,
),
}
}
enum TipoModal {
Info,
Aviso,
Erro,
Confirmacao,
}
fn caixa_modal<'a>(
titulo: &'a str,
mensagem: &'a str,
tipo: TipoModal,
input: Option<&'a str>,
com_confirmar: bool,
) -> Element<'a, Message> {
// Cor e label de ícone por tipo
let (cor_titulo, icone) = match tipo {
TipoModal::Info => (t::PRIMARY, "i"),
TipoModal::Aviso => (t::WARNING, "!"),
TipoModal::Erro => (t::DANGER, "x"),
TipoModal::Confirmacao => (t::WARNING, "?"),
};
let badge_icone = container(text(icone).size(13).color(cor_titulo))
.padding([2, 8])
.style(move |_theme| iced::widget::container::Style {
background: Some(
Color {
a: 0.15,
..cor_titulo
}
.into(),
),
border: iced::Border {
color: Color {
a: 0.3,
..cor_titulo
},
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
let titulo_row = row![badge_icone, text(titulo).size(17).color(t::TEXT),]
.spacing(8)
.align_y(Alignment::Center);
let mut col =
column![titulo_row, text(mensagem).size(14).color(t::TEXT_SECONDARY),].spacing(12);
if let Some(valor) = input {
col = col.push(
text_input("Nome...", valor)
.on_input(Message::ModalTextoAlterado)
.padding(8)
.style(t::input_dark),
);
}
// Separador
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
// Botões
let btn_fechar = button("Fechar")
.on_press(Message::ModalCancelado)
.style(t::btn_ghost);
let mut botoes = row![btn_fechar].spacing(8);
if com_confirmar {
let btn_confirmar = button("Confirmar")
.on_press(Message::ModalConfirmado)
.style(t::btn_primary);
botoes = botoes.push(btn_confirmar);
}
col = col.push(botoes);
container(col.align_x(Alignment::Start))
.max_width(420)
.width(Length::Fill)
.padding(24)
.style(t::card)
.into()
}
+31
View File
@@ -0,0 +1,31 @@
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, row, text};
use iced::{Alignment, Element};
/// Renderiza controles de paginação reutilizáveis.
pub fn controles_paginacao(
pagina_atual: usize,
total_paginas: usize,
msg_anterior: Message,
msg_proximo: Message,
) -> Element<'static, Message> {
let btn_anterior = button("<")
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
.style(t::btn_ghost);
let btn_proximo = button(">")
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
.style(t::btn_ghost);
row![
btn_anterior,
text(format!("Página {} / {}", pagina_atual + 1, total_paginas))
.size(13)
.color(t::TEXT_SECONDARY),
btn_proximo,
]
.spacing(8)
.align_y(Alignment::Center)
.into()
}
+97
View File
@@ -0,0 +1,97 @@
use crate::ui::message::Message;
use crate::ui::screens::indice_para_letra;
use crate::ui::theme as t;
use iced::widget::{column, container, row, scrollable, text};
use iced::{Border, Color, Element, Font, Length};
/// Renderiza uma tabela de pré-visualização das primeiras linhas do arquivo.
/// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados.
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 0 {
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
}
// Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
let cabecalho = container(
row((0..num_colunas)
.map(|i| {
container(
text(format!("{} ({})", indice_para_letra(i), i))
.font(Font::MONOSPACE)
.size(12)
.color(t::TEXT_SECONDARY),
)
.width(Length::Fixed(100.0))
.padding([4, 6])
.into()
})
.collect::<Vec<_>>())
.spacing(0),
)
.width(Length::Shrink)
.style(t::cabecalho_tabela);
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
// Linhas alternadas: SURFACE e SURFACE_2 levemente
let bg = if idx % 2 == 0 {
t::SURFACE
} else {
Color {
r: t::SURFACE.r + 0.01,
g: t::SURFACE.g + 0.01,
b: t::SURFACE.b + 0.015,
a: 1.0,
}
};
container(
row((0..num_colunas)
.map(|col| {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let truncado: String = if celula.chars().count() > 30 {
format!("{}...", celula.chars().take(30).collect::<String>())
} else {
celula.to_string()
};
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
.width(Length::Fixed(100.0))
.padding([3, 6])
.into()
})
.collect::<Vec<_>>())
.spacing(0),
)
.width(Length::Shrink)
.style(move |_theme| iced::widget::container::Style {
background: Some(bg.into()),
..Default::default()
})
.into()
});
let todas_linhas = column(
std::iter::once(cabecalho.into())
.chain(linhas_view)
.collect::<Vec<_>>(),
)
.spacing(0);
container(
scrollable(todas_linhas)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(),
))
.height(Length::Fixed(200.0)),
)
.style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE.into()),
border: Border {
color: t::BORDER,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
.into()
}
+130
View File
@@ -0,0 +1,130 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutXlsx},
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
};
use crate::domain::errors::ResumoAvisos;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Todos os eventos/interações da UI.
#[derive(Debug, Clone)]
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),
/// Arquivo arrastado e solto sobre a janela (drag-and-drop).
ArquivoSolto(PathBuf),
/// Arquivo está sendo arrastado sobre a janela (hover).
ArquivoEmHover,
/// Arquivo arrastado saiu da janela sem ser solto.
ArquivoHoverSaiu,
AbaSelecionada(String),
// --- XLSX: abas carregadas em background ---
AbaxlsxCarregadas {
caminho: PathBuf,
abas: Vec<String>,
layout_xlsx: LayoutXlsx,
nome_layout: String,
},
XlsxErroAoCarregar(String),
// --- Background tasks ---
AnaliseCompleta(ResultadoPendente),
// --- Configuração CSV ---
DelimitadorAlterado(char),
EncodingAlterado(String),
LinhaCabecalhoAlterada(usize),
IndiceNumeroAlterado(usize),
IndiceSerieAlterado(usize),
IndiceValorToggle(bool),
IndiceValorAlterado(usize),
IndiceDataToggle(bool),
IndiceDataAlterado(usize),
IndiceDocTipoToggle(bool),
IndiceDocTipoAlterado(usize),
// --- Configuração XLSX ---
AbaXlsxAlterada(String),
PosNumeroAlterada(String),
PosSerieAlterada(String),
PosValorToggle(bool),
PosValorAlterada(String),
PosDataToggle(bool),
PosDataAlterada(String),
PosDocTipoToggle(bool),
PosDocTipoAlterada(String),
// --- Análise ---
ExecutarImportacao,
ReanalisarArquivo,
ConfirmarExpansaoFaltantes,
NovaAnalise,
// --- Resultado ---
PaginaFaltantesAlterada(ChaveSerie, usize),
PaginaDuplicatasAlterada(ChaveSerie, usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie),
ExportarPdf,
PdfExportado(Result<PathBuf, String>),
// --- Layouts ---
LayoutSelecionado(i64),
SalvarLayout,
NomeLayoutAlterado(String),
ExcluirLayout(i64),
ExclusaoConfirmada(i64),
ExportarLayoutJson(i64),
ImportarLayoutJson,
LayoutJsonImportado(String),
SobrescreverLayout(Layout),
// --- Modal ---
ModalTextoAlterado(String),
ModalConfirmado,
ModalCancelado,
// --- Sem operação (used as fallback) ---
Noop,
// --- Animação ---
SpinnerTick,
}
/// Resultado enviado pela task de análise em background para a UI.
#[derive(Debug, Clone)]
pub enum ResultadoPendente {
/// Análise concluída com sucesso.
Concluido {
resultado: ResultadoAnalise,
avisos: Option<ResumoAvisos>,
notas: Option<Vec<Nota>>,
},
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
/// Arquivo importado não continha notas válidas.
Vazio,
/// Erro durante importação ou análise.
Erro(String),
}
+3
View File
@@ -1,2 +1,5 @@
pub mod app; pub mod app;
pub mod components;
pub mod message;
pub mod screens; pub mod screens;
pub mod theme;
+459 -248
View File
@@ -1,251 +1,506 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
use crate::domain::entities::layout::TipoArquivo; use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::{App, EstadoApp}; use crate::ui::app::App;
use egui::{Context, Ui}; use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column,
};
use iced::{Alignment, Element, Length};
/// Renderiza a tela de configuração de colunas. const OPCOES_DELIMITADOR: &[(&str, char)] = &[
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) { ("Vírgula (,)", ','),
ui.heading("Configuração de Colunas"); ("Ponto e vírgula (;)", ';'),
ui.add_space(8.0); ("Tabulação (Tab)", '\t'),
];
if let Some(caminho) = &app.caminho_arquivo.clone() { const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"];
ui.label(format!("Arquivo: {}", caminho.display()));
/// Tela de configuração de colunas.
pub fn view(app: &App) -> Element<'_, Message> {
// ── Cabeçalho ─────────────────────────────────────────────────────────────
let header = column![
text("Configuração de Colunas").size(20).color(t::TEXT),
if let Some(caminho) = &app.caminho_arquivo {
row![
text("Arquivo:").size(12).color(t::TEXT_MUTED),
text(
caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
)
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_y(Alignment::Center)
} else {
row![text("").size(12)]
},
]
.spacing(4);
// ── Seletor de layout ──────────────────────────────────────────────────────
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<String> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.map(|l| l.nome().to_string())
.collect();
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
None
} else {
Some(app.nome_layout_atual.clone())
};
let secao_layout = container(
row![
text("Layout:")
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| {
if let Some(id) = layouts
.iter()
.find(|l| l.nome() == nome_selecionado)
.and_then(|l| l.id())
{
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome_selecionado)
} }
ui.add_space(12.0);
match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => renderizar_csv(ui, app),
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
} }
})
.placeholder("— Selecionar layout —")
.style(input_style_pick)
.width(Length::Fill),
]
.spacing(8)
.align_y(Alignment::Center),
)
.style(t::card_secondary)
.padding([10, 14])
.width(Length::Fill);
ui.add_space(16.0); // ── Configuração específica ───────────────────────────────────────────────
ui.separator(); let config_section = match app.tipo_arquivo_atual {
ui.add_space(8.0); TipoArquivo::Csv => view_csv(app),
TipoArquivo::Xlsx => view_xlsx(app),
};
// Validação e botões de ação // ── Pré-visualização ──────────────────────────────────────────────────────
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
container(
column![
text("Pré-visualização (5 primeiras linhas)")
.size(13)
.color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas),
]
.spacing(8),
)
.style(t::card)
.padding(14)
.width(Length::Fill)
.into()
} else {
text("").size(1).into()
};
// ── Validação ─────────────────────────────────────────────────────────────
let (valido, erros) = validar_config(app); let (valido, erros) = validar_config(app);
if !erros.is_empty() { let erros_section: Element<Message> = if erros.is_empty() {
for erro in &erros { text("").size(1).into()
ui.colored_label(egui::Color32::RED, format!("{}", erro)); } else {
} container(
ui.add_space(8.0); Column::with_children(
} erros
.iter()
.map(|e| text(format!("(!) {}", e)).size(13).color(t::DANGER).into())
.collect::<Vec<_>>(),
)
.spacing(4),
)
.style(t::area_erro)
.padding([10, 14])
.width(Length::Fill)
.into()
};
ui.horizontal(|ui| { // ── Botões ────────────────────────────────────────────────────────────────
if ui.button("< Voltar").clicked() { let tem_arquivo = app.caminho_arquivo.is_some();
app.estado = EstadoApp::Importando; let tem_notas = !app.notas_importadas.is_empty();
}
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| { let botoes = container(
if ui.button("▶ Importar e Analisar").clicked() { row![
executar_importacao(app); button(text("Voltar").size(13))
} .on_press(Message::Voltar)
}); .style(t::btn_ghost)
}); .padding([9, 14]),
button(text("Importar e Analisar").size(13))
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao))
.style(t::btn_primary)
.padding([9, 14]),
button(text("Reanalisar").size(13))
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
.style(t::btn_secondary)
.padding([9, 14]),
button(text("Salvar layout").size(13))
.on_press(Message::SalvarLayout)
.style(t::btn_ghost)
.padding([9, 14]),
]
.spacing(8)
.align_y(Alignment::Center),
)
.style(t::card_secondary)
.padding([12, 16])
.width(Length::Fill);
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![
header,
secao_layout,
config_section,
preview_section,
erros_section,
botoes,
]
.spacing(14)
.padding([20, 24])
.width(Length::Fill);
container(scrollable(content))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
} }
fn renderizar_csv(ui: &mut Ui, app: &mut App) { // ─── Estilo do pick_list ──────────────────────────────────────────────────────
ui.group(|ui| {
ui.label("Configurações CSV");
ui.add_space(4.0);
// Delimitador fn input_style_pick(
ui.horizontal(|ui| { theme: &iced::Theme,
ui.label("Delimitador:"); status: iced::widget::pick_list::Status,
let delim_str = match app.layout_csv_atual.delimitador { ) -> iced::widget::pick_list::Style {
let base = iced::widget::pick_list::Style {
text_color: t::TEXT,
placeholder_color: t::TEXT_MUTED,
handle_color: t::TEXT_SECONDARY,
background: t::BG.into(),
border: iced::Border {
color: t::BORDER,
width: 1.0,
radius: 6.0.into(),
},
};
match status {
iced::widget::pick_list::Status::Opened | iced::widget::pick_list::Status::Hovered => {
iced::widget::pick_list::Style {
border: iced::Border {
color: t::PRIMARY,
..base.border
},
..base
}
}
_ => {
let _ = theme;
base
}
}
}
// ─── Configuração CSV ────────────────────────────────────────────────────────
fn view_csv(app: &App) -> Element<'_, Message> {
let c = &app.layout_csv_atual;
let delim_str = match c.delimitador {
',' => "Vírgula (,)", ',' => "Vírgula (,)",
';' => "Ponto e vírgula (;)", ';' => "Ponto e vírgula (;)",
'\t' => "Tabulação (Tab)", '\t' => "Tabulação (Tab)",
_ => "Outro", _ => "Outro",
};
egui::ComboBox::from_id_salt("combo_delimitador")
.selected_text(delim_str)
.show_ui(ui, |ui| {
if ui
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
.clicked()
{
app.layout_csv_atual.delimitador = ',';
} }
if ui .to_string();
.selectable_label(
app.layout_csv_atual.delimitador == ';', let opcoes_delim: Vec<String> = OPCOES_DELIMITADOR
"Ponto e vírgula (;)", .iter()
.map(|(s, _)| s.to_string())
.collect();
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
let linha_cabecalho_str = c.linha_cabecalho.to_string();
let indice_numero_str = c.indice_numero.to_string();
let indice_serie_str = c.indice_serie.to_string();
let inner = column![
secao_titulo("Importação CSV"),
campo_row(
"Delimitador",
pick_list(opcoes_delim, Some(delim_str), |selecionado| {
let c = OPCOES_DELIMITADOR
.iter()
.find(|(s, _)| *s == selecionado)
.map(|(_, c)| *c)
.unwrap_or(',');
Message::DelimitadorAlterado(c)
})
.style(input_style_pick)
.width(Length::Fill)
.into(),
),
campo_row(
"Encoding",
pick_list(
opcoes_enc,
Some(c.encoding.clone()),
Message::EncodingAlterado
) )
.clicked() .style(input_style_pick)
{ .width(Length::Fill)
app.layout_csv_atual.delimitador = ';'; .into(),
} ),
if ui campo_row(
.selectable_label( "Linha cabeçalho",
app.layout_csv_atual.delimitador == '\t', text_input("0", &linha_cabecalho_str)
"Tabulação (Tab)", .on_input(|s| {
) s.parse::<usize>()
.clicked() .map(Message::LinhaCabecalhoAlterada)
{ .unwrap_or(Message::Noop)
app.layout_csv_atual.delimitador = '\t'; })
} .style(t::input_dark)
}); .width(Length::Fixed(90.0))
}); .into(),
),
secao_subtitulo("Mapeamento de colunas (índice base 0)"),
campo_row(
"Número (obrigatório)",
text_input("0", &indice_numero_str)
.on_input(|s| {
s.parse::<usize>()
.map(Message::IndiceNumeroAlterado)
.unwrap_or(Message::Noop)
})
.style(t::input_dark)
.width(Length::Fixed(90.0))
.into(),
),
campo_row(
"Série (obrigatório)",
text_input("0", &indice_serie_str)
.on_input(|s| {
s.parse::<usize>()
.map(Message::IndiceSerieAlterado)
.unwrap_or(Message::Noop)
})
.style(t::input_dark)
.width(Length::Fixed(90.0))
.into(),
),
campo_indice_opcional_csv(
"Valor (opcional)",
c.indice_valor,
Message::IndiceValorToggle,
Message::IndiceValorAlterado,
),
campo_indice_opcional_csv(
"Data (opcional)",
c.indice_data,
Message::IndiceDataToggle,
Message::IndiceDataAlterado,
),
campo_indice_opcional_csv(
"Tipo Documento (opcional)",
c.indice_documento_tipo,
Message::IndiceDocTipoToggle,
Message::IndiceDocTipoAlterado,
),
]
.spacing(10);
// Encoding container(inner)
ui.horizontal(|ui| { .style(t::card)
ui.label("Encoding:"); .padding([14, 18])
egui::ComboBox::from_id_salt("combo_encoding") .width(Length::Fill)
.selected_text(&app.layout_csv_atual.encoding) .into()
.show_ui(ui, |ui| {
if ui
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
.clicked()
{
app.layout_csv_atual.encoding = "utf-8".to_string();
}
if ui
.selectable_label(
app.layout_csv_atual.encoding == "windows-1252",
"Windows-1252 (Latin-1)",
)
.clicked()
{
app.layout_csv_atual.encoding = "windows-1252".to_string();
}
});
});
// Linha cabeçalho
ui.horizontal(|ui| {
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
let mut val = app.layout_csv_atual.linha_cabecalho;
ui.add(egui::DragValue::new(&mut val).range(0..=100));
app.layout_csv_atual.linha_cabecalho = val;
});
});
ui.add_space(8.0);
ui.group(|ui| {
ui.label("Mapeamento de Colunas (índice base 0)");
ui.add_space(4.0);
campo_indice(
ui,
"Número (obrigatório):",
&mut app.layout_csv_atual.indice_numero,
);
campo_indice(
ui,
"Série (obrigatório):",
&mut app.layout_csv_atual.indice_serie,
);
campo_indice_opcional(
ui,
"Valor (opcional):",
&mut app.layout_csv_atual.indice_valor,
);
campo_indice_opcional(
ui,
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
});
} }
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) { // ─── Configuração XLSX ────────────────────────────────────────────────────────
ui.group(|ui| {
ui.label("Configurações XLSX");
ui.add_space(4.0);
ui.horizontal(|ui| { fn view_xlsx(app: &App) -> Element<'_, Message> {
ui.label("Aba:"); let c = &app.layout_xlsx_atual;
if app.abas_xlsx.is_empty() {
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba); let secao_aba: Element<Message> = if app.abas_xlsx.is_empty() {
campo_row(
"Aba",
text_input("Nome da aba", &c.aba)
.on_input(Message::AbaXlsxAlterada)
.style(t::input_dark)
.width(Length::Fill)
.into(),
)
} else { } else {
let aba_atual = app.layout_xlsx_atual.aba.clone(); let aba_sel = if c.aba.is_empty() {
egui::ComboBox::from_id_salt("combo_aba") None
.selected_text(&aba_atual) } else {
.show_ui(ui, |ui| { Some(c.aba.clone())
for aba in &app.abas_xlsx.clone() { };
if ui.selectable_label(aba_atual == *aba, aba).clicked() { campo_row(
app.layout_xlsx_atual.aba = aba.clone(); "Aba",
} pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
} .style(input_style_pick)
}); .width(Length::Fill)
} .into(),
}); )
}); };
ui.add_space(8.0);
ui.group(|ui| {
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
ui.add_space(4.0);
let inner = column![
secao_titulo("Importação XLSX"),
secao_aba,
secao_subtitulo("Mapeamento de colunas (formato LetraLinha, ex: B3)"),
campo_letra_linha( campo_letra_linha(
ui, "Número (obrigatório)",
"Número (obrigatório):", &c.pos_numero,
&mut app.layout_xlsx_atual.pos_numero, Message::PosNumeroAlterada
); ),
campo_letra_linha( campo_letra_linha(
ui, "Série (obrigatório)",
"Série (obrigatório):", &c.pos_serie,
&mut app.layout_xlsx_atual.pos_serie, Message::PosSerieAlterada
); ),
campo_letra_linha_opcional( campo_letra_linha_opcional(
ui, "Valor (opcional)",
"Valor (opcional):", c.pos_valor.as_deref(),
&mut app.layout_xlsx_atual.pos_valor, Message::PosValorToggle,
); Message::PosValorAlterada,
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data); ),
}); campo_letra_linha_opcional(
"Data (opcional)",
c.pos_data.as_deref(),
Message::PosDataToggle,
Message::PosDataAlterada,
),
campo_letra_linha_opcional(
"Tipo Documento (opcional)",
c.pos_documento_tipo.as_deref(),
Message::PosDocTipoToggle,
Message::PosDocTipoAlterada,
),
]
.spacing(10);
container(inner)
.style(t::card)
.padding([14, 18])
.width(Length::Fill)
.into()
} }
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) { // ─── Helpers visuais ─────────────────────────────────────────────────────────
ui.horizontal(|ui| {
ui.label(label); fn secao_titulo(label: &str) -> Element<'_, Message> {
ui.add(egui::DragValue::new(valor).range(0..=999usize)); text(label).size(14).color(t::TEXT).into()
});
} }
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) { fn secao_subtitulo(label: &str) -> Element<'_, Message> {
ui.horizontal(|ui| { text(label).size(12).color(t::TEXT_SECONDARY).into()
let mut ativo = valor.is_some(); }
if ui.checkbox(&mut ativo, label).changed() {
*valor = if ativo { Some(0) } else { None }; fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
row![
text(label)
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::FillPortion(3)),
input,
]
.spacing(10)
.align_y(Alignment::Center)
.into()
}
fn campo_indice_opcional_csv(
label: &str,
valor: Option<usize>,
msg_toggle: impl Fn(bool) -> Message + 'static,
msg_valor: impl Fn(usize) -> Message + 'static,
) -> Element<'static, Message> {
let ativo = valor.is_some();
let val_str = valor.map(|v| v.to_string()).unwrap_or_default();
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("0", &val_str)
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
.style(t::input_dark)
.width(Length::Fixed(90.0)),
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
} }
if let Some(v) = valor {
ui.add(egui::DragValue::new(v).range(0..=999usize));
}
});
} }
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) { fn campo_letra_linha<'a>(
ui.horizontal(|ui| { label: &'a str,
ui.label(label); valor: &'a str,
ui.text_edit_singleline(valor); msg: impl Fn(String) -> Message + 'a,
}); ) -> Element<'a, Message> {
campo_row(
label,
text_input("ex: B3", valor)
.on_input(msg)
.style(t::input_dark)
.width(Length::Fixed(110.0))
.into(),
)
} }
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) { fn campo_letra_linha_opcional<'a>(
ui.horizontal(|ui| { label: &'a str,
let mut ativo = valor.is_some(); valor: Option<&'a str>,
if ui.checkbox(&mut ativo, label).changed() { msg_toggle: impl Fn(bool) -> Message + 'a,
*valor = if ativo { Some(String::new()) } else { None }; msg_valor: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
let ativo = valor.is_some();
let val_str = valor.unwrap_or("").to_string();
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("ex: B3", &val_str)
.on_input(msg_valor)
.style(t::input_dark)
.width(Length::Fixed(110.0)),
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
} }
if let Some(v) = valor {
ui.text_edit_singleline(v);
}
});
} }
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros). // ─── Validação ────────────────────────────────────────────────────────────────
fn validar_config(app: &App) -> (bool, Vec<String>) { fn validar_config(app: &App) -> (bool, Vec<String>) {
let mut erros = Vec::new(); let mut erros = Vec::new();
match &app.tipo_arquivo_atual { match &app.tipo_arquivo_atual {
TipoArquivo::Csv => { TipoArquivo::Csv => {
let c = &app.layout_csv_atual; let c = &app.layout_csv_atual;
// Verificar índices duplicados
let mut indices: Vec<(String, usize)> = vec![ let mut indices: Vec<(String, usize)> = vec![
("Numero".to_string(), c.indice_numero), ("Numero".to_string(), c.indice_numero),
("Serie".to_string(), c.indice_serie), ("Serie".to_string(), c.indice_serie),
@@ -256,6 +511,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
if let Some(d) = c.indice_data { if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d)); indices.push(("Data".to_string(), d));
} }
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros); verificar_duplicados(&indices, &mut erros);
} }
TipoArquivo::Xlsx => { TipoArquivo::Xlsx => {
@@ -296,50 +554,3 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
} }
} }
} }
fn executar_importacao(app: &mut App) {
let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(),
None => return,
};
let resultado = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => {
importar_csv(&caminho, &app.layout_csv_atual).map_err(|e| e.to_string())
}
TipoArquivo::Xlsx => {
importar_xlsx(&caminho, &app.layout_xlsx_atual).map_err(|e| e.to_string())
}
};
match resultado {
Ok(res) => {
if res.notas.is_empty() {
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
return;
}
let avisos = res.avisos.clone();
app.notas_importadas = res.notas;
app.avisos_importacao = if avisos.tem_avisos() {
Some(avisos.clone())
} else {
None
};
// Executar análise
app.executar_analise();
// Exibir avisos consolidados após análise
if let Some(av) = &app.avisos_importacao {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
app.exibir_aviso("Avisos de Importação", linhas);
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
}
+207 -153
View File
@@ -1,176 +1,230 @@
use crate::application::usecases::importar_arquivo::{ use crate::ui::app::App;
importar_csv, importar_xlsx, listar_abas_xlsx, use crate::ui::message::Message;
}; use crate::ui::theme as t;
use crate::domain::entities::layout::TipoArquivo; use iced::widget::{button, column, container, pick_list, row, scrollable, text};
use crate::ui::app::{App, EstadoApp}; use iced::{Alignment, Element, Length};
use egui::{Context, Ui};
use std::path::PathBuf;
/// Renderiza a tela de importação de arquivos. /// Tela de importação de arquivos.
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) { pub fn view(app: &App) -> Element<'_, Message> {
ui.heading("Comparador de Notas — Importar Arquivo"); // ── Área de seleção de arquivo ────────────────────────────────────────────
ui.add_space(16.0); let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() {
("Nenhum arquivo selecionado".to_string(), false)
// --- Seleção de arquivo ---
ui.group(|ui| {
ui.label("Arquivo:");
ui.horizontal(|ui| {
let nome = if app.nome_arquivo.is_empty() {
"Nenhum arquivo selecionado".to_string()
} else { } else {
app.nome_arquivo.clone() (app.nome_arquivo.clone(), true)
}; };
ui.label(nome);
if ui.button("📂 Selecionar arquivo...").clicked() { let em_hover = app.arquivo_em_hover;
if let Some(caminho) = rfd::FileDialog::new()
.add_filter("Planilhas", &["csv", "xlsx", "xls"]) let icone_arquivo: Element<Message> = container(
.pick_file() text(if tem_arquivo { "CSV / XLSX" } else { "Arquivo" })
{ .size(12)
on_arquivo_selecionado(app, caminho); .color(if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
}),
)
.padding([6, 12])
.style(move |_theme| iced::widget::container::Style {
background: Some(
iced::Color {
a: 0.1,
..if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
} }
} }
}); .into(),
}); ),
border: iced::Border {
color: iced::Color {
a: 0.3,
..if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
}
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
.into();
ui.add_space(8.0); let texto_arquivo: Element<Message> = if tem_arquivo {
let nome_str = nome_arquivo.clone();
column![
text(nome_str).size(14).color(t::TEXT),
text("Arquivo pronto para configuração")
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_x(Alignment::Center)
.into()
} else {
column![
text("Selecione ou arraste um arquivo CSV ou XLSX")
.size(14)
.color(t::TEXT_SECONDARY),
text("Suportado: .csv, .xlsx, .xls")
.size(12)
.color(t::TEXT_MUTED),
]
.spacing(4)
.align_x(Alignment::Center)
.into()
};
// Coletar infos dos layouts antecipadamente para evitar borrow duplo let drop_zone = container(
column![icone_arquivo, texto_arquivo]
.spacing(12)
.align_x(Alignment::Center)
.width(Length::Fill),
)
.style(move |_theme| iced::widget::container::Style {
background: Some(
iced::Color {
a: if em_hover { 0.12 } else { 0.05 },
..t::PRIMARY
}
.into(),
),
border: iced::Border {
color: if em_hover {
iced::Color {
a: 0.9,
..t::PRIMARY
}
} else if tem_arquivo {
iced::Color {
a: 0.5,
..t::PRIMARY
}
} else {
iced::Color {
a: 0.3,
..t::BORDER
}
},
width: if em_hover { 2.0 } else { 1.5 },
radius: 8.0.into(),
},
..Default::default()
})
.padding([28, 20])
.width(Length::Fill);
let btn_selecionar = button(
row![text("Selecionar arquivo...").size(14)]
.align_y(Alignment::Center)
.spacing(6),
)
.on_press(Message::SelecionarArquivo)
.style(t::btn_primary)
.width(Length::Fill)
.padding([10, 16]);
// ── Seção de layout ───────────────────────────────────────────────────────
let tipo_atual = app.tipo_arquivo_atual.clone(); let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<(i64, String)> = app let opcoes_layout: Vec<String> = app
.layouts_salvos .layouts_salvos
.iter() .iter()
.filter(|l| l.tipo() == tipo_atual) .filter(|l| l.tipo() == tipo_atual)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string()))) .map(|l| l.nome().to_string())
.collect(); .collect();
let nome_layout_atual = app.nome_layout_atual.clone();
ui.horizontal(|ui| { let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
ui.label("Layout:"); None
egui::ComboBox::from_id_salt("combo_layouts_import")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else { } else {
&nome_layout_atual Some(app.nome_layout_atual.clone())
};
let secao_layout = row![
text("Layout salvo:")
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| {
if let Some(id) = layouts
.iter()
.find(|l| l.nome() == nome_selecionado)
.and_then(|l| l.id())
{
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome_selecionado)
}
}
}) })
.show_ui(ui, |ui| { .placeholder("— Selecionar layout —")
for (id, nome) in &opcoes_layout { .width(Length::Fill),
if ui button(text("Gerenciar").size(13))
.selectable_label(nome_layout_atual == *nome, nome.as_str()) .on_press(Message::IrParaLayouts)
.clicked() .style(t::btn_ghost)
{ .padding([8, 12]),
app.nome_layout_atual = nome.clone(); ]
if let Some(layout) = .spacing(8)
app.layouts_salvos.iter().find(|l| l.id() == Some(*id)) .align_y(Alignment::Center);
{
let layout = layout.clone();
aplicar_layout(app, &layout);
}
}
}
});
if ui.button("⚙ Gerenciar Layouts").clicked() { // ── Botão avançar ─────────────────────────────────────────────────────────
app.estado = EstadoApp::GerenciandoLayouts; let botao_avancar: Element<Message> = if tem_arquivo {
} button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center))
}); .on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_primary)
ui.add_space(16.0); .width(Length::Fill)
.padding([11, 16])
if app.caminho_arquivo.is_some() { .into()
if ui.button("▶ Configurar Colunas").clicked() { } else {
app.estado = EstadoApp::ConfigurandoColunas; container(text("").size(1))
} .height(Length::Fixed(42.0))
} .into()
}
/// Renderiza a tela de seleção de aba (XLSX).
pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
ui.heading("Selecionar Aba da Planilha");
ui.add_space(16.0);
let (abas, caminho) = match &app.estado {
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
_ => return,
}; };
ui.label(format!("Arquivo: {}", caminho.display())); // ── Separador visual ──────────────────────────────────────────────────────
ui.add_space(8.0); let separador = container(text(""))
ui.label("Selecione a aba a processar:"); .height(Length::Fixed(1.0))
.width(Length::Fill)
.style(t::separador);
let aba_atual = app.layout_xlsx_atual.aba.clone(); // ── Card central ──────────────────────────────────────────────────────────
for aba in &abas { let card_inner = column![
if ui.selectable_label(aba_atual == *aba, aba).clicked() { drop_zone,
app.layout_xlsx_atual.aba = aba.clone(); btn_selecionar,
} separador,
} secao_layout,
botao_avancar,
]
.spacing(14)
.padding(24)
.width(Length::Fill);
ui.add_space(12.0); let card = container(card_inner)
if !app.layout_xlsx_atual.aba.is_empty() { .style(t::card)
if ui.button("▶ Configurar Colunas").clicked() { .max_width(560)
app.nome_arquivo = caminho .width(Length::Fill);
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
app.caminho_arquivo = Some(caminho);
app.estado = EstadoApp::ConfigurandoColunas;
}
}
if ui.button("< Voltar").clicked() { // ── Layout geral ──────────────────────────────────────────────────────────
app.estado = EstadoApp::Importando; container(scrollable(
} column![
} text("Importar Arquivo").size(20).color(t::TEXT),
text("Selecione e configure sua planilha para análise")
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) { .size(13)
let extensao = caminho .color(t::TEXT_SECONDARY),
.extension() card,
.and_then(|e| e.to_str()) ]
.unwrap_or("") .spacing(16)
.to_lowercase(); .align_x(Alignment::Center)
.padding([32, 20])
app.nome_arquivo = caminho .width(Length::Fill),
.file_name() ))
.map(|n| n.to_string_lossy().to_string()) .style(t::fundo)
.unwrap_or_default(); .width(Length::Fill)
.height(Length::Fill)
match extensao.as_str() { .center_x(Length::Fill)
"csv" => { .into()
app.tipo_arquivo_atual = TipoArquivo::Csv;
app.caminho_arquivo = Some(caminho);
app.notas_importadas.clear();
}
"xlsx" | "xls" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
match listar_abas_xlsx(&caminho) {
Ok(info) => {
app.abas_xlsx = info.abas.clone();
app.estado = EstadoApp::SelecionandoAba {
abas: info.abas,
caminho: caminho.clone(),
};
app.notas_importadas.clear();
}
Err(e) => {
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
}
}
}
_ => {
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
}
}
}
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
match layout {
crate::domain::entities::layout::Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
}
} }
+81 -197
View File
@@ -1,213 +1,97 @@
use crate::application::usecases::layouts::{ use crate::domain::entities::layout::{Layout, TipoArquivo};
exportar_layout_json, importar_layout_json, salvar_layout, use crate::ui::app::App;
}; use crate::ui::message::Message;
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo}; use crate::ui::theme as t;
use crate::domain::errors::ErroLayout; use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
use crate::ui::app::{AcaoModal, App, EstadoApp}; use iced::{Alignment, Element, Length};
use egui::{Context, Ui};
/// Renderiza a tela de gerenciamento de layouts. /// Tela de gerenciamento de layouts.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) { pub fn view(app: &App) -> Element<'_, Message> {
ui.heading("Gerenciar Layouts"); let cabecalho = row![
ui.add_space(8.0); button("Voltar")
.on_press(Message::IrParaImportacao)
.style(t::btn_secondary),
horizontal_space(),
button("Importar JSON")
.on_press(Message::ImportarLayoutJson)
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center)
.width(Length::Fill);
ui.horizontal(|ui| { let titulo = text("Gerenciar Layouts").size(22).color(t::TEXT);
if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando;
}
});
ui.add_space(12.0); let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
ui.separator(); let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
// Salvar layout atual let content = column![titulo, cabecalho, secao_csv, secao_xlsx,]
ui.add_space(8.0); .spacing(16)
ui.group(|ui| { .padding(20)
ui.label("Salvar Layout Atual"); .width(Length::Fill);
ui.horizontal(|ui| {
ui.label("Nome:");
ui.text_edit_singleline(&mut app.nome_layout_atual);
if ui.button("💾 Salvar").clicked() {
salvar_layout_atual(app);
}
});
});
ui.add_space(12.0); container(scrollable(content))
ui.separator(); .width(Length::Fill)
ui.add_space(8.0); .height(Length::Fill)
.style(t::fundo)
// Layouts CSV .into()
let layouts_csv: Vec<_> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Csv)
.cloned()
.collect();
let layouts_xlsx: Vec<_> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
.cloned()
.collect();
egui::ScrollArea::vertical().show(ui, |ui| {
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
ui.add_space(12.0);
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
// Importar de JSON
ui.horizontal(|ui| {
ui.label("Importar layout de arquivo JSON:");
if ui.button("📥 Importar JSON").clicked() {
if let Some(caminho) = rfd::FileDialog::new()
.add_filter("JSON", &["json"])
.pick_file()
{
match std::fs::read_to_string(&caminho) {
Ok(conteudo) => importar_json(app, &conteudo),
Err(e) => {
app.exibir_erro(format!("Erro ao ler arquivo JSON: {}", e));
}
}
}
}
});
});
} }
fn renderizar_secao_layouts( fn view_secao_layouts<'a>(
ui: &mut Ui, titulo: &'a str,
ctx: &Context, layouts: &'a [Layout],
app: &mut App, tipo: TipoArquivo,
titulo: &str, ) -> Element<'a, Message> {
layouts: &[Layout], let titulo_widget = text(titulo).size(16).color(t::TEXT_SECONDARY);
) {
ui.label(egui::RichText::new(titulo).strong());
ui.add_space(4.0);
if layouts.is_empty() { let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
ui.label("(nenhum layout salvo)");
return; let mut col = column![titulo_widget].spacing(4);
if filtrados.is_empty() {
col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED));
return container(col)
.padding([12, 16])
.width(Length::Fill)
.style(t::card)
.into();
} }
for layout in layouts { for layout in filtrados {
ui.horizontal(|ui| {
ui.label(layout.nome());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Excluir
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
if ui.button("🗑 Excluir").clicked() { let linha = container(
app.exibir_confirmacao( row![
"Confirmar exclusão", text(layout.nome())
format!("Deseja excluir o layout '{}'?", layout.nome()), .size(14)
AcaoModal::ConfirmarExclusaoLayout(id), .color(t::TEXT)
); .width(Length::Fill),
} horizontal_space(),
button("Carregar")
.on_press(Message::LayoutSelecionado(id))
.style(t::btn_primary),
button("Exportar JSON")
.on_press(Message::ExportarLayoutJson(id))
.style(t::btn_ghost),
button("Excluir")
.on_press(Message::ExcluirLayout(id))
.style(t::btn_danger),
]
.spacing(8)
.align_y(Alignment::Center)
.padding([10, 0]),
)
.width(Length::Fill);
// Exportar col = col.push(linha);
if ui.button("📤 Exportar JSON").clicked() {
match exportar_layout_json(layout) { // Separador entre linhas
Ok((conteudo, nome_sugerido)) => { col = col.push(container(iced::widget::horizontal_rule(1)).width(Length::Fill));
if let Some(caminho) = rfd::FileDialog::new()
.set_file_name(&nome_sugerido)
.add_filter("JSON", &["json"])
.save_file()
{
if let Err(e) = std::fs::write(&caminho, &conteudo) {
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
}
} }
} }
// Carregar container(col)
if ui.button("📂 Carregar").clicked() { .padding([12, 16])
aplicar_layout(app, layout); .width(Length::Fill)
app.nome_layout_atual = layout.nome().to_string(); .style(t::card)
app.estado = EstadoApp::ConfigurandoColunas; .into()
}
}
});
});
}
}
fn salvar_layout_atual(app: &mut App) {
if app.nome_layout_atual.trim().is_empty() {
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
return;
}
let layout = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: None,
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: None,
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_xlsx_atual.clone(),
},
};
if let Some(conn) = &app.conn {
match salvar_layout(conn, &layout) {
Ok(_) => {
app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
}
Err(e) => {
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
}
}
}
}
fn importar_json(app: &mut App, conteudo: &str) {
if let Some(conn) = &app.conn {
match importar_layout_json(conn, conteudo, false, None) {
Ok(_) => {
app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
}
Err(ErroLayout::NomeConflitante(nome)) => {
// Exibir opções: sobrescrever ou cancelar
app.exibir_aviso(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
nome
),
);
// TODO: implementar fluxo completo de sobrescrever com entrada de novo nome
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar layout: {}", e));
}
}
}
}
fn aplicar_layout(app: &mut App, layout: &Layout) {
match layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
}
} }
+14
View File
@@ -2,3 +2,17 @@ pub mod configuracao_colunas;
pub mod import; pub mod import;
pub mod layouts; pub mod layouts;
pub mod resultado; pub mod resultado;
pub mod selecionar_aba;
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
pub fn indice_para_letra(mut idx: usize) -> String {
let mut resultado = String::new();
loop {
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
if idx < 26 {
break;
}
idx = idx / 26 - 1;
}
resultado
}
+386 -208
View File
@@ -1,259 +1,437 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{ use crate::domain::{
entities::resultado_analise::ResultadoAnalise, entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::parser_monetario::formatar_valor_br, services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
}; };
use crate::infrastructure::pdf_generator::GenpdfGenerator; use crate::ui::app::App;
use crate::ui::app::{App, EstadoApp}; use crate::ui::message::Message;
use egui::{Context, Ui}; use crate::ui::theme as t;
use iced::widget::{button, column, container, progress_bar, row, scrollable, text};
use iced::{Alignment, Element, Length};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
/// Renderiza a tela de resultados. /// Tela de resultados da análise.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) { pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
// Extrair resultado do estado (sem mover) // ── Stat cards no topo ────────────────────────────────────────────────────
let resultado = match &app.estado {
EstadoApp::ExibindoResultado(r) => r.clone(),
_ => return,
};
ui.heading("Resultado da Análise");
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("< Nova Análise").clicked() {
app.estado = EstadoApp::Importando;
app.notas_importadas.clear();
return;
}
if ui.button("⚙ Reconfigurar Colunas").clicked() {
app.estado = EstadoApp::ConfigurandoColunas;
return;
}
if ui.button("📄 Exportar PDF").clicked() {
exportar_para_pdf(app, &resultado);
}
});
ui.add_space(8.0);
// Controle de itens por página
ui.horizontal(|ui| {
ui.label("Itens por página:");
for &opcao in OPCOES_PAGINA {
if ui
.selectable_label(app.itens_por_pagina == opcao, opcao.to_string())
.clicked()
{
app.itens_por_pagina = opcao;
app.pagina_faltantes = 0;
app.pagina_duplicatas = 0;
}
}
});
ui.add_space(12.0);
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
// Totais
renderizar_totais(ui, &resultado);
ui.add_space(12.0);
ui.separator();
// Faltantes
renderizar_faltantes(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Duplicatas
renderizar_duplicatas(ui, app, &resultado);
});
}
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
ui.label(egui::RichText::new("Totais").heading().strong());
ui.add_space(4.0);
ui.label(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
));
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
series.sort();
for serie in series {
let soma = &resultado.soma_por_serie[serie];
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
total_notas,
formatar_valor_br(soma)
));
}
}
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes(); let total_faltantes = resultado.total_faltantes();
ui.label( let total_duplicatas = resultado.total_duplicatas();
egui::RichText::new(format!( let total_notas: usize = resultado.total_por_serie.values().sum();
"Notas Faltantes ({} total)", let valor_total_str = format!("R$ {}", formatar_valor_br(&resultado.soma_total));
total_faltantes
))
.heading()
.strong(),
);
ui.add_space(4.0);
if total_faltantes == 0 { let stat_faltantes = stat_card_widget("Faltantes", total_faltantes.to_string(), t::WARNING);
ui.label("✔ Nenhuma nota faltante."); let stat_duplicatas = stat_card_widget("Duplicatas", total_duplicatas.to_string(), t::DANGER);
return; let stat_total = stat_card_widget("Total de Notas", total_notas.to_string(), t::PRIMARY);
let stat_valor = stat_card_widget("Valor Total", valor_total_str, t::SUCCESS);
let stat_row = row![stat_faltantes, stat_duplicatas, stat_total, stat_valor]
.spacing(12)
.width(Length::Fill);
// ── Botões de ação ────────────────────────────────────────────────────────
let botoes_topo = row![
button("Nova Análise")
.on_press(Message::NovaAnalise)
.style(t::btn_secondary)
.width(Length::Shrink),
button("Reconfigurar")
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_ghost)
.width(Length::Shrink),
button("Reanalisar")
.on_press_maybe(
app.caminho_arquivo
.as_ref()
.map(|_| Message::ReanalisarArquivo)
)
.style(t::btn_ghost)
.width(Length::Shrink),
button("Exportar PDF")
.on_press(Message::ExportarPdf)
.style(t::btn_primary)
.width(Length::Shrink),
]
.spacing(8)
.wrap();
// ── Controle de itens por página ──────────────────────────────────────────
let opcoes_por_pagina = row(OPCOES_PAGINA
.iter()
.map(|&n| {
let ativo = n == app.itens_por_pagina;
button(text(n.to_string()).size(13))
.on_press(Message::ItensPorPaginaAlterado(n))
.style(if ativo {
t::btn_pagina_ativo
} else {
t::btn_pagina_inativo
})
.into()
})
.collect::<Vec<_>>())
.spacing(4);
let controle_pagina = row![
text("Itens por página:").size(13).color(t::TEXT_SECONDARY),
opcoes_por_pagina,
]
.spacing(8)
.align_y(Alignment::Center);
// ── Seções principais ─────────────────────────────────────────────────────
let secao_faltantes = view_faltantes(app, resultado);
let secao_duplicatas = view_duplicatas(app, resultado);
let secao_totais = view_totais(resultado);
let mut col_principal = column![
row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo,
stat_row,
controle_pagina,
]
.spacing(16)
.padding(20)
.width(Length::Fill);
// Badge "Sem inconsistências" quando não há faltantes nem duplicatas
if resultado.sem_inconsistencias() {
col_principal = col_principal.push(
container(
row![
container(text("").size(13).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Sem inconsistências — todas as séries estão completas.")
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([10, 16])
.width(Length::Fill)
.style(t::card),
);
} }
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect(); col_principal = col_principal
series.sort(); .push(secao_faltantes)
.push(secao_duplicatas)
.push(secao_totais);
container(scrollable(col_principal))
.width(Length::Fill)
.height(Length::Fill)
.style(t::fundo)
.into()
}
/// Cria um stat card com label, valor e cor de destaque.
fn stat_card_widget<'a>(label: &'a str, valor: String, cor: iced::Color) -> Element<'a, Message> {
let conteudo = column![
text(label).size(12).color(t::TEXT_SECONDARY),
text(valor).size(24).color(cor),
]
.spacing(4);
container(conteudo)
.padding([14, 18])
.width(Length::Fill)
.style(t::stat_card)
.into()
}
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_faltantes();
let titulo_row = row![
text("Notas Faltantes").size(18).color(t::TEXT),
container(text(format!(" {} ", total)).size(12).color(t::WARNING))
.padding([2, 8])
.style(t::badge_aviso),
]
.spacing(8)
.align_y(Alignment::Center);
let mut col = column![titulo_row].spacing(12);
if total == 0 {
col = col.push(
row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Nenhuma nota faltante.").size(14).color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
);
return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
}
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
for serie in series {
let faltantes = &resultado.faltantes_por_serie[serie];
if faltantes.is_empty() { if faltantes.is_empty() {
// Série completa — exibir badge verde
let linha_serie = row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text(format!("Série {} — completa", chave.label()))
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center);
col = col.push(linha_serie);
continue; continue;
} }
ui.label(format!( let total_esperado = total_notas + faltantes.len();
"Série {} — {} faltante(s):", let percentual = total_notas as f64 / total_esperado as f64;
serie, let percentual_f32 = percentual as f32;
faltantes.len()
));
// Paginação // Cabeçalho da série
let cabecalho_serie = row![
column![
text(format!("Série {}", chave.label()))
.size(14)
.color(t::TEXT),
text(format!(
"{} faltante(s) — {}/{} notas ({:.1}% completo)",
faltantes.len(),
total_notas,
total_esperado,
percentual * 100.0,
))
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(2)
.width(Length::Fill),
button("Copiar")
.on_press(Message::CopiarFaltantes(chave.clone()))
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center);
// Progress bar de completude
let barra = progress_bar(0.0..=1.0, percentual_f32)
.height(6)
.style(t::progress_bar_por_percentual(percentual_f32));
col = col.push(cabecalho_serie);
col = col.push(barra);
// Paginação e lista
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas { let pagina = app
app.pagina_faltantes = 0; .pagina_faltantes
} .get(chave)
.copied()
let inicio = app.pagina_faltantes * app.itens_por_pagina; .unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
for numero in &faltantes[inicio..fim] { let mut lista = column![].spacing(2);
ui.label(format!("{}", numero)); for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
let txt = if a == b {
text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY)
} else {
text(format!(" {}{} ({} notas)", a, b, b - a + 1))
.size(13)
.color(t::TEXT_SECONDARY)
};
lista = lista.push(txt);
} }
col = col.push(lista);
if total_paginas > 1 { if total_paginas > 1 {
ui.horizontal(|ui| { col = col.push(crate::ui::components::paginacao::controles_paginacao(
if ui.button("").clicked() && app.pagina_faltantes > 0 { pagina,
app.pagina_faltantes -= 1; total_paginas,
} Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
ui.label(format!( Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
"Página {} / {}",
app.pagina_faltantes + 1,
total_paginas
)); ));
if ui.button("").clicked() && app.pagina_faltantes + 1 < total_paginas {
app.pagina_faltantes += 1;
}
});
} }
// Separador
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
} }
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
} }
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) { fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total_dup = resultado.total_duplicatas(); let total = resultado.total_duplicatas();
ui.label(
egui::RichText::new(format!(
"Notas Duplicadas ({} grupo(s))",
total_dup
))
.heading()
.strong(),
);
ui.add_space(4.0);
if total_dup == 0 { let titulo_row = row![
ui.label("✔ Nenhuma nota duplicada."); text("Notas Duplicadas").size(18).color(t::TEXT),
return; container(text(format!(" {} ", total)).size(12).color(t::DANGER))
.padding([2, 8])
.style(t::badge_perigo),
]
.spacing(8)
.align_y(Alignment::Center);
let mut col = column![titulo_row].spacing(12);
if total == 0 {
col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS));
return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
} }
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect(); let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
series.sort(); chaves.sort();
for serie in series { for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[serie]; let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() { if duplicatas.is_empty() {
continue; continue;
} }
ui.label(format!( let cabecalho_serie = row![
"Série {} — {} grupo(s) duplicado(s):", text(format!(
serie, "Série {} — {} grupo(s) duplicado(s)",
chave.label(),
duplicatas.len() duplicatas.len()
)); ))
.size(14)
.color(t::TEXT)
.width(Length::Fill),
button("Copiar")
.on_press(Message::CopiarDuplicatas(chave.clone()))
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center);
let total_paginas = col = col.push(cabecalho_serie);
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_duplicatas >= total_paginas {
app.pagina_duplicatas = 0;
}
let inicio = app.pagina_duplicatas * app.itens_por_pagina; let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app
.pagina_duplicatas
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
let mut lista = column![].spacing(2);
for (numero, count) in &duplicatas[inicio..fim] { for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!( lista = lista.push(
" • NF {} / Série {} — {} ocorrências", text(format!(
numero, serie, count " NF {} / Série {} — {} ocorrências",
)); numero,
chave.label(),
count
))
.size(13)
.color(t::TEXT_SECONDARY),
);
} }
col = col.push(lista);
if total_paginas > 1 { if total_paginas > 1 {
ui.horizontal(|ui| { col = col.push(crate::ui::components::paginacao::controles_paginacao(
if ui.button("").clicked() && app.pagina_duplicatas > 0 { pagina,
app.pagina_duplicatas -= 1; total_paginas,
} Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
ui.label(format!( Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
"Página {} / {}",
app.pagina_duplicatas + 1,
total_paginas
)); ));
if ui.button("").clicked() && app.pagina_duplicatas + 1 < total_paginas {
app.pagina_duplicatas += 1;
}
});
} }
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
} }
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
} }
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) { fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
if let Some(caminho) = rfd::FileDialog::new() let titulo = text("Totais por Série").size(18).color(t::TEXT);
.set_file_name("relatorio.pdf")
.add_filter("PDF", &["pdf"])
.save_file()
{
let gerador = GenpdfGenerator;
let nome_layout = if app.nome_layout_atual.is_empty() {
None
} else {
Some(app.nome_layout_atual.as_str())
};
match exportar_pdf( let mut col = column![titulo].spacing(8);
&gerador,
resultado, let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
&app.nome_arquivo, chaves.sort();
nome_layout,
&caminho, for chave in chaves {
) { let soma = &resultado.soma_por_serie[chave];
Ok(_) => { let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
} let linha = row![
Err(e) => { text(format!("Série {}", chave.label()))
app.exibir_erro(format!("Erro ao exportar PDF: {}", e)); .size(14)
} .color(t::TEXT)
} .width(Length::Fill),
text(format!("{} nota(s)", total_notas))
.size(13)
.color(t::TEXT_SECONDARY),
text(format!("R$ {}", formatar_valor_br(soma)))
.size(13)
.color(t::SUCCESS),
]
.spacing(12)
.align_y(Alignment::Center);
col = col.push(linha);
} }
// Total geral
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
col = col.push(
row![
text("Total Geral")
.size(15)
.color(t::TEXT)
.width(Length::Fill),
text(format!("R$ {}", formatar_valor_br(&resultado.soma_total)))
.size(15)
.color(t::SUCCESS),
]
.spacing(12)
.align_y(Alignment::Center),
);
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
} }
+156
View File
@@ -0,0 +1,156 @@
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Alignment, Element, Length};
use std::path::PathBuf;
/// Tela de seleção de aba de arquivo XLSX.
pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Element<'a, Message> {
let nome_arquivo = caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| caminho.display().to_string());
let nome_arquivo_owned = nome_arquivo.clone();
// ── Cabeçalho ─────────────────────────────────────────────────────────────
let header = column![
text("Selecionar Aba").size(20).color(t::TEXT),
row![
text("Arquivo:").size(12).color(t::TEXT_MUTED),
text(nome_arquivo_owned).size(12).color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_y(Alignment::Center),
]
.spacing(4);
// ── Lista de abas ─────────────────────────────────────────────────────────
let aba_atual = &app.layout_xlsx_atual.aba;
let lista_abas = column(
abas.iter()
.map(|aba| {
let selecionada = aba == aba_atual;
let label = row![
text(if selecionada { ">>" } else { " " })
.size(12)
.color(if selecionada {
t::PRIMARY
} else {
t::TEXT_MUTED
}),
text(aba)
.size(14)
.color(if selecionada { t::PRIMARY } else { t::TEXT }),
]
.spacing(8)
.align_y(Alignment::Center);
button(label)
.on_press(Message::AbaSelecionada(aba.clone()))
.style(if selecionada {
t::btn_aba_ativa
} else {
t::btn_aba_inativa
})
.width(Length::Fill)
.padding([8, 12])
.into()
})
.collect::<Vec<_>>(),
)
.spacing(4)
.width(Length::Fill);
let card_abas = container(
column![
text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY),
scrollable(lista_abas).height(Length::Fixed(220.0)),
]
.spacing(10),
)
.style(t::card)
.padding(16)
.width(Length::Fill);
// ── Preview ───────────────────────────────────────────────────────────────
let preview_section: Element<Message> = if !aba_atual.is_empty() {
if let Some(linhas) = &app.preview_arquivo {
container(
column![
text(format!("Pré-visualização: {}", aba_atual))
.size(13)
.color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas),
]
.spacing(8),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
} else {
container(
text("(sem pré-visualização disponível)")
.size(12)
.color(t::TEXT_MUTED),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
}
} else {
container(
text("Selecione uma aba para pré-visualizar o conteúdo.")
.size(13)
.color(t::TEXT_MUTED),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
};
// ── Botões de ação ────────────────────────────────────────────────────────
let tem_preset = !app.nome_layout_atual.is_empty();
let aba_selecionada = !aba_atual.is_empty();
let mut botoes = row![button(text("Voltar").size(13))
.on_press(Message::Voltar)
.style(t::btn_ghost)
.padding([9, 14]),]
.spacing(8)
.align_y(Alignment::Center);
if aba_selecionada && tem_preset {
botoes = botoes.push(
button(text("Processar").size(13))
.on_press(Message::ExecutarImportacao)
.style(t::btn_primary)
.padding([9, 14]),
);
}
if aba_selecionada {
botoes = botoes.push(
button(text("Configurar Colunas").size(13))
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_secondary)
.padding([9, 14]),
);
}
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![header, card_abas, preview_section, botoes]
.spacing(16)
.padding([20, 24])
.width(Length::Fill);
container(scrollable(content))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
+527
View File
@@ -0,0 +1,527 @@
use iced::widget::{button, container, progress_bar, text_input};
use iced::{Border, Color, Theme};
// ─── Paleta de cores ──────────────────────────────────────────────────────────
pub const BG: Color = Color {
r: 0.059,
g: 0.090,
b: 0.165,
a: 1.0,
}; // #0F172A
pub const SURFACE: Color = Color {
r: 0.118,
g: 0.161,
b: 0.231,
a: 1.0,
}; // #1E293B
pub const SURFACE_2: Color = Color {
r: 0.200,
g: 0.255,
b: 0.333,
a: 1.0,
}; // #334155
pub const BORDER: Color = Color {
r: 0.200,
g: 0.255,
b: 0.333,
a: 1.0,
}; // #334155
pub const TEXT: Color = Color {
r: 0.945,
g: 0.961,
b: 0.980,
a: 1.0,
}; // #F1F5F9
pub const TEXT_SECONDARY: Color = Color {
r: 0.580,
g: 0.639,
b: 0.722,
a: 1.0,
}; // #94A3B8
pub const TEXT_MUTED: Color = Color {
r: 0.392,
g: 0.455,
b: 0.545,
a: 1.0,
}; // #64748B
pub const PRIMARY: Color = Color {
r: 0.231,
g: 0.510,
b: 0.965,
a: 1.0,
}; // #3B82F6
pub const PRIMARY_HOVER: Color = Color {
r: 0.145,
g: 0.388,
b: 0.922,
a: 1.0,
}; // #2563EB
pub const SUCCESS: Color = Color {
r: 0.133,
g: 0.773,
b: 0.369,
a: 1.0,
}; // #22C55E
pub const WARNING: Color = Color {
r: 0.961,
g: 0.620,
b: 0.043,
a: 1.0,
}; // #F59E0B
pub const DANGER: Color = Color {
r: 0.937,
g: 0.267,
b: 0.267,
a: 1.0,
}; // #EF4444
pub const TRACK_BG: Color = Color {
r: 0.067,
g: 0.094,
b: 0.153,
a: 1.0,
}; // #111827
// ─── Tema global ─────────────────────────────────────────────────────────────
pub fn tema_dark() -> Theme {
Theme::custom(
"dark".to_string(),
iced::theme::Palette {
background: BG,
text: TEXT,
primary: PRIMARY,
success: SUCCESS,
danger: DANGER,
},
)
}
// ─── Container styles ────────────────────────────────────────────────────────
/// Card principal: surface + borda + radius 8
pub fn card(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Card secundário: surface_2 + borda + radius 6
pub fn card_secondary(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE_2.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Fundo geral da tela
pub fn fundo(_theme: &Theme) -> container::Style {
container::Style {
background: Some(BG.into()),
text_color: Some(TEXT),
..Default::default()
}
}
/// Cabeçalho de tabela (surface_2 sem borda)
pub fn cabecalho_tabela(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE_2.into()),
text_color: Some(TEXT_SECONDARY),
..Default::default()
}
}
/// Badge de sucesso (OK)
pub fn badge_sucesso(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..SUCCESS }.into()),
border: Border {
color: Color { a: 0.3, ..SUCCESS },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(SUCCESS),
..Default::default()
}
}
/// Badge de aviso (faltante)
pub fn badge_aviso(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..WARNING }.into()),
border: Border {
color: Color { a: 0.3, ..WARNING },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(WARNING),
..Default::default()
}
}
/// Badge de perigo (duplicada)
pub fn badge_perigo(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..DANGER }.into()),
border: Border {
color: Color { a: 0.3, ..DANGER },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(DANGER),
..Default::default()
}
}
/// Área de erro/validação
pub fn area_erro(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.10, ..DANGER }.into()),
border: Border {
color: Color { a: 0.4, ..DANGER },
width: 1.0,
radius: 6.0.into(),
},
text_color: Some(DANGER),
..Default::default()
}
}
/// Stat card com borda colorida no topo
pub fn stat_card(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Separador horizontal (linha fina)
pub fn separador(_theme: &Theme) -> container::Style {
container::Style {
background: Some(BORDER.into()),
..Default::default()
}
}
/// Fundo do breadcrumb
pub fn breadcrumb_bg(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 0.0,
radius: 0.0.into(),
},
text_color: Some(TEXT_SECONDARY),
..Default::default()
}
}
// ─── Button styles ───────────────────────────────────────────────────────────
/// Botão primário (azul sólido)
pub fn btn_primary(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(PRIMARY.into()),
text_color: Color::WHITE,
border: Border {
radius: 6.0.into(),
..Default::default()
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(PRIMARY_HOVER.into()),
..base
},
button::Status::Pressed => button::Style {
background: Some(
Color {
r: 0.114,
g: 0.306,
b: 0.847,
a: 1.0,
}
.into(),
), // #1D4ED8
..base
},
button::Status::Disabled => button::Style {
background: Some(Color { a: 0.4, ..PRIMARY }.into()),
text_color: Color {
a: 0.5,
..Color::WHITE
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão secundário (surface_2)
pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
let base = 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()
};
match status {
button::Status::Hovered => button::Style {
background: Some(
Color {
r: 0.25,
g: 0.31,
b: 0.40,
a: 1.0,
}
.into(),
),
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base
},
button::Status::Disabled => button::Style {
background: Some(
Color {
a: 0.5,
..SURFACE_2
}
.into(),
),
text_color: Color { a: 0.4, ..TEXT },
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// 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,
border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
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.2, ..BORDER },
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão de perigo (exclusão)
pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(Color { a: 0.15, ..DANGER }.into()),
text_color: DANGER,
border: Border {
color: Color { a: 0.3, ..DANGER },
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.25, ..DANGER }.into()),
..base
},
_ => {
let _ = theme;
base
}
}
}
/// 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 {
background: Some(Color { a: 0.20, ..PRIMARY }.into()),
text_color: PRIMARY,
border: Border {
color: PRIMARY,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.30, ..PRIMARY }.into()),
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão de aba inativa (tela selecionar_aba)
pub fn btn_aba_inativa(theme: &Theme, status: button::Status) -> button::Style {
btn_secondary(theme, status)
}
/// Botão de itens por página (ativo)
pub fn btn_pagina_ativo(theme: &Theme, status: button::Status) -> button::Style {
btn_primary(theme, status)
}
/// Botão de itens por página (inativo)
pub fn btn_pagina_inativo(theme: &Theme, status: button::Status) -> button::Style {
btn_ghost(theme, status)
}
// ─── Text input styles ────────────────────────────────────────────────────────
pub fn input_dark(theme: &Theme, status: text_input::Status) -> text_input::Style {
let base = text_input::Style {
background: BG.into(),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
icon: TEXT_MUTED,
placeholder: TEXT_MUTED,
value: TEXT,
selection: Color { a: 0.3, ..PRIMARY },
};
match status {
text_input::Status::Focused => text_input::Style {
border: Border {
color: PRIMARY,
..base.border
},
..base
},
text_input::Status::Hovered => text_input::Style {
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
// ─── Progress bar styles ─────────────────────────────────────────────────────
/// Progress bar colorida por percentual (0.0 1.0)
pub fn progress_bar_por_percentual(percentual: f32) -> impl Fn(&Theme) -> progress_bar::Style {
move |_theme| {
let cor = if percentual >= 0.90 {
SUCCESS
} else if percentual >= 0.60 {
WARNING
} else {
DANGER
};
progress_bar::Style {
background: TRACK_BG.into(),
bar: cor.into(),
border: Border {
radius: 10.0.into(),
..Default::default()
},
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+109
View File
@@ -0,0 +1,109 @@
{
"color": {
"primary": {
"base": "#3B82F6",
"hover": "#2563EB",
"active": "#1D4ED8"
},
"semantic": {
"success": "#22C55E",
"warning": "#F59E0B",
"error": "#EF4444",
"info": "#0EA5E9"
},
"dark": {
"background": "#0F172A",
"surface": "#1E293B",
"surfaceSecondary": "#334155",
"text": {
"primary": "#F1F5F9",
"secondary": "#94A3B8",
"muted": "#64748B"
},
"border": "#334155",
"interaction": {
"hover": "#3B82F622",
"selection": "#3B82F633",
"focus": "#3B82F6"
}
},
"light": {
"background": "#F8FAFC",
"surface": "#FFFFFF",
"surfaceSecondary": "#F1F5F9",
"text": {
"primary": "#0F172A",
"secondary": "#64748B",
"muted": "#94A3B8"
},
"border": "#E2E8F0",
"interaction": {
"hover": "#3B82F611",
"selection": "#3B82F622",
"focus": "#3B82F6"
}
}
},
"table": {
"row": {
"hover": "#3B82F611",
"selected": "#3B82F622"
},
"status": {
"ok": "#22C55E",
"missing": "#F59E0B",
"duplicate": "#EF4444",
"invalid": "#EF4444"
}
},
"spacing": {
"xs": 4,
"sm": 8,
"md": 12,
"lg": 16,
"xl": 24,
"xxl": 32
},
"radius": {
"sm": 4,
"md": 6,
"lg": 8
},
"font": {
"family": "Inter, system-ui, sans-serif",
"size": {
"xs": 11,
"sm": 12,
"md": 14,
"lg": 16,
"xl": 20
},
"weight": {
"normal": 400,
"medium": 500,
"bold": 600
}
},
"shadow": {
"sm": "0 1px 2px rgba(0,0,0,0.05)",
"md": "0 4px 8px rgba(0,0,0,0.08)",
"lg": "0 10px 20px rgba(0,0,0,0.12)"
}
}
+287
View File
@@ -0,0 +1,287 @@
```html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Comparador de Notas - Mockup</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--border: #334155;
--primary: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
}
.container {
width: 1100px;
margin: 40px auto;
background: var(--surface);
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 20px;
font-weight: 600;
}
.actions button {
background: var(--primary);
border: none;
padding: 10px 16px;
border-radius: 6px;
color: white;
margin-left: 8px;
cursor: pointer;
}
.actions button.secondary {
background: var(--surface-2);
}
.stats {
display: flex;
gap: 40px;
margin: 20px 0;
}
.stat {
font-size: 14px;
}
.stat strong {
font-size: 20px;
}
.series-box {
background: var(--surface-2);
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
}
.series {
margin-bottom: 14px;
}
.progress {
height: 8px;
background: #111827;
border-radius: 10px;
overflow: hidden;
margin-top: 6px;
}
.bar {
height: 100%;
}
.green {
background: var(--success);
}
.orange {
background: var(--warning);
}
.red {
background: var(--error);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
thead {
background: var(--surface-2);
}
th,
td {
padding: 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.ok {
background: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.dup {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.missing {
background: rgba(245, 158, 11, 0.2);
color: var(--warning);
}
.footer {
margin-top: 20px;
display: flex;
gap: 10px;
}
.footer button {
background: var(--surface-2);
border: none;
padding: 10px 14px;
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.footer button.primary {
background: var(--primary);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="title">Comparador de Notas</div>
<div class="actions">
<button>Importar Planilha</button>
<button class="secondary">Configurar Campos</button>
<button class="secondary">Exportar Relatório</button>
</div>
</div>
<div class="stats">
<div class="stat">
<strong style="color: #3b82f6">48</strong> Notas Faltantes
</div>
<div class="stat">
<strong style="color: #ef4444">6</strong> Notas Duplicadas
</div>
<div class="stat"><strong>R$ 125.600,00</strong> Total</div>
</div>
<div class="series-box">
<div class="series">
Série 1 NFE — 48 / 50 notas — 96% completo
<div class="progress">
<div class="bar green" style="width: 96%"></div>
</div>
</div>
<div class="series">
Série 2 NFCE — 20 / 25 notas — 80% completo
<div class="progress">
<div class="bar orange" style="width: 80%"></div>
</div>
</div>
<div class="series">
Série 3 NFE — 12 / 20 notas — 60% completo
<div class="progress">
<div class="bar red" style="width: 60%"></div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Número</th>
<th>Série</th>
<th>Tipo</th>
<th>Valor</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>1005</td>
<td>1</td>
<td>NFE</td>
<td>R$ 2.500,00</td>
<td><span class="status dup">Duplicada</span></td>
</tr>
<tr>
<td>1006</td>
<td>1</td>
<td>NFE</td>
<td>R$ 3.200,00</td>
<td><span class="status ok">OK</span></td>
</tr>
<tr>
<td>1010</td>
<td>1</td>
<td>NFE</td>
<td>R$ 4.000,00</td>
<td>
<span class="status missing">Falta: 10101050</span>
</td>
</tr>
<tr>
<td>1051</td>
<td>1</td>
<td>NFE</td>
<td>R$ 2.800,00</td>
<td><span class="status ok">OK</span></td>
</tr>
<tr>
<td>1075</td>
<td>2</td>
<td>NFCE</td>
<td>R$ 1.200,00</td>
<td><span class="status dup">Duplicada</span></td>
</tr>
</tbody>
</table>
<div class="footer">
<button>Copiar Faltantes</button>
<button>Copiar Duplicadas</button>
<button class="primary">Exportar PDF</button>
</div>
</div>
</body>
</html>
```