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": {
|
"permissions": {
|
||||||
"allow": [
|
"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::{
|
use crate::domain::{
|
||||||
entities::{
|
entities::{
|
||||||
|
chave_serie::ChaveSerie,
|
||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
},
|
},
|
||||||
@@ -15,30 +16,31 @@ use std::collections::HashMap;
|
|||||||
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
|
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
|
||||||
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
|
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
|
||||||
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
||||||
// Agrupar por série
|
// Agrupar por (serie, documento_tipo)
|
||||||
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
|
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
por_serie.entry(nota.serie.clone()).or_default().push(nota);
|
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
|
||||||
|
por_chave.entry(chave).or_default().push(nota);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut intervalos_por_serie = HashMap::new();
|
let mut intervalos_por_serie = HashMap::new();
|
||||||
let mut soma_total = Decimal::ZERO;
|
let mut soma_total = Decimal::ZERO;
|
||||||
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
|
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
|
||||||
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
|
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
|
||||||
|
|
||||||
for (serie, notas_serie) in &por_serie {
|
for (chave, notas_grupo) in &por_chave {
|
||||||
// Somar valores
|
// Somar valores
|
||||||
for nota in notas_serie.iter() {
|
for nota in notas_grupo.iter() {
|
||||||
if let Some(v) = nota.valor {
|
if let Some(v) = nota.valor {
|
||||||
soma_total += v;
|
soma_total += v;
|
||||||
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
|
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
|
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
|
||||||
|
|
||||||
// Calcular intervalo de faltantes
|
// Calcular intervalo de faltantes
|
||||||
if let Some(intervalo) = calcular_intervalo(notas_serie) {
|
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
|
||||||
intervalos_por_serie.insert(serie.clone(), intervalo);
|
intervalos_por_serie.insert(chave.clone(), intervalo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,29 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica se alguma série excede o limite de faltantes.
|
/// Verifica se algum grupo excede o limite de faltantes.
|
||||||
/// Retorna lista de séries que precisam de confirmação.
|
/// Retorna lista de chaves que precisam de confirmação.
|
||||||
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(String, u64)> {
|
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
|
||||||
pre.intervalos_por_serie
|
pre.intervalos_por_serie
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||||
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
|
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
|
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
|
||||||
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
|
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
|
||||||
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
|
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
|
||||||
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
|
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
por_serie.entry(nota.serie.clone()).or_default().push(nota);
|
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
|
||||||
|
por_chave.entry(chave).or_default().push(nota);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut faltantes_por_serie = HashMap::new();
|
let mut faltantes_por_serie = HashMap::new();
|
||||||
|
|
||||||
for (serie, notas_serie) in &por_serie {
|
for (chave, notas_grupo) in &por_chave {
|
||||||
let faltantes = detectar_faltantes(notas_serie);
|
let faltantes = detectar_faltantes(notas_grupo);
|
||||||
faltantes_por_serie.insert(serie.clone(), faltantes);
|
faltantes_por_serie.insert(chave.clone(), faltantes);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResultadoAnalise {
|
ResultadoAnalise {
|
||||||
@@ -100,7 +103,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
|
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), valor, None)
|
Nota::new(numero, serie.to_string(), None, valor, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
|
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -111,7 +118,8 @@ mod tests {
|
|||||||
nota(4, "001", Some(dec!(50.00))),
|
nota(4, "001", Some(dec!(50.00))),
|
||||||
];
|
];
|
||||||
let resultado = executar_analise(¬as);
|
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));
|
assert_eq!(resultado.soma_total, dec!(350.00));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,8 +132,10 @@ mod tests {
|
|||||||
nota(2, "002", None),
|
nota(2, "002", None),
|
||||||
];
|
];
|
||||||
let resultado = executar_analise(¬as);
|
let resultado = executar_analise(¬as);
|
||||||
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
|
let chave001 = ChaveSerie::new("001".to_string(), None);
|
||||||
assert!(resultado.faltantes_por_serie["002"].is_empty());
|
let chave002 = ChaveSerie::new("002".to_string(), None);
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
|
||||||
|
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -134,6 +144,23 @@ mod tests {
|
|||||||
let pre = pre_analisar(¬as);
|
let pre = pre_analisar(¬as);
|
||||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
assert_eq!(excessivos.len(), 1);
|
assert_eq!(excessivos.len(), 1);
|
||||||
assert_eq!(excessivos[0].0, "001");
|
assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
|
||||||
|
// NFE série 001: 1, 3 → faltante 2
|
||||||
|
// NFCE série 001: 2, 5 → faltante 3 e 4
|
||||||
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(3, "001", "NFE"),
|
||||||
|
nota_com_tipo(2, "001", "NFCE"),
|
||||||
|
nota_com_tipo(5, "001", "NFCE"),
|
||||||
|
];
|
||||||
|
let resultado = executar_analise(¬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,
|
&resultado.linhas,
|
||||||
config.indice_numero,
|
config.indice_numero,
|
||||||
config.indice_serie,
|
config.indice_serie,
|
||||||
|
config.indice_documento_tipo,
|
||||||
config.indice_valor,
|
config.indice_valor,
|
||||||
config.indice_data,
|
config.indice_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
@@ -86,12 +87,18 @@ pub fn importar_xlsx(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
||||||
.map(|c| c.coluna as usize);
|
.map(|c| c.coluna as usize);
|
||||||
|
let col_documento_tipo = config
|
||||||
|
.pos_documento_tipo
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
||||||
|
.map(|c| c.coluna as usize);
|
||||||
|
|
||||||
let mut avisos = resultado.avisos;
|
let mut avisos = resultado.avisos;
|
||||||
let notas = mapear_linhas_para_notas(
|
let notas = mapear_linhas_para_notas(
|
||||||
&resultado.linhas,
|
&resultado.linhas,
|
||||||
col_numero,
|
col_numero,
|
||||||
col_serie,
|
col_serie,
|
||||||
|
col_documento_tipo,
|
||||||
col_valor,
|
col_valor,
|
||||||
col_data,
|
col_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
@@ -106,6 +113,7 @@ fn mapear_linhas_para_notas(
|
|||||||
linhas: &[Vec<String>],
|
linhas: &[Vec<String>],
|
||||||
idx_numero: usize,
|
idx_numero: usize,
|
||||||
idx_serie: usize,
|
idx_serie: usize,
|
||||||
|
idx_documento_tipo: Option<usize>,
|
||||||
idx_valor: Option<usize>,
|
idx_valor: Option<usize>,
|
||||||
idx_data: Option<usize>,
|
idx_data: Option<usize>,
|
||||||
avisos: &mut ResumoAvisos,
|
avisos: &mut ResumoAvisos,
|
||||||
@@ -195,7 +203,17 @@ fn mapear_linhas_para_notas(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
notas.push(Nota::new(numero, serie, valor, data));
|
// Extrair tipo de documento (opcional) — qualquer string não vazia
|
||||||
|
let documento_tipo = if let Some(idx) = idx_documento_tipo {
|
||||||
|
match linha.get(idx) {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
notas
|
notas
|
||||||
|
|||||||
@@ -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>,
|
pub indice_valor: Option<usize>,
|
||||||
/// Índice da coluna Data (base 0, None se não mapeado)
|
/// Índice da coluna Data (base 0, None se não mapeado)
|
||||||
pub indice_data: Option<usize>,
|
pub indice_data: Option<usize>,
|
||||||
|
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
|
||||||
|
pub indice_documento_tipo: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LayoutCsv {
|
impl Default for LayoutCsv {
|
||||||
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
|
|||||||
indice_serie: 1,
|
indice_serie: 1,
|
||||||
indice_valor: None,
|
indice_valor: None,
|
||||||
indice_data: None,
|
indice_data: None,
|
||||||
|
indice_documento_tipo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
|
|||||||
pub pos_valor: Option<String>,
|
pub pos_valor: Option<String>,
|
||||||
/// Posição inicial da coluna Data (None se não mapeado)
|
/// Posição inicial da coluna Data (None se não mapeado)
|
||||||
pub pos_data: Option<String>,
|
pub pos_data: Option<String>,
|
||||||
|
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
|
||||||
|
pub pos_documento_tipo: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LayoutXlsx {
|
impl Default for LayoutXlsx {
|
||||||
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
|
|||||||
pos_serie: String::new(),
|
pos_serie: String::new(),
|
||||||
pos_valor: None,
|
pos_valor: None,
|
||||||
pos_data: None,
|
pos_data: None,
|
||||||
|
pos_documento_tipo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +135,8 @@ pub enum LayoutJson {
|
|||||||
indice_serie: usize,
|
indice_serie: usize,
|
||||||
indice_valor: Option<usize>,
|
indice_valor: Option<usize>,
|
||||||
indice_data: Option<usize>,
|
indice_data: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
indice_documento_tipo: Option<usize>,
|
||||||
},
|
},
|
||||||
Xlsx {
|
Xlsx {
|
||||||
nome: String,
|
nome: String,
|
||||||
@@ -137,6 +145,8 @@ pub enum LayoutJson {
|
|||||||
pos_serie: String,
|
pos_serie: String,
|
||||||
pos_valor: Option<String>,
|
pos_valor: Option<String>,
|
||||||
pos_data: Option<String>,
|
pos_data: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pos_documento_tipo: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
indice_serie,
|
indice_serie,
|
||||||
indice_valor,
|
indice_valor,
|
||||||
indice_data,
|
indice_data,
|
||||||
|
indice_documento_tipo,
|
||||||
} => {
|
} => {
|
||||||
if nome.trim().is_empty() {
|
if nome.trim().is_empty() {
|
||||||
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
indice_serie,
|
indice_serie,
|
||||||
indice_valor,
|
indice_valor,
|
||||||
indice_data,
|
indice_data,
|
||||||
|
indice_documento_tipo,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
pos_serie,
|
pos_serie,
|
||||||
pos_valor,
|
pos_valor,
|
||||||
pos_data,
|
pos_data,
|
||||||
|
pos_documento_tipo,
|
||||||
} => {
|
} => {
|
||||||
if nome.trim().is_empty() {
|
if nome.trim().is_empty() {
|
||||||
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
pos_serie,
|
pos_serie,
|
||||||
pos_valor,
|
pos_valor,
|
||||||
pos_data,
|
pos_data,
|
||||||
|
pos_documento_tipo,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
|
|||||||
indice_serie: config.indice_serie,
|
indice_serie: config.indice_serie,
|
||||||
indice_valor: config.indice_valor,
|
indice_valor: config.indice_valor,
|
||||||
indice_data: config.indice_data,
|
indice_data: config.indice_data,
|
||||||
|
indice_documento_tipo: config.indice_documento_tipo,
|
||||||
},
|
},
|
||||||
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
|
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
|
||||||
nome: nome.clone(),
|
nome: nome.clone(),
|
||||||
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
|
|||||||
pos_serie: config.pos_serie.clone(),
|
pos_serie: config.pos_serie.clone(),
|
||||||
pos_valor: config.pos_valor.clone(),
|
pos_valor: config.pos_valor.clone(),
|
||||||
pos_data: config.pos_data.clone(),
|
pos_data: config.pos_data.clone(),
|
||||||
|
pos_documento_tipo: config.pos_documento_tipo.clone(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod layout;
|
|||||||
pub mod nota;
|
pub mod nota;
|
||||||
pub mod resultado_analise;
|
pub mod resultado_analise;
|
||||||
pub mod serie;
|
pub mod serie;
|
||||||
|
pub mod chave_serie;
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
|
|||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
/// Representa uma nota fiscal com seus campos lógicos.
|
/// Representa uma nota fiscal com seus campos lógicos.
|
||||||
/// `numero + serie` é o identificador único de cada nota.
|
/// `numero + serie + documento_tipo` é o identificador único de cada nota.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Nota {
|
pub struct Nota {
|
||||||
/// Número incremental da nota. Armazenado como u64.
|
/// Número incremental da nota. Armazenado como u64.
|
||||||
pub numero: u64,
|
pub numero: u64,
|
||||||
/// Série da nota (1–3 dígitos numéricos). Ex: "001", "1".
|
/// Série da nota (1–3 dígitos numéricos). Ex: "001", "1".
|
||||||
pub serie: String,
|
pub serie: String,
|
||||||
|
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
|
||||||
|
pub documento_tipo: Option<String>,
|
||||||
/// Valor monetário da nota (opcional).
|
/// Valor monetário da nota (opcional).
|
||||||
pub valor: Option<Decimal>,
|
pub valor: Option<Decimal>,
|
||||||
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
|
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
|
||||||
@@ -19,12 +21,14 @@ impl Nota {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
numero: u64,
|
numero: u64,
|
||||||
serie: String,
|
serie: String,
|
||||||
|
documento_tipo: Option<String>,
|
||||||
valor: Option<Decimal>,
|
valor: Option<Decimal>,
|
||||||
data: Option<NaiveDate>,
|
data: Option<NaiveDate>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
numero,
|
numero,
|
||||||
serie,
|
serie,
|
||||||
|
documento_tipo,
|
||||||
valor,
|
valor,
|
||||||
data,
|
data,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -5,15 +6,15 @@ use std::collections::HashMap;
|
|||||||
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
|
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResultadoPreAnalise {
|
pub struct ResultadoPreAnalise {
|
||||||
/// Mapa série → (minimo, maximo, contagem_faltantes)
|
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
|
||||||
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
|
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
|
||||||
/// Duplicatas já processadas (não dependem dos faltantes)
|
/// Duplicatas já processadas (não dependem dos faltantes)
|
||||||
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||||
/// Somas já calculadas
|
/// Somas já calculadas
|
||||||
pub soma_total: Decimal,
|
pub soma_total: Decimal,
|
||||||
pub soma_por_serie: HashMap<String, Decimal>,
|
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||||
/// Total de notas processadas por série
|
/// Total de notas processadas por (serie, documento_tipo)
|
||||||
pub total_por_serie: HashMap<String, usize>,
|
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Intervalo de sequência de uma série.
|
/// Intervalo de sequência de uma série.
|
||||||
@@ -33,16 +34,16 @@ impl IntervaloSerie {
|
|||||||
/// Resultado completo da análise, com a lista materializada de faltantes.
|
/// Resultado completo da análise, com a lista materializada de faltantes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResultadoAnalise {
|
pub struct ResultadoAnalise {
|
||||||
/// Números faltantes por série (ordenados crescentemente)
|
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
|
||||||
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
|
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
|
||||||
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
|
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
|
||||||
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||||
/// Soma total de todos os valores
|
/// Soma total de todos os valores
|
||||||
pub soma_total: Decimal,
|
pub soma_total: Decimal,
|
||||||
/// Soma por série
|
/// Soma por (serie, documento_tipo)
|
||||||
pub soma_por_serie: HashMap<String, Decimal>,
|
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||||
/// Total de notas processadas por série
|
/// Total de notas processadas por (serie, documento_tipo)
|
||||||
pub total_por_serie: HashMap<String, usize>,
|
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResultadoAnalise {
|
impl ResultadoAnalise {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Detecta registros duplicados em uma lista de notas.
|
/// Detecta registros duplicados em uma lista de notas.
|
||||||
///
|
///
|
||||||
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
|
/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
|
||||||
/// contendo apenas grupos com mais de uma ocorrência.
|
/// contendo apenas grupos com mais de uma ocorrência.
|
||||||
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
|
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
|
||||||
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
|
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
|
||||||
|
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
*contagem
|
*contagem
|
||||||
.entry((nota.numero, nota.serie.clone()))
|
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
|
||||||
.or_insert(0) += 1;
|
.or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
|
|||||||
contagem
|
contagem
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agrupa as duplicidades por série.
|
/// Agrupa as duplicidades por (serie, documento_tipo).
|
||||||
///
|
///
|
||||||
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
|
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
|
||||||
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
|
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
|
||||||
let raw = detectar_duplicidades(notas);
|
let raw = detectar_duplicidades(notas);
|
||||||
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
|
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
|
||||||
|
|
||||||
for ((numero, serie), contagem) in raw {
|
for ((numero, serie, documento_tipo), contagem) in raw {
|
||||||
result.entry(serie).or_default().push((numero, contagem));
|
let chave = ChaveSerie::new(serie, documento_tipo);
|
||||||
|
result.entry(chave).or_default().push((numero, contagem));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordenar por numero dentro de cada série
|
// Ordenar por numero dentro de cada grupo
|
||||||
for lista in result.values_mut() {
|
for lista in result.values_mut() {
|
||||||
lista.sort_by_key(|(num, _)| *num);
|
lista.sort_by_key(|(num, _)| *num);
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,11 @@ mod tests {
|
|||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
|
|
||||||
fn nota(numero: u64, serie: &str) -> Nota {
|
fn nota(numero: u64, serie: &str) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), None, None)
|
Nota::new(numero, serie.to_string(), None, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
|
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -58,7 +64,7 @@ mod tests {
|
|||||||
fn com_duplicata_simples() {
|
fn com_duplicata_simples() {
|
||||||
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
|
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
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);
|
assert_eq!(dup.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +72,30 @@ mod tests {
|
|||||||
fn duplicata_multiplas_ocorrencias() {
|
fn duplicata_multiplas_ocorrencias() {
|
||||||
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
|
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
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]
|
#[test]
|
||||||
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
|
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
|
||||||
// Número 1 em séries diferentes não é duplicata
|
|
||||||
let notas = vec![nota(1, "001"), nota(1, "002")];
|
let notas = vec![nota(1, "001"), nota(1, "002")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert!(dup.is_empty());
|
assert!(dup.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
||||||
|
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
|
||||||
|
let dup = detectar_duplicidades(¬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]
|
#[test]
|
||||||
fn agrupamento_por_serie() {
|
fn agrupamento_por_serie() {
|
||||||
let notas = vec![
|
let notas = vec![
|
||||||
@@ -88,13 +107,13 @@ mod tests {
|
|||||||
nota(2, "001"),
|
nota(2, "001"),
|
||||||
];
|
];
|
||||||
let por_serie = duplicidades_por_serie(¬as);
|
let por_serie = duplicidades_por_serie(¬as);
|
||||||
// Série 001 deve ter notas 1 e 2 duplicadas
|
let chave001 = ChaveSerie::new("001".to_string(), None);
|
||||||
let serie001 = por_serie.get("001").unwrap();
|
let chave002 = ChaveSerie::new("002".to_string(), None);
|
||||||
|
let serie001 = por_serie.get(&chave001).unwrap();
|
||||||
assert_eq!(serie001.len(), 2);
|
assert_eq!(serie001.len(), 2);
|
||||||
assert_eq!(serie001[0], (1, 2));
|
assert_eq!(serie001[0], (1, 2));
|
||||||
assert_eq!(serie001[1], (2, 2));
|
assert_eq!(serie001[1], (2, 2));
|
||||||
// Série 002 deve ter nota 1 duplicada
|
let serie002 = por_serie.get(&chave002).unwrap();
|
||||||
let serie002 = por_serie.get("002").unwrap();
|
|
||||||
assert_eq!(serie002.len(), 1);
|
assert_eq!(serie002.len(), 1);
|
||||||
assert_eq!(serie002[0], (1, 2));
|
assert_eq!(serie002[0], (1, 2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
|
|||||||
faltantes
|
faltantes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
|
||||||
|
///
|
||||||
|
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
|
||||||
|
///
|
||||||
|
/// # Exemplo
|
||||||
|
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
|
||||||
|
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
|
||||||
|
if faltantes.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut grupos = Vec::new();
|
||||||
|
let mut inicio = faltantes[0];
|
||||||
|
let mut anterior = faltantes[0];
|
||||||
|
|
||||||
|
for &num in &faltantes[1..] {
|
||||||
|
if num != anterior + 1 {
|
||||||
|
grupos.push((inicio, anterior));
|
||||||
|
inicio = num;
|
||||||
|
}
|
||||||
|
anterior = num;
|
||||||
|
}
|
||||||
|
grupos.push((inicio, anterior));
|
||||||
|
grupos
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -74,7 +100,7 @@ mod tests {
|
|||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
fn nota(numero: u64) -> Nota {
|
fn nota(numero: u64) -> Nota {
|
||||||
Nota::new(numero, "001".to_string(), None, None)
|
Nota::new(numero, "001".to_string(), None, None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -136,4 +162,27 @@ mod tests {
|
|||||||
let refs: Vec<&Nota> = notas.iter().collect();
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_vazio() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_isolados() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_um_intervalo() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_misto() {
|
||||||
|
assert_eq!(
|
||||||
|
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
|
||||||
|
vec![(1, 3), (5, 5), (8, 9)]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||||
use crate::domain::services::parser_monetario::formatar_valor_br;
|
use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
@@ -7,6 +8,14 @@ use genpdf::{
|
|||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
// Fontes embutidas no binário em tempo de compilação.
|
||||||
|
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
|
||||||
|
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
|
||||||
|
const FONT_REGULAR: &[u8] =
|
||||||
|
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||||
|
const FONT_BOLD: &[u8] =
|
||||||
|
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||||
|
|
||||||
/// Metadados do relatório.
|
/// Metadados do relatório.
|
||||||
pub struct MetadadosRelatorio {
|
pub struct MetadadosRelatorio {
|
||||||
pub nome_arquivo: String,
|
pub nome_arquivo: String,
|
||||||
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
meta: &MetadadosRelatorio,
|
meta: &MetadadosRelatorio,
|
||||||
caminho_saida: &Path,
|
caminho_saida: &Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Carregar fonte do sistema (DejaVu Sans)
|
let font_family = carregar_fonte_familia()?;
|
||||||
let font_family = carregar_fonte_sistema()
|
|
||||||
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
|
|
||||||
|
|
||||||
let mut doc = Document::new(font_family);
|
let mut doc = Document::new(font_family);
|
||||||
doc.set_title("Relatório — Comparador de Notas");
|
doc.set_title("Relatório — Comparador de Notas");
|
||||||
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
)));
|
)));
|
||||||
doc.push(Break::new(1));
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Ordenar chaves
|
||||||
|
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||||
|
chaves_ordenadas.sort();
|
||||||
|
|
||||||
// Totais
|
// Totais
|
||||||
doc.push(
|
doc.push(
|
||||||
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||||
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
formatar_valor_br(&resultado.soma_total)
|
formatar_valor_br(&resultado.soma_total)
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
for chave in &chaves_ordenadas {
|
||||||
series_ordenadas.sort();
|
let soma = &resultado.soma_por_serie[*chave];
|
||||||
|
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
|
||||||
for serie in &series_ordenadas {
|
|
||||||
let soma = &resultado.soma_por_serie[*serie];
|
|
||||||
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} nota(s) — R$ {}",
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
serie,
|
chave.label(),
|
||||||
total,
|
total,
|
||||||
formatar_valor_br(soma)
|
formatar_valor_br(soma)
|
||||||
)));
|
)));
|
||||||
@@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
);
|
);
|
||||||
|
|
||||||
for serie in &series_ordenadas {
|
// Use faltantes keys for this section (may differ from soma keys if no values)
|
||||||
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
|
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
|
chaves_faltantes.sort();
|
||||||
|
|
||||||
|
for chave in &chaves_faltantes {
|
||||||
|
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
|
||||||
Some(f) if !f.is_empty() => f,
|
Some(f) if !f.is_empty() => f,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} faltante(s)",
|
" Série {}: {} faltante(s)",
|
||||||
serie,
|
chave.label(),
|
||||||
faltantes.len()
|
faltantes.len()
|
||||||
)));
|
)));
|
||||||
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||||
@@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
);
|
);
|
||||||
|
|
||||||
for serie in &series_ordenadas {
|
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
|
chaves_dup.sort();
|
||||||
|
|
||||||
|
for chave in &chaves_dup {
|
||||||
|
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
|
||||||
Some(d) if !d.is_empty() => d,
|
Some(d) if !d.is_empty() => d,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} grupo(s) duplicado(s)",
|
" Série {}: {} grupo(s) duplicado(s)",
|
||||||
serie,
|
chave.label(),
|
||||||
duplicatas.len()
|
duplicatas.len()
|
||||||
)));
|
)));
|
||||||
for (numero, count) in duplicatas {
|
for (numero, count) in duplicatas {
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" NF {} / Série {} — {} ocorrências",
|
" NF {} / Série {} — {} ocorrências",
|
||||||
numero, serie, count
|
numero, chave.label(), count
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenta carregar fontes DejaVu Sans do sistema.
|
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
|
||||||
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
|
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
|
||||||
// Caminhos comuns no Linux, Windows e macOS
|
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
|
||||||
let candidatos_regular = [
|
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
|
||||||
"C:\\Windows\\Fonts\\arial.ttf",
|
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
|
||||||
"/Library/Fonts/Arial.ttf",
|
.map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
|
||||||
];
|
|
||||||
let candidatos_bold = [
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
||||||
"C:\\Windows\\Fonts\\arialbd.ttf",
|
|
||||||
"/Library/Fonts/Arial Bold.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
let regular_path = candidatos_regular
|
|
||||||
.iter()
|
|
||||||
.find(|p| std::path::Path::new(p).exists())
|
|
||||||
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
|
|
||||||
|
|
||||||
let bold_path = candidatos_bold
|
|
||||||
.iter()
|
|
||||||
.find(|p| std::path::Path::new(p).exists())
|
|
||||||
.unwrap_or(regular_path);
|
|
||||||
|
|
||||||
let regular = fonts::FontData::load(regular_path, None)
|
|
||||||
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
|
|
||||||
let bold = fonts::FontData::load(bold_path, None)
|
|
||||||
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
|
|
||||||
|
|
||||||
|
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
|
||||||
|
// italic e bold_italic reusam os dados de regular/bold respectivamente.
|
||||||
Ok(fonts::FontFamily {
|
Ok(fonts::FontFamily {
|
||||||
regular: regular.clone(),
|
|
||||||
bold,
|
|
||||||
italic: regular.clone(),
|
italic: regular.clone(),
|
||||||
bold_italic: regular,
|
bold_italic: bold.clone(),
|
||||||
|
regular,
|
||||||
|
bold,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO layouts
|
"INSERT INTO layouts
|
||||||
(nome, tipo, delimitador, encoding, linha_cabecalho,
|
(nome, tipo, delimitador, encoding, linha_cabecalho,
|
||||||
indice_numero, indice_serie, indice_valor, indice_data)
|
indice_numero, indice_serie, indice_valor, indice_data,
|
||||||
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
indice_documento_tipo)
|
||||||
|
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
config.delimitador.to_string(),
|
||||||
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
config.indice_serie as i64,
|
config.indice_serie as i64,
|
||||||
config.indice_valor.map(|v| v as i64),
|
config.indice_valor.map(|v| v as i64),
|
||||||
config.indice_data.map(|v| v as i64),
|
config.indice_data.map(|v| v as i64),
|
||||||
|
config.indice_documento_tipo.map(|v| v as i64),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
Layout::Xlsx { nome, config, .. } => {
|
Layout::Xlsx { nome, config, .. } => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO layouts
|
"INSERT INTO layouts
|
||||||
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
|
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
|
||||||
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
|
pos_documento_tipo)
|
||||||
|
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.aba,
|
config.aba,
|
||||||
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
config.pos_serie,
|
config.pos_serie,
|
||||||
config.pos_valor,
|
config.pos_valor,
|
||||||
config.pos_data,
|
config.pos_data,
|
||||||
|
config.pos_documento_tipo,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, delimitador = ?2, encoding = ?3,
|
nome = ?1, delimitador = ?2, encoding = ?3,
|
||||||
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
||||||
indice_valor = ?7, indice_data = ?8
|
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
|
||||||
WHERE id = ?9",
|
WHERE id = ?10",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
config.delimitador.to_string(),
|
||||||
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
config.indice_serie as i64,
|
config.indice_serie as i64,
|
||||||
config.indice_valor.map(|v| v as i64),
|
config.indice_valor.map(|v| v as i64),
|
||||||
config.indice_data.map(|v| v as i64),
|
config.indice_data.map(|v| v as i64),
|
||||||
|
config.indice_documento_tipo.map(|v| v as i64),
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
||||||
pos_valor = ?5, pos_data = ?6
|
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
|
||||||
WHERE id = ?7",
|
WHERE id = ?8",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.aba,
|
config.aba,
|
||||||
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
config.pos_serie,
|
config.pos_serie,
|
||||||
config.pos_valor,
|
config.pos_valor,
|
||||||
config.pos_data,
|
config.pos_data,
|
||||||
|
config.pos_documento_tipo,
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
"SELECT id, nome, tipo,
|
"SELECT id, nome, tipo,
|
||||||
delimitador, encoding, linha_cabecalho,
|
delimitador, encoding, linha_cabecalho,
|
||||||
indice_numero, indice_serie, indice_valor, indice_data,
|
indice_numero, indice_serie, indice_valor, indice_data,
|
||||||
aba, pos_numero, pos_serie, pos_valor, pos_data
|
aba, pos_numero, pos_serie, pos_valor, pos_data,
|
||||||
|
indice_documento_tipo, pos_documento_tipo
|
||||||
FROM layouts ORDER BY nome ASC",
|
FROM layouts ORDER BY nome ASC",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
indice_data: row
|
indice_data: row
|
||||||
.get::<_, Option<i64>>(9)?
|
.get::<_, Option<i64>>(9)?
|
||||||
.map(|v| v as usize),
|
.map(|v| v as usize),
|
||||||
|
indice_documento_tipo: row
|
||||||
|
.get::<_, Option<i64>>(15)?
|
||||||
|
.map(|v| v as usize),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
pos_serie: row.get(12)?,
|
pos_serie: row.get(12)?,
|
||||||
pos_valor: row.get(13)?,
|
pos_valor: row.get(13)?,
|
||||||
pos_data: row.get(14)?,
|
pos_data: row.get(14)?,
|
||||||
|
pos_documento_tipo: row.get(16)?,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
|
|
||||||
/// Versão atual do schema do banco de dados.
|
/// Versão atual do schema do banco de dados.
|
||||||
const VERSAO_SCHEMA_ATUAL: i64 = 2;
|
const VERSAO_SCHEMA_ATUAL: i64 = 3;
|
||||||
|
|
||||||
/// Aplica todas as migrations necessárias para atualizar o banco
|
/// Aplica todas as migrations necessárias para atualizar o banco
|
||||||
/// para a versão mais recente.
|
/// para a versão mais recente.
|
||||||
@@ -27,6 +27,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
|||||||
if versao_atual < 2 {
|
if versao_atual < 2 {
|
||||||
migration_v2(conn)?;
|
migration_v2(conn)?;
|
||||||
}
|
}
|
||||||
|
if versao_atual < 3 {
|
||||||
|
migration_v3(conn)?;
|
||||||
|
}
|
||||||
|
|
||||||
if versao_atual == 0 {
|
if versao_atual == 0 {
|
||||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
|
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 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
|
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
|
||||||
/// em bancos que já possuem nomes repetidos.
|
/// em bancos que já possuem nomes repetidos.
|
||||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||||
|
|||||||
+6
-5
@@ -4,6 +4,7 @@ use crate::application::usecases::{
|
|||||||
};
|
};
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::{
|
entities::{
|
||||||
|
chave_serie::ChaveSerie,
|
||||||
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
|
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
|
||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
@@ -82,7 +83,7 @@ pub enum ResultadoPendente {
|
|||||||
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||||
AguardandoConfirmacao {
|
AguardandoConfirmacao {
|
||||||
pre: ResultadoPreAnalise,
|
pre: ResultadoPreAnalise,
|
||||||
series_excessivas: Vec<(String, u64)>,
|
series_excessivas: Vec<(ChaveSerie, u64)>,
|
||||||
avisos: ResumoAvisos,
|
avisos: ResumoAvisos,
|
||||||
notas: Vec<Nota>,
|
notas: Vec<Nota>,
|
||||||
},
|
},
|
||||||
@@ -464,10 +465,10 @@ impl App {
|
|||||||
};
|
};
|
||||||
let msg = series_excessivas
|
let msg = series_excessivas
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(serie, count)| {
|
.map(|(chave, count)| {
|
||||||
format!(
|
format!(
|
||||||
"Série {}: intervalo de {} faltantes detectado",
|
"Série {}: intervalo de {} faltantes detectado",
|
||||||
serie, count
|
chave.label(), count
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -506,10 +507,10 @@ impl App {
|
|||||||
if !excessivos.is_empty() {
|
if !excessivos.is_empty() {
|
||||||
let msg = excessivos
|
let msg = excessivos
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(serie, count)| {
|
.map(|(chave, count)| {
|
||||||
format!(
|
format!(
|
||||||
"Série {}: intervalo de {} faltantes detectado",
|
"Série {}: intervalo de {} faltantes detectado",
|
||||||
serie, count
|
chave.label(), count
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|||||||
@@ -233,6 +233,11 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
"Data (opcional):",
|
"Data (opcional):",
|
||||||
&mut app.layout_csv_atual.indice_data,
|
&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,
|
&mut app.layout_xlsx_atual.pos_valor,
|
||||||
);
|
);
|
||||||
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
||||||
|
campo_letra_linha_opcional(
|
||||||
|
ui,
|
||||||
|
"Tipo Documento (opcional):",
|
||||||
|
&mut app.layout_xlsx_atual.pos_documento_tipo,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
|
|||||||
if let Some(d) = c.indice_data {
|
if let Some(d) = c.indice_data {
|
||||||
indices.push(("Data".to_string(), d));
|
indices.push(("Data".to_string(), d));
|
||||||
}
|
}
|
||||||
|
if let Some(t) = c.indice_documento_tipo {
|
||||||
|
indices.push(("Tipo Documento".to_string(), t));
|
||||||
|
}
|
||||||
verificar_duplicados(&indices, &mut erros);
|
verificar_duplicados(&indices, &mut erros);
|
||||||
}
|
}
|
||||||
TipoArquivo::Xlsx => {
|
TipoArquivo::Xlsx => {
|
||||||
|
|||||||
+62
-36
@@ -1,7 +1,7 @@
|
|||||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::resultado_analise::ResultadoAnalise,
|
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
||||||
services::parser_monetario::formatar_valor_br,
|
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
||||||
};
|
};
|
||||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
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)
|
formatar_valor_br(&resultado.soma_total)
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let soma = &resultado.soma_por_serie[serie];
|
let soma = &resultado.soma_por_serie[chave];
|
||||||
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
" Série {}: {} nota(s) — R$ {}",
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
serie,
|
chave.label(),
|
||||||
total_notas,
|
total_notas,
|
||||||
formatar_valor_br(soma)
|
formatar_valor_br(soma)
|
||||||
));
|
));
|
||||||
@@ -104,12 +104,9 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
|||||||
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
let total_faltantes = resultado.total_faltantes();
|
let total_faltantes = resultado.total_faltantes();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
|
||||||
"Notas Faltantes ({} total)",
|
.heading()
|
||||||
total_faltantes
|
.strong(),
|
||||||
))
|
|
||||||
.heading()
|
|
||||||
.strong(),
|
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
@@ -118,22 +115,36 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let faltantes = &resultado.faltantes_por_serie[serie];
|
let faltantes = &resultado.faltantes_por_serie[chave];
|
||||||
if faltantes.is_empty() {
|
if faltantes.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.label(format!(
|
// Estatística de completude por série
|
||||||
"Série {} — {} faltante(s):",
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
serie,
|
let total_esperado = total_notas + faltantes.len();
|
||||||
faltantes.len()
|
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
|
||||||
));
|
|
||||||
|
|
||||||
// Paginação
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!(
|
||||||
|
"Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):",
|
||||||
|
chave.label(),
|
||||||
|
faltantes.len(),
|
||||||
|
total_notas,
|
||||||
|
total_esperado,
|
||||||
|
percentual,
|
||||||
|
));
|
||||||
|
if ui.button("📋 Copiar").on_hover_text("Copiar todos os números faltantes").clicked() {
|
||||||
|
let texto = faltantes.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(", ");
|
||||||
|
ui.ctx().copy_text(texto);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paginação (por faltante individual)
|
||||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
if app.pagina_faltantes >= total_paginas {
|
if app.pagina_faltantes >= total_paginas {
|
||||||
app.pagina_faltantes = 0;
|
app.pagina_faltantes = 0;
|
||||||
@@ -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 inicio = app.pagina_faltantes * app.itens_por_pagina;
|
||||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||||
|
|
||||||
for numero in &faltantes[inicio..fim] {
|
// Exibir grupos contíguos da página atual
|
||||||
ui.label(format!(" • {}", numero));
|
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
||||||
|
if a == b {
|
||||||
|
ui.label(format!(" • {}", a));
|
||||||
|
} else {
|
||||||
|
ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if total_paginas > 1 {
|
if total_paginas > 1 {
|
||||||
@@ -181,20 +197,30 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let duplicatas = &resultado.duplicadas_por_serie[serie];
|
let duplicatas = &resultado.duplicadas_por_serie[chave];
|
||||||
if duplicatas.is_empty() {
|
if duplicatas.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.label(format!(
|
ui.horizontal(|ui| {
|
||||||
"Série {} — {} grupo(s) duplicado(s):",
|
ui.label(format!(
|
||||||
serie,
|
"Série {} — {} grupo(s) duplicado(s):",
|
||||||
duplicatas.len()
|
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 =
|
let total_paginas =
|
||||||
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
@@ -208,7 +234,7 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
|||||||
for (numero, count) in &duplicatas[inicio..fim] {
|
for (numero, count) in &duplicatas[inicio..fim] {
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
" • NF {} / Série {} — {} ocorrências",
|
" • NF {} / Série {} — {} ocorrências",
|
||||||
numero, serie, count
|
numero, chave.label(), count
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user