Compare commits

...
6 Commits
Author SHA1 Message Date
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
29 changed files with 1366 additions and 980 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)"
]
}
}
+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"
}
]
}
+166
View File
@@ -0,0 +1,166 @@
# Backlog de Features — Comparador de Notas
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Alta Prioridade
### F-01 — Exportar Resultado em CSV.
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
- `faltantes.csv`: `serie,documento_tipo,numero`
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
**Escopo técnico:**
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
- Botão na barra de ações da tela `resultado.rs`
- Dependência `csv` já está no `Cargo.toml`
**Esforço estimado:** Médio (23h)
**Impacto:** Alto — elimina retrabalho manual
**Observações:**
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar)
**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura.
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite.
**Escopo técnico:**
- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader
- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024`
**Esforço estimado:** Baixo (30min)
**Impacto:** Médio — evita travamentos inesperados
---
## 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-04 — Busca por Número na Tela de Resultado (Util)
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
- está **faltante** (aparece na lista de faltantes)
- está **duplicada** (aparece na lista de duplicatas)
- está **presente** (está nas notas importadas)
- **não encontrada** (fora do intervalo conhecido)
**Escopo técnico:**
- Campo `filtro_numero: String` no `App`
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
- Banner de resultado no topo da tela
**Esforço estimado:** Médio (23h)
**Impacto:** Médio — uso diário em auditorias pontuais
---
### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario)
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
**Escopo técnico:**
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
- Botão na barra de ações da tela `resultado.rs`
**Esforço estimado:** Baixo (1h)
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
---
### F-06 — Auto-detecção de Delimitador CSV (Util)
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
**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 |
|---|---|---|---|---|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
| F-04 | Busca por número no resultado | Médio | Médio | Média |
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
-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
**Versão:** 1.6
**Data:** 02/03/2026
**Status:** Planejamento
**Versão:** 1.7
**Data:** 03/03/2026
**Status:** Implementado (MVP)
---
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Notas faltantes
* Notas duplicadas
* Soma total dos valores
* Agrupamento por série
* Agrupamento por série e tipo de documento
## 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 duplicidades
* 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
* 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)
* Carregar layouts salvos
* Selecionar layout por menu dropdown
* Excluir layouts
* Exportar layout para 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)
@@ -91,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Integração com banco de dados externo
* Multiusuário
* 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:
| 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`. |
| 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. |
| --------------- | ----------- | ------------------------------------------------------------------------- |
| 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. |
| 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.
> **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 |
| 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 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 o valor (opcional)
* Qual índice representa a data (opcional)
* Qual índice representa o tipo de documento (opcional)
### RF02.2 — Mapeamento XLSX (letra + linha)
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
* 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.
### 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
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:
* Sobrescrever o layout existente
* Salvar com novo nome
* Cancelar a importação
### 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:
* 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
### 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
@@ -251,26 +264,30 @@ Registros com o campo Numero igual a `0` devem ser descartados e reportados ao u
### 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
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
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
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
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
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: intervalo de 999.996 faltantes detectado")
1. Interromper o processamento desse grupo
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
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
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:
@@ -294,11 +311,11 @@ Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
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
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
@@ -311,12 +328,14 @@ Todas as ocorrências de registros duplicados são incluídas na soma de valores
O sistema deve calcular:
* Soma total
* Soma por série
* Soma por grupo `(Serie, TipoDocumento)`
### 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 parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
#### Algoritmo de Parsing Monetário
**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.
> **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
@@ -360,15 +379,17 @@ Todos os valores monetários são exibidos com **2 casas decimais fixas** no for
O sistema deve exibir:
* Lista de notas faltantes agrupadas por série
* Lista de duplicadas agrupadas por série
* Totais (soma total e soma por série)
* Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
* Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
* 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
| 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 |
| 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`.
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:
* Notas faltantes por série
* Duplicatas por série
* Totais por série e total geral
* Notas faltantes por grupo `(Serie, TipoDocumento)`
* Duplicatas por grupo
* Totais por grupo e total geral
**Metadados do relatório:**
* Nome do arquivo importado
* 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
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.
---
@@ -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:
* "32 linhas descartadas por mal formação"
* "32 linhas descartadas por malformação"
* "12 valores de Numero inválidos convertidos ou 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.
@@ -462,9 +492,16 @@ Fluxo principal:
1. Usuário abre o sistema
2. Usuário importa planilha
3. Usuário configura colunas
4. Usuário executa análise
5. Sistema exibe resultado
3. (Para XLSX) Usuário seleciona aba
4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
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
│ ├─ app.rs
│ ├─ screens/
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
│ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs
@@ -493,20 +531,25 @@ src/
├─ application/
│ ├─ mod.rs
│ ├─ usecases/
│ │ ├─ mod.rs
│ │ ├─ importar_arquivo.rs
│ │ ├─ executar_analise.rs
│ │ ├─ exportar_pdf.rs
│ │ ├─ layouts.rs
├─ domain/
│ ├─ mod.rs
│ ├─ errors.rs
│ ├─ entities/
│ │ ├─ mod.rs
│ │ ├─ nota.rs
│ │ ├─ serie.rs
│ │ ├─ chave_serie.rs
│ │ ├─ layout.rs
│ │ ├─ resultado_analise.rs
│ │
│ ├─ services/
│ │ ├─ mod.rs
│ │ ├─ detector_sequencia.rs
│ │ ├─ detector_duplicidade.rs
│ │ ├─ parser_monetario.rs
@@ -533,47 +576,77 @@ Contém toda a lógica de negócio real.
**Não pode depender de:**
* egui
* egui / eframe
* rusqlite
* calamine
* csv
* 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
**`Nota`**
| Campo | Tipo |
| ------ | ----------------- |
| -------------- | ------------------- |
| numero | `u64` |
| serie | `String` |
| documento_tipo | `Option<String>` |
| valor | `Option<Decimal>` |
| 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`**
| Campo | Tipo |
| -------------------- | -------------------------------------- |
| faltantes_por_serie | `HashMap<String, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` |
| -------------------- | ---------------------------------------- |
| faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| 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.
#### 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
`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`**
1. Recebe dados crus
2. Chama `parser_monetario`
3. Chama `detector_sequencia`
4. Chama `detector_duplicidade`
5. Monta `ResultadoAnalise`
Expõe três funções:
1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
**`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 |
| -------------------------------- | ----------------------------------------- |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` |
| `pdf_generator.rs` | Geração de PDF via `genpdf` |
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite |
| `sqlite/migrations.rs` | Aplicação de migrations de schema |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
| `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; tratamento de banco corrompido |
| `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
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)
@@ -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.
#### 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
| Arquivo | Responsabilidade |
| ------------------------- | ----------------------------------------------------- |
| `import.rs` | Seleção de arquivo e configurações de importação |
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) |
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) |
| `resultado.rs` | Exibição de resultados com paginação (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.
| `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
| `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
| `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
| `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) |
---
@@ -646,26 +740,34 @@ Infrastructure entra apenas quando necessário.
Exemplo real:
1. UI chama `importar_arquivo`
1. UI chama `executar_importacao` (thread separada)
2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota`
4. Domain executa análise
5. Application retorna `ResultadoAnalise`
6. UI renderiza
4. Domain executa pré-análise (`pre_analisar`)
5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
6. Application retorna `ResultadoAnalise` via `mpsc::channel`
7. UI renderiza
---
## 9.4 Stack Tecnológica
| Camada | Tecnologia | Status |
| ---------------- | ------------------ | -------------- |
| Linguagem | Rust | Definido |
| Framework de UI | egui | Definido |
| SQLite | rusqlite | Definido |
| Leitura de CSV | csv | Definido |
| Leitura de XLSX | calamine | Definido |
| Decimal fixo | rust_decimal | Definido |
| Geração de PDF | genpdf | Definido |
| Camada | Tecnologia | Versão |
| ---------------- | ------------------ | ------- |
| Linguagem | Rust | edition 2024 |
| Framework de UI | egui + eframe | 0.31 |
| Diálogos nativos | rfd | 0.15 |
| SQLite | rusqlite (bundled) | 0.32 |
| Leitura de CSV | csv | 1.3 |
| Leitura de XLSX | calamine | 0.26 |
| 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.
**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
@@ -741,31 +851,33 @@ Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configuraç
| Campo | Tipo | Descrição |
| ---------- | ------- | -------------------------------------- |
| id | inteiro | Identificador único |
| nome | texto | Nome do layout |
| id | inteiro | Identificador único (auto-incremento) |
| nome | texto | Nome do layout (único no banco) |
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
**Campos exclusivos de layouts CSV:**
| Campo | Tipo | Descrição |
| ------------------ | ------ | -------------------------------------------------- |
| ---------------------- | ------- | ---------------------------------------------------------- |
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
| 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_serie | inteiro | Índice da coluna Serie (base 0) |
| 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_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
**Campos exclusivos de layouts XLSX:**
| 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_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_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
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:
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
{
"nome": "Layout Padrão CSV",
"tipo": "csv",
"nome": "Layout Padrão CSV",
"delimitador": ";",
"encoding": "utf-8",
"linha_cabecalho": 1,
"indice_numero": 3,
"indice_serie": 1,
"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
{
"nome": "Layout Padrão XLSX",
"tipo": "xlsx",
"nome": "Layout Padrão XLSX",
"aba": "Plan1",
"pos_numero": "D3",
"pos_serie": "B3",
"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
@@ -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 XLSX com seleção de aba e posicionamento por `LetraLinha`
* Detectar notas faltantes por série corretamente
* Detectar duplicatas por série corretamente
* Calcular soma total e soma por série corretamente
* Exibir resultados agrupados por série com paginação
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
* Detectar duplicatas por grupo corretamente
* Calcular soma total e soma por grupo corretamente
* 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
* Salvar, carregar, selecionar e excluir layouts
* 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:
* Importar CSV e XLSX
* Mapear colunas Numero, Serie, Valor e Data
* Detectar notas faltantes por série
* Detectar duplicatas por série
* Calcular soma total e por série
* Exibir resultados agrupados por série com paginação
* Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
* Detectar duplicatas por grupo
* Calcular soma total e por grupo
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exportar relatório para PDF
* Gerenciar layouts (salvar, carregar, excluir)
* Exportar e importar layouts via JSON
* Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
* Reanalisar arquivo sem reconfiguração
---
# 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
* Histórico de análises
* Automação de importação (monitorar pasta)
* Exportação para CSV
* Multiusuário
---
@@ -918,8 +1045,16 @@ Série
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
Ordem numérica crescente sem lacunas.
Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
---
Binary file not shown.
Binary file not shown.
+61 -40
View File
@@ -1,11 +1,12 @@
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
services::{
detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
},
};
use rust_decimal::Decimal;
@@ -15,30 +16,31 @@ use std::collections::HashMap;
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
// Agrupar por (serie, documento_tipo)
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
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 soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = 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
for nota in notas_serie.iter() {
for nota in notas_grupo.iter() {
if let Some(v) = nota.valor {
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
if let Some(intervalo) = calcular_intervalo(notas_serie) {
intervalos_por_serie.insert(serie.clone(), intervalo);
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(chave.clone(), intervalo);
}
}
@@ -53,31 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
}
}
/// Verifica se alguma série excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação.
pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> {
/// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves que precisam de confirmação.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
.collect()
}
/// 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).
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 {
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();
for (serie, notas_serie) in &por_serie {
let faltantes = detectar_faltantes(notas_serie);
faltantes_por_serie.insert(serie.clone(), faltantes);
for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(chave.clone(), faltantes);
}
ResultadoAnalise {
@@ -89,21 +90,24 @@ 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)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
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 {
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]
@@ -114,7 +118,8 @@ mod tests {
nota(4, "001", Some(dec!(50.00))),
];
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));
}
@@ -127,19 +132,35 @@ mod tests {
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
assert!(resultado.faltantes_por_serie["002"].is_empty());
let chave001 = ChaveSerie::new("001".to_string(), None);
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]
fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![
nota(1, "001", None),
nota(20_000, "001", None),
];
let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
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]);
}
}
+46 -15
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::serie::validar_serie;
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.
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(
caminho,
config.delimitador as u8,
@@ -37,6 +40,7 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_documento_tipo,
config.indice_valor,
config.indice_data,
&mut avisos,
@@ -46,17 +50,21 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
}
/// 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
let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero)
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
"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_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
ErroArquivo::ErroLeitura(format!(
"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))
})?;
coord_num.linha.min(coord_ser.linha)
};
@@ -79,12 +87,18 @@ pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImp
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.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 notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
col_documento_tipo,
col_valor,
col_data,
&mut avisos,
@@ -99,6 +113,7 @@ fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
@@ -113,7 +128,9 @@ fn mapear_linhas_para_notas(
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
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;
}
};
@@ -123,7 +140,9 @@ fn mapear_linhas_para_notas(
Ok(n) => n,
Err(msg) => {
avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg));
avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue;
}
};
@@ -133,7 +152,9 @@ fn mapear_linhas_para_notas(
Some(s) => s.trim().to_string(),
None => {
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;
}
};
@@ -182,7 +203,17 @@ fn mapear_linhas_para_notas(
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
+15 -14
View File
@@ -2,24 +2,31 @@ use crate::domain::{
entities::layout::{Layout, LayoutJson},
errors::ErroLayout,
};
use crate::infrastructure::sqlite::{layout_repository, migrations};
use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection;
/// Salva um layout no banco de dados.
/// 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
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()));
}
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout)
.map_err(|e| e.to_string())?;
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
Ok(id)
} else {
let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
if existe {
return Err(ErroLayout::NomeConflitante(nome));
}
layout_repository::salvar(conn, layout)
.map_err(|e| e.to_string())
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
}
}
@@ -28,11 +35,6 @@ pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, 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.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
@@ -61,8 +63,8 @@ pub fn importar_layout_json(
novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> {
// Deserializar
let json_repr: LayoutJson = serde_json::from_str(json)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let json_repr: LayoutJson =
serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?;
@@ -105,6 +107,5 @@ pub fn importar_layout_json(
}
// Inserir novo
layout_repository::salvar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
}
+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>,
/// Índice da coluna Data (base 0, None se não mapeado)
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 {
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
indice_serie: 1,
indice_valor: None,
indice_data: None,
indice_documento_tipo: None,
}
}
}
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado)
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 {
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
pos_serie: String::new(),
pos_valor: None,
pos_data: None,
pos_documento_tipo: None,
}
}
}
@@ -129,6 +135,8 @@ pub enum LayoutJson {
indice_serie: usize,
indice_valor: Option<usize>,
indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
},
Xlsx {
nome: String,
@@ -137,6 +145,8 @@ pub enum LayoutJson {
pos_serie: String,
pos_valor: 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_valor,
indice_data,
indice_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
},
})
}
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
},
})
}
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
indice_serie: config.indice_serie,
indice_valor: config.indice_valor,
indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
},
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(),
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
},
}
}
+1
View File
@@ -2,3 +2,4 @@ pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
pub mod chave_serie;
+5 -1
View File
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
use rust_decimal::Decimal;
/// 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)]
pub struct Nota {
/// Número incremental da nota. Armazenado como u64.
pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1".
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).
pub valor: Option<Decimal>,
/// 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(
numero: u64,
serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
documento_tipo,
valor,
data,
}
+15 -14
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal;
use std::collections::HashMap;
@@ -5,15 +6,15 @@ use std::collections::HashMap;
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)]
pub struct ResultadoPreAnalise {
/// Mapa série → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// 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
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
/// 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.
#[derive(Debug, Clone)]
pub struct ResultadoAnalise {
/// Números faltantes por série (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por série
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
/// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
impl ResultadoAnalise {
-50
View File
@@ -1,13 +1,5 @@
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)]
pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
@@ -40,52 +32,10 @@ pub enum ErroArquivo {
TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String),
#[error("Formato não suportado: {0}")]
FormatoNaoSuportado(String),
#[error("Erro de leitura: {0}")]
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.
#[derive(Debug, Default, Clone)]
pub struct ResumoAvisos {
+38 -19
View File
@@ -1,16 +1,17 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use std::collections::HashMap;
/// 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.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.clone()))
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1;
}
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
contagem
}
/// Agrupa as duplicidades por série.
/// Agrupa as duplicidades por (serie, documento_tipo).
///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
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 {
result.entry(serie).or_default().push((numero, contagem));
for ((numero, serie, documento_tipo), contagem) in raw {
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() {
lista.sort_by_key(|(num, _)| *num);
}
@@ -44,7 +46,11 @@ mod tests {
use crate::domain::entities::nota::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]
@@ -58,7 +64,7 @@ mod tests {
fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
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);
}
@@ -66,17 +72,30 @@ mod tests {
fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
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]
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 dup = detectar_duplicidades(&notas);
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]
fn agrupamento_por_serie() {
let notas = vec![
@@ -88,13 +107,13 @@ mod tests {
nota(2, "001"),
];
let por_serie = duplicidades_por_serie(&notas);
// Série 001 deve ter notas 1 e 2 duplicadas
let serie001 = por_serie.get("001").unwrap();
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada
let serie002 = por_serie.get("002").unwrap();
let serie002 = por_serie.get(&chave002).unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
+50 -1
View File
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
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)]
mod tests {
use super::*;
@@ -74,7 +100,7 @@ mod tests {
use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None)
Nota::new(numero, "001".to_string(), None, None, None)
}
#[test]
@@ -136,4 +162,27 @@ mod tests {
let refs: Vec<&Nota> = notas.iter().collect();
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)]
);
}
}
+48 -53
View File
@@ -1,12 +1,21 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
use genpdf::{
elements::{Break, Paragraph},
fonts, style, Document, Element, SimplePageDecorator,
fonts, style, Document, SimplePageDecorator,
};
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.
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans)
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
)));
doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais
doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
formatar_valor_br(&resultado.soma_total)
)));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
series_ordenadas.sort();
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
for chave in &chaves_ordenadas {
let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total,
formatar_valor_br(soma)
)));
@@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
// Use faltantes keys for this section (may differ from soma keys if no values)
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,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
serie,
chave.label(),
faltantes.len()
)));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
@@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
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;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
serie,
chave.label(),
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, serie, count
numero, chave.label(), count
)));
}
}
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
}
}
/// Tenta carregar fontes DejaVu Sans do sistema.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
// Caminhos comuns no Linux, Windows e macOS
let candidatos_regular = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/Library/Fonts/Arial.ttf",
];
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))?;
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar 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 {
regular: regular.clone(),
bold,
italic: regular.clone(),
bold_italic: regular,
bold_italic: bold.clone(),
regular,
bold,
})
}
+1 -3
View File
@@ -1,8 +1,6 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
@@ -33,7 +31,7 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
// Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(e) => {
Err(_e) => {
// Banco corrompido
drop(conn);
let bak = caminho.with_extension("db.bak");
+20 -58
View File
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
indice_numero, indice_serie, indice_valor, indice_data,
indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
config.delimitador.to_string(),
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.indice_serie as i64,
config.indice_valor.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())
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![
nome,
config.aba,
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
],
)?;
Ok(conn.last_insert_rowid())
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8
WHERE id = ?9",
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
WHERE id = ?10",
params![
nome,
config.delimitador.to_string(),
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id,
],
)?;
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6
WHERE id = ?7",
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
WHERE id = ?8",
params![
nome,
config.aba,
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
id,
],
)?;
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
"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
aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC",
)?;
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
indice_data: row
.get::<_, Option<i64>>(9)?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>(15)?
.map(|v| v as usize),
},
})
} else {
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
},
})
}
@@ -145,55 +156,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
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.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
+37 -5
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 1;
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
@@ -23,13 +23,45 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
if versao_atual < 1 {
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)?;
}
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
}
Ok(())
}
/// Migration v2: adicionar índice único em layouts.nome.
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
)?;
Ok(())
}
/// 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(
"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);",
)?;
Ok(())
}
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn ler_xlsx(
.worksheet_range(nome_aba)
.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 linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
+164 -38
View File
@@ -1,23 +1,18 @@
use crate::application::usecases::{
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao},
layouts::{
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
listar_layouts, salvar_layout,
},
importar_arquivo::{importar_csv, importar_xlsx},
layouts::{excluir_layout, listar_layouts, salvar_layout},
};
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
errors::ResumoAvisos,
};
use crate::infrastructure::{
pdf_generator::GenpdfGenerator,
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
errors::{ErroLayout, ResumoAvisos},
};
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use egui::Context;
use rusqlite::Connection;
use std::path::PathBuf;
@@ -34,10 +29,7 @@ pub enum EstadoApp {
/// Resultado pronto para exibição.
ExibindoResultado(ResultadoAnalise),
/// Aguardando confirmação do usuário para expandir faltantes.
ConfirmandoIntervalo {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
},
ConfirmandoIntervalo { pre: ResultadoPreAnalise },
/// Gerenciamento de layouts.
GerenciandoLayouts,
/// Análise em execução em background (thread separada).
@@ -72,7 +64,8 @@ pub enum TipoModal {
pub enum AcaoModal {
ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64),
SobrescreverLayout,
/// Sobrescrever layout existente — layout já carrega o id correto.
SobrescreverLayout(Layout),
ConfirmarNovaAnalise,
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
SalvarLayoutConfig,
@@ -91,7 +84,7 @@ pub enum ResultadoPendente {
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
series_excessivas: Vec<(ChaveSerie, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
@@ -139,6 +132,8 @@ pub struct App {
pub preview_arquivo: Option<Vec<Vec<String>>>,
// Canal para receber resultado da análise em background
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
// Resultado anterior, preservado durante reanálise para restaurar em caso de falha
pub resultado_anterior: Option<ResultadoAnalise>,
}
impl Default for App {
@@ -163,6 +158,7 @@ impl Default for App {
itens_por_pagina: 100,
preview_arquivo: None,
resultado_pendente: None,
resultado_anterior: None,
}
}
}
@@ -350,7 +346,18 @@ impl App {
}
}
}
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
AcaoModal::SobrescreverLayout(layout) => {
if let Some(conn) = &self.conn {
match salvar_layout(conn, &layout) {
Ok(_) => {
self.recarregar_layouts();
}
Err(e) => {
self.exibir_erro(e.to_string());
}
}
}
}
AcaoModal::ConfirmarNovaAnalise => {
self.notas_importadas.clear();
self.preview_arquivo = None;
@@ -386,7 +393,36 @@ impl App {
self.recarregar_layouts();
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
}
Some(Err(e)) => self.exibir_erro(e),
Some(Err(ErroLayout::NomeConflitante(nome_conflito))) => {
let id_existente = self
.layouts_salvos
.iter()
.find(|l| l.nome() == nome_conflito)
.and_then(|l| l.id());
if let Some(id) = id_existente {
let layout_com_id = match &self.tipo_arquivo_atual {
TipoArquivo::Csv => Layout::Csv {
id: Some(id),
nome: nome_conflito.clone(),
config: self.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: Some(id),
nome: nome_conflito.clone(),
config: self.layout_xlsx_atual.clone(),
},
};
self.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome_conflito
),
AcaoModal::SobrescreverLayout(layout_com_id),
);
}
}
Some(Err(e)) => self.exibir_erro(e.to_string()),
None => {}
}
}
@@ -396,13 +432,22 @@ impl App {
/// Processa o resultado recebido do canal de análise em background.
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
match resultado {
ResultadoPendente::Concluido { resultado, avisos, notas } => {
ResultadoPendente::Concluido {
resultado,
avisos,
notas,
} => {
if let Some(n) = notas {
self.notas_importadas = n;
}
if let Some(av) = &avisos {
self.avisos_importacao = if av.tem_avisos() { Some(av.clone()) } else { None };
self.avisos_importacao = if av.tem_avisos() {
Some(av.clone())
} else {
None
};
}
self.resultado_anterior = None;
self.estado = EstadoApp::ExibindoResultado(resultado);
if let Some(av) = &self.avisos_importacao.clone() {
if av.tem_avisos() {
@@ -418,18 +463,23 @@ impl App {
notas,
} => {
self.notas_importadas = notas;
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
self.avisos_importacao = if avisos.tem_avisos() {
Some(avisos)
} else {
None
};
let msg = series_excessivas
.iter()
.map(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
})
.collect::<Vec<_>>()
.join("\n");
self.estado = EstadoApp::ConfirmandoIntervalo {
pre,
series_excessivas,
};
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
@@ -440,16 +490,92 @@ impl App {
);
}
ResultadoPendente::Vazio => {
if let Some(resultado) = self.resultado_anterior.take() {
self.estado = EstadoApp::ExibindoResultado(resultado);
} else {
self.estado = EstadoApp::ConfigurandoColunas;
}
self.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
}
ResultadoPendente::Erro(e) => {
if let Some(resultado) = self.resultado_anterior.take() {
self.estado = EstadoApp::ExibindoResultado(resultado);
} else {
self.estado = EstadoApp::ConfigurandoColunas;
}
self.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
}
/// Reimporta o arquivo atual com o layout atual e executa análise.
/// Segue o mesmo padrão assíncrono de `executar_importacao` em configuracao_colunas.rs.
pub fn reimportar_e_analisar(&mut self, ctx: &Context) {
let caminho = match &self.caminho_arquivo {
Some(p) => p.clone(),
None => {
self.exibir_erro("Nenhum arquivo carregado.");
return;
}
};
// Preservar resultado atual para restaurar em caso de erro ou arquivo vazio
if let EstadoApp::ExibindoResultado(r) = &self.estado {
self.resultado_anterior = Some(r.clone());
}
let tipo = self.tipo_arquivo_atual.clone();
let layout_csv = self.layout_csv_atual.clone();
let layout_xlsx = self.layout_xlsx_atual.clone();
let (tx, rx) = mpsc::channel();
self.resultado_pendente = Some(rx);
self.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
TipoArquivo::Xlsx => {
importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string())
}
};
let res = match res_importacao {
Err(e) => ResultadoPendente::Erro(e),
Ok(importado) => {
if importado.notas.is_empty() {
ResultadoPendente::Vazio
} else {
let avisos = importado.avisos.clone();
let notas = importado.notas;
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
/// Executa a análise com as notas importadas.
pub fn executar_analise(&mut self) {
if self.notas_importadas.is_empty() {
@@ -463,17 +589,18 @@ impl App {
if !excessivos.is_empty() {
let msg = excessivos
.iter()
.map(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
})
.collect::<Vec<_>>()
.join("\n");
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
self.estado = EstadoApp::ConfirmandoIntervalo {
pre,
series_excessivas: excessivos,
};
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
@@ -502,9 +629,10 @@ impl App {
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate() {
for (i, label) in ["Arquivo", "Colunas", "Resultado"].iter().enumerate() {
let n = i + 1;
let texto = egui::RichText::new(*label);
let texto_completo = format!("{}. {}", n, label);
let texto = egui::RichText::new(texto_completo);
if n == passo_ativo {
ui.label(texto.strong());
} else if n < passo_ativo {
@@ -552,8 +680,7 @@ impl eframe::App for App {
let tela_id = self.tela_atual();
// Renderizar a tela atual
egui::CentralPanel::default().show(ctx, |ui| {
match tela_id {
egui::CentralPanel::default().show(ctx, |ui| match tela_id {
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
@@ -570,7 +697,6 @@ impl eframe::App for App {
});
}
_ => {}
}
});
}
}
+23
View File
@@ -100,6 +100,16 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
}
});
if valido && !app.notas_importadas.is_empty() {
if ui
.button("🔄 Reanalisar")
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
.clicked()
{
app.executar_analise();
}
}
if ui.button("💾 Salvar como layout...").clicked() {
app.exibir_modal_salvar_layout();
}
@@ -223,6 +233,11 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
campo_indice_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_csv_atual.indice_documento_tipo,
);
});
}
@@ -271,6 +286,11 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
&mut app.layout_xlsx_atual.pos_valor,
);
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
campo_letra_linha_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_xlsx_atual.pos_documento_tipo,
);
});
}
@@ -330,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
if let Some(d) = c.indice_data {
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);
}
TipoArquivo::Xlsx => {
+150 -18
View File
@@ -1,13 +1,14 @@
use crate::application::usecases::importar_arquivo::{
importar_csv, importar_xlsx, listar_abas_xlsx,
use crate::application::usecases::executar_analise::{
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
};
use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::{App, EstadoApp};
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
use egui::{Context, Ui};
use std::path::PathBuf;
/// Renderiza a tela de importação de arquivos.
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Comparador de Notas — Importar Arquivo");
ui.add_space(16.0);
@@ -27,7 +28,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
.pick_file()
{
on_arquivo_selecionado(app, caminho);
on_arquivo_selecionado(app, ctx, caminho);
}
}
});
@@ -85,7 +86,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
}
/// Renderiza a tela de seleção de aba (XLSX).
pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Selecionar Aba da Planilha");
ui.add_space(16.0);
@@ -96,6 +97,45 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
ui.label(format!("Arquivo: {}", caminho.display()));
ui.add_space(8.0);
// --- Seleção de preset ---
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
if !opcoes_layout.is_empty() {
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_aba")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else {
&nome_layout_atual
})
.show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout {
if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone();
if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone();
aplicar_layout(app, &layout);
}
}
}
});
});
ui.add_space(8.0);
}
ui.label("Selecione a aba a processar:");
let aba_atual = app.layout_xlsx_atual.aba.clone();
@@ -103,10 +143,8 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
app.layout_xlsx_atual.aba = aba.clone();
// Gerar pré-visualização da aba selecionada
app.preview_arquivo = crate::infrastructure::xlsx_reader::preview_xlsx(
&caminho,
aba,
).ok();
app.preview_arquivo =
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
}
}
@@ -119,8 +157,24 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
}
ui.add_space(12.0);
if !app.layout_xlsx_atual.aba.is_empty() {
if ui.button("▶ Configurar Colunas").clicked() {
ui.horizontal(|ui| {
// Se há preset selecionado, oferecer processamento direto
let tem_preset = !app.nome_layout_atual.is_empty();
if tem_preset {
let caminho_clone = caminho.clone();
if ui.button("▶ Processar").clicked() {
app.nome_arquivo = caminho_clone
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
app.caminho_arquivo = Some(caminho_clone);
disparar_analise(app, ctx);
}
}
if ui.button("⚙ Configurar Colunas").clicked() {
app.nome_arquivo = caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
@@ -128,6 +182,7 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
app.caminho_arquivo = Some(caminho);
app.estado = EstadoApp::ConfigurandoColunas;
}
});
}
if ui.button("< Voltar").clicked() {
@@ -135,7 +190,7 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
}
}
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
let extensao = caminho
.extension()
.and_then(|e| e.to_str())
@@ -158,18 +213,43 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
).ok();
)
.ok();
}
"xlsx" | "xls" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
match listar_abas_xlsx(&caminho) {
Ok(info) => {
app.notas_importadas.clear();
// Verificar se há preset XLSX ativo com aba compatível
let preset_aba = if !app.nome_layout_atual.is_empty() {
let aba = app.layout_xlsx_atual.aba.clone();
if !aba.is_empty() && info.abas.contains(&aba) {
Some(aba)
} else {
None
}
} else {
None
};
if let Some(aba) = preset_aba {
// Fluxo rápido: aba do preset existe → disparar análise direto
app.layout_xlsx_atual.aba = aba.clone();
app.caminho_arquivo = Some(caminho.clone());
app.abas_xlsx = info.abas;
app.preview_arquivo =
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
disparar_analise(app, ctx);
} else {
// Fluxo normal: exibir tela de seleção de aba
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));
@@ -182,13 +262,65 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
}
}
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
/// Dispara a análise assíncrona com o layout XLSX atual.
/// Usado tanto no fluxo rápido (preset com aba compatível) quanto no botão "Processar" da tela de aba.
fn disparar_analise(app: &mut App, ctx: &Context) {
let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(),
None => return,
};
let layout_xlsx = app.layout_xlsx_atual.clone();
let (tx, rx) = std::sync::mpsc::channel();
app.resultado_pendente = Some(rx);
app.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
let res_importacao = importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string());
let res = match res_importacao {
Err(e) => ResultadoPendente::Erro(e),
Ok(importado) => {
if importado.notas.is_empty() {
ResultadoPendente::Vazio
} else {
let avisos = importado.avisos.clone();
let notas = importado.notas;
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
fn aplicar_layout(app: &mut App, layout: &Layout) {
match layout {
crate::domain::entities::layout::Layout::Csv { config, .. } => {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
+60 -6
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::layouts::{
exportar_layout_json, importar_layout_json, salvar_layout,
};
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo};
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
use crate::domain::errors::ErroLayout;
use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui};
@@ -83,7 +83,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
fn renderizar_secao_layouts(
ui: &mut Ui,
ctx: &Context,
_ctx: &Context,
app: &mut App,
titulo: &str,
layouts: &[Layout],
@@ -167,6 +167,35 @@ fn salvar_layout_atual(app: &mut App) {
app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
}
Err(ErroLayout::NomeConflitante(nome)) => {
let id_existente = app
.layouts_salvos
.iter()
.find(|l| l.nome() == nome)
.and_then(|l| l.id());
if let Some(id) = id_existente {
let layout_com_id = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: Some(id),
nome: nome.clone(),
config: app.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: Some(id),
nome: nome.clone(),
config: app.layout_xlsx_atual.clone(),
},
};
app.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome
),
AcaoModal::SobrescreverLayout(layout_com_id),
);
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
}
@@ -182,15 +211,40 @@ fn importar_json(app: &mut App, conteudo: &str) {
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
}
Err(ErroLayout::NomeConflitante(nome)) => {
// Exibir opções: sobrescrever ou cancelar
app.exibir_aviso(
// Recriar o layout parseado para passá-lo no modal de confirmação.
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
.ok()
.and_then(|json_repr| Layout::try_from(json_repr).ok());
let id_existente = app
.layouts_salvos
.iter()
.find(|l| l.nome() == nome)
.and_then(|l| l.id());
match (parsed, id_existente) {
(Some(mut layout), Some(id)) => {
match &mut layout {
Layout::Csv { id: i, .. } => *i = Some(id),
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
app.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome
),
AcaoModal::SobrescreverLayout(layout),
);
// TODO: implementar fluxo completo de sobrescrever com entrada de novo nome
}
_ => {
app.exibir_erro(format!(
"Conflito de nome: layout '{}' já existe.",
nome
));
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar layout: {}", e));
+85 -37
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{
entities::resultado_analise::ResultadoAnalise,
services::parser_monetario::formatar_valor_br,
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
};
use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{AcaoModal, App, EstadoApp};
@@ -34,6 +34,15 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
return;
}
let pode_reanalisar = app.caminho_arquivo.is_some();
if ui
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
.clicked()
{
app.reimportar_e_analisar(ctx);
}
if ui.button("📄 Exportar PDF").clicked() {
exportar_para_pdf(app, &resultado);
}
@@ -86,15 +95,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
formatar_valor_br(&resultado.soma_total)
));
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.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);
for chave in chaves {
let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total_notas,
formatar_valor_br(soma)
));
@@ -104,10 +113,7 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes();
ui.label(
egui::RichText::new(format!(
"Notas Faltantes ({} total)",
total_faltantes
))
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
.heading()
.strong(),
);
@@ -118,22 +124,44 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
return;
}
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for serie in series {
let faltantes = &resultado.faltantes_por_serie[serie];
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
if faltantes.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} faltante(s):",
serie,
faltantes.len()
));
// Estatística de completude por série
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
// Paginação
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} faltante(s) — {}/{} notas ({:.1}% completo):",
chave.label(),
faltantes.len(),
total_notas,
total_esperado,
percentual,
));
if ui
.button("📋 Copiar")
.on_hover_text("Copiar todos os números faltantes")
.clicked()
{
let texto = faltantes
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
// Paginação (por faltante individual)
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas {
app.pagina_faltantes = 0;
@@ -142,8 +170,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
let inicio = app.pagina_faltantes * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
for numero in &faltantes[inicio..fim] {
ui.label(format!("{}", numero));
// Exibir grupos contíguos da página atual
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
if a == b {
ui.label(format!("{}", a));
} else {
ui.label(format!("{}{} ({} notas)", a, b, b - a + 1));
}
}
if total_paginas > 1 {
@@ -167,10 +200,7 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_dup = resultado.total_duplicatas();
ui.label(
egui::RichText::new(format!(
"Notas Duplicadas ({} grupo(s))",
total_dup
))
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
.heading()
.strong(),
);
@@ -181,23 +211,36 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
return;
}
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves.sort();
for serie in series {
let duplicatas = &resultado.duplicadas_por_serie[serie];
for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() {
continue;
}
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
serie,
chave.label(),
duplicatas.len()
));
if ui
.button("📋 Copiar")
.on_hover_text("Copiar números duplicados")
.clicked()
{
let texto = duplicatas
.iter()
.map(|(n, c)| format!("{} ({}x)", n, c))
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
let total_paginas =
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_duplicatas >= total_paginas {
app.pagina_duplicatas = 0;
}
@@ -208,7 +251,9 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!(
" • NF {} / Série {}{} ocorrências",
numero, serie, count
numero,
chave.label(),
count
));
}
@@ -251,7 +296,10 @@ fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
&caminho,
) {
Ok(_) => {
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
app.exibir_aviso(
"Sucesso",
format!("PDF exportado para: {}", caminho.display()),
);
}
Err(e) => {
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));