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.
This commit is contained in:
FelipeCN
2026-03-03 16:41:32 -03:00
parent 2c96e99ccc
commit b9052e073f
21 changed files with 558 additions and 778 deletions
+8 -1
View File
@@ -1,7 +1,14 @@
{
"permissions": {
"allow": [
"Bash(cargo build)"
"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)"
]
}
}
-184
View File
@@ -1,184 +0,0 @@
# Análise — Duplicidade de Layouts ao Salvar
**Data:** 03/03/2026
**Status:** Análise / Proposta de Correção
---
## 1. Problema Identificado
Ao clicar em **"💾 Salvar"** na tela de gerenciamento de layouts, o sistema permite salvar um layout com um nome já existente, criando entradas duplicadas no banco sem qualquer aviso ou confirmação ao usuário.
O PRD (RF03) exige que, ao existir conflito de nome, o sistema pergunte ao usuário se deseja **sobrescrever** o existente ou **salvar com novo nome**. Esse comportamento está **implementado apenas no fluxo de importação JSON**, mas está **ausente no fluxo de salvar layout atual**.
---
## 2. Raiz do Problema — Análise por Camada
### 2.1 Camada Infrastructure — `sqlite/migrations.rs`
A tabela `layouts` foi criada na `migration_v1` **sem nenhuma constraint `UNIQUE` no campo `nome`**:
```sql
-- migration_v1 (src/infrastructure/sqlite/migrations.rs)
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, -- ← sem UNIQUE
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
...
);
```
O banco de dados não impede, por si só, a inserção de registros com o mesmo nome.
---
### 2.2 Camada Infrastructure — `sqlite/layout_repository.rs`
A função `salvar` executa um `INSERT` direto, sem qualquer verificação prévia:
```rust
// src/infrastructure/sqlite/layout_repository.rs — fn salvar()
conn.execute(
"INSERT INTO layouts (nome, tipo, ...) VALUES (?1, 'csv', ...)",
params![nome, ...],
)?;
```
Existe a função `existe_nome` no mesmo arquivo, que verifica duplicidade por consulta, mas ela **não é chamada** pelo fluxo de salvar layout atual — apenas pelo fluxo de importação JSON.
---
### 2.3 Camada Application — `usecases/layouts.rs`
A função `salvar_layout` só verifica se o `id` está presente:
```rust
// src/application/usecases/layouts.rs — fn salvar_layout()
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout) // atualiza se tem id
} else {
layout_repository::salvar(conn, layout) // insere direto sem checar nome
}
```
A verificação de nome duplicado (`existe_nome`) **está implementada em `importar_layout_json`**, mas **não foi replicada** em `salvar_layout`.
---
### 2.4 Camada UI — `ui/screens/layouts.rs`
A função `salvar_layout_atual` monta o layout sempre com `id: None` e chama `salvar_layout` sem tratar o caso de conflito de nome:
```rust
// src/ui/screens/layouts.rs — fn salvar_layout_atual()
let layout = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: None, // ← sempre None, nunca verifica se nome já existe
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_csv_atual.clone(),
},
...
};
match salvar_layout(conn, &layout) {
Ok(_) => { ... } // ← não há tratamento para conflito de nome
Err(e) => { ... }
}
```
O fluxo de resolução de conflito (sobrescrever / salvar com novo nome) **existe apenas em `importar_json`**, que trata `ErroLayout::NomeConflitante`, mas mesmo nesse caso o tratamento está incompleto (há um `TODO` no código).
---
## 3. Resumo dos Pontos Falhos
| # | Localização | Problema |
|---|-------------|----------|
| 1 | `sqlite/migrations.rs``migration_v1` | Nenhum `UNIQUE` constraint em `nome` na tabela `layouts` |
| 2 | `usecases/layouts.rs``salvar_layout` | Não consulta `existe_nome` antes de inserir |
| 3 | `domain/errors.rs` | Falta variante de erro para duplicidade no fluxo de salvar (apenas existe em importar JSON) |
| 4 | `ui/screens/layouts.rs``salvar_layout_atual` | Não trata conflito de nome; não exibe opção de sobrescrever ou renomear |
| 5 | `ui/screens/layouts.rs``importar_json` | Tratamento de `NomeConflitante` incompleto (TODO pendente) |
---
## 4. Proposta de Correção
### 4.1 Migration v2 — Adicionar `UNIQUE` em `nome`
**Arquivo:** `src/infrastructure/sqlite/migrations.rs`
Adicionar uma segunda migration que cria uma `UNIQUE` constraint na coluna `nome`.
Atualizar `VERSAO_SCHEMA_ATUAL` de `1` para `2` e chamar `migration_v2` no bloco condicional de aplicação de migrations.
```
-- migration_v2
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
```
> Usar um índice único em vez de recriar a tabela é mais seguro, pois preserva dados já existentes. Bancos que já possuem duplicatas precisariam de tratamento antes da migração (ex.: renomear automaticamente os conflitantes com sufixo numérico).
---
### 4.2 Retornar Erro Tipado de Conflito em `salvar_layout`
**Arquivo:** `src/application/usecases/layouts.rs` — função `salvar_layout`
Antes da inserção, verificar se já existe layout com o mesmo nome usando `layout_repository::existe_nome`. Retornar um erro tipado (ex.: `ErroLayout::NomeConflitante`) em vez de uma `String` quando o nome já existir e nenhum `id` foi fornecido.
Fluxo esperado:
```
1. layout.id() é None (novo layout)
2. Consultar existe_nome(conn, layout.nome())
3. Se existir → retornar Err(ErroLayout::NomeConflitante(nome))
4. Se não existir → prosseguir com INSERT
```
A assinatura da função deverá mudar de `Result<i64, String>` para `Result<i64, ErroLayout>` para que o chamador consiga distinguir o tipo de erro.
---
### 4.3 Tratar Conflito na UI — Fluxo de Sobrescrever ou Renomear
**Arquivo:** `src/ui/screens/layouts.rs` — função `salvar_layout_atual`
Ao receber `Err(ErroLayout::NomeConflitante(nome))`, a UI deve exibir um modal de confirmação com duas opções:
- **Sobrescrever** — buscar o `id` do layout existente pelo nome, montar o layout com esse `id` e chamar `salvar_layout` novamente (o use case irá para o branch `atualizar`)
- **Cancelar** — descartar a operação sem salvar
> A opção "Salvar com novo nome" pode ser considerada, porém aumenta a complexidade da tela. Para a correção mínima, sobrescrever ou cancelar já atende ao PRD.
O estado da `App` pode precisar de um campo intermediário para guardar o layout pendente de confirmação entre frames do egui, ou se o sistema já possui um mecanismo de `AcaoModal`, criar uma variante `AcaoModal::ConfirmarSobrescritaLayout(Layout)` e processar no handler de ações do modal.
---
### 4.4 Completar o `TODO` em `importar_json`
**Arquivo:** `src/ui/screens/layouts.rs` — função `importar_json`
O bloco `Err(ErroLayout::NomeConflitante(nome))` contém um `TODO` que não implementa o fluxo completo. Após a correção do item 4.3, a mesma lógica de modal de confirmação deve ser reutilizada aqui, chamando `importar_layout_json` com `sobrescrever_se_existir: true` quando o usuário confirmar.
---
## 5. Ordem de Implementação Sugerida
```
1. migration_v2 (constraint UNIQUE) ............ infrastructure/sqlite/migrations.rs
2. Erro tipado em salvar_layout ................. application/usecases/layouts.rs
3. Modal de conflito na UI (salvar atual) ....... ui/screens/layouts.rs
4. Modal de conflito na UI (importar JSON) ...... ui/screens/layouts.rs (completar TODO)
```
---
## 6. Impacto da Correção
| Aspecto | Antes | Depois |
|---------|-------|--------|
| Layouts duplicados no banco | Possível | Bloqueado pelo banco (UNIQUE) |
| Feedback ao usuário | Nenhum — salva silenciosamente | Modal com escolha: sobrescrever ou cancelar |
| Conformidade com PRD (RF03) | Parcial (apenas importação JSON) | Completa em todos os fluxos |
| Importação JSON com conflito | TODO incompleto | Resolvido com mesma lógica |
+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.
Binary file not shown.
Binary file not shown.
+52 -25
View File
@@ -1,5 +1,6 @@
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
@@ -15,30 +16,31 @@ use std::collections::HashMap;
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
// Agrupar por (serie, documento_tipo)
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie {
for (chave, notas_grupo) in &por_chave {
// Somar valores
for nota in notas_serie.iter() {
for nota in notas_grupo.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) {
intervalos_por_serie.insert(serie.clone(), intervalo);
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(chave.clone(), intervalo);
}
}
@@ -53,29 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
}
}
/// Verifica se alguma série excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(String, u64)> {
/// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves que precisam de confirmação.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
.collect()
}
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie {
let faltantes = detectar_faltantes(notas_serie);
faltantes_por_serie.insert(serie.clone(), faltantes);
for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(chave.clone(), faltantes);
}
ResultadoAnalise {
@@ -100,7 +103,11 @@ mod tests {
}
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]
@@ -111,7 +118,8 @@ mod tests {
nota(4, "001", Some(dec!(50.00))),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]);
let chave = ChaveSerie::new("001".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]);
assert_eq!(resultado.soma_total, dec!(350.00));
}
@@ -124,8 +132,10 @@ mod tests {
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
assert!(resultado.faltantes_por_serie["002"].is_empty());
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
}
#[test]
@@ -134,6 +144,23 @@ mod tests {
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, "001");
assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
}
#[test]
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
// NFE série 001: 1, 3 → faltante 2
// NFCE série 001: 2, 5 → faltante 3 e 4
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(3, "001", "NFE"),
nota_com_tipo(2, "001", "NFCE"),
nota_com_tipo(5, "001", "NFCE"),
];
let resultado = executar_analise(&notas);
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
}
}
+19 -1
View File
@@ -40,6 +40,7 @@ pub fn importar_csv(
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_documento_tipo,
config.indice_valor,
config.indice_data,
&mut avisos,
@@ -86,12 +87,18 @@ pub fn importar_xlsx(
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_documento_tipo = config
.pos_documento_tipo
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
col_documento_tipo,
col_valor,
col_data,
&mut avisos,
@@ -106,6 +113,7 @@ fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
@@ -195,7 +203,17 @@ fn mapear_linhas_para_notas(
None
};
notas.push(Nota::new(numero, serie, valor, data));
// Extrair tipo de documento (opcional) — qualquer string não vazia
let documento_tipo = if let Some(idx) = idx_documento_tipo {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
}
} else {
None
};
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
}
notas
+26
View File
@@ -0,0 +1,26 @@
/// Chave composta que identifica um grupo de notas fiscais.
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
/// somente por série (retrocompatível).
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
impl ChaveSerie {
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
Self {
serie,
documento_tipo,
}
}
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
pub fn label(&self) -> String {
match &self.documento_tipo {
Some(tipo) => format!("{} / {}", self.serie, tipo),
None => self.serie.clone(),
}
}
}
+16
View File
@@ -34,6 +34,8 @@ pub struct LayoutCsv {
pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
}
impl Default for LayoutCsv {
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
indice_serie: 1,
indice_valor: None,
indice_data: None,
indice_documento_tipo: None,
}
}
}
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
}
impl Default for LayoutXlsx {
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
pos_serie: String::new(),
pos_valor: None,
pos_data: None,
pos_documento_tipo: None,
}
}
}
@@ -129,6 +135,8 @@ pub enum LayoutJson {
indice_serie: usize,
indice_valor: Option<usize>,
indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
},
Xlsx {
nome: String,
@@ -137,6 +145,8 @@ pub enum LayoutJson {
pos_serie: String,
pos_valor: Option<String>,
pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
},
}
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
},
})
}
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
},
})
}
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
indice_serie: config.indice_serie,
indice_valor: config.indice_valor,
indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
},
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(),
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
},
}
}
+1
View File
@@ -2,3 +2,4 @@ pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
pub mod chave_serie;
+5 -1
View File
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie` é o identificador único de cada nota.
/// `numero + serie + documento_tipo` é o identificador único de cada nota.
#[derive(Debug, Clone)]
pub struct Nota {
/// Número incremental da nota. Armazenado como u64.
pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1".
pub serie: String,
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
pub documento_tipo: Option<String>,
/// Valor monetário da nota (opcional).
pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
@@ -19,12 +21,14 @@ impl Nota {
pub fn new(
numero: u64,
serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
documento_tipo,
valor,
data,
}
+15 -14
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal;
use std::collections::HashMap;
@@ -5,15 +6,15 @@ use std::collections::HashMap;
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)]
pub struct ResultadoPreAnalise {
/// Mapa série → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
/// Intervalo de sequência de uma série.
@@ -33,16 +34,16 @@ impl IntervaloSerie {
/// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)]
pub struct ResultadoAnalise {
/// Números faltantes por série (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por série
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
/// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
impl ResultadoAnalise {
+38 -19
View File
@@ -1,16 +1,17 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas.
///
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.clone()))
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1;
}
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
contagem
}
/// Agrupa as duplicidades por série.
/// Agrupa as duplicidades por (serie, documento_tipo).
///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas);
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie), contagem) in raw {
result.entry(serie).or_default().push((numero, contagem));
for ((numero, serie, documento_tipo), contagem) in raw {
let chave = ChaveSerie::new(serie, documento_tipo);
result.entry(chave).or_default().push((numero, contagem));
}
// Ordenar por numero dentro de cada série
// Ordenar por numero dentro de cada grupo
for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num);
}
@@ -44,7 +46,11 @@ mod tests {
use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota {
Nota::new(numero, serie.to_string(), None, None)
Nota::new(numero, serie.to_string(), None, None, None)
}
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
}
#[test]
@@ -58,7 +64,7 @@ mod tests {
fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2));
assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1);
}
@@ -66,17 +72,30 @@ mod tests {
fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3));
assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
}
#[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
// Número 1 em séries diferentes não é duplicata
let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
}
#[test]
fn agrupamento_por_serie() {
let notas = vec![
@@ -88,13 +107,13 @@ mod tests {
nota(2, "001"),
];
let por_serie = duplicidades_por_serie(&notas);
// Série 001 deve ter notas 1 e 2 duplicadas
let serie001 = por_serie.get("001").unwrap();
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada
let serie002 = por_serie.get("002").unwrap();
let serie002 = por_serie.get(&chave002).unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
+50 -1
View File
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
faltantes
}
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
///
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
///
/// # Exemplo
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
if faltantes.is_empty() {
return Vec::new();
}
let mut grupos = Vec::new();
let mut inicio = faltantes[0];
let mut anterior = faltantes[0];
for &num in &faltantes[1..] {
if num != anterior + 1 {
grupos.push((inicio, anterior));
inicio = num;
}
anterior = num;
}
grupos.push((inicio, anterior));
grupos
}
#[cfg(test)]
mod tests {
use super::*;
@@ -74,7 +100,7 @@ mod tests {
use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None)
Nota::new(numero, "001".to_string(), None, None, None)
}
#[test]
@@ -136,4 +162,27 @@ mod tests {
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn agrupar_vazio() {
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
}
#[test]
fn agrupar_isolados() {
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
}
#[test]
fn agrupar_um_intervalo() {
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
}
#[test]
fn agrupar_misto() {
assert_eq!(
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
vec![(1, 3), (5, 5), (8, 9)]
);
}
}
+47 -52
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
@@ -7,6 +8,14 @@ use genpdf::{
};
use std::path::Path;
// Fontes embutidas no binário em tempo de compilação.
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
const FONT_REGULAR: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
/// Metadados do relatório.
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
// Carregar fonte do sistema (DejaVu Sans)
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
)));
doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais
doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
formatar_valor_br(&resultado.soma_total)
)));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
series_ordenadas.sort();
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
for chave in &chaves_ordenadas {
let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total,
formatar_valor_br(soma)
)));
@@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
// Use faltantes keys for this section (may differ from soma keys if no values)
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves_faltantes.sort();
for chave in &chaves_faltantes {
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
Some(f) if !f.is_empty() => f,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
serie,
chave.label(),
faltantes.len()
)));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
@@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator {
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
);
for serie in &series_ordenadas {
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
Some(d) if !d.is_empty() => d,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
serie,
chave.label(),
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, serie, count
numero, chave.label(), count
)));
}
}
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
}
}
/// Tenta carregar fontes DejaVu Sans do sistema.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
// Caminhos comuns no Linux, Windows e macOS
let candidatos_regular = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/Library/Fonts/Arial.ttf",
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
// italic e bold_italic reusam os dados de regular/bold respectivamente.
Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(),
bold_italic: regular,
bold_italic: bold.clone(),
regular,
bold,
})
}
+20 -9
View File
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
indice_numero, indice_serie, indice_valor, indice_data,
indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
config.delimitador.to_string(),
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
],
)?;
Ok(conn.last_insert_rowid())
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![
nome,
config.aba,
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
],
)?;
Ok(conn.last_insert_rowid())
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8
WHERE id = ?9",
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
WHERE id = ?10",
params![
nome,
config.delimitador.to_string(),
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id,
],
)?;
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6
WHERE id = ?7",
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
WHERE id = ?8",
params![
nome,
config.aba,
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
id,
],
)?;
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC",
)?;
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
indice_data: row
.get::<_, Option<i64>>(9)?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>(15)?
.map(|v| v as usize),
},
})
} else {
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
},
})
}
+14 -1
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 2;
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
@@ -27,6 +27,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
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])?;
@@ -38,6 +41,16 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
}
/// 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<()> {
+6 -5
View File
@@ -4,6 +4,7 @@ use crate::application::usecases::{
};
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
@@ -82,7 +83,7 @@ pub enum ResultadoPendente {
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
series_excessivas: Vec<(ChaveSerie, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
@@ -464,10 +465,10 @@ impl App {
};
let msg = series_excessivas
.iter()
.map(|(serie, count)| {
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
chave.label(), count
)
})
.collect::<Vec<_>>()
@@ -506,10 +507,10 @@ impl App {
if !excessivos.is_empty() {
let msg = excessivos
.iter()
.map(|(serie, count)| {
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
chave.label(), count
)
})
.collect::<Vec<_>>()
+13
View File
@@ -233,6 +233,11 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
campo_indice_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_csv_atual.indice_documento_tipo,
);
});
}
@@ -281,6 +286,11 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
&mut app.layout_xlsx_atual.pos_valor,
);
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
campo_letra_linha_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_xlsx_atual.pos_documento_tipo,
);
});
}
@@ -340,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d));
}
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros);
}
TipoArquivo::Xlsx => {
+62 -36
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{
entities::resultado_analise::ResultadoAnalise,
services::parser_monetario::formatar_valor_br,
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
};
use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{AcaoModal, App, EstadoApp};
@@ -86,15 +86,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
formatar_valor_br(&resultado.soma_total)
));
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.sort();
for serie in series {
let soma = &resultado.soma_por_serie[serie];
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
for chave in chaves {
let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
chave.label(),
total_notas,
formatar_valor_br(soma)
));
@@ -104,12 +104,9 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes();
ui.label(
egui::RichText::new(format!(
"Notas Faltantes ({} total)",
total_faltantes
))
.heading()
.strong(),
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
.heading()
.strong(),
);
ui.add_space(4.0);
@@ -118,22 +115,36 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
return;
}
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for serie in series {
let faltantes = &resultado.faltantes_por_serie[serie];
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
if faltantes.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} faltante(s):",
serie,
faltantes.len()
));
// Estatística de completude por série
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
// Paginação
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} faltante(s) — {}/{} notas ({:.1}% completo):",
chave.label(),
faltantes.len(),
total_notas,
total_esperado,
percentual,
));
if ui.button("📋 Copiar").on_hover_text("Copiar todos os números faltantes").clicked() {
let texto = faltantes.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
ui.ctx().copy_text(texto);
}
});
// Paginação (por faltante individual)
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas {
app.pagina_faltantes = 0;
@@ -142,8 +153,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
let inicio = app.pagina_faltantes * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
for numero in &faltantes[inicio..fim] {
ui.label(format!("{}", numero));
// Exibir grupos contíguos da página atual
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
if a == b {
ui.label(format!("{}", a));
} else {
ui.label(format!("{}{} ({} notas)", a, b, b - a + 1));
}
}
if total_paginas > 1 {
@@ -181,20 +197,30 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
return;
}
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
series.sort();
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves.sort();
for serie in series {
let duplicatas = &resultado.duplicadas_por_serie[serie];
for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
serie,
duplicatas.len()
));
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
chave.label(),
duplicatas.len()
));
if ui.button("📋 Copiar").on_hover_text("Copiar números duplicados").clicked() {
let texto = duplicatas
.iter()
.map(|(n, c)| format!("{} ({}x)", n, c))
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
let total_paginas =
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
@@ -208,7 +234,7 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!(
" • NF {} / Série {}{} ocorrências",
numero, serie, count
numero, chave.label(), count
));
}