Compare commits

...
14 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
FelipeCN 2c08f237e9 feat: adiciona suporte para análise em background e processamento de resultados pendentes 2026-03-03 10:16:50 -03:00
FelipeCN dedb6a72c2 feat: atualiza a renderização da tabela de pré-visualização para exibir letras do Excel como cabeçalho 2026-03-03 09:27:22 -03:00
FelipeCN 41c75e5ed1 feat: adiciona pré-visualização de arquivos CSV e XLSX na interface 2026-03-03 09:25:12 -03:00
FelipeCN 0f728b9684 feat: adiciona modal para salvar layout com campo de texto e botão na tela de configuração de colunas 2026-03-03 09:19:23 -03:00
FelipeCN b2b91cda55 feat: adiciona nova ação de confirmação para iniciar nova análise e implementa breadcrumb na interface 2026-03-03 08:48:19 -03:00
FelipeCN b0e216a6ff feat: adiciona suporte para arquivos XLS e atualiza mensagens de erro na importação 2026-03-03 08:30:28 -03:00
Felipe bbfa66979b feat: adiciona sugestões de melhoria para o fluxo da interface 2026-03-02 22:24:48 -03:00
Felipe f3e6fa313d refactor: altera o texto do botão "Voltar" para "< Voltar" em várias telas 2026-03-02 22:19:43 -03:00
32 changed files with 2084 additions and 1033 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.
+247 -112
View File
@@ -1,8 +1,8 @@
# PRD — Comparador de Notas # PRD — Comparador de Notas
**Versão:** 1.6 **Versão:** 1.7
**Data:** 02/03/2026 **Data:** 03/03/2026
**Status:** Planejamento **Status:** Implementado (MVP)
--- ---
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Notas faltantes * Notas faltantes
* Notas duplicadas * Notas duplicadas
* Soma total dos valores * Soma total dos valores
* Agrupamento por série * Agrupamento por série e tipo de documento
## 3.2 Objetivos Secundários ## 3.2 Objetivos Secundários
@@ -75,15 +75,21 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Detecção de quebras de sequência * Detecção de quebras de sequência
* Detecção de duplicidades * Detecção de duplicidades
* Soma de valores * Soma de valores
* Agrupamento por série * Agrupamento por série e tipo de documento
* Pré-visualização das primeiras linhas do arquivo na tela de configuração
* Relatório visual com paginação * Relatório visual com paginação
* Exportação de relatório para PDF * Exibição de faltantes agrupados em intervalos contíguos (ex: `1050 (41 notas)`)
* Indicador de completude por série (ex: `48/50 notas — 96,0% completo`)
* Botão de cópia rápida de listas de faltantes/duplicatas para área de transferência
* Exportação de relatório para PDF (fontes Liberation Sans embutidas no binário)
* Salvar layouts personalizados (exclusivos por tipo de arquivo) * Salvar layouts personalizados (exclusivos por tipo de arquivo)
* Carregar layouts salvos * Carregar layouts salvos
* Selecionar layout por menu dropdown * Selecionar layout por menu dropdown
* Excluir layouts * Excluir layouts
* Exportar layout para JSON * Exportar layout para JSON
* Importar layout de JSON * Importar layout de JSON
* Reanalisar arquivo sem reconfiguração (reimporta o mesmo arquivo com o layout atual)
* Análise em background thread (UI não bloqueia durante importação e análise)
## 4.2 Não Incluído (MVP) ## 4.2 Não Incluído (MVP)
@@ -91,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Integração com banco de dados externo * Integração com banco de dados externo
* Multiusuário * Multiusuário
* Acesso remoto * Acesso remoto
* Exportação de resultado em CSV
--- ---
@@ -99,15 +106,16 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
O sistema trabalhará com os seguintes campos lógicos: O sistema trabalhará com os seguintes campos lógicos:
| Campo | Obrigatório | Descrição | | Campo | Obrigatório | Descrição |
| ------ | ----------- | ------------------------------------------------------------------------- | | --------------- | ----------- | ------------------------------------------------------------------------- |
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único. Armazenado internamente como `u64`. | | Numero | Sim | Número incremental da nota. Armazenado internamente como `u64`. |
| Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `[0-9]{1,3}`, ex: `1`, `01`, `001`). Separa lotes de emissão. | | Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `^[0-9]{1,3}$`, ex: `1`, `01`, `001`). Separa lotes de emissão. |
| Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). | | Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). |
| Data | Não | Data de emissão da nota. Quando mapeado, exibido como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. | | Data | Não | Data de emissão da nota. Exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. Formatos aceitos: `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. |
| TipoDocumento | Não | Tipo do documento (ex: `NFE`, `NFCE`). Quando mapeado, compõe a chave de agrupamento junto com a Série. Qualquer string não vazia é aceita. |
Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX. Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
> **Identificador único de uma nota:** combinação de `Numero + Serie`. Duplicidade e sequência são sempre avaliadas dentro da mesma série. > **Identificador único de uma nota:** combinação de `Numero + Serie + TipoDocumento`. Duplicidade e sequência são sempre avaliadas dentro do mesmo grupo `(Serie, TipoDocumento)`. Quando `TipoDocumento` não é mapeado, o agrupamento é feito somente por `Serie` (retrocompatível).
--- ---
@@ -126,7 +134,7 @@ O sistema deve permitir importar arquivos:
| ----------------- | ----------------------------------------------------------------------------- | | ----------------- | ----------------------------------------------------------------------------- |
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação | | Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário | | Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.) | | Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.). 0 = sem cabeçalho |
| Linhas em branco | Devem ser ignoradas silenciosamente | | Linhas em branco | Devem ser ignoradas silenciosamente |
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação | | Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
@@ -160,16 +168,21 @@ O usuário define, via **índice numérico** (posição da coluna, base 0), qual
* Qual índice representa a série (obrigatório) * Qual índice representa a série (obrigatório)
* Qual índice representa o valor (opcional) * Qual índice representa o valor (opcional)
* Qual índice representa a data (opcional) * Qual índice representa a data (opcional)
* Qual índice representa o tipo de documento (opcional)
### RF02.2 — Mapeamento XLSX (letra + linha) ### RF02.2 — Mapeamento XLSX (letra + linha)
O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha: O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha:
* Ex: `B3` indica coluna B a partir da linha 3 * Ex: `B3` indica coluna B a partir da linha 3
* Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional) e Data (opcional) * Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional), Data (opcional) e TipoDocumento (opcional)
> A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior. > A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior.
### RF02.3 — Pré-visualização do Arquivo
A tela de configuração de colunas exibe as primeiras 5 linhas do arquivo com cabeçalho em notação de letras (A, B, C, ... com índice base-0 entre parênteses). A pré-visualização é atualizada automaticamente ao mudar o delimitador ou a aba selecionada.
### Comportamento de Memória ### Comportamento de Memória
Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória. Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória.
@@ -207,7 +220,7 @@ Cada layout é exclusivo de um tipo de arquivo: **CSV** ou **XLSX**. Um layout C
Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções: Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções:
* Sobrescrever o layout existente * Sobrescrever o layout existente
* Salvar com novo nome * Cancelar a importação
### Erros na Importação de Layout JSON ### Erros na Importação de Layout JSON
@@ -227,14 +240,14 @@ Não há limite no número de layouts que podem ser armazenados.
O sistema deve: O sistema deve:
* Ordenar os registros por número dentro de cada série * Ordenar os registros por número dentro de cada grupo `(Serie, TipoDocumento)`
* Detectar números faltantes na sequência * Detectar números faltantes na sequência
### Regra de Detecção ### Regra de Detecção
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada série. Qualquer número ausente nesse intervalo é considerado faltante. A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada grupo. Qualquer número ausente nesse intervalo é considerado faltante.
Exemplo: série 001 contém os números `0001, 0002, 0003, 0005``0004` está faltando. Exemplo: série 001 / NFE contém os números `0001, 0002, 0003, 0005``0004` está faltando.
### Tratamento de Valores Não Numéricos ### Tratamento de Valores Não Numéricos
@@ -251,26 +264,30 @@ Registros com o campo Numero igual a `0` devem ser descartados e reportados ao u
### Escopo da Detecção ### Escopo da Detecção
Faltantes são detectados **por série**. Cada série possui sua própria sequência independente. Faltantes são detectados **por grupo `(Serie, TipoDocumento)`**. Cada grupo possui sua própria sequência independente.
### Série com Apenas um Registro ### Série com Apenas um Registro
Se uma série contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. A série é processada e exibida normalmente. Se um grupo contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. O grupo é processado e exibido normalmente.
### Ordenação ### Ordenação
A ordenação dos registros dentro de cada série é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada. A ordenação dos registros dentro de cada grupo é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada.
### Exibição do Campo Numero ### Exibição do Campo Numero
O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação. O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação.
### Agrupamento de Faltantes Contíguos
Na tela de resultado, faltantes consecutivos são exibidos agrupados em intervalos (ex: `1050 (41 notas)`) para facilitar a leitura. Faltantes isolados são exibidos individualmente (ex: `• 75`).
### Proteção contra Intervalos Anormalmente Grandes ### Proteção contra Intervalos Anormalmente Grandes
Se o intervalo de faltantes de qualquer série exceder **10.000 registros**, o sistema deve: Se o intervalo de faltantes de qualquer grupo exceder **10.000 registros**, o sistema deve:
1. Interromper o processamento dessa série 1. Interromper o processamento desse grupo
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: intervalo de 999.996 faltantes detectado") 2. Exibir aviso informando o intervalo calculado (ex: "Série 001 / NFE: intervalo de 999.996 faltantes detectado")
3. Solicitar confirmação do usuário antes de continuar 3. Solicitar confirmação do usuário antes de continuar
Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação. Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação.
@@ -279,7 +296,7 @@ Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam
### Tratamento de Série com Valor Inválido ### Tratamento de Série com Valor Inválido
A Série é válida se e somente se corresponder à regex `[0-9]{1,3}` após remoção de espaços. A Série é válida se e somente se corresponder à regex `^[0-9]{1,3}$` após remoção de espaços.
Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável: Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável:
@@ -294,11 +311,11 @@ Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
O sistema deve identificar registros duplicados. O sistema deve identificar registros duplicados.
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie** no arquivo importado. Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie + TipoDocumento** no arquivo importado.
### Exibição das Duplicatas ### Exibição das Duplicatas
A lista de duplicatas exibe o identificador (Numero + Serie) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 — 3 ocorrências`. As ocorrências individuais não são listadas separadamente. A lista de duplicatas exibe o identificador (`Numero + Serie + TipoDocumento`) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 / NFE — 3 ocorrências`. As ocorrências individuais não são listadas separadamente.
### Impacto na Soma de Valores ### Impacto na Soma de Valores
@@ -311,12 +328,14 @@ Todas as ocorrências de registros duplicados são incluídas na soma de valores
O sistema deve calcular: O sistema deve calcular:
* Soma total * Soma total
* Soma por série * Soma por grupo `(Serie, TipoDocumento)`
### Formato de Valor Aceito ### Formato de Valor Aceito
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo. O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
O parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
#### Algoritmo de Parsing Monetário #### Algoritmo de Parsing Monetário
**Regra 1 — Contém ambos ponto e vírgula:** **Regra 1 — Contém ambos ponto e vírgula:**
@@ -348,7 +367,7 @@ Valores negativos (precedidos de `-`) devem ser rejeitados e reportados ao usuá
Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário. Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário.
> **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por série devem ser calculadas inteiramente em `Decimal`. > **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por grupo devem ser calculadas inteiramente em `Decimal`.
### Formato de Exibição de Valores ### Formato de Exibição de Valores
@@ -360,15 +379,17 @@ Todos os valores monetários são exibidos com **2 casas decimais fixas** no for
O sistema deve exibir: O sistema deve exibir:
* Lista de notas faltantes agrupadas por série * Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
* Lista de duplicadas agrupadas por série * Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
* Totais (soma total e soma por série) * Lista de duplicadas agrupadas por `(Serie, TipoDocumento)`
* Totais (soma total e soma por grupo, com contagem de notas por grupo)
* Botão de cópia rápida de listas para a área de transferência
### Organização dos Resultados ### Organização dos Resultados
| Aspecto | Comportamento | | Aspecto | Comportamento |
| ------------- | ----------------------------------------------------- | | ------------- | ----------------------------------------------------- |
| Agrupamento | Resultados sempre agrupados por série | | Agrupamento | Resultados sempre agrupados por `(Serie, TipoDocumento)`. Quando TipoDocumento não está mapeado, o label do grupo exibe apenas a Série |
| Listas longas | Paginação — o usuário navega entre páginas de resultados | | Listas longas | Paginação — o usuário navega entre páginas de resultados |
| Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 | | Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 |
@@ -376,15 +397,21 @@ O sistema deve exibir:
O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`. O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`.
As fontes do PDF (Liberation Sans) são embutidas no binário em tempo de compilação, eliminando dependência de fontes instaladas no sistema operacional.
O PDF deve conter: O PDF deve conter:
* Notas faltantes por série * Notas faltantes por grupo `(Serie, TipoDocumento)`
* Duplicatas por série * Duplicatas por grupo
* Totais por série e total geral * Totais por grupo e total geral
**Metadados do relatório:** **Metadados do relatório:**
* Nome do arquivo importado * Nome do arquivo importado
* Data e hora da geração * Data e hora da geração
* Nome do layout utilizado * Nome do layout utilizado (se houver)
### RF07.2 — Reanalisar Arquivo
O sistema deve permitir reimportar o mesmo arquivo do disco com o layout atual e executar a análise novamente, sem nenhuma interação adicional. A operação é executada em background thread para não bloquear a interface.
--- ---
@@ -424,6 +451,8 @@ O sistema deve suportar no mínimo:
100.000 registros por arquivo 100.000 registros por arquivo
A análise é executada em uma thread separada (background) para não bloquear a interface gráfica durante o processamento.
> **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo. > **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo.
--- ---
@@ -451,6 +480,7 @@ Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltip
* "32 linhas descartadas por malformação" * "32 linhas descartadas por malformação"
* "12 valores de Numero inválidos convertidos ou descartados" * "12 valores de Numero inválidos convertidos ou descartados"
* "5 registros com Série inválida descartados" * "5 registros com Série inválida descartados"
* "3 valores monetários inválidos descartados"
Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação. Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação.
@@ -462,9 +492,16 @@ Fluxo principal:
1. Usuário abre o sistema 1. Usuário abre o sistema
2. Usuário importa planilha 2. Usuário importa planilha
3. Usuário configura colunas 3. (Para XLSX) Usuário seleciona aba
4. Usuário executa análise 4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
5. Sistema exibe resultado 5. Usuário executa análise (processamento em background)
6. Sistema exibe resultado
Fluxo alternativo — layout salvo:
1. Usuário abre o sistema
2. Usuário seleciona layout no dropdown
3. Usuário importa planilha → análise é disparada automaticamente
--- ---
@@ -485,6 +522,7 @@ src/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ app.rs │ ├─ app.rs
│ ├─ screens/ │ ├─ screens/
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
│ │ ├─ import.rs │ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs │ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs │ │ ├─ layouts.rs
@@ -493,20 +531,25 @@ src/
├─ application/ ├─ application/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ usecases/ │ ├─ usecases/
│ │ ├─ mod.rs
│ │ ├─ importar_arquivo.rs │ │ ├─ importar_arquivo.rs
│ │ ├─ executar_analise.rs │ │ ├─ executar_analise.rs
│ │ ├─ exportar_pdf.rs │ │ ├─ exportar_pdf.rs
│ │ ├─ layouts.rs
├─ domain/ ├─ domain/
│ ├─ mod.rs │ ├─ mod.rs
│ ├─ errors.rs │ ├─ errors.rs
│ ├─ entities/ │ ├─ entities/
│ │ ├─ mod.rs
│ │ ├─ nota.rs │ │ ├─ nota.rs
│ │ ├─ serie.rs │ │ ├─ serie.rs
│ │ ├─ chave_serie.rs
│ │ ├─ layout.rs │ │ ├─ layout.rs
│ │ ├─ resultado_analise.rs │ │ ├─ resultado_analise.rs
│ │ │ │
│ ├─ services/ │ ├─ services/
│ │ ├─ mod.rs
│ │ ├─ detector_sequencia.rs │ │ ├─ detector_sequencia.rs
│ │ ├─ detector_duplicidade.rs │ │ ├─ detector_duplicidade.rs
│ │ ├─ parser_monetario.rs │ │ ├─ parser_monetario.rs
@@ -533,47 +576,77 @@ Contém toda a lógica de negócio real.
**Não pode depender de:** **Não pode depender de:**
* egui * egui / eframe
* rusqlite * rusqlite
* calamine * calamine
* csv * csv
* genpdf * genpdf
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`). Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`) e utilitários de erros (`thiserror`, `regex`).
#### Entidades #### Entidades
**`Nota`** **`Nota`**
| Campo | Tipo | | Campo | Tipo |
| ------ | ----------------- | | -------------- | ------------------- |
| numero | `u64` | | numero | `u64` |
| serie | `String` | | serie | `String` |
| documento_tipo | `Option<String>` |
| valor | `Option<Decimal>` | | valor | `Option<Decimal>` |
| data | `Option<NaiveDate>` | | data | `Option<NaiveDate>` |
**`ChaveSerie`**
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
| Campo | Tipo |
| -------------- | ---------------- |
| serie | `String` |
| documento_tipo | `Option<String>` |
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
**`ResultadoPreAnalise`**
Resultado intermediário, antes de materializar os faltantes.
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
**`ResultadoAnalise`** **`ResultadoAnalise`**
| Campo | Tipo | | Campo | Tipo |
| -------------------- | -------------------------------------- | | -------------------- | ---------------------------------------- |
| faltantes_por_serie | `HashMap<String, Vec<u64>>` | | faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` | | duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` | | soma_total | `Decimal` |
| soma_por_serie | `HashMap<String, Decimal>` | | soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
> **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas. > **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas.
#### Services #### Services
`detector_sequencia` — recebe `Vec<Nota>` agrupadas por rie, retorna faltantes. `detector_sequencia` — recebe `Vec<&Nota>` agrupadas por `ChaveSerie`, retorna faltantes. Funções:
- `calcular_intervalo` — retorna `IntervaloSerie` sem materializar a lista completa
- `detectar_faltantes` — materializa a lista completa após confirmação
- `agrupar_contiguos` — agrupa uma lista ordenada de faltantes em pares `(inicio, fim)` para exibição compacta
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie)`. `detector_duplicidade` — retorna mapa de contagem por `(numero, serie, documento_tipo)`.
`parser_monetario` — implementa exatamente o algoritmo definido no RF06. `parser_monetario` — implementa exatamente o algoritmo definido no RF06. Também expõe `formatar_valor_br` para exibição no formato `1.234,56`.
#### Erros #### Erros
`domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`). Nenhuma camada deve propagar `String` livre como erro de domínio. `domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`, `ErroLayout::NomeConflitante`, `ErroArquivo::TamanhoExcedido`). Nenhuma camada deve propagar `String` livre como erro de domínio.
`ResumoAvisos` consolida contagens de linhas malformadas, números inválidos, séries inválidas e valores inválidos para exibição em um único modal ao final da importação.
--- ---
@@ -585,15 +658,25 @@ Conhece o domain. O domain não conhece o application.
**`executar_analise.rs`** **`executar_analise.rs`**
1. Recebe dados crus Expõe três funções:
2. Chama `parser_monetario` 1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
3. Chama `detector_sequencia` 2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
4. Chama `detector_duplicidade` 3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
5. Monta `ResultadoAnalise`
**`exportar_pdf.rs`** **`exportar_pdf.rs`**
Depende de uma trait abstrata (`PdfGenerator`) definida no próprio módulo application. A implementação concreta fica em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`. Depende da trait abstrata `PdfGenerator` definida em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`.
**`importar_arquivo.rs`**
Expõe:
- `importar_csv(caminho, config)` — lê CSV e mapeia para notas
- `importar_xlsx(caminho, config)` — lê XLSX e mapeia para notas
- `listar_abas_xlsx(caminho)` — lista abas antes de configurar
**`layouts.rs`**
Expõe operações de CRUD e import/export de layouts: `salvar_layout`, `listar_layouts`, `excluir_layout`, `exportar_layout_json`, `importar_layout_json`.
--- ---
@@ -603,19 +686,15 @@ Implementa leitores, persistência e geração de arquivos.
| Arquivo | Responsabilidade | | Arquivo | Responsabilidade |
| -------------------------------- | ----------------------------------------- | | -------------------------------- | ----------------------------------------- |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` | | `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` | | `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
| `pdf_generator.rs` | Geração de PDF via `genpdf` | | `pdf_generator.rs` | Trait `PdfGenerator` + implementação `GenpdfGenerator` via `genpdf`; fontes Liberation Sans embutidas no binário |
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite | | `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite; tratamento de banco corrompido |
| `sqlite/migrations.rs` | Aplicação de migrations de schema | | `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` | | `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
Nada de infrastructure sobe para domain. Nada de infrastructure sobe para domain.
#### Por que `layout_repository.rs` dentro de `sqlite/`
Manter o repositório dentro de `sqlite/` concentra todos os artefatos SQLite em um único módulo. Se futuramente o sistema armazenar histórico de análises ou configurações do usuário (RF14/10.8), novos repositórios são adicionados no mesmo lugar sem dispersão.
--- ---
### UI (interface) ### UI (interface)
@@ -624,16 +703,31 @@ Apenas coleta input, chama use cases e renderiza resultado.
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI. Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
#### App (estado global)
`app.rs` contém o estado global da aplicação (`App`), o enum `EstadoApp`, os tipos `Modal`/`TipoModal`/`AcaoModal`, e a lógica de processamento de resultados assíncronos via `mpsc::channel`.
**Estados da aplicação:**
| Estado | Descrição |
| ----------------------- | --------- |
| `Importando` | Tela inicial: seleção de arquivo e layout |
| `SelecionandoAba` | Aguardando seleção de aba XLSX |
| `ConfigurandoColunas` | Mapeamento de colunas com pré-visualização |
| `Analisando` | Análise em execução em background thread |
| `ConfirmandoIntervalo` | Aguardando confirmação do usuário para expandir faltantes |
| `ExibindoResultado` | Resultado pronto para exibição |
| `GerenciandoLayouts` | Gerenciamento de layouts salvos |
#### Screens #### Screens
| Arquivo | Responsabilidade | | Arquivo | Responsabilidade |
| ------------------------- | ----------------------------------------------------- | | ------------------------- | ----------------------------------------------------- |
| `import.rs` | Seleção de arquivo e configurações de importação | | `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) | | `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) | | `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
| `resultado.rs` | Exibição de resultados com paginação (RF07) | | `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir, exportar/importar JSON (RF08) |
| `resultado.rs` | Exibição de resultados com paginação, intervalos contíguos, indicador de completude, botões copiar/reanalisar/exportar PDF (RF07) |
> `configuracao_colunas.rs` e `layouts.rs` são mantidos separados porque tratam de responsabilidades distintas do RF02 e RF08, evitando que uma única screen acumule lógica de mapeamento de colunas e gerenciamento de persistência.
--- ---
@@ -646,26 +740,34 @@ Infrastructure entra apenas quando necessário.
Exemplo real: Exemplo real:
1. UI chama `importar_arquivo` 1. UI chama `executar_importacao` (thread separada)
2. Infrastructure lê CSV/XLSX 2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota` 3. Application transforma registros em entidades `Nota`
4. Domain executa análise 4. Domain executa pré-análise (`pre_analisar`)
5. Application retorna `ResultadoAnalise` 5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
6. UI renderiza 6. Application retorna `ResultadoAnalise` via `mpsc::channel`
7. UI renderiza
--- ---
## 9.4 Stack Tecnológica ## 9.4 Stack Tecnológica
| Camada | Tecnologia | Status | | Camada | Tecnologia | Versão |
| ---------------- | ------------------ | -------------- | | ---------------- | ------------------ | ------- |
| Linguagem | Rust | Definido | | Linguagem | Rust | edition 2024 |
| Framework de UI | egui | Definido | | Framework de UI | egui + eframe | 0.31 |
| SQLite | rusqlite | Definido | | Diálogos nativos | rfd | 0.15 |
| Leitura de CSV | csv | Definido | | SQLite | rusqlite (bundled) | 0.32 |
| Leitura de XLSX | calamine | Definido | | Leitura de CSV | csv | 1.3 |
| Decimal fixo | rust_decimal | Definido | | Leitura de XLSX | calamine | 0.26 |
| Geração de PDF | genpdf | Definido | | Decimal fixo | rust_decimal | 1.36 |
| Geração de PDF | genpdf | 0.2 |
| Datas | chrono | 0.4 |
| Encoding | encoding_rs | 0.8 |
| Caminhos de dados| dirs | 5 |
| Erros tipados | thiserror | 2 |
| Regex | regex | 1 |
| Serialização | serde + serde_json | 1 |
--- ---
@@ -723,6 +825,14 @@ Os layouts salvos anteriormente serão perdidos neste cenário. O arquivo `confi
O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco. O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco.
**Versão atual do schema: 3**
| Versão | Alteração |
| ------ | --------- |
| 1 | Criação da tabela `layouts` |
| 2 | Índice único em `layouts.nome`; renomeia duplicatas com sufixo `(id)` |
| 3 | Adição das colunas `indice_documento_tipo` (CSV) e `pos_documento_tipo` (XLSX) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
## 10.3 Dados Armazenados ## 10.3 Dados Armazenados
@@ -741,31 +851,33 @@ Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configuraç
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| ---------- | ------- | -------------------------------------- | | ---------- | ------- | -------------------------------------- |
| id | inteiro | Identificador único | | id | inteiro | Identificador único (auto-incremento) |
| nome | texto | Nome do layout | | nome | texto | Nome do layout (único no banco) |
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` | | tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
**Campos exclusivos de layouts CSV:** **Campos exclusivos de layouts CSV:**
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| ------------------ | ------ | -------------------------------------------------- | | ---------------------- | ------- | ---------------------------------------------------------- |
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) | | delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) | | encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1) | | linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho |
| indice_numero | inteiro | Índice da coluna Numero (base 0) | | indice_numero | inteiro | Índice da coluna Numero (base 0) |
| indice_serie | inteiro | Índice da coluna Serie (base 0) | | indice_serie | inteiro | Índice da coluna Serie (base 0) |
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) | | indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) | | indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
**Campos exclusivos de layouts XLSX:** **Campos exclusivos de layouts XLSX:**
| Campo | Tipo | Descrição | | Campo | Tipo | Descrição |
| -------------- | ------ | ---------------------------------------------------------------- | | ------------------ | ------ | ----------------------------------------------------------------- |
| aba | texto | Nome ou índice da aba a ser processada | | aba | texto | Nome da aba a ser processada |
| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) | | pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) |
| pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) | | pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) |
| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) | | pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) |
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) | | pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -785,15 +897,19 @@ Excluir layout
## 10.6 Interface do Usuário ## 10.6 Interface do Usuário
Os layouts salvos devem ser exibidos em um menu dropdown. Os layouts salvos devem ser exibidos em um menu dropdown, filtrado pelo tipo de arquivo atual (CSV ou XLSX).
O usuário deve poder: O usuário deve poder:
Selecionar layout existente Selecionar layout existente
Criar novo layout Criar novo layout (via modal com campo de texto ou via tela de gerenciamento)
Excluir layout Excluir layout (com confirmação)
Exportar layout para JSON
Importar layout de JSON (com tratamento de conflito de nome)
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -835,15 +951,16 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
```json ```json
{ {
"nome": "Layout Padrão CSV",
"tipo": "csv", "tipo": "csv",
"nome": "Layout Padrão CSV",
"delimitador": ";", "delimitador": ";",
"encoding": "utf-8", "encoding": "utf-8",
"linha_cabecalho": 1, "linha_cabecalho": 1,
"indice_numero": 3, "indice_numero": 3,
"indice_serie": 1, "indice_serie": 1,
"indice_valor": 5, "indice_valor": 5,
"indice_data": null "indice_data": null,
"indice_documento_tipo": null
} }
``` ```
@@ -851,16 +968,19 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
```json ```json
{ {
"nome": "Layout Padrão XLSX",
"tipo": "xlsx", "tipo": "xlsx",
"nome": "Layout Padrão XLSX",
"aba": "Plan1", "aba": "Plan1",
"pos_numero": "D3", "pos_numero": "D3",
"pos_serie": "B3", "pos_serie": "B3",
"pos_valor": "F3", "pos_valor": "F3",
"pos_data": null "pos_data": null,
"pos_documento_tipo": null
} }
``` ```
> **Nota:** o campo `tipo` é usado como tag de discriminante pelo `serde` (`#[serde(tag = "tipo")]`). O campo `indice_documento_tipo` / `pos_documento_tipo` usa `#[serde(default)]` para retrocompatibilidade com arquivos JSON exportados antes da versão 1.7.
--- ---
# 12. Critérios de Aceite # 12. Critérios de Aceite
@@ -869,14 +989,17 @@ O sistema será considerado funcional quando:
* Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho * Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
* Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha` * Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
* Detectar notas faltantes por série corretamente * Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
* Detectar duplicatas por série corretamente * Detectar duplicatas por grupo corretamente
* Calcular soma total e soma por série corretamente * Calcular soma total e soma por grupo corretamente
* Exibir resultados agrupados por série com paginação * Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exibir indicador de completude por grupo
* Exportar relatório de resultados para PDF * Exportar relatório de resultados para PDF
* Salvar, carregar, selecionar e excluir layouts * Salvar, carregar, selecionar e excluir layouts
* Exportar e importar layouts via JSON * Exportar e importar layouts via JSON
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por série * Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por grupo
* Executar análise em background sem bloquear a interface
* Exibir pré-visualização das primeiras 5 linhas do arquivo na tela de configuração
--- ---
@@ -885,25 +1008,29 @@ O sistema será considerado funcional quando:
O MVP inclui o escopo completo descrito neste PRD: O MVP inclui o escopo completo descrito neste PRD:
* Importar CSV e XLSX * Importar CSV e XLSX
* Mapear colunas Numero, Serie, Valor e Data * Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
* Detectar notas faltantes por série * Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
* Detectar duplicatas por série * Detectar duplicatas por grupo
* Calcular soma total e por série * Calcular soma total e por grupo
* Exibir resultados agrupados por série com paginação * Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exportar relatório para PDF * Exportar relatório para PDF
* Gerenciar layouts (salvar, carregar, excluir) * Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
* Exportar e importar layouts via JSON * Reanalisar arquivo sem reconfiguração
--- ---
# 14. Evoluções Futuras # 14. Evoluções Futuras
Possíveis melhorias: Possíveis melhorias (ver `FEATURES_BACKLOG.md` para detalhes):
* Exportação de resultado em CSV (`faltantes.csv`, `duplicatas.csv`)
* Busca por número na tela de resultado
* Auto-detecção de delimitador CSV
* Auto-detecção de encoding CSV
* Agrupamento de faltantes como intervalos no PDF
* Integração com ERP * Integração com ERP
* Histórico de análises * Histórico de análises
* Automação de importação (monitorar pasta) * Automação de importação (monitorar pasta)
* Exportação para CSV
* Multiusuário * Multiusuário
--- ---
@@ -918,8 +1045,16 @@ Série
Agrupador independente de sequência. Agrupador independente de sequência.
TipoDocumento
Subtipo de documento dentro de uma série (ex: NFE, NFCE). Quando mapeado, compõe a chave de agrupamento junto com a Série.
ChaveSerie
Chave composta `(Serie, TipoDocumento)` que identifica um grupo de notas para fins de detecção de sequência, duplicidade e cálculo de somas.
Sequência Sequência
Ordem numérica crescente sem lacunas. Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
--- ---
+113
View File
@@ -0,0 +1,113 @@
# Sugestões de Melhoria — Fluxo da Interface
**Data:** 02/03/2026
---
## 1. Indicador de progresso das etapas
O fluxo possui 34 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
```
① Arquivo ② Colunas ③ Resultado
```
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
---
## 2. Unificar importação e configuração de colunas em um único painel
Atualmente o caminho é:
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
São 3 ações separadas para chegar à análise.
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
---
## 3. Pré-visualização das primeiras linhas do arquivo
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
**Sugestão:** Exibir as primeiras 35 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
---
## 4. Confirmação ao clicar em "Nova Análise"
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
**Sugestão:** Exibir modal de confirmação com a mensagem:
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
Botões: **Confirmar** | **Cancelar**
---
## 5. Reorganizar seções do resultado
A ordem atual das seções em `resultado.rs` é:
```
Totais → Faltantes → Duplicatas
```
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
**Sugestão:** Inverter para:
```
Faltantes → Duplicatas → Totais
```
Isso coloca a informação mais relevante no topo da tela.
---
## 6. Seletor de layout também na tela de configuração de colunas
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
---
## 7. Botão "Salvar como layout..." na tela de configuração
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
---
## 8. Feedback visual durante a análise
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
```
⏳ Analisando... aguarde.
```
Mesmo sem progresso percentual, já elimina a percepção de travamento.
---
## Resumo de Impacto
| # | Sugestão | Impacto UX | Esforço estimado |
|---|----------|------------|-----------------|
| 1 | Breadcrumb de etapas | Médio | Baixo |
| 2 | Unificar importação + configuração | Alto | Médio |
| 3 | Pré-visualização do arquivo | Alto | Médio |
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
| 5 | Reordenar seções do resultado | Médio | Baixo |
| 6 | Seletor de layout em configuração | Médio | Baixo |
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
| 8 | Feedback durante análise | Alto | Baixo |
Binary file not shown.
Binary file not shown.
+61 -40
View File
@@ -1,11 +1,12 @@
use crate::domain::{ use crate::domain::{
entities::{ entities::{
chave_serie::ChaveSerie,
nota::Nota, nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
}, },
services::{ services::{
detector_duplicidade::duplicidades_por_serie, 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; use rust_decimal::Decimal;
@@ -15,30 +16,31 @@ use std::collections::HashMap;
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e, /// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`. /// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise { pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série // Agrupar por (serie, documento_tipo)
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new(); let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas { for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota); let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
} }
let mut intervalos_por_serie = HashMap::new(); let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO; let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new(); let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new(); let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie { for (chave, notas_grupo) in &por_chave {
// Somar valores // Somar valores
for nota in notas_serie.iter() { for nota in notas_grupo.iter() {
if let Some(v) = nota.valor { if let Some(v) = nota.valor {
soma_total += v; soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v; *soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
} }
} }
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len(); *total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes // Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) { if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(serie.clone(), intervalo); intervalos_por_serie.insert(chave.clone(), intervalo);
} }
} }
@@ -53,31 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
} }
} }
/// Verifica se alguma série excede o limite de faltantes. /// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação. /// Retorna lista de chaves que precisam de confirmação.
pub fn series_com_intervalo_excessivo( pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> {
pre.intervalos_por_serie pre.intervalos_por_serie
.iter() .iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes)) .map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
.collect() .collect()
} }
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes. /// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite). /// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise { pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new(); let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas { for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota); let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
} }
let mut faltantes_por_serie = HashMap::new(); let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie { for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_serie); let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(serie.clone(), faltantes); faltantes_por_serie.insert(chave.clone(), faltantes);
} }
ResultadoAnalise { ResultadoAnalise {
@@ -89,21 +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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec; use rust_decimal_macros::dec;
/// Executa análise completa sem verificar limites (atalho para testes).
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota { fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, None) Nota::new(numero, serie.to_string(), None, valor, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
} }
#[test] #[test]
@@ -114,7 +118,8 @@ mod tests {
nota(4, "001", Some(dec!(50.00))), nota(4, "001", Some(dec!(50.00))),
]; ];
let resultado = executar_analise(&notas); let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]); let chave = ChaveSerie::new("001".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]);
assert_eq!(resultado.soma_total, dec!(350.00)); assert_eq!(resultado.soma_total, dec!(350.00));
} }
@@ -127,19 +132,35 @@ mod tests {
nota(2, "002", None), nota(2, "002", None),
]; ];
let resultado = executar_analise(&notas); let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]); let chave001 = ChaveSerie::new("001".to_string(), None);
assert!(resultado.faltantes_por_serie["002"].is_empty()); let chave002 = ChaveSerie::new("002".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
} }
#[test] #[test]
fn pre_analise_detecta_intervalo_excessivo() { fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![ let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
nota(1, "001", None),
nota(20_000, "001", None),
];
let pre = pre_analisar(&notas); let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre); let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1); assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, "001"); assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
}
#[test]
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
// NFE série 001: 1, 3 → faltante 2
// NFCE série 001: 2, 5 → faltante 3 e 4
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(3, "001", "NFE"),
nota_com_tipo(2, "001", "NFCE"),
nota_com_tipo(5, "001", "NFCE"),
];
let resultado = executar_analise(&notas);
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
} }
} }
+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::nota::Nota;
use crate::domain::entities::serie::validar_serie; use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos}; use crate::domain::errors::{ErroArquivo, ResumoAvisos};
@@ -24,7 +24,10 @@ pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
} }
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados. /// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_csv(
caminho: &Path,
config: &LayoutCsv,
) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv( let resultado = csv_reader::ler_csv(
caminho, caminho,
config.delimitador as u8, config.delimitador as u8,
@@ -37,6 +40,7 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
&resultado.linhas, &resultado.linhas,
config.indice_numero, config.indice_numero,
config.indice_serie, config.indice_serie,
config.indice_documento_tipo,
config.indice_valor, config.indice_valor,
config.indice_data, config.indice_data,
&mut avisos, &mut avisos,
@@ -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. /// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_xlsx(
caminho: &Path,
config: &LayoutXlsx,
) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado // Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = { let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero) let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
.ok_or_else(|| ErroArquivo::ErroLeitura(format!( ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'", config.pos_numero "Posição de Numero inválida: '{}'",
)))?; config.pos_numero
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie) ))
.ok_or_else(|| ErroArquivo::ErroLeitura(format!( })?;
"Posição de Serie inválida: '{}'", config.pos_serie let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie).ok_or_else(|| {
)))?; ErroArquivo::ErroLeitura(format!("Posição de Serie inválida: '{}'", config.pos_serie))
})?;
coord_num.linha.min(coord_ser.linha) coord_num.linha.min(coord_ser.linha)
}; };
@@ -79,12 +87,18 @@ pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImp
.as_ref() .as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p)) .and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize); .map(|c| c.coluna as usize);
let col_documento_tipo = config
.pos_documento_tipo
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos; let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas( let notas = mapear_linhas_para_notas(
&resultado.linhas, &resultado.linhas,
col_numero, col_numero,
col_serie, col_serie,
col_documento_tipo,
col_valor, col_valor,
col_data, col_data,
&mut avisos, &mut avisos,
@@ -99,6 +113,7 @@ fn mapear_linhas_para_notas(
linhas: &[Vec<String>], linhas: &[Vec<String>],
idx_numero: usize, idx_numero: usize,
idx_serie: usize, idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>, idx_valor: Option<usize>,
idx_data: Option<usize>, idx_data: Option<usize>,
avisos: &mut ResumoAvisos, avisos: &mut ResumoAvisos,
@@ -113,7 +128,9 @@ fn mapear_linhas_para_notas(
Some(s) if !s.trim().is_empty() => s.trim().to_string(), Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => { _ => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Numero ausente", linha_num));
continue; continue;
} }
}; };
@@ -123,7 +140,9 @@ fn mapear_linhas_para_notas(
Ok(n) => n, Ok(n) => n,
Err(msg) => { Err(msg) => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg)); avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue; continue;
} }
}; };
@@ -133,7 +152,9 @@ fn mapear_linhas_para_notas(
Some(s) => s.trim().to_string(), Some(s) => s.trim().to_string(),
None => { None => {
avisos.series_invalidas += 1; avisos.series_invalidas += 1;
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Serie ausente", linha_num));
continue; continue;
} }
}; };
@@ -182,7 +203,17 @@ fn mapear_linhas_para_notas(
None None
}; };
notas.push(Nota::new(numero, serie, valor, data)); // Extrair tipo de documento (opcional) — qualquer string não vazia
let documento_tipo = if let Some(idx) = idx_documento_tipo {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
}
} else {
None
};
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
} }
notas notas
+15 -14
View File
@@ -2,24 +2,31 @@ use crate::domain::{
entities::layout::{Layout, LayoutJson}, entities::layout::{Layout, LayoutJson},
errors::ErroLayout, errors::ErroLayout,
}; };
use crate::infrastructure::sqlite::{layout_repository, migrations}; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; use rusqlite::Connection;
/// Salva um layout no banco de dados. /// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Se o layout já tem um id, atualiza. Caso contrário, insere.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String> { /// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayout> {
// Validar campos obrigatórios // Validar campos obrigatórios
if layout.nome().trim().is_empty() { if layout.nome().trim().is_empty() {
return Err("Nome do layout não pode ser vazio".to_string()); return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
} }
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout) layout_repository::atualizar(conn, layout)
.map_err(|e| e.to_string())?; .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
Ok(id) Ok(id)
} else { } 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) 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()) layout_repository::listar(conn).map_err(|e| e.to_string())
} }
/// Carrega um layout pelo id.
pub fn carregar_layout(conn: &Connection, id: i64) -> Result<Option<Layout>, String> {
layout_repository::buscar_por_id(conn, id).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id. /// Exclui um layout pelo id.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> { pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string()) layout_repository::excluir(conn, id).map_err(|e| e.to_string())
@@ -61,8 +63,8 @@ pub fn importar_layout_json(
novo_nome: Option<&str>, novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> { ) -> Result<i64, ErroLayout> {
// Deserializar // Deserializar
let json_repr: LayoutJson = serde_json::from_str(json) let json_repr: LayoutJson =
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?; let mut layout = Layout::try_from(json_repr)?;
@@ -105,6 +107,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // Inserir novo
layout_repository::salvar(conn, &layout) layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
.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>, pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado) /// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>, pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
} }
impl Default for LayoutCsv { impl Default for LayoutCsv {
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
indice_serie: 1, indice_serie: 1,
indice_valor: None, indice_valor: None,
indice_data: None, indice_data: None,
indice_documento_tipo: None,
} }
} }
} }
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
pub pos_valor: Option<String>, pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado) /// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>, pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
} }
impl Default for LayoutXlsx { impl Default for LayoutXlsx {
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
pos_serie: String::new(), pos_serie: String::new(),
pos_valor: None, pos_valor: None,
pos_data: None, pos_data: None,
pos_documento_tipo: None,
} }
} }
} }
@@ -129,6 +135,8 @@ pub enum LayoutJson {
indice_serie: usize, indice_serie: usize,
indice_valor: Option<usize>, indice_valor: Option<usize>,
indice_data: Option<usize>, indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
}, },
Xlsx { Xlsx {
nome: String, nome: String,
@@ -137,6 +145,8 @@ pub enum LayoutJson {
pos_serie: String, pos_serie: String,
pos_valor: Option<String>, pos_valor: Option<String>,
pos_data: Option<String>, pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
}, },
} }
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie, indice_serie,
indice_valor, indice_valor,
indice_data, indice_data,
indice_documento_tipo,
} => { } => {
if nome.trim().is_empty() { if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie, indice_serie,
indice_valor, indice_valor,
indice_data, indice_data,
indice_documento_tipo,
}, },
}) })
} }
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie, pos_serie,
pos_valor, pos_valor,
pos_data, pos_data,
pos_documento_tipo,
} => { } => {
if nome.trim().is_empty() { if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie, pos_serie,
pos_valor, pos_valor,
pos_data, pos_data,
pos_documento_tipo,
}, },
}) })
} }
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
indice_serie: config.indice_serie, indice_serie: config.indice_serie,
indice_valor: config.indice_valor, indice_valor: config.indice_valor,
indice_data: config.indice_data, indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
}, },
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx { Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(), nome: nome.clone(),
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
pos_serie: config.pos_serie.clone(), pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(), pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(), pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
}, },
} }
} }
+1
View File
@@ -2,3 +2,4 @@ pub mod layout;
pub mod nota; pub mod nota;
pub mod resultado_analise; pub mod resultado_analise;
pub mod serie; pub mod serie;
pub mod chave_serie;
+5 -1
View File
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
use rust_decimal::Decimal; use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos. /// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie` é o identificador único de cada nota. /// `numero + serie + documento_tipo` é o identificador único de cada nota.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Nota { pub struct Nota {
/// Número incremental da nota. Armazenado como u64. /// Número incremental da nota. Armazenado como u64.
pub numero: u64, pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1". /// Série da nota (13 dígitos numéricos). Ex: "001", "1".
pub serie: String, pub serie: String,
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
pub documento_tipo: Option<String>,
/// Valor monetário da nota (opcional). /// Valor monetário da nota (opcional).
pub valor: Option<Decimal>, pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras). /// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
@@ -19,12 +21,14 @@ impl Nota {
pub fn new( pub fn new(
numero: u64, numero: u64,
serie: String, serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>, valor: Option<Decimal>,
data: Option<NaiveDate>, data: Option<NaiveDate>,
) -> Self { ) -> Self {
Self { Self {
numero, numero,
serie, serie,
documento_tipo,
valor, valor,
data, data,
} }
+15 -14
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal; use rust_decimal::Decimal;
use std::collections::HashMap; use std::collections::HashMap;
@@ -5,15 +6,15 @@ use std::collections::HashMap;
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04). /// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResultadoPreAnalise { pub struct ResultadoPreAnalise {
/// Mapa série → (minimo, maximo, contagem_faltantes) /// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>, pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes) /// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>, pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas /// Somas já calculadas
pub soma_total: Decimal, pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>, pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por série /// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<String, usize>, pub total_por_serie: HashMap<ChaveSerie, usize>,
} }
/// Intervalo de sequência de uma série. /// Intervalo de sequência de uma série.
@@ -33,16 +34,16 @@ impl IntervaloSerie {
/// Resultado completo da análise, com a lista materializada de faltantes. /// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResultadoAnalise { pub struct ResultadoAnalise {
/// Números faltantes por série (ordenados crescentemente) /// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>, pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)] /// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>, pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores /// Soma total de todos os valores
pub soma_total: Decimal, pub soma_total: Decimal,
/// Soma por série /// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<String, Decimal>, pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por série /// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<String, usize>, pub total_por_serie: HashMap<ChaveSerie, usize>,
} }
impl ResultadoAnalise { impl ResultadoAnalise {
-50
View File
@@ -1,13 +1,5 @@
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroNumero {
#[error("Número zero não é válido para uma nota fiscal")]
Zero,
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
pub enum ErroSerie { pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")] #[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
@@ -40,52 +32,10 @@ pub enum ErroArquivo {
TamanhoExcedido(u64), TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")] #[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String), Corrompido(String),
#[error("Formato não suportado: {0}")]
FormatoNaoSuportado(String),
#[error("Erro de leitura: {0}")] #[error("Erro de leitura: {0}")]
ErroLeitura(String), ErroLeitura(String),
} }
#[derive(Debug, Error)]
pub enum ErroAnalise {
#[error("Intervalo de faltantes muito grande para a série '{serie}': {intervalo} registros")]
IntervaloMuitoGrande { serie: String, intervalo: u64 },
#[error("Campo obrigatório não mapeado: {0}")]
CampoObrigatorioNaoMapeado(String),
#[error("Índice de coluna inválido: campo '{campo}', índice {indice}")]
IndiceInvalido { campo: String, indice: usize },
#[error("Dois campos mapeados para o mesmo índice: {0}")]
IndicesDuplicados(String),
}
/// Aviso coletado durante a importação, para ser exibido de forma consolidada ao usuário.
#[derive(Debug, Clone)]
pub struct AvisoImportacao {
pub categoria: CategoriaAviso,
pub mensagem: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CategoriaAviso {
LinhaMalformada,
NumeroInvalido,
SerieInvalida,
ValorInvalido,
RegistroDescartado,
}
impl std::fmt::Display for CategoriaAviso {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CategoriaAviso::LinhaMalformada => write!(f, "Linhas malformadas"),
CategoriaAviso::NumeroInvalido => write!(f, "Valores de Numero inválidos"),
CategoriaAviso::SerieInvalida => write!(f, "Registros com Série inválida"),
CategoriaAviso::ValorInvalido => write!(f, "Valores monetários inválidos"),
CategoriaAviso::RegistroDescartado => write!(f, "Registros descartados"),
}
}
}
/// Resumo consolidado de avisos para exibição em um único modal. /// Resumo consolidado de avisos para exibição em um único modal.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct ResumoAvisos { pub struct ResumoAvisos {
+38 -19
View File
@@ -1,16 +1,17 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use std::collections::HashMap; use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas. /// Detecta registros duplicados em uma lista de notas.
/// ///
/// Retorna um mapa (numero, serie) → contagem de ocorrências, /// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência. /// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> { pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new(); let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas { for nota in notas {
*contagem *contagem
.entry((nota.numero, nota.serie.clone())) .entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1; .or_insert(0) += 1;
} }
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
contagem contagem
} }
/// Agrupa as duplicidades por série. /// Agrupa as duplicidades por (serie, documento_tipo).
/// ///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente. /// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> { pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas); let raw = detectar_duplicidades(notas);
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new(); let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie), contagem) in raw { for ((numero, serie, documento_tipo), contagem) in raw {
result.entry(serie).or_default().push((numero, contagem)); let chave = ChaveSerie::new(serie, documento_tipo);
result.entry(chave).or_default().push((numero, contagem));
} }
// Ordenar por numero dentro de cada série // Ordenar por numero dentro de cada grupo
for lista in result.values_mut() { for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num); lista.sort_by_key(|(num, _)| *num);
} }
@@ -44,7 +46,11 @@ mod tests {
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota { fn nota(numero: u64, serie: &str) -> Nota {
Nota::new(numero, serie.to_string(), None, None) Nota::new(numero, serie.to_string(), None, None, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
} }
#[test] #[test]
@@ -58,7 +64,7 @@ mod tests {
fn com_duplicata_simples() { fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")]; let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2)); assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1); assert_eq!(dup.len(), 1);
} }
@@ -66,17 +72,30 @@ mod tests {
fn duplicata_multiplas_ocorrencias() { fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")]; let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3)); assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
} }
#[test] #[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() { fn mesmo_numero_series_diferentes_nao_e_duplicata() {
// Número 1 em séries diferentes não é duplicata
let notas = vec![nota(1, "001"), nota(1, "002")]; let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas); let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty()); assert!(dup.is_empty());
} }
#[test]
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
}
#[test] #[test]
fn agrupamento_por_serie() { fn agrupamento_por_serie() {
let notas = vec![ let notas = vec![
@@ -88,13 +107,13 @@ mod tests {
nota(2, "001"), nota(2, "001"),
]; ];
let por_serie = duplicidades_por_serie(&notas); let por_serie = duplicidades_por_serie(&notas);
// Série 001 deve ter notas 1 e 2 duplicadas let chave001 = ChaveSerie::new("001".to_string(), None);
let serie001 = por_serie.get("001").unwrap(); let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2); assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2)); assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2)); assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada let serie002 = por_serie.get(&chave002).unwrap();
let serie002 = por_serie.get("002").unwrap();
assert_eq!(serie002.len(), 1); assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2)); assert_eq!(serie002[0], (1, 2));
} }
+50 -1
View File
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
faltantes faltantes
} }
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
///
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
///
/// # Exemplo
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
if faltantes.is_empty() {
return Vec::new();
}
let mut grupos = Vec::new();
let mut inicio = faltantes[0];
let mut anterior = faltantes[0];
for &num in &faltantes[1..] {
if num != anterior + 1 {
grupos.push((inicio, anterior));
inicio = num;
}
anterior = num;
}
grupos.push((inicio, anterior));
grupos
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -74,7 +100,7 @@ mod tests {
use rust_decimal::Decimal; use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota { fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None) Nota::new(numero, "001".to_string(), None, None, None)
} }
#[test] #[test]
@@ -136,4 +162,27 @@ mod tests {
let refs: Vec<&Nota> = notas.iter().collect(); let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new()); assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
} }
#[test]
fn agrupar_vazio() {
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
}
#[test]
fn agrupar_isolados() {
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
}
#[test]
fn agrupar_um_intervalo() {
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
}
#[test]
fn agrupar_misto() {
assert_eq!(
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
vec![(1, 3), (5, 5), (8, 9)]
);
}
} }
+36
View File
@@ -88,3 +88,39 @@ pub fn ler_csv(
avisos, avisos,
}) })
} }
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
n: usize,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
let bytes = std::fs::read(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
let linhas = reader
.records()
.take(n)
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
Ok(linhas)
}
+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::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br; use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use genpdf::{ use genpdf::{
elements::{Break, Paragraph}, elements::{Break, Paragraph},
fonts, style, Document, Element, SimplePageDecorator, fonts, style, Document, SimplePageDecorator,
}; };
use std::path::Path; use std::path::Path;
// Fontes embutidas no binário em tempo de compilação.
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
const FONT_REGULAR: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
/// Metadados do relatório. /// Metadados do relatório.
pub struct MetadadosRelatorio { pub struct MetadadosRelatorio {
pub nome_arquivo: String, pub nome_arquivo: String,
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans) let font_family = carregar_fonte_familia()?;
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
let mut doc = Document::new(font_family); let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas"); doc.set_title("Relatório — Comparador de Notas");
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
))); )));
doc.push(Break::new(1)); doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais // Totais
doc.push( doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)), Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
formatar_valor_br(&resultado.soma_total) formatar_valor_br(&resultado.soma_total)
))); )));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect(); for chave in &chaves_ordenadas {
series_ordenadas.sort(); let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}", " Série {}: {} nota(s) — R$ {}",
serie, chave.label(),
total, total,
formatar_valor_br(soma) formatar_valor_br(soma)
))); )));
@@ -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)), Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
); );
for serie in &series_ordenadas { // Use faltantes keys for this section (may differ from soma keys if no values)
let faltantes = match resultado.faltantes_por_serie.get(*serie) { let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves_faltantes.sort();
for chave in &chaves_faltantes {
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
Some(f) if !f.is_empty() => f, Some(f) if !f.is_empty() => f,
_ => { _ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie))); doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
continue; continue;
} }
}; };
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)", " Série {}: {} faltante(s)",
serie, chave.label(),
faltantes.len() faltantes.len()
))); )));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect(); 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)), Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
); );
for serie in &series_ordenadas { let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) { chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
Some(d) if !d.is_empty() => d, Some(d) if !d.is_empty() => d,
_ => { _ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie))); doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
continue; continue;
} }
}; };
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)", " Série {}: {} grupo(s) duplicado(s)",
serie, chave.label(),
duplicatas.len() duplicatas.len()
))); )));
for (numero, count) in duplicatas { for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!( doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências", " NF {} / Série {}{} ocorrências",
numero, serie, count numero, chave.label(), count
))); )));
} }
} }
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
} }
} }
/// Tenta carregar fontes DejaVu Sans do sistema. /// Constrói a família de fontes a partir dos bytes embutidos no binário.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> { /// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
// Caminhos comuns no Linux, Windows e macOS /// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
let candidatos_regular = [ fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
"/usr/share/fonts/TTF/DejaVuSans.ttf", .map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
"C:\\Windows\\Fonts\\arial.ttf", let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
"/Library/Fonts/Arial.ttf", .map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
// italic e bold_italic reusam os dados de regular/bold respectivamente.
Ok(fonts::FontFamily { Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(), italic: regular.clone(),
bold_italic: regular, bold_italic: bold.clone(),
regular,
bold,
}) })
} }
+1 -3
View File
@@ -1,8 +1,6 @@
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// Determina o caminho do banco de dados conforme o sistema operacional. /// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf { pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
@@ -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 // Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
drop(conn); drop(conn);
let bak = caminho.with_extension("db.bak"); let bak = caminho.with_extension("db.bak");
+20 -58
View File
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
conn.execute( conn.execute(
"INSERT INTO layouts "INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho, (nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data) indice_numero, indice_serie, indice_valor, indice_data,
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)", indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
nome, nome,
config.delimitador.to_string(), config.delimitador.to_string(),
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.indice_serie as i64, config.indice_serie as i64,
config.indice_valor.map(|v| v as i64), config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64), config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
], ],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
Layout::Xlsx { nome, config, .. } => { Layout::Xlsx { nome, config, .. } => {
conn.execute( conn.execute(
"INSERT INTO layouts "INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data) (nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)", pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
nome, nome,
config.aba, config.aba,
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.pos_serie, config.pos_serie,
config.pos_valor, config.pos_valor,
config.pos_data, config.pos_data,
config.pos_documento_tipo,
], ],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8 indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
WHERE id = ?9", WHERE id = ?10",
params![ params![
nome, nome,
config.delimitador.to_string(), config.delimitador.to_string(),
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.indice_serie as i64, config.indice_serie as i64,
config.indice_valor.map(|v| v as i64), config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64), config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id, id,
], ],
)?; )?;
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute( conn.execute(
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6 pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
WHERE id = ?7", WHERE id = ?8",
params![ params![
nome, nome,
config.aba, config.aba,
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.pos_serie, config.pos_serie,
config.pos_valor, config.pos_valor,
config.pos_data, config.pos_data,
config.pos_documento_tipo,
id, id,
], ],
)?; )?;
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
"SELECT id, nome, tipo, "SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data, indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC", FROM layouts ORDER BY nome ASC",
)?; )?;
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
indice_data: row indice_data: row
.get::<_, Option<i64>>(9)? .get::<_, Option<i64>>(9)?
.map(|v| v as usize), .map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>(15)?
.map(|v| v as usize),
}, },
}) })
} else { } else {
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
pos_serie: row.get(12)?, pos_serie: row.get(12)?,
pos_valor: row.get(13)?, pos_valor: row.get(13)?,
pos_data: row.get(14)?, pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
}, },
}) })
} }
@@ -145,55 +156,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
layouts layouts
} }
/// Busca um layout pelo id.
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
FROM layouts WHERE id = ?1",
)?;
let mut results = stmt.query_map([id], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
},
})
}
})?;
results.next().transpose()
}
/// Remove um layout pelo id. /// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> { pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?; conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
+37 -5
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados. /// 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 /// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente. /// para a versão mais recente.
@@ -23,13 +23,45 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
if versao_atual < 1 { if versao_atual < 1 {
migration_v1(conn)?; migration_v1(conn)?;
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
} else {
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
} }
if versao_atual < 2 {
migration_v2(conn)?;
}
if versao_atual < 3 {
migration_v3(conn)?;
} }
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(()) Ok(())
} }
+55 -8
View File
@@ -1,7 +1,5 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos}; use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use calamine::{open_workbook, Reader, Xlsx}; use calamine::{open_workbook_auto, Reader};
use std::fs::File;
use std::io::BufReader;
use std::path::Path; use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
@@ -22,17 +20,19 @@ pub struct ResultadoXlsx {
pub avisos: ResumoAvisos, pub avisos: ResumoAvisos,
} }
/// Lista as abas disponíveis em um arquivo XLSX. /// Lista as abas disponíveis em um arquivo XLSX (ou XLS com extensão .xls mas conteúdo XLSX).
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> { pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?; verificar_tamanho(caminho)?;
let workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho) let workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; .map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec()) Ok(workbook.sheet_names().to_vec())
} }
/// Lê os dados de uma aba XLSX a partir de uma linha de início. /// Lê os dados de uma aba XLSX (ou XLS com extensão .xls mas conteúdo XLSX) a partir de uma linha de início.
///
/// O formato real é detectado pelo conteúdo do arquivo (magic bytes), não pela extensão.
/// ///
/// # Parâmetros /// # Parâmetros
/// - `caminho`: caminho do arquivo /// - `caminho`: caminho do arquivo
@@ -45,14 +45,14 @@ pub fn ler_xlsx(
) -> Result<ResultadoXlsx, ErroArquivo> { ) -> Result<ResultadoXlsx, ErroArquivo> {
verificar_tamanho(caminho)?; verificar_tamanho(caminho)?;
let mut workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho) let mut workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; .map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba) .worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; .map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let mut avisos = ResumoAvisos::default(); let avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new(); let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize; let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
@@ -97,6 +97,53 @@ pub fn ler_xlsx(
Ok(ResultadoXlsx { linhas, avisos }) Ok(ResultadoXlsx { linhas, avisos })
} }
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_xlsx(
caminho: &Path,
nome_aba: &str,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let linhas = range
.rows()
.take(5)
.map(|row| {
row.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect()
})
.collect();
Ok(linhas)
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1). /// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
/// ///
/// Retorna `None` se a notação for inválida. /// Retorna `None` se a notação for inválida.
+392 -30
View File
@@ -1,26 +1,22 @@
use crate::application::usecases::{ use crate::application::usecases::{
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo}, executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao}, importar_arquivo::{importar_csv, importar_xlsx},
layouts::{ layouts::{excluir_layout, listar_layouts, salvar_layout},
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
listar_layouts, salvar_layout,
},
}; };
use crate::domain::{ use crate::domain::{
entities::{ entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo}, layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota, nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
}, },
errors::ResumoAvisos, errors::{ErroLayout, ResumoAvisos},
};
use crate::infrastructure::{
pdf_generator::GenpdfGenerator,
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
}; };
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use egui::Context; use egui::Context;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc;
/// Estado global da aplicação. /// Estado global da aplicação.
pub enum EstadoApp { pub enum EstadoApp {
@@ -33,12 +29,11 @@ pub enum EstadoApp {
/// Resultado pronto para exibição. /// Resultado pronto para exibição.
ExibindoResultado(ResultadoAnalise), ExibindoResultado(ResultadoAnalise),
/// Aguardando confirmação do usuário para expandir faltantes. /// Aguardando confirmação do usuário para expandir faltantes.
ConfirmandoIntervalo { ConfirmandoIntervalo { pre: ResultadoPreAnalise },
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
},
/// Gerenciamento de layouts. /// Gerenciamento de layouts.
GerenciandoLayouts, GerenciandoLayouts,
/// Análise em execução em background (thread separada).
Analisando,
} }
/// Modal bloqueante a ser exibido sobre qualquer tela. /// Modal bloqueante a ser exibido sobre qualquer tela.
@@ -50,6 +45,8 @@ pub struct Modal {
pub tipo: TipoModal, pub tipo: TipoModal,
/// Para modal de confirmação, a ação ao confirmar. /// Para modal de confirmação, a ação ao confirmar.
pub acao_confirmacao: Option<AcaoModal>, pub acao_confirmacao: Option<AcaoModal>,
/// Para modal com campo de texto (InputTexto).
pub input_texto: String,
} }
#[derive(Default, Clone, PartialEq)] #[derive(Default, Clone, PartialEq)]
@@ -59,13 +56,42 @@ pub enum TipoModal {
Aviso, Aviso,
Erro, Erro,
Confirmacao, Confirmacao,
/// Modal com campo de texto para entrada do usuário.
InputTexto,
} }
#[derive(Clone)] #[derive(Clone)]
pub enum AcaoModal { pub enum AcaoModal {
ConfirmarExpansaoFaltantes, ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64), 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,
}
/// Resultado enviado pela thread de análise de volta para a UI.
pub enum ResultadoPendente {
/// Análise concluída com sucesso.
Concluido {
resultado: ResultadoAnalise,
/// Avisos de importação (None quando vier do path de expansão).
avisos: Option<ResumoAvisos>,
/// Notas importadas (None quando o App já as possui).
notas: Option<Vec<Nota>>,
},
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
/// Arquivo importado não continha notas válidas.
Vazio,
/// Erro durante importação ou análise.
Erro(String),
} }
/// Struct principal da aplicação egui. /// Struct principal da aplicação egui.
@@ -101,6 +127,13 @@ pub struct App {
pub pagina_faltantes: usize, pub pagina_faltantes: usize,
pub pagina_duplicatas: usize, pub pagina_duplicatas: usize,
pub itens_por_pagina: usize, pub itens_por_pagina: usize,
// Pré-visualização das primeiras linhas do arquivo
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 { impl Default for App {
@@ -123,6 +156,9 @@ impl Default for App {
pagina_faltantes: 0, pagina_faltantes: 0,
pagina_duplicatas: 0, pagina_duplicatas: 0,
itens_por_pagina: 100, itens_por_pagina: 100,
preview_arquivo: None,
resultado_pendente: None,
resultado_anterior: None,
} }
} }
} }
@@ -137,6 +173,7 @@ impl App {
EstadoApp::ExibindoResultado(_) => 3, EstadoApp::ExibindoResultado(_) => 3,
EstadoApp::ConfirmandoIntervalo { .. } => 4, EstadoApp::ConfirmandoIntervalo { .. } => 4,
EstadoApp::GerenciandoLayouts => 5, EstadoApp::GerenciandoLayouts => 5,
EstadoApp::Analisando => 6,
} }
} }
@@ -175,6 +212,7 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Erro, tipo: TipoModal::Erro,
acao_confirmacao: None, acao_confirmacao: None,
input_texto: String::new(),
}; };
} }
@@ -185,6 +223,7 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Aviso, tipo: TipoModal::Aviso,
acao_confirmacao: None, acao_confirmacao: None,
input_texto: String::new(),
}; };
} }
@@ -200,10 +239,23 @@ impl App {
mensagem: msg.into(), mensagem: msg.into(),
tipo: TipoModal::Confirmacao, tipo: TipoModal::Confirmacao,
acao_confirmacao: Some(acao), acao_confirmacao: Some(acao),
input_texto: String::new(),
}; };
} }
/// Recarrega a lista de layouts do banco. /// Recarrega a lista de layouts do banco.
/// Exibe modal com campo de texto para salvar o layout atual como novo preset.
pub fn exibir_modal_salvar_layout(&mut self) {
self.modal = Modal {
visivel: true,
titulo: "Salvar como layout...".to_string(),
mensagem: "Nome do layout:".to_string(),
tipo: TipoModal::InputTexto,
acao_confirmacao: Some(AcaoModal::SalvarLayoutConfig),
input_texto: self.nome_layout_atual.clone(),
};
}
pub fn recarregar_layouts(&mut self) { pub fn recarregar_layouts(&mut self) {
if let Some(conn) = &self.conn { if let Some(conn) = &self.conn {
match listar_layouts(conn) { match listar_layouts(conn) {
@@ -227,14 +279,26 @@ impl App {
egui::Window::new(&titulo) egui::Window::new(&titulo)
.collapsible(false) .collapsible(false)
.resizable(false) .resizable(false)
.min_width(320.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| { .show(ctx, |ui| {
ui.add_space(8.0); ui.add_space(8.0);
ui.label(&mensagem); ui.label(&mensagem);
if tipo == TipoModal::InputTexto {
ui.add_space(4.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut self.modal.input_texto)
.desired_width(280.0)
.hint_text("Nome do layout"),
);
resp.request_focus();
}
ui.add_space(12.0); ui.add_space(12.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if tipo == TipoModal::Confirmacao { if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
if ui.button("✔ Confirmar").clicked() { if ui.button("✔ Confirmar").clicked() {
self.modal.visivel = false; self.modal.visivel = false;
if let Some(acao) = acao.clone() { if let Some(acao) = acao.clone() {
@@ -243,6 +307,7 @@ impl App {
} }
if ui.button("✖ Cancelar").clicked() { if ui.button("✖ Cancelar").clicked() {
self.modal.visivel = false; self.modal.visivel = false;
self.modal.input_texto.clear();
} }
} else { } else {
if ui.button("OK").clicked() { if ui.button("OK").clicked() {
@@ -256,12 +321,20 @@ impl App {
fn executar_acao_modal(&mut self, acao: AcaoModal) { fn executar_acao_modal(&mut self, acao: AcaoModal) {
match acao { match acao {
AcaoModal::ConfirmarExpansaoFaltantes => { AcaoModal::ConfirmarExpansaoFaltantes => {
// Retirar pre-análise do estado e expandir
if let EstadoApp::ConfirmandoIntervalo { pre, .. } = if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
std::mem::replace(&mut self.estado, EstadoApp::Importando) std::mem::replace(&mut self.estado, EstadoApp::Analisando)
{ {
let resultado = expandir_analise(pre, &self.notas_importadas); let notas = self.notas_importadas.clone();
self.estado = EstadoApp::ExibindoResultado(resultado); let (tx, rx) = mpsc::channel();
self.resultado_pendente = Some(rx);
std::thread::spawn(move || {
let resultado = expandir_analise(pre, &notas);
let _ = tx.send(ResultadoPendente::Concluido {
resultado,
avisos: None,
notas: None,
});
});
} }
} }
AcaoModal::ConfirmarExclusaoLayout(id) => { AcaoModal::ConfirmarExclusaoLayout(id) => {
@@ -273,8 +346,234 @@ 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;
self.estado = EstadoApp::Importando;
}
AcaoModal::SalvarLayoutConfig => {
let nome = self.modal.input_texto.trim().to_string();
self.modal.input_texto.clear();
if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio.");
return;
}
let layout = match &self.tipo_arquivo_atual {
TipoArquivo::Csv => Layout::Csv {
id: None,
nome: nome.clone(),
config: self.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: None,
nome: nome.clone(),
config: self.layout_xlsx_atual.clone(),
},
};
let resultado = if let Some(conn) = &self.conn {
Some(salvar_layout(conn, &layout))
} else {
None
};
match resultado {
Some(Ok(_)) => {
self.nome_layout_atual = nome;
self.recarregar_layouts();
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
}
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 => {}
}
}
}
}
/// 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,
} => {
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.resultado_anterior = None;
self.estado = EstadoApp::ExibindoResultado(resultado);
if let Some(av) = &self.avisos_importacao.clone() {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
self.exibir_aviso("Avisos de Importação", linhas);
}
}
}
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas,
avisos,
notas,
} => {
self.notas_importadas = notas;
self.avisos_importacao = if avisos.tem_avisos() {
Some(avisos)
} else {
None
};
let msg = series_excessivas
.iter()
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
})
.collect::<Vec<_>>()
.join("\n");
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
msg
),
AcaoModal::ConfirmarExpansaoFaltantes,
);
}
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. /// Executa a análise com as notas importadas.
@@ -290,17 +589,18 @@ impl App {
if !excessivos.is_empty() { if !excessivos.is_empty() {
let msg = excessivos let msg = excessivos
.iter() .iter()
.map(|(serie, count)| { .map(|(chave, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count) format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let acao = AcaoModal::ConfirmarExpansaoFaltantes; let acao = AcaoModal::ConfirmarExpansaoFaltantes;
self.estado = EstadoApp::ConfirmandoIntervalo { self.estado = EstadoApp::ConfirmandoIntervalo { pre };
pre,
series_excessivas: excessivos,
};
self.exibir_confirmacao( self.exibir_confirmacao(
"Intervalo muito grande", "Intervalo muito grande",
format!( format!(
@@ -316,25 +616,87 @@ impl App {
} }
} }
impl App {
/// Renderiza o breadcrumb de etapas no topo (exceto na tela de layouts).
pub fn renderizar_breadcrumb(&self, ctx: &Context) {
let passo_ativo: usize = match self.tela_atual() {
0 | 1 => 1,
2 | 4 | 6 => 2,
3 => 3,
_ => return, // GerenciandoLayouts: sem breadcrumb
};
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, label) in ["Arquivo", "Colunas", "Resultado"].iter().enumerate() {
let n = i + 1;
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 {
ui.label(texto);
} else {
ui.label(texto.weak());
}
if n < 3 {
ui.label(egui::RichText::new(" ").weak());
}
}
});
ui.add_space(4.0);
});
}
}
impl eframe::App for App { impl eframe::App for App {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
// Verificar resultado pendente da análise em background
if let Some(rx) = self.resultado_pendente.take() {
match rx.try_recv() {
Ok(resultado) => {
self.processar_resultado_pendente(resultado);
}
Err(mpsc::TryRecvError::Empty) => {
// Ainda processando: devolver receiver e solicitar repaint
self.resultado_pendente = Some(rx);
ctx.request_repaint();
}
Err(mpsc::TryRecvError::Disconnected) => {
self.estado = EstadoApp::ConfigurandoColunas;
self.exibir_erro("Erro interno: a análise foi interrompida inesperadamente.");
}
}
}
// Sempre renderizar modal por cima de tudo // Sempre renderizar modal por cima de tudo
self.renderizar_modal(ctx); self.renderizar_modal(ctx);
// Breadcrumb de etapas no topo
self.renderizar_breadcrumb(ctx);
// Determinar qual tela exibir sem borrar self.estado // Determinar qual tela exibir sem borrar self.estado
let tela_id = self.tela_atual(); let tela_id = self.tela_atual();
// Renderizar a tela atual // Renderizar a tela atual
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| match tela_id {
match tela_id {
0 => crate::ui::screens::import::renderizar(ui, ctx, self), 0 => crate::ui::screens::import::renderizar(ui, ctx, self),
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self), 1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self), 2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self), 3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self), 4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self), 5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
_ => {} 6 => {
ui.centered_and_justified(|ui| {
ui.label(
egui::RichText::new("⏳ Analisando... aguarde.")
.size(22.0)
.strong(),
);
});
} }
_ => {}
}); });
} }
} }
+212 -53
View File
@@ -1,6 +1,9 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx}; use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
use crate::domain::entities::layout::TipoArquivo; use crate::application::usecases::executar_analise::{
use crate::ui::app::{App, EstadoApp}; expandir_analise, pre_analisar, series_com_intervalo_excessivo,
};
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
use egui::{Context, Ui}; use egui::{Context, Ui};
/// Renderiza a tela de configuração de colunas. /// Renderiza a tela de configuração de colunas.
@@ -12,6 +15,51 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.label(format!("Arquivo: {}", caminho.display())); ui.label(format!("Arquivo: {}", caminho.display()));
} }
ui.add_space(8.0);
// Seletor de layout
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_config")
.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();
match &layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
}
}
}
}
}
});
});
ui.add_space(12.0); ui.add_space(12.0);
match app.tipo_arquivo_atual.clone() { match app.tipo_arquivo_atual.clone() {
@@ -19,6 +67,14 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
TipoArquivo::Xlsx => renderizar_xlsx(ui, app), TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
} }
// Pré-visualização do arquivo
if let Some(preview) = &app.preview_arquivo.clone() {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
}
ui.add_space(16.0); ui.add_space(16.0);
ui.separator(); ui.separator();
ui.add_space(8.0); ui.add_space(8.0);
@@ -34,15 +90,29 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
} }
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button(" Voltar").clicked() { if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando; app.estado = EstadoApp::Importando;
} }
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| { ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
if ui.button("▶ Importar e Analisar").clicked() { if ui.button("▶ Importar e Analisar").clicked() {
executar_importacao(app); executar_importacao(app, ctx);
} }
}); });
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();
}
}); });
} }
@@ -60,19 +130,48 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
'\t' => "Tabulação (Tab)", '\t' => "Tabulação (Tab)",
_ => "Outro", _ => "Outro",
}; };
let mut delim_mudou = false;
egui::ComboBox::from_id_salt("combo_delimitador") egui::ComboBox::from_id_salt("combo_delimitador")
.selected_text(delim_str) .selected_text(delim_str)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if ui.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)").clicked() { if ui
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
.clicked()
{
app.layout_csv_atual.delimitador = ','; app.layout_csv_atual.delimitador = ',';
delim_mudou = true;
} }
if ui.selectable_label(app.layout_csv_atual.delimitador == ';', "Ponto e vírgula (;)").clicked() { if ui
.selectable_label(
app.layout_csv_atual.delimitador == ';',
"Ponto e vírgula (;)",
)
.clicked()
{
app.layout_csv_atual.delimitador = ';'; app.layout_csv_atual.delimitador = ';';
delim_mudou = true;
} }
if ui.selectable_label(app.layout_csv_atual.delimitador == '\t', "Tabulação (Tab)").clicked() { if ui
.selectable_label(
app.layout_csv_atual.delimitador == '\t',
"Tabulação (Tab)",
)
.clicked()
{
app.layout_csv_atual.delimitador = '\t'; app.layout_csv_atual.delimitador = '\t';
delim_mudou = true;
} }
}); });
if delim_mudou {
if let Some(caminho) = &app.caminho_arquivo.clone() {
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
).ok();
}
}
}); });
// Encoding // Encoding
@@ -81,10 +180,19 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
egui::ComboBox::from_id_salt("combo_encoding") egui::ComboBox::from_id_salt("combo_encoding")
.selected_text(&app.layout_csv_atual.encoding) .selected_text(&app.layout_csv_atual.encoding)
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
if ui.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8").clicked() { if ui
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
.clicked()
{
app.layout_csv_atual.encoding = "utf-8".to_string(); app.layout_csv_atual.encoding = "utf-8".to_string();
} }
if ui.selectable_label(app.layout_csv_atual.encoding == "windows-1252", "Windows-1252 (Latin-1)").clicked() { if ui
.selectable_label(
app.layout_csv_atual.encoding == "windows-1252",
"Windows-1252 (Latin-1)",
)
.clicked()
{
app.layout_csv_atual.encoding = "windows-1252".to_string(); app.layout_csv_atual.encoding = "windows-1252".to_string();
} }
}); });
@@ -104,11 +212,32 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
ui.label("Mapeamento de Colunas (índice base 0)"); ui.label("Mapeamento de Colunas (índice base 0)");
ui.add_space(4.0); ui.add_space(4.0);
campo_indice(ui, "Número (obrigatório):", &mut app.layout_csv_atual.indice_numero); campo_indice(
campo_indice(ui, "Série (obrigatório):", &mut app.layout_csv_atual.indice_serie); ui,
"Número (obrigatório):",
&mut app.layout_csv_atual.indice_numero,
);
campo_indice(
ui,
"Série (obrigatório):",
&mut app.layout_csv_atual.indice_serie,
);
campo_indice_opcional(ui, "Valor (opcional):", &mut app.layout_csv_atual.indice_valor); campo_indice_opcional(
campo_indice_opcional(ui, "Data (opcional):", &mut app.layout_csv_atual.indice_data); ui,
"Valor (opcional):",
&mut app.layout_csv_atual.indice_valor,
);
campo_indice_opcional(
ui,
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
campo_indice_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_csv_atual.indice_documento_tipo,
);
}); });
} }
@@ -141,10 +270,27 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)"); ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
ui.add_space(4.0); ui.add_space(4.0);
campo_letra_linha(ui, "Número (obrigatório):", &mut app.layout_xlsx_atual.pos_numero); campo_letra_linha(
campo_letra_linha(ui, "Série (obrigatório):", &mut app.layout_xlsx_atual.pos_serie); ui,
campo_letra_linha_opcional(ui, "Valor (opcional):", &mut app.layout_xlsx_atual.pos_valor); "Número (obrigatório):",
&mut app.layout_xlsx_atual.pos_numero,
);
campo_letra_linha(
ui,
"Série (obrigatório):",
&mut app.layout_xlsx_atual.pos_serie,
);
campo_letra_linha_opcional(
ui,
"Valor (opcional):",
&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, "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,
);
}); });
} }
@@ -204,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
if let Some(d) = c.indice_data { if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d)); indices.push(("Data".to_string(), d));
} }
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros); verificar_duplicados(&indices, &mut erros);
} }
TipoArquivo::Xlsx => { TipoArquivo::Xlsx => {
@@ -245,51 +394,61 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
} }
} }
fn executar_importacao(app: &mut App) { fn executar_importacao(app: &mut App, ctx: &egui::Context) {
let caminho = match &app.caminho_arquivo { let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(), Some(p) => p.clone(),
None => return, None => return,
}; };
let resultado = match app.tipo_arquivo_atual.clone() { let tipo = app.tipo_arquivo_atual.clone();
TipoArquivo::Csv => { let layout_csv = app.layout_csv_atual.clone();
importar_csv(&caminho, &app.layout_csv_atual) let layout_xlsx = app.layout_xlsx_atual.clone();
.map_err(|e| e.to_string())
} let (tx, rx) = std::sync::mpsc::channel();
TipoArquivo::Xlsx => { app.resultado_pendente = Some(rx);
importar_xlsx(&caminho, &app.layout_xlsx_atual) app.estado = EstadoApp::Analisando;
.map_err(|e| e.to_string()) ctx.request_repaint();
}
std::thread::spawn(move || {
// 1. Importar arquivo
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()),
}; };
match resultado { let res = match res_importacao {
Ok(res) => { Err(e) => ResultadoPendente::Erro(e),
if res.notas.is_empty() { Ok(importado) => {
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo."); if importado.notas.is_empty() {
return; ResultadoPendente::Vazio
}
let avisos = res.avisos.clone();
app.notas_importadas = res.notas;
app.avisos_importacao = if avisos.tem_avisos() {
Some(avisos.clone())
} else { } else {
None let avisos = importado.avisos.clone();
let notas = importado.notas;
// 2. Pré-análise
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 {
// 3. Expandir faltantes
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
}; };
// Executar análise let _ = tx.send(res);
app.executar_analise(); });
// Exibir avisos consolidados após análise
if let Some(av) = &app.avisos_importacao {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
app.exibir_aviso("Avisos de Importação", linhas);
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
} }
+178 -19
View File
@@ -1,11 +1,14 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx}; use crate::application::usecases::executar_analise::{
use crate::domain::entities::layout::TipoArquivo; expandir_analise, pre_analisar, series_com_intervalo_excessivo,
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 egui::{Context, Ui};
use std::path::PathBuf; use std::path::PathBuf;
/// Renderiza a tela de importação de arquivos. /// 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.heading("Comparador de Notas — Importar Arquivo");
ui.add_space(16.0); ui.add_space(16.0);
@@ -22,10 +25,10 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
if ui.button("📂 Selecionar arquivo...").clicked() { if ui.button("📂 Selecionar arquivo...").clicked() {
if let Some(caminho) = rfd::FileDialog::new() if let Some(caminho) = rfd::FileDialog::new()
.add_filter("Planilhas", &["csv", "xlsx"]) .add_filter("Planilhas", &["csv", "xlsx", "xls"])
.pick_file() .pick_file()
{ {
on_arquivo_selecionado(app, caminho); on_arquivo_selecionado(app, ctx, caminho);
} }
} }
}); });
@@ -53,9 +56,14 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
}) })
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout { for (id, nome) in &opcoes_layout {
if ui.selectable_label(nome_layout_atual == *nome, nome.as_str()).clicked() { if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone(); app.nome_layout_atual = nome.clone();
if let Some(layout) = app.layouts_salvos.iter().find(|l| l.id() == Some(*id)) { if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone(); let layout = layout.clone();
aplicar_layout(app, &layout); aplicar_layout(app, &layout);
} }
@@ -78,7 +86,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
} }
/// Renderiza a tela de seleção de aba (XLSX). /// 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.heading("Selecionar Aba da Planilha");
ui.add_space(16.0); ui.add_space(16.0);
@@ -89,18 +97,84 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
ui.label(format!("Arquivo: {}", caminho.display())); ui.label(format!("Arquivo: {}", caminho.display()));
ui.add_space(8.0); 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:"); ui.label("Selecione a aba a processar:");
let aba_atual = app.layout_xlsx_atual.aba.clone(); let aba_atual = app.layout_xlsx_atual.aba.clone();
for aba in &abas { for aba in &abas {
if ui.selectable_label(aba_atual == *aba, aba).clicked() { if ui.selectable_label(aba_atual == *aba, aba).clicked() {
app.layout_xlsx_atual.aba = aba.clone(); 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();
}
}
// Pré-visualização da aba selecionada
if !app.layout_xlsx_atual.aba.is_empty() {
if let Some(preview) = &app.preview_arquivo {
ui.add_space(8.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
} }
} }
ui.add_space(12.0); ui.add_space(12.0);
if !app.layout_xlsx_atual.aba.is_empty() { 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 app.nome_arquivo = caminho
.file_name() .file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
@@ -108,14 +182,15 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
app.caminho_arquivo = Some(caminho); app.caminho_arquivo = Some(caminho);
app.estado = EstadoApp::ConfigurandoColunas; app.estado = EstadoApp::ConfigurandoColunas;
} }
});
} }
if ui.button(" Voltar").clicked() { if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando; app.estado = EstadoApp::Importando;
} }
} }
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) { fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
let extensao = caminho let extensao = caminho
.extension() .extension()
.and_then(|e| e.to_str()) .and_then(|e| e.to_str())
@@ -130,19 +205,51 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
match extensao.as_str() { match extensao.as_str() {
"csv" => { "csv" => {
app.tipo_arquivo_atual = TipoArquivo::Csv; app.tipo_arquivo_atual = TipoArquivo::Csv;
app.caminho_arquivo = Some(caminho); app.caminho_arquivo = Some(caminho.clone());
app.notas_importadas.clear(); app.notas_importadas.clear();
// Gerar pré-visualização com o delimitador atual
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
&caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
)
.ok();
} }
"xlsx" => { "xlsx" | "xls" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx; app.tipo_arquivo_atual = TipoArquivo::Xlsx;
match listar_abas_xlsx(&caminho) { match listar_abas_xlsx(&caminho) {
Ok(info) => { 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.abas_xlsx = info.abas.clone();
app.estado = EstadoApp::SelecionandoAba { app.estado = EstadoApp::SelecionandoAba {
abas: info.abas, abas: info.abas,
caminho: caminho.clone(), caminho: caminho.clone(),
}; };
app.notas_importadas.clear(); }
} }
Err(e) => { Err(e) => {
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e)); app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
@@ -150,18 +257,70 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
} }
} }
_ => { _ => {
app.exibir_erro("Formato de arquivo não suportado. Use CSV ou XLSX."); app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
} }
} }
} }
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) { /// 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 { match layout {
crate::domain::entities::layout::Layout::Csv { config, .. } => { Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone(); app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv; app.tipo_arquivo_atual = TipoArquivo::Csv;
} }
crate::domain::entities::layout::Layout::Xlsx { config, .. } => { Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone(); app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx; app.tipo_arquivo_atual = TipoArquivo::Xlsx;
} }
+61 -7
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::layouts::{ use crate::application::usecases::layouts::{
exportar_layout_json, importar_layout_json, salvar_layout, 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::domain::errors::ErroLayout;
use crate::ui::app::{AcaoModal, App, EstadoApp}; use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui}; use egui::{Context, Ui};
@@ -12,7 +12,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.add_space(8.0); ui.add_space(8.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button(" Voltar").clicked() { if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando; app.estado = EstadoApp::Importando;
} }
}); });
@@ -83,7 +83,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
fn renderizar_secao_layouts( fn renderizar_secao_layouts(
ui: &mut Ui, ui: &mut Ui,
ctx: &Context, _ctx: &Context,
app: &mut App, app: &mut App,
titulo: &str, titulo: &str,
layouts: &[Layout], layouts: &[Layout],
@@ -167,6 +167,35 @@ fn salvar_layout_atual(app: &mut App) {
app.recarregar_layouts(); app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout salvo com sucesso."); 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) => { Err(e) => {
app.exibir_erro(format!("Erro ao salvar layout: {}", 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."); app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
} }
Err(ErroLayout::NomeConflitante(nome)) => { Err(ErroLayout::NomeConflitante(nome)) => {
// Exibir opções: sobrescrever ou cancelar // Recriar o layout parseado para passá-lo no modal de confirmação.
app.exibir_aviso( // 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", "Conflito de nome",
format!( 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 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) => { Err(e) => {
app.exibir_erro(format!("Erro ao importar layout: {}", e)); app.exibir_erro(format!("Erro ao importar layout: {}", e));
+68
View File
@@ -2,3 +2,71 @@ pub mod configuracao_colunas;
pub mod import; pub mod import;
pub mod layouts; pub mod layouts;
pub mod resultado; pub mod resultado;
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
fn indice_para_letra(mut idx: usize) -> String {
let mut resultado = String::new();
loop {
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
if idx < 26 {
break;
}
idx = idx / 26 - 1;
}
resultado
}
/// Renderiza uma tabela simples de pré-visualização do arquivo.
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
/// seguida pelas linhas de dados.
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 0 {
return;
}
ui.label(
egui::RichText::new(format!(
"Pré-visualização ({} linha(s))",
linhas.len()
))
.small()
.weak(),
);
ui.add_space(2.0);
egui::ScrollArea::horizontal()
.id_salt("scroll_preview")
.max_height(160.0)
.show(ui, |ui| {
egui::Grid::new("tabela_preview")
.striped(true)
.spacing([8.0, 2.0])
.show(ui, |ui| {
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
for i in 0..num_colunas {
ui.label(
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
.strong()
.monospace(),
);
}
ui.end_row();
// Linhas de dados
for linha in linhas {
for col in 0..num_colunas {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let texto = if celula.chars().count() > 30 {
let truncado: String = celula.chars().take(30).collect();
format!("{}...", truncado)
} else {
celula.to_string()
};
ui.label(egui::RichText::new(texto).monospace().small());
}
ui.end_row();
}
});
});
}
+98 -48
View File
@@ -1,10 +1,10 @@
use crate::application::usecases::exportar_pdf::exportar_pdf; use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{ use crate::domain::{
entities::resultado_analise::ResultadoAnalise, entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::parser_monetario::formatar_valor_br, services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
}; };
use crate::infrastructure::pdf_generator::GenpdfGenerator; use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{App, EstadoApp}; use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui}; use egui::{Context, Ui};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
@@ -21,10 +21,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.add_space(8.0); ui.add_space(8.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
if ui.button(" Nova Análise").clicked() { if ui.button("< Nova Análise").clicked() {
app.estado = EstadoApp::Importando; app.exibir_confirmacao(
app.notas_importadas.clear(); "Nova Análise",
return; "Deseja iniciar uma nova análise? O resultado atual será descartado.",
AcaoModal::ConfirmarNovaAnalise,
);
} }
if ui.button("⚙ Reconfigurar Colunas").clicked() { if ui.button("⚙ Reconfigurar Colunas").clicked() {
@@ -32,6 +34,15 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
return; 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() { if ui.button("📄 Exportar PDF").clicked() {
exportar_para_pdf(app, &resultado); exportar_para_pdf(app, &resultado);
} }
@@ -58,12 +69,6 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.separator(); ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
// Totais
renderizar_totais(ui, &resultado);
ui.add_space(12.0);
ui.separator();
// Faltantes // Faltantes
renderizar_faltantes(ui, app, &resultado); renderizar_faltantes(ui, app, &resultado);
@@ -72,6 +77,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
// Duplicatas // Duplicatas
renderizar_duplicatas(ui, app, &resultado); renderizar_duplicatas(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Totais
renderizar_totais(ui, &resultado);
}); });
} }
@@ -84,15 +95,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
formatar_valor_br(&resultado.soma_total) formatar_valor_br(&resultado.soma_total)
)); ));
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect(); let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
series.sort(); chaves.sort();
for serie in series { for chave in chaves {
let soma = &resultado.soma_por_serie[serie]; let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0); let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
ui.label(format!( ui.label(format!(
" Série {}: {} nota(s) — R$ {}", " Série {}: {} nota(s) — R$ {}",
serie, chave.label(),
total_notas, total_notas,
formatar_valor_br(soma) formatar_valor_br(soma)
)); ));
@@ -102,10 +113,7 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) { fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes(); let total_faltantes = resultado.total_faltantes();
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
"Notas Faltantes ({} total)",
total_faltantes
))
.heading() .heading()
.strong(), .strong(),
); );
@@ -116,22 +124,44 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
return; return;
} }
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect(); let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
series.sort(); chaves.sort();
for serie in series { for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[serie]; let faltantes = &resultado.faltantes_por_serie[chave];
if faltantes.is_empty() { if faltantes.is_empty() {
continue; continue;
} }
ui.label(format!( // Estatística de completude por série
"Série {}{} faltante(s):", let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
serie, let total_esperado = total_notas + faltantes.len();
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; let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas { if app.pagina_faltantes >= total_paginas {
app.pagina_faltantes = 0; app.pagina_faltantes = 0;
@@ -140,8 +170,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
let inicio = app.pagina_faltantes * app.itens_por_pagina; let inicio = app.pagina_faltantes * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
for numero in &faltantes[inicio..fim] { // Exibir grupos contíguos da página atual
ui.label(format!("{}", numero)); 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 { if total_paginas > 1 {
@@ -165,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) { fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_dup = resultado.total_duplicatas(); let total_dup = resultado.total_duplicatas();
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
"Notas Duplicadas ({} grupo(s))",
total_dup
))
.heading() .heading()
.strong(), .strong(),
); );
@@ -179,23 +211,36 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
return; return;
} }
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect(); let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
series.sort(); chaves.sort();
for serie in series { for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[serie]; let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() { if duplicatas.is_empty() {
continue; continue;
} }
ui.horizontal(|ui| {
ui.label(format!( ui.label(format!(
"Série {}{} grupo(s) duplicado(s):", "Série {}{} grupo(s) duplicado(s):",
serie, chave.label(),
duplicatas.len() 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 = let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_duplicatas >= total_paginas { if app.pagina_duplicatas >= total_paginas {
app.pagina_duplicatas = 0; app.pagina_duplicatas = 0;
} }
@@ -206,7 +251,9 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
for (numero, count) in &duplicatas[inicio..fim] { for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!( ui.label(format!(
" • NF {} / Série {}{} ocorrências", " • NF {} / Série {}{} ocorrências",
numero, serie, count numero,
chave.label(),
count
)); ));
} }
@@ -249,7 +296,10 @@ fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
&caminho, &caminho,
) { ) {
Ok(_) => { Ok(_) => {
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display())); app.exibir_aviso(
"Sucesso",
format!("PDF exportado para: {}", caminho.display()),
);
} }
Err(e) => { Err(e) => {
app.exibir_erro(format!("Erro ao exportar PDF: {}", e)); app.exibir_erro(format!("Erro ao exportar PDF: {}", e));