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:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 (2–3h)
|
||||
**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 (4–6h)
|
||||
**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 (2–3h)
|
||||
**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 `100–104 (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 |
|
||||
@@ -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 1–4 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.
@@ -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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod layout;
|
||||
pub mod nota;
|
||||
pub mod resultado_analise;
|
||||
pub mod serie;
|
||||
pub mod chave_serie;
|
||||
|
||||
@@ -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 (1–3 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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
// 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));
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<_>>()
|
||||
|
||||
@@ -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
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user