Compare commits

...
18 Commits
Author SHA1 Message Date
Felipe 2a21138bf8 feat: implement reimport and analysis functionality for XLSX files
- Added `reimportar_e_analisar` method to `App` struct to handle reimporting and analyzing files.
- Preserved previous analysis results to restore in case of errors or empty imports.
- Updated UI to include a button for reanalyzing the current file.
- Enhanced file selection logic to allow direct processing if a compatible preset is selected.
- Refactored import logic to streamline the analysis process for both CSV and XLSX files.
- Improved user feedback with appropriate error and success messages during file operations.
2026-03-03 23:28:55 -03:00
FelipeCN e7d72c7e01 F-03 está 40% implementado. O preview raw existe e funciona bem. O que
seria novo é sobrepor ao preview existente uma segunda linha de
"interpretação" — indicando qual coluna mapeada seria o número, qual
seria a série, e se o valor parsearia com sucesso.
2026-03-03 17:04:38 -03:00
FelipeCN b9052e073f Implementado (3 features)
**Agrupamento de faltantes contíguos** (`detector_sequencia.rs`)
- Nova função pública `agrupar_contiguos(&[u64]) -> Vec<(u64, u64)>` com
  4 testes
- Na tela de resultado, faltantes agora aparecem como `• 100–104 (5
  notas)` em vez de 5 linhas separadas

**Estatísticas de completude** (`resultado.rs`)
- Cada série exibe: `Série 001 — 10 faltante(s) — 990/1000 notas (99.0%
  completo):`

**Copiar para clipboard** (`resultado.rs`)
- Botão `📋 Copiar` ao lado de cada série nos faltantes — copia todos os
  números (não só a página atual)
- Botão `📋 Copiar` nas duplicatas — copia no formato `1234 (3x), 5678
  (2x)`

Total: **42 → 46 testes**, todos passando.
2026-03-03 16:41:32 -03:00
FelipeCN 2c96e99ccc update. 2026-03-03 13:51:26 -03:00
Felipe a5605de6a3 feat: adiciona análise e proposta de correção para duplicidade de layouts ao salvar 2026-03-03 12:37:16 -03:00
Felipe 0354ece935 refactor: organiza imports e simplifica funções em diversos arquivos 2026-03-03 11:59:50 -03:00
FelipeCN 2c08f237e9 feat: adiciona suporte para análise em background e processamento de resultados pendentes 2026-03-03 10:16:50 -03:00
FelipeCN dedb6a72c2 feat: atualiza a renderização da tabela de pré-visualização para exibir letras do Excel como cabeçalho 2026-03-03 09:27:22 -03:00
FelipeCN 41c75e5ed1 feat: adiciona pré-visualização de arquivos CSV e XLSX na interface 2026-03-03 09:25:12 -03:00
FelipeCN 0f728b9684 feat: adiciona modal para salvar layout com campo de texto e botão na tela de configuração de colunas 2026-03-03 09:19:23 -03:00
FelipeCN b2b91cda55 feat: adiciona nova ação de confirmação para iniciar nova análise e implementa breadcrumb na interface 2026-03-03 08:48:19 -03:00
FelipeCN b0e216a6ff feat: adiciona suporte para arquivos XLS e atualiza mensagens de erro na importação 2026-03-03 08:30:28 -03:00
Felipe bbfa66979b feat: adiciona sugestões de melhoria para o fluxo da interface 2026-03-02 22:24:48 -03:00
Felipe f3e6fa313d refactor: altera o texto do botão "Voltar" para "< Voltar" em várias telas 2026-03-02 22:19:43 -03:00
Felipe e64988a139 feat: implement main application structure with UI and layout management
- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs.
- Created module structure for UI components in `src/ui/mod.rs`.
- Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`.
- Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files.
- Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts.
- Created result display screen in `src/ui/screens/resultado.rs` to show analysis results.
- Introduced modular organization for screens in `src/ui/screens/mod.rs`.
2026-03-02 22:14:02 -03:00
Felipe 31cd94907a atualiza versão do PRD para 1.6 e detalha arquitetura e estrutura de pastas do projeto 2026-03-02 21:39:05 -03:00
Felipe 9d212747e7 atualiza versão do PRD para 1.5 e detalha campos lógicos e regras de validação 2026-03-02 21:21:18 -03:00
Felipe 913bbf3060 atualiza versão do PRD para 1.3 e detalha configurações de importação de arquivos CSV e XLSX 2026-03-02 21:09:45 -03:00
44 changed files with 11178 additions and 98 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(cargo build)",
"Bash(xargs grep -l \"tipo\\\\|invoice\\\\|tipo.*documento\")",
"Bash(xargs wc -l)",
"Bash(xargs grep -l \"grupo\\\\|grouping\\\\|agrupa\")",
"Bash(cargo metadata --no-deps --format-version 1)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''name''], p[''version'']\\) for p in d[''packages'']]\")",
"WebFetch(domain:github.com)",
"WebFetch(domain:api.github.com)",
"Bash(ls D:/PROJECTS/comparador-notas/*.md)"
]
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db",
"name": "comparador-notas"
}
]
}
Generated
+5562
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -4,3 +4,19 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [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"
regex = "1"
rfd = "0.15"
+166
View File
@@ -0,0 +1,166 @@
# Backlog de Features — Comparador de Notas
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Alta Prioridade
### F-01 — Exportar Resultado em CSV.
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
- `faltantes.csv`: `serie,documento_tipo,numero`
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
**Escopo técnico:**
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
- Botão na barra de ações da tela `resultado.rs`
- Dependência `csv` já está no `Cargo.toml`
**Esforço estimado:** Médio (23h)
**Impacto:** Alto — elimina retrabalho manual
**Observações:**
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar)
**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura.
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite.
**Escopo técnico:**
- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader
- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024`
**Esforço estimado:** Baixo (30min)
**Impacto:** Médio — evita travamentos inesperados
---
## Média Prioridade
### F-03 — Preview de Colunas Antes da Análise (Isso já existe?)
**Problema:** Erros de mapeamento de colunas (índice errado, encoding incorreto) só são descobertos depois da importação, que pode falhar silenciosamente com "0 notas válidas".
**Solução:** Na tela de configuração de colunas, exibir uma tabela das primeiras 5 linhas mostrando como os campos serão interpretados, com highlight em células que falhariam o parse.
**Escopo técnico:**
- Função `pre_visualizar(caminho, layout, n_linhas: usize) -> Vec<LinhaPreview>`
- `LinhaPreview`: `{ numero: Result<u64>, serie: Result<String>, valor: Result<Decimal>, ... }`
- Widget de tabela em `configuracao_colunas.rs`
- Atualizar preview ao mudar qualquer campo de configuração (via `changed()`)
**Esforço estimado:** Alto (46h)
**Impacto:** Alto — reduz drasticamente "por que não importou nada?"
**Observações:**
Verificar se já existe no codigo, pois na tela de configuração do Layout ele exibe as 5 primeiras linhas do arquivo.
---
### F-04 — Busca por Número na Tela de Resultado (Util)
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
- está **faltante** (aparece na lista de faltantes)
- está **duplicada** (aparece na lista de duplicatas)
- está **presente** (está nas notas importadas)
- **não encontrada** (fora do intervalo conhecido)
**Escopo técnico:**
- Campo `filtro_numero: String` no `App`
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
- Banner de resultado no topo da tela
**Esforço estimado:** Médio (23h)
**Impacto:** Médio — uso diário em auditorias pontuais
---
### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario)
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
**Escopo técnico:**
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
- Botão na barra de ações da tela `resultado.rs`
**Esforço estimado:** Baixo (1h)
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
---
### F-06 — Auto-detecção de Delimitador CSV (Util)
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
**Solução:** Ao selecionar o arquivo CSV, amostrar as primeiras 5 linhas e contar ocorrências de cada delimitador candidato. Sugerir o mais frequente como padrão, mantendo a ComboBox editável.
**Escopo técnico:**
- Função `detectar_delimitador(primeiras_linhas: &[&str]) -> char`
- Chamar na transição para `ConfigurandoColunas` quando `TipoArquivo::Csv`
- Pré-selecionar na ComboBox sem impedir mudança manual
**Esforço estimado:** Médio (2h)
**Impacto:** Baixo-Médio — reduz um passo de configuração na maioria dos casos
---
## Baixa Prioridade
### F-07 — Auto-detecção de Encoding CSV (Util)
**Problema:** Arquivos gerados por sistemas legados brasileiros frequentemente usam `windows-1252`, mas o padrão selecionado é `utf-8`. O usuário descobre o problema somente ao ver caracteres estranhos nos dados.
**Solução:** Tentar ler as primeiras linhas em `utf-8`; se houver erro de decodificação, sugerir automaticamente `windows-1252` com aviso visual na tela de configuração.
**Escopo técnico:**
- Função `detectar_encoding(caminho: &Path) -> String`
- Usar `encoding_rs` (já dependência) para tentar decode e verificar `had_errors`
- Aviso na tela de configuração: "Encoding detectado: windows-1252"
**Esforço estimado:** Médio (2h)
**Impacto:** Baixo — problema raro, mas confuso quando ocorre
---
### F-09 — Paginação Automática de Faltantes no PDF (Util)
**Problema:** PDFs com muitos faltantes (ex: 5.000 números) geram um único bloco de texto muito longo, difícil de ler e imprimir.
**Solução:** No `pdf_generator.rs`, ao adicionar a lista de faltantes, agrupar em intervalos contíguos (usando a função `agrupar_contiguos` já implementada) e formatar como `100104 (5 notas)`. Reduz drasticamente o tamanho do PDF e melhora legibilidade.
**Escopo técnico:**
- Importar `agrupar_contiguos` em `pdf_generator.rs`
- Substituir o `join(", ")` atual pelo formato de intervalos
- Sem novas dependências
**Esforço estimado:** Baixo (30min)
**Impacto:** Baixo-Médio — melhora legibilidade de PDFs com muitos faltantes
> **Nota:** Esta feature é especialmente fácil porque a lógica de agrupamento já existe — é só aplicar no PDF.
---
## Resumo
| ID | Feature | Esforço | Impacto | Prioridade |
|---|---|---|---|---|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
| F-04 | Busca por número no resultado | Médio | Médio | Média |
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
+667 -96
View File
@@ -1,8 +1,8 @@
# PRD — Comparador de Notas # PRD — Comparador de Notas
**Versão:** 1.1 **Versão:** 1.7
**Data:** 02/03/2026 **Data:** 03/03/2026
**Status:** Planejamento **Status:** Implementado (MVP)
--- ---
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Notas faltantes * Notas faltantes
* Notas duplicadas * Notas duplicadas
* Soma total dos valores * Soma total dos valores
* Agrupamento por série * Agrupamento por série e tipo de documento
## 3.2 Objetivos Secundários ## 3.2 Objetivos Secundários
@@ -71,16 +71,25 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Importação de arquivos CSV * Importação de arquivos CSV
* Importação de arquivos XLSX * Importação de arquivos XLSX
* Interface gráfica * Interface gráfica
* Configuração de colunas * Configuração de colunas (CSV por índice numérico; XLSX por letra+linha)
* Detecção de quebras de sequência * Detecção de quebras de sequência
* Detecção de duplicidades * Detecção de duplicidades
* Soma de valores * Soma de valores
* Agrupamento por série * Agrupamento por série e tipo de documento
* Relatório visual * Pré-visualização das primeiras linhas do arquivo na tela de configuração
* Salvar layouts personalizados * Relatório visual com paginação
* Exibição de faltantes agrupados em intervalos contíguos (ex: `1050 (41 notas)`)
* Indicador de completude por série (ex: `48/50 notas — 96,0% completo`)
* Botão de cópia rápida de listas de faltantes/duplicatas para área de transferência
* Exportação de relatório para PDF (fontes Liberation Sans embutidas no binário)
* Salvar layouts personalizados (exclusivos por tipo de arquivo)
* Carregar layouts salvos * Carregar layouts salvos
* Selecionar layout por menu dropdown * Selecionar layout por menu dropdown
* Excluir layouts * Excluir layouts
* Exportar layout para JSON
* Importar layout de JSON
* Reanalisar arquivo sem reconfiguração (reimporta o mesmo arquivo com o layout atual)
* Análise em background thread (UI não bloqueia durante importação e análise)
## 4.2 Não Incluído (MVP) ## 4.2 Não Incluído (MVP)
@@ -88,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Integração com banco de dados externo * Integração com banco de dados externo
* Multiusuário * Multiusuário
* Acesso remoto * Acesso remoto
* Exportação de resultado em CSV
--- ---
@@ -95,16 +105,17 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
O sistema trabalhará com os seguintes campos lógicos: O sistema trabalhará com os seguintes campos lógicos:
| Campo | Obrigatório | Descrição | | Campo | Obrigatório | Descrição |
| ------ | ----------- | ------------------------------------------------------------------------- | | --------------- | ----------- | ------------------------------------------------------------------------- |
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único | | Numero | Sim | Número incremental da nota. Armazenado internamente como `u64`. |
| Serie | Sim | Série da nota (13 dígitos, ex: 001999). Separa lotes de emissão | | Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `^[0-9]{1,3}$`, ex: `1`, `01`, `001`). Separa lotes de emissão. |
| Valor | Não | Valor monetário da nota | | Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). |
| Data | Não | Data de emissão da nota | | Data | Não | Data de emissão da nota. Exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. Formatos aceitos: `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. |
| TipoDocumento | Não | Tipo do documento (ex: `NFE`, `NFCE`). Quando mapeado, compõe a chave de agrupamento junto com a Série. Qualquer string não vazia é aceita. |
Os campos serão mapeados pelo usuário via índice de coluna. Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
> **Identificador único de uma nota:** combinação de `Numero + Serie`. Duplicidade e sequência são sempre avaliadas dentro da mesma série. > **Identificador único de uma nota:** combinação de `Numero + Serie + TipoDocumento`. Duplicidade e sequência são sempre avaliadas dentro do mesmo grupo `(Serie, TipoDocumento)`. Quando `TipoDocumento` não é mapeado, o agrupamento é feito somente por `Serie` (retrocompatível).
--- ---
@@ -123,34 +134,67 @@ O sistema deve permitir importar arquivos:
| ----------------- | ----------------------------------------------------------------------------- | | ----------------- | ----------------------------------------------------------------------------- |
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação | | Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário | | Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.) | | Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.). 0 = sem cabeçalho |
| Linhas em branco | Devem ser ignoradas silenciosamente | | Linhas em branco | Devem ser ignoradas silenciosamente |
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação | | Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
### RF01.2 — Limite de Tamanho de Arquivo ### RF01.2 — Configurações de Importação XLSX
| Parâmetro | Comportamento |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| Seleção de aba | Imediatamente após a seleção do arquivo, o sistema exibe a lista de abas disponíveis para o usuário selecionar, antes de qualquer configuração de campos |
| Coluna e linha de início | O usuário informa a posição inicial de cada campo no formato `LetraLinha` (ex: `B3`) |
| Linhas em branco | Devem ser ignoradas silenciosamente |
| Linhas malformadas | Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas |
| Arquivo corrompido | Se o arquivo não puder ser lido, exibir mensagem de erro em modal e limpar o arquivo carregado; o estado anterior é descartado |
### RF01.3 — Limite de Tamanho de Arquivo
O sistema deve recusar arquivos maiores que **50 MB** e exibir mensagem de erro ao usuário. O sistema deve recusar arquivos maiores que **50 MB** e exibir mensagem de erro ao usuário.
> Base de cálculo: 100.000 registros com 510 colunas geram aproximadamente 515 MB em CSV e até 30 MB em XLSX. O limite de 50 MB oferece margem adequada. > O limite refere-se ao **tamanho do arquivo no disco** (tamanho comprimido para XLSX, que é um arquivo ZIP internamente). Base de cálculo: 100.000 registros com 510 colunas geram aproximadamente 515 MB em CSV e até 30 MB em XLSX. O limite de 50 MB oferece margem adequada.
--- ---
## RF02 — Configuração de Colunas ## RF02 — Configuração de Colunas
O sistema deve permitir ao usuário definir, via **índice numérico** (posição da coluna), qual coluna representa cada campo: O mapeamento de colunas varia conforme o tipo de arquivo.
* Qual coluna representa o número (obrigatório) ### RF02.1 — Mapeamento CSV (índice numérico)
* Qual coluna representa a série (obrigatório)
* Qual coluna representa o valor (opcional) O usuário define, via **índice numérico** (posição da coluna, base 0), qual coluna representa cada campo:
* Qual coluna representa a data (opcional)
* Qual índice representa o número (obrigatório)
* Qual índice representa a série (obrigatório)
* Qual índice representa o valor (opcional)
* Qual índice representa a data (opcional)
* Qual índice representa o tipo de documento (opcional)
### RF02.2 — Mapeamento XLSX (letra + linha)
O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha:
* Ex: `B3` indica coluna B a partir da linha 3
* Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional), Data (opcional) e TipoDocumento (opcional)
> A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior.
### RF02.3 — Pré-visualização do Arquivo
A tela de configuração de colunas exibe as primeiras 5 linhas do arquivo com cabeçalho em notação de letras (A, B, C, ... com índice base-0 entre parênteses). A pré-visualização é atualizada automaticamente ao mudar o delimitador ou a aba selecionada.
### Comportamento de Memória
Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória.
### Tratamento de Erros de Configuração ### Tratamento de Erros de Configuração
| Situação | Comportamento | | Situação | Comportamento |
| ----------------------------------------------------- | --------------------------------------------------------------- | | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| Índice configurado não existe no arquivo importado | Exibir erro ao usuário identificando qual campo está inválido | | Índice/posição configurado não existe no arquivo importado | Exibir erro ao usuário identificando qual campo está inválido |
| Campo obrigatório (Numero ou Serie) não mapeado | Bloquear execução da análise e solicitar configuração | | Campo obrigatório (Numero ou Serie) não mapeado | Bloquear execução da análise e solicitar configuração |
| Campo opcional não mapeado | Ignorar o campo; funcionalidades dependentes ficam desabilitadas| | Campo opcional não mapeado | Ignorar o campo; funcionalidades dependentes ficam desabilitadas |
| Dois campos mapeados para o mesmo índice/posição | Bloquear e exibir erro de validação imediatamente, antes de executar a análise |
--- ---
@@ -167,20 +211,43 @@ O formato JSON é utilizado exclusivamente para importação e exportação de l
O armazenamento interno dos layouts é realizado via SQLite (ver Seção 10). O armazenamento interno dos layouts é realizado via SQLite (ver Seção 10).
### Tipo de Layout
Cada layout é exclusivo de um tipo de arquivo: **CSV** ou **XLSX**. Um layout CSV armazena configurações específicas de CSV (delimitador, encoding, índices de coluna). Um layout XLSX armazena configurações específicas de XLSX (aba, posições no formato `LetraLinha`).
### Conflito ao Importar Layout JSON
Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções:
* Sobrescrever o layout existente
* Cancelar a importação
### Erros na Importação de Layout JSON
Se o arquivo JSON estiver malformado ou com campos obrigatórios ausentes, o sistema deve exibir mensagem de erro descrevendo o problema e cancelar a importação. Nenhum dado parcial deve ser salvo.
### Nome do Arquivo ao Exportar Layout
Ao exportar um layout para JSON, o sistema deve sugerir o nome do arquivo com base no nome do layout (ex: layout `Padrão CSV``Padrão CSV.json`). O usuário pode alterar o nome antes de salvar.
### Limite de Layouts
Não há limite no número de layouts que podem ser armazenados.
--- ---
## RF04 — Detecção de Sequência ## RF04 — Detecção de Sequência
O sistema deve: O sistema deve:
* Ordenar os registros por número dentro de cada série * Ordenar os registros por número dentro de cada grupo `(Serie, TipoDocumento)`
* Detectar números faltantes na sequência * Detectar números faltantes na sequência
### Regra de Detecção ### Regra de Detecção
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada série. Qualquer número ausente nesse intervalo é considerado faltante. A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada grupo. Qualquer número ausente nesse intervalo é considerado faltante.
Exemplo: série 001 contém os números `0001, 0002, 0003, 0005``0004` está faltando. Exemplo: série 001 / NFE contém os números `0001, 0002, 0003, 0005``0004` está faltando.
### Tratamento de Valores Não Numéricos ### Tratamento de Valores Não Numéricos
@@ -191,9 +258,52 @@ Se o valor de um campo Numero não for numérico (ex: `NF-001`, `ABC`):
3. Se não restar valor numérico utilizável, descartar o registro 3. Se não restar valor numérico utilizável, descartar o registro
4. Em ambos os casos, reportar ao usuário quais registros foram afetados 4. Em ambos os casos, reportar ao usuário quais registros foram afetados
### Número Zero
Registros com o campo Numero igual a `0` devem ser descartados e reportados ao usuário. O valor `0` não é considerado um número de nota válido.
### Escopo da Detecção ### Escopo da Detecção
Faltantes são detectados **por série**. Cada série possui sua própria sequência independente. Faltantes são detectados **por grupo `(Serie, TipoDocumento)`**. Cada grupo possui sua própria sequência independente.
### Série com Apenas um Registro
Se um grupo contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. O grupo é processado e exibido normalmente.
### Ordenação
A ordenação dos registros dentro de cada grupo é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada.
### Exibição do Campo Numero
O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação.
### Agrupamento de Faltantes Contíguos
Na tela de resultado, faltantes consecutivos são exibidos agrupados em intervalos (ex: `1050 (41 notas)`) para facilitar a leitura. Faltantes isolados são exibidos individualmente (ex: `• 75`).
### Proteção contra Intervalos Anormalmente Grandes
Se o intervalo de faltantes de qualquer grupo exceder **10.000 registros**, o sistema deve:
1. Interromper o processamento desse grupo
2. Exibir aviso informando o intervalo calculado (ex: "Série 001 / NFE: intervalo de 999.996 faltantes detectado")
3. Solicitar confirmação do usuário antes de continuar
Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação.
Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam listas ilegíveis.
### Tratamento de Série com Valor Inválido
A Série é válida se e somente se corresponder à regex `^[0-9]{1,3}$` após remoção de espaços.
Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável:
1. O registro deve ser descartado
2. O sistema deve reportar ao usuário quais linhas foram afetadas, sem interromper a importação
Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
--- ---
@@ -201,7 +311,15 @@ Faltantes são detectados **por série**. Cada série possui sua própria sequê
O sistema deve identificar registros duplicados. O sistema deve identificar registros duplicados.
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie** no arquivo importado. Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie + TipoDocumento** no arquivo importado.
### Exibição das Duplicatas
A lista de duplicatas exibe o identificador (`Numero + Serie + TipoDocumento`) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 / NFE — 3 ocorrências`. As ocorrências individuais não são listadas separadamente.
### Impacto na Soma de Valores
Todas as ocorrências de registros duplicados são incluídas na soma de valores, pois refletem os lançamentos reais presentes no arquivo.
--- ---
@@ -210,7 +328,50 @@ Um registro é considerado duplicado quando existe mais de uma ocorrência da me
O sistema deve calcular: O sistema deve calcular:
* Soma total * Soma total
* Soma por série * Soma por grupo `(Serie, TipoDocumento)`
### Formato de Valor Aceito
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
O parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
#### Algoritmo de Parsing Monetário
**Regra 1 — Contém ambos ponto e vírgula:**
- O último separador encontrado é o decimal; o outro é o separador de milhar.
- Exemplos: `1.000,00` → 1.000,00 | `1,000.00` → 1.000,00
**Regra 2 — Contém apenas um separador:**
- Vírgula ou ponto com **exatamente 2 dígitos** após → separador decimal.
- Exemplos: `1000,00` → 1.000,00 | `1000.00` → 1.000,00
- Vírgula ou ponto com **exatamente 3 dígitos** após → separador de milhar.
- Exemplos: `1,234` → 1.234,00 | `1.234` → 1.234,00
- Demais casos → separador tratado como decimal.
**Regra 3 — Sem separador:**
- Interpretar como número inteiro.
- Exemplo: `1000` → 1.000,00
| Exemplo de entrada | Regra aplicada | Resultado parseado |
| ------------------ | ---------------------------------- | ------------------ |
| `1000.00` | Regra 2 (ponto + 2 dígitos) | 1.000,00 |
| `1000,00` | Regra 2 (vírgula + 2 dígitos) | 1.000,00 |
| `1.000,00` | Regra 1 (ambos separadores) | 1.000,00 |
| `1,000.00` | Regra 1 (ambos separadores) | 1.000,00 |
| `1.000` | Regra 2 (ponto + 3 dígitos) | 1.000,00 |
| `1,234` | Regra 2 (vírgula + 3 dígitos) | 1.234,00 |
| `1000` | Regra 3 (sem separador) | 1.000,00 |
Valores negativos (precedidos de `-`) devem ser rejeitados e reportados ao usuário como inválidos.
Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário.
> **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por grupo devem ser calculadas inteiramente em `Decimal`.
### Formato de Exibição de Valores
Todos os valores monetários são exibidos com **2 casas decimais fixas** no formato brasileiro (ex: `1.234,56`).
--- ---
@@ -218,9 +379,39 @@ O sistema deve calcular:
O sistema deve exibir: O sistema deve exibir:
* Lista de notas faltantes * Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
* Lista de duplicadas * Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
* Totais * Lista de duplicadas agrupadas por `(Serie, TipoDocumento)`
* Totais (soma total e soma por grupo, com contagem de notas por grupo)
* Botão de cópia rápida de listas para a área de transferência
### Organização dos Resultados
| Aspecto | Comportamento |
| ------------- | ----------------------------------------------------- |
| Agrupamento | Resultados sempre agrupados por `(Serie, TipoDocumento)`. Quando TipoDocumento não está mapeado, o label do grupo exibe apenas a Série |
| Listas longas | Paginação — o usuário navega entre páginas de resultados |
| Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 |
### RF07.1 — Exportação de Relatório
O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`.
As fontes do PDF (Liberation Sans) são embutidas no binário em tempo de compilação, eliminando dependência de fontes instaladas no sistema operacional.
O PDF deve conter:
* Notas faltantes por grupo `(Serie, TipoDocumento)`
* Duplicatas por grupo
* Totais por grupo e total geral
**Metadados do relatório:**
* Nome do arquivo importado
* Data e hora da geração
* Nome do layout utilizado (se houver)
### RF07.2 — Reanalisar Arquivo
O sistema deve permitir reimportar o mesmo arquivo do disco com o layout atual e executar a análise novamente, sem nenhuma interação adicional. A operação é executada em background thread para não bloquear a interface.
--- ---
@@ -260,6 +451,10 @@ O sistema deve suportar no mínimo:
100.000 registros por arquivo 100.000 registros por arquivo
A análise é executada em uma thread separada (background) para não bloquear a interface gráfica durante o processamento.
> **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo.
--- ---
## RNF04 — Usabilidade ## RNF04 — Usabilidade
@@ -268,42 +463,311 @@ O sistema deve possuir interface simples.
--- ---
## RNF05 — Idioma
O idioma da interface é **Português do Brasil (PT-BR)**.
---
## RNF06 — Mensagens de Erro e Aviso
Todas as mensagens de erro e aviso devem ser exibidas em **modal/popup bloqueante**. O usuário deve fechar o modal explicitamente para continuar.
### Consolidação de Mensagens
Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltiplas linhas malformadas, múltiplos registros descartados), esses avisos devem ser **consolidados em um único modal**, exibindo um resumo ao final da operação. Exemplo de conteúdo consolidado:
* "32 linhas descartadas por malformação"
* "12 valores de Numero inválidos convertidos ou descartados"
* "5 registros com Série inválida descartados"
* "3 valores monetários inválidos descartados"
Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação.
---
# 8. Fluxo do Usuário # 8. Fluxo do Usuário
Fluxo principal: Fluxo principal:
1. Usuário abre o sistema 1. Usuário abre o sistema
2. Usuário importa planilha 2. Usuário importa planilha
3. Usuário configura colunas 3. (Para XLSX) Usuário seleciona aba
4. Usuário executa análise 4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
5. Sistema exibe resultado 5. Usuário executa análise (processamento em background)
6. Sistema exibe resultado
Fluxo alternativo — layout salvo:
1. Usuário abre o sistema
2. Usuário seleciona layout no dropdown
3. Usuário importa planilha → análise é disparada automaticamente
--- ---
# 9. Arquitetura Inicial Sugerida # 9. Arquitetura
Arquitetura desktop local. Arquitetura desktop local com separação em quatro camadas: `domain`, `application`, `infrastructure` e `ui`.
Componentes: Não é Clean Architecture radical. É apenas separação suficiente para manter fronteiras claras, domínio isolado e infraestrutura concreta sem vazar para a lógica de negócio.
* Interface gráfica
* Módulo de importação
* Módulo de processamento
* Módulo de configuração
Sem dependências externas obrigatórias.
--- ---
## 9.1 Stack Tecnológica ## 9.1 Estrutura de Pastas
| Camada | Tecnologia | Status | ```
| ---------------- | ------------------ | -------------- | src/
| Linguagem | Rust | Definido | ├─ main.rs
| Framework de UI | egui | Definido | ├─ ui/
| SQLite | rusqlite | Definido | │ ├─ mod.rs
| Leitura de CSV | csv | Definido | │ ├─ app.rs
| Leitura de XLSX | calamine | Definido | │ ├─ screens/
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
│ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs
│ │ ├─ resultado.rs
├─ application/
│ ├─ mod.rs
│ ├─ usecases/
│ │ ├─ mod.rs
│ │ ├─ importar_arquivo.rs
│ │ ├─ executar_analise.rs
│ │ ├─ exportar_pdf.rs
│ │ ├─ layouts.rs
├─ domain/
│ ├─ mod.rs
│ ├─ errors.rs
│ ├─ entities/
│ │ ├─ mod.rs
│ │ ├─ nota.rs
│ │ ├─ serie.rs
│ │ ├─ chave_serie.rs
│ │ ├─ layout.rs
│ │ ├─ resultado_analise.rs
│ │
│ ├─ services/
│ │ ├─ mod.rs
│ │ ├─ detector_sequencia.rs
│ │ ├─ detector_duplicidade.rs
│ │ ├─ parser_monetario.rs
├─ infrastructure/
│ ├─ mod.rs
│ ├─ csv_reader.rs
│ ├─ xlsx_reader.rs
│ ├─ pdf_generator.rs
│ ├─ sqlite/
│ │ ├─ mod.rs
│ │ ├─ connection.rs
│ │ ├─ migrations.rs
│ │ ├─ layout_repository.rs
```
---
## 9.2 Papel de Cada Camada
### Domain (núcleo puro)
Contém toda a lógica de negócio real.
**Não pode depender de:**
* egui / eframe
* rusqlite
* calamine
* csv
* genpdf
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`) e utilitários de erros (`thiserror`, `regex`).
#### Entidades
**`Nota`**
| Campo | Tipo |
| -------------- | ------------------- |
| numero | `u64` |
| serie | `String` |
| documento_tipo | `Option<String>` |
| valor | `Option<Decimal>` |
| data | `Option<NaiveDate>` |
**`ChaveSerie`**
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
| Campo | Tipo |
| -------------- | ---------------- |
| serie | `String` |
| documento_tipo | `Option<String>` |
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
**`ResultadoPreAnalise`**
Resultado intermediário, antes de materializar os faltantes.
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
**`ResultadoAnalise`**
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
> **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas.
#### Services
`detector_sequencia` — recebe `Vec<&Nota>` agrupadas por `ChaveSerie`, retorna faltantes. Funções:
- `calcular_intervalo` — retorna `IntervaloSerie` sem materializar a lista completa
- `detectar_faltantes` — materializa a lista completa após confirmação
- `agrupar_contiguos` — agrupa uma lista ordenada de faltantes em pares `(inicio, fim)` para exibição compacta
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie, documento_tipo)`.
`parser_monetario` — implementa exatamente o algoritmo definido no RF06. Também expõe `formatar_valor_br` para exibição no formato `1.234,56`.
#### Erros
`domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`, `ErroLayout::NomeConflitante`, `ErroArquivo::TamanhoExcedido`). Nenhuma camada deve propagar `String` livre como erro de domínio.
`ResumoAvisos` consolida contagens de linhas malformadas, números inválidos, séries inválidas e valores inválidos para exibição em um único modal ao final da importação.
---
### Application (orquestração)
Coordenam o fluxo entre domain e infrastructure.
Conhece o domain. O domain não conhece o application.
**`executar_analise.rs`**
Expõe três funções:
1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
**`exportar_pdf.rs`**
Depende da trait abstrata `PdfGenerator` definida em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`.
**`importar_arquivo.rs`**
Expõe:
- `importar_csv(caminho, config)` — lê CSV e mapeia para notas
- `importar_xlsx(caminho, config)` — lê XLSX e mapeia para notas
- `listar_abas_xlsx(caminho)` — lista abas antes de configurar
**`layouts.rs`**
Expõe operações de CRUD e import/export de layouts: `salvar_layout`, `listar_layouts`, `excluir_layout`, `exportar_layout_json`, `importar_layout_json`.
---
### Infrastructure (implementações concretas)
Implementa leitores, persistência e geração de arquivos.
| Arquivo | Responsabilidade |
| -------------------------------- | ----------------------------------------- |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
| `pdf_generator.rs` | Trait `PdfGenerator` + implementação `GenpdfGenerator` via `genpdf`; fontes Liberation Sans embutidas no binário |
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite; tratamento de banco corrompido |
| `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
Nada de infrastructure sobe para domain.
---
### UI (interface)
Apenas coleta input, chama use cases e renderiza resultado.
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
#### App (estado global)
`app.rs` contém o estado global da aplicação (`App`), o enum `EstadoApp`, os tipos `Modal`/`TipoModal`/`AcaoModal`, e a lógica de processamento de resultados assíncronos via `mpsc::channel`.
**Estados da aplicação:**
| Estado | Descrição |
| ----------------------- | --------- |
| `Importando` | Tela inicial: seleção de arquivo e layout |
| `SelecionandoAba` | Aguardando seleção de aba XLSX |
| `ConfigurandoColunas` | Mapeamento de colunas com pré-visualização |
| `Analisando` | Análise em execução em background thread |
| `ConfirmandoIntervalo` | Aguardando confirmação do usuário para expandir faltantes |
| `ExibindoResultado` | Resultado pronto para exibição |
| `GerenciandoLayouts` | Gerenciamento de layouts salvos |
#### Screens
| Arquivo | Responsabilidade |
| ------------------------- | ----------------------------------------------------- |
| `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
| `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
| `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir, exportar/importar JSON (RF08) |
| `resultado.rs` | Exibição de resultados com paginação, intervalos contíguos, indicador de completude, botões copiar/reanalisar/exportar PDF (RF07) |
---
## 9.3 Fluxo de Execução
```
UI → Application → Domain
Infrastructure entra apenas quando necessário.
```
Exemplo real:
1. UI chama `executar_importacao` (thread separada)
2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota`
4. Domain executa pré-análise (`pre_analisar`)
5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
6. Application retorna `ResultadoAnalise` via `mpsc::channel`
7. UI renderiza
---
## 9.4 Stack Tecnológica
| Camada | Tecnologia | Versão |
| ---------------- | ------------------ | ------- |
| Linguagem | Rust | edition 2024 |
| Framework de UI | egui + eframe | 0.31 |
| Diálogos nativos | rfd | 0.15 |
| SQLite | rusqlite (bundled) | 0.32 |
| Leitura de CSV | csv | 1.3 |
| Leitura de XLSX | calamine | 0.26 |
| Decimal fixo | rust_decimal | 1.36 |
| Geração de PDF | genpdf | 0.2 |
| Datas | chrono | 0.4 |
| Encoding | encoding_rs | 0.8 |
| Caminhos de dados| dirs | 5 |
| Erros tipados | thiserror | 2 |
| Regex | regex | 1 |
| Serialização | serde + serde_json | 1 |
--- ---
@@ -332,12 +796,42 @@ Motivos:
- Multiplataforma - Multiplataforma
- Alta confiabilidade - Alta confiabilidade
O banco de dados deve ser armazenado localmente no dispositivo do O banco de dados deve ser armazenado na **pasta de dados do usuário**, de acordo com o sistema operacional:
usuário.
Exemplo: | Sistema Operacional | Caminho |
| ------------------- | ---------------------------------------------------- |
| Linux | `~/.config/comparador-notas/config.db` |
| Windows | `%APPDATA%\comparador-notas\config.db` |
| macOS | `~/Library/Application Support/comparador-notas/config.db` |
config.db O diretório e o arquivo devem ser **criados automaticamente** pelo sistema na primeira execução.
------------------------------------------------------------------------
## 10.2.1 — Banco de Dados Corrompido
Se o arquivo `config.db` estiver ilegível ou corrompido ao iniciar o programa, o sistema deve:
1. Exibir aviso ao usuário informando que o banco de dados está corrompido e será recriado
2. Renomear o arquivo corrompido para `config.db.bak` (sobrescrevendo qualquer `.bak` anterior)
3. Criar um novo banco de dados vazio
4. Continuar a execução normalmente
Os layouts salvos anteriormente serão perdidos neste cenário. O arquivo `config.db.bak` permanece no disco e permite recuperação manual por usuários avançados.
------------------------------------------------------------------------
## 10.2.2 — Migração de Schema
O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco.
**Versão atual do schema: 3**
| Versão | Alteração |
| ------ | --------- |
| 1 | Criação da tabela `layouts` |
| 2 | Índice único em `layouts.nome`; renomeia duplicatas com sufixo `(id)` |
| 3 | Adição das colunas `indice_documento_tipo` (CSV) e `pos_documento_tipo` (XLSX) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -351,16 +845,39 @@ Layouts personalizados
## 10.4 Entidade: Layout ## 10.4 Entidade: Layout
Campos: Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configurações variam conforme o tipo.
Campo Tipo Descrição **Campos comuns:**
--------------- --------- --------------------------
id inteiro Identificador único | Campo | Tipo | Descrição |
nome texto Nome do layout | ---------- | ------- | -------------------------------------- |
coluna_numero texto Nome da coluna do número | id | inteiro | Identificador único (auto-incremento) |
coluna_serie texto Nome da coluna da série | nome | texto | Nome do layout (único no banco) |
coluna_valor texto Nome da coluna do valor | tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
coluna_data texto Nome da coluna da data
**Campos exclusivos de layouts CSV:**
| Campo | Tipo | Descrição |
| ---------------------- | ------- | ---------------------------------------------------------- |
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho |
| indice_numero | inteiro | Índice da coluna Numero (base 0) |
| indice_serie | inteiro | Índice da coluna Serie (base 0) |
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
**Campos exclusivos de layouts XLSX:**
| Campo | Tipo | Descrição |
| ------------------ | ------ | ----------------------------------------------------------------- |
| aba | texto | Nome da aba a ser processada |
| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) |
| pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) |
| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) |
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -380,15 +897,19 @@ Excluir layout
## 10.6 Interface do Usuário ## 10.6 Interface do Usuário
Os layouts salvos devem ser exibidos em um menu dropdown. Os layouts salvos devem ser exibidos em um menu dropdown, filtrado pelo tipo de arquivo atual (CSV ou XLSX).
O usuário deve poder: O usuário deve poder:
Selecionar layout existente Selecionar layout existente
Criar novo layout Criar novo layout (via modal com campo de texto ou via tela de gerenciamento)
Excluir layout Excluir layout (com confirmação)
Exportar layout para JSON
Importar layout de JSON (com tratamento de conflito de nome)
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -424,51 +945,93 @@ Quando o usuário exportar ou importar um layout, o arquivo gerado será no form
Esse formato é usado apenas para portabilidade entre dispositivos. Esse formato é usado apenas para portabilidade entre dispositivos.
O armazenamento interno é sempre via SQLite. O armazenamento interno é sempre via SQLite.
Exemplo de arquivo JSON exportado: O campo `tipo` define qual conjunto de configurações está presente no arquivo.
**Exemplo — layout CSV:**
```json ```json
{ {
"nome": "Layout Padrão", "tipo": "csv",
"coluna_numero": "Nota", "nome": "Layout Padrão CSV",
"coluna_serie": "Serie", "delimitador": ";",
"coluna_valor": "Valor", "encoding": "utf-8",
"coluna_data": "Data" "linha_cabecalho": 1,
"indice_numero": 3,
"indice_serie": 1,
"indice_valor": 5,
"indice_data": null,
"indice_documento_tipo": null
} }
``` ```
**Exemplo — layout XLSX:**
```json
{
"tipo": "xlsx",
"nome": "Layout Padrão XLSX",
"aba": "Plan1",
"pos_numero": "D3",
"pos_serie": "B3",
"pos_valor": "F3",
"pos_data": null,
"pos_documento_tipo": null
}
```
> **Nota:** o campo `tipo` é usado como tag de discriminante pelo `serde` (`#[serde(tag = "tipo")]`). O campo `indice_documento_tipo` / `pos_documento_tipo` usa `#[serde(default)]` para retrocompatibilidade com arquivos JSON exportados antes da versão 1.7.
--- ---
# 12. Critérios de Aceite # 12. Critérios de Aceite
O sistema será considerado funcional quando: O sistema será considerado funcional quando:
* Importar planilha * Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
* Detectar notas faltantes corretamente * Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
* Calcular totais corretamente * Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
* Permitir configuração * Detectar duplicatas por grupo corretamente
* Calcular soma total e soma por grupo corretamente
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exibir indicador de completude por grupo
* Exportar relatório de resultados para PDF
* Salvar, carregar, selecionar e excluir layouts
* Exportar e importar layouts via JSON
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por grupo
* Executar análise em background sem bloquear a interface
* Exibir pré-visualização das primeiras 5 linhas do arquivo na tela de configuração
--- ---
# 13. MVP — Versão Inicial # 13. MVP — Versão Inicial
Escopo mínimo: O MVP inclui o escopo completo descrito neste PRD:
* Importar CSV * Importar CSV e XLSX
* Mapear coluna Número * Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
* Detectar faltantes * Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
* Exibir resultado * Detectar duplicatas por grupo
* Calcular soma total e por grupo
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
* Exportar relatório para PDF
* Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
* Reanalisar arquivo sem reconfiguração
--- ---
# 14. Evoluções Futuras # 14. Evoluções Futuras
Possíveis melhorias: Possíveis melhorias (ver `FEATURES_BACKLOG.md` para detalhes):
* Exportação de relatório * Exportação de resultado em CSV (`faltantes.csv`, `duplicatas.csv`)
* Exportação PDF * Busca por número na tela de resultado
* Auto-detecção de delimitador CSV
* Auto-detecção de encoding CSV
* Agrupamento de faltantes como intervalos no PDF
* Integração com ERP * Integração com ERP
* Banco de dados * Histórico de análises
* Automação * Automação de importação (monitorar pasta)
* Multiusuário
--- ---
@@ -482,8 +1045,16 @@ Série
Agrupador independente de sequência. Agrupador independente de sequência.
TipoDocumento
Subtipo de documento dentro de uma série (ex: NFE, NFCE). Quando mapeado, compõe a chave de agrupamento junto com a Série.
ChaveSerie
Chave composta `(Serie, TipoDocumento)` que identifica um grupo de notas para fins de detecção de sequência, duplicidade e cálculo de somas.
Sequência Sequência
Ordem numérica crescente sem lacunas. Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
--- ---
+113
View File
@@ -0,0 +1,113 @@
# Sugestões de Melhoria — Fluxo da Interface
**Data:** 02/03/2026
---
## 1. Indicador de progresso das etapas
O fluxo possui 34 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
```
① Arquivo ② Colunas ③ Resultado
```
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
---
## 2. Unificar importação e configuração de colunas em um único painel
Atualmente o caminho é:
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
São 3 ações separadas para chegar à análise.
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
---
## 3. Pré-visualização das primeiras linhas do arquivo
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
**Sugestão:** Exibir as primeiras 35 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
---
## 4. Confirmação ao clicar em "Nova Análise"
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
**Sugestão:** Exibir modal de confirmação com a mensagem:
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
Botões: **Confirmar** | **Cancelar**
---
## 5. Reorganizar seções do resultado
A ordem atual das seções em `resultado.rs` é:
```
Totais → Faltantes → Duplicatas
```
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
**Sugestão:** Inverter para:
```
Faltantes → Duplicatas → Totais
```
Isso coloca a informação mais relevante no topo da tela.
---
## 6. Seletor de layout também na tela de configuração de colunas
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
---
## 7. Botão "Salvar como layout..." na tela de configuração
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
---
## 8. Feedback visual durante a análise
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
```
⏳ Analisando... aguarde.
```
Mesmo sem progresso percentual, já elimina a percepção de travamento.
---
## Resumo de Impacto
| # | Sugestão | Impacto UX | Esforço estimado |
|---|----------|------------|-----------------|
| 1 | Breadcrumb de etapas | Médio | Baixo |
| 2 | Unificar importação + configuração | Alto | Médio |
| 3 | Pré-visualização do arquivo | Alto | Médio |
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
| 5 | Reordenar seções do resultado | Médio | Baixo |
| 6 | Seletor de layout em configuração | Médio | Baixo |
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
| 8 | Feedback durante análise | Alto | Baixo |
Binary file not shown.
Binary file not shown.
+2
View File
@@ -0,0 +1,2 @@
[tools]
rust = "latest"
+1
View File
@@ -0,0 +1 @@
pub mod usecases;
@@ -0,0 +1,166 @@
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
services::{
detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
},
};
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Executa a pré-análise: calcula intervalos e duplicatas, sem expandir faltantes.
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por (serie, documento_tipo)
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (chave, notas_grupo) in &por_chave {
// Somar valores
for nota in notas_grupo.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(chave.clone(), intervalo);
}
}
let duplicadas_por_serie = duplicidades_por_serie(notas);
ResultadoPreAnalise {
intervalos_por_serie,
duplicadas_por_serie,
soma_total,
soma_por_serie,
total_por_serie,
}
}
/// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves que precisam de confirmação.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
.collect()
}
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
por_chave.entry(chave).or_default().push(nota);
}
let mut faltantes_por_serie = HashMap::new();
for (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(chave.clone(), faltantes);
}
ResultadoAnalise {
faltantes_por_serie,
duplicadas_por_serie: pre.duplicadas_por_serie,
soma_total: pre.soma_total,
soma_por_serie: pre.soma_por_serie,
total_por_serie: pre.total_por_serie,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec;
/// Executa análise completa sem verificar limites (atalho para testes).
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), 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]
fn analise_simples() {
let notas = vec![
nota(1, "001", Some(dec!(100.00))),
nota(2, "001", Some(dec!(200.00))),
nota(4, "001", Some(dec!(50.00))),
];
let resultado = executar_analise(&notas);
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));
}
#[test]
fn analise_multiplas_series() {
let notas = vec![
nota(1, "001", None),
nota(3, "001", None),
nota(1, "002", None),
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
}
#[test]
fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
}
#[test]
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
// NFE série 001: 1, 3 → faltante 2
// NFCE série 001: 2, 5 → faltante 3 e 4
let notas = vec![
nota_com_tipo(1, "001", "NFE"),
nota_com_tipo(3, "001", "NFE"),
nota_com_tipo(2, "001", "NFCE"),
nota_com_tipo(5, "001", "NFCE"),
];
let resultado = executar_analise(&notas);
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
}
}
+23
View File
@@ -0,0 +1,23 @@
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local;
use std::path::Path;
/// Exporta o resultado da análise para um arquivo PDF.
///
/// Depende da trait abstrata `PdfGenerator`, não de `genpdf` diretamente.
pub fn exportar_pdf(
gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise,
nome_arquivo: &str,
nome_layout: Option<&str>,
caminho_saida: &Path,
) -> Result<(), String> {
let meta = MetadadosRelatorio {
nome_arquivo: nome_arquivo.to_string(),
nome_layout: nome_layout.map(|s| s.to_string()),
gerado_em: Local::now(),
};
gerador.gerar(resultado, &meta, caminho_saida)
}
@@ -0,0 +1,261 @@
use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
use crate::domain::entities::nota::Nota;
use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use crate::infrastructure::{csv_reader, xlsx_reader};
use chrono::NaiveDate;
use std::path::Path;
/// Resultado da importação de arquivo.
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
/// Resultado parcial após seleção de arquivo XLSX (antes de configurar campos).
pub struct InfoXlsx {
pub abas: Vec<String>,
}
/// Lista as abas de um arquivo XLSX para exibição ao usuário.
pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
let abas = xlsx_reader::listar_abas(caminho)?;
Ok(InfoXlsx { abas })
}
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(
caminho: &Path,
config: &LayoutCsv,
) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv(
caminho,
config.delimitador as u8,
&config.encoding,
config.linha_cabecalho,
)?;
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_documento_tipo,
config.indice_valor,
config.indice_data,
&mut avisos,
);
Ok(ResultadoImportacao { notas, avisos })
}
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(
caminho: &Path,
config: &LayoutXlsx,
) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'",
config.pos_numero
))
})?;
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie).ok_or_else(|| {
ErroArquivo::ErroLeitura(format!("Posição de Serie inválida: '{}'", config.pos_serie))
})?;
coord_num.linha.min(coord_ser.linha)
};
let resultado = xlsx_reader::ler_xlsx(caminho, &config.aba, linha_inicio)?;
// Calcular índices de coluna para cada campo
let col_numero = xlsx_reader::parsear_letra_linha(&config.pos_numero)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Numero inválida".to_string()))?;
let col_serie = xlsx_reader::parsear_letra_linha(&config.pos_serie)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Serie inválida".to_string()))?;
let col_valor = config
.pos_valor
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_data = config
.pos_data
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_documento_tipo = config
.pos_documento_tipo
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
col_documento_tipo,
col_valor,
col_data,
&mut avisos,
);
Ok(ResultadoImportacao { notas, avisos })
}
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos.
fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
) -> Vec<Nota> {
let mut notas = Vec::new();
for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1;
// Extrair número
let str_numero = match linha.get(idx_numero) {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
avisos.numeros_invalidos += 1;
avisos
.detalhes
.push(format!("Linha {}: campo Numero ausente", linha_num));
continue;
}
};
// Tentar extrair apenas dígitos se não for numérico
let numero = match parse_numero(&str_numero) {
Ok(n) => n,
Err(msg) => {
avisos.numeros_invalidos += 1;
avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue;
}
};
// Extrair série
let str_serie = match linha.get(idx_serie) {
Some(s) => s.trim().to_string(),
None => {
avisos.series_invalidas += 1;
avisos
.detalhes
.push(format!("Linha {}: campo Serie ausente", linha_num));
continue;
}
};
let serie = match validar_serie(&str_serie) {
Ok(s) => s,
Err(_) => {
avisos.series_invalidas += 1;
avisos.detalhes.push(format!(
"Linha {}: Série inválida '{}'",
linha_num, str_serie
));
continue;
}
};
// Extrair valor (opcional)
let valor = if let Some(idx) = idx_valor {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => {
match crate::domain::services::parser_monetario::parse_valor(s.trim()) {
Ok(v) => Some(v),
Err(_) => {
avisos.valores_invalidos += 1;
avisos.detalhes.push(format!(
"Linha {}: valor monetário inválido '{}'",
linha_num, s
));
None
}
}
}
_ => None,
}
} else {
None
};
// Extrair data (opcional) — suporte a formatos dd/mm/aaaa e aaaa-mm-dd
let data = if let Some(idx) = idx_data {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => parse_data(s.trim()),
_ => None,
}
} else {
None
};
// 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
}
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
/// Rejeita zero.
fn parse_numero(s: &str) -> Result<u64, String> {
// Tentativa direta
if let Ok(n) = s.parse::<u64>() {
if n == 0 {
return Err(format!("Numero zero ('{}')", s));
}
return Ok(n);
}
// Tentar extrair apenas dígitos
let apenas_digitos: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if apenas_digitos.is_empty() {
return Err(format!("Numero não numérico: '{}'", s));
}
match apenas_digitos.parse::<u64>() {
Ok(0) => Err(format!("Numero zero após extração de dígitos: '{}'", s)),
Ok(n) => Ok(n),
Err(_) => Err(format!("Numero inválido: '{}'", s)),
}
}
/// Tenta parsear uma string de data em diversos formatos.
fn parse_data(s: &str) -> Option<NaiveDate> {
// dd/mm/aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
return Some(d);
}
// aaaa-mm-dd
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d);
}
// dd-mm-aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d-%m-%Y") {
return Some(d);
}
None
}
+111
View File
@@ -0,0 +1,111 @@
use crate::domain::{
entities::layout::{Layout, LayoutJson},
errors::ErroLayout,
};
use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection;
/// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayout> {
// Validar campos obrigatórios
if layout.nome().trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
}
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
Ok(id)
} else {
let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
if existe {
return Err(ErroLayout::NomeConflitante(nome));
}
layout_repository::salvar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
}
}
/// Lista todos os layouts salvos.
pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
layout_repository::listar(conn).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
}
/// Exporta um layout para JSON.
/// Retorna o conteúdo JSON como string e o nome de arquivo sugerido.
pub fn exportar_layout_json(layout: &Layout) -> Result<(String, String), String> {
let json_repr = LayoutJson::from(layout);
let conteudo = serde_json::to_string_pretty(&json_repr)
.map_err(|e| format!("Erro ao serializar layout: {}", e))?;
let nome_arquivo = format!("{}.json", layout.nome());
Ok((conteudo, nome_arquivo))
}
/// Importa um layout a partir de uma string JSON.
///
/// # Comportamento de conflito de nome
/// Se `sobrescrever_se_existir` for `true` e o nome já existir no banco, sobrescreve.
/// Se `false` e o nome já existir, retorna `ErroLayout::NomeConflitante`.
pub fn importar_layout_json(
conn: &Connection,
json: &str,
sobrescrever_se_existir: bool,
novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> {
// Deserializar
let json_repr: LayoutJson =
serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?;
// Aplicar novo nome se fornecido
if let Some(nome) = novo_nome {
match &mut layout {
Layout::Csv { nome: n, .. } => *n = nome.to_string(),
Layout::Xlsx { nome: n, .. } => *n = nome.to_string(),
}
}
// Verificar conflito de nome
let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
if existe {
if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let id_existente = layouts_existentes
.iter()
.find(|l| l.nome() == nome_atual)
.and_then(|l| l.id());
if let Some(id) = id_existente {
match &mut layout {
Layout::Csv { id: i, .. } => *i = Some(id),
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
layout_repository::atualizar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
return Ok(id);
}
} else {
return Err(ErroLayout::NomeConflitante(nome_atual));
}
}
// Inserir novo
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;
+26
View File
@@ -0,0 +1,26 @@
/// Chave composta que identifica um grupo de notas fiscais.
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
/// somente por série (retrocompatível).
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
impl ChaveSerie {
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
Self {
serie,
documento_tipo,
}
}
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
pub fn label(&self) -> String {
match &self.documento_tipo {
Some(tipo) => format!("{} / {}", self.serie, tipo),
None => self.serie.clone(),
}
}
}
+260
View File
@@ -0,0 +1,260 @@
use serde::{Deserialize, Serialize};
/// Tipo de arquivo suportado pelo sistema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TipoArquivo {
Csv,
Xlsx,
}
impl std::fmt::Display for TipoArquivo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TipoArquivo::Csv => write!(f, "csv"),
TipoArquivo::Xlsx => write!(f, "xlsx"),
}
}
}
/// Configurações de um layout para arquivo CSV.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutCsv {
/// Caractere delimitador: ',', ';' ou '\t'
pub delimitador: char,
/// Encoding do arquivo: "utf-8" ou "windows-1252"
pub encoding: String,
/// Número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
pub linha_cabecalho: usize,
/// Índice da coluna Numero (base 0)
pub indice_numero: usize,
/// Índice da coluna Serie (base 0)
pub indice_serie: usize,
/// Índice da coluna Valor (base 0, None se não mapeado)
pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
}
impl Default for LayoutCsv {
fn default() -> Self {
Self {
delimitador: ';',
encoding: "utf-8".to_string(),
linha_cabecalho: 1,
indice_numero: 0,
indice_serie: 1,
indice_valor: None,
indice_data: None,
indice_documento_tipo: None,
}
}
}
/// Configurações de um layout para arquivo XLSX.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutXlsx {
/// Nome ou índice (como string) da aba a ser processada
pub aba: String,
/// Posição inicial da coluna Numero no formato LetraLinha (ex: "D3")
pub pos_numero: String,
/// Posição inicial da coluna Serie no formato LetraLinha (ex: "B3")
pub pos_serie: String,
/// Posição inicial da coluna Valor (None se não mapeado)
pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
}
impl Default for LayoutXlsx {
fn default() -> Self {
Self {
aba: String::new(),
pos_numero: String::new(),
pos_serie: String::new(),
pos_valor: None,
pos_data: None,
pos_documento_tipo: None,
}
}
}
/// Layout de configuração do usuário.
/// Cada layout é exclusivo de um tipo de arquivo (CSV ou XLSX).
#[derive(Debug, Clone)]
pub enum Layout {
Csv {
id: Option<i64>,
nome: String,
config: LayoutCsv,
},
Xlsx {
id: Option<i64>,
nome: String,
config: LayoutXlsx,
},
}
impl Layout {
pub fn id(&self) -> Option<i64> {
match self {
Layout::Csv { id, .. } => *id,
Layout::Xlsx { id, .. } => *id,
}
}
pub fn nome(&self) -> &str {
match self {
Layout::Csv { nome, .. } => nome,
Layout::Xlsx { nome, .. } => nome,
}
}
pub fn tipo(&self) -> TipoArquivo {
match self {
Layout::Csv { .. } => TipoArquivo::Csv,
Layout::Xlsx { .. } => TipoArquivo::Xlsx,
}
}
}
/// Representação JSON de um layout (para importação/exportação).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "tipo", rename_all = "lowercase")]
pub enum LayoutJson {
Csv {
nome: String,
delimitador: String,
encoding: String,
linha_cabecalho: usize,
indice_numero: usize,
indice_serie: usize,
indice_valor: Option<usize>,
indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
},
Xlsx {
nome: String,
aba: String,
pos_numero: String,
pos_serie: String,
pos_valor: Option<String>,
pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
},
}
impl TryFrom<LayoutJson> for Layout {
type Error = crate::domain::errors::ErroLayout;
fn try_from(json: LayoutJson) -> Result<Self, Self::Error> {
match json {
LayoutJson::Csv {
nome,
delimitador,
encoding,
linha_cabecalho,
indice_numero,
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"nome".to_string(),
));
}
let delim_char = delimitador.chars().next().ok_or_else(|| {
crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"delimitador".to_string(),
)
})?;
Ok(Layout::Csv {
id: None,
nome,
config: LayoutCsv {
delimitador: delim_char,
encoding,
linha_cabecalho,
indice_numero,
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
},
})
}
LayoutJson::Xlsx {
nome,
aba,
pos_numero,
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"nome".to_string(),
));
}
if pos_numero.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if pos_serie.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"pos_serie".to_string(),
));
}
Ok(Layout::Xlsx {
id: None,
nome,
config: LayoutXlsx {
aba,
pos_numero,
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
},
})
}
}
}
}
impl From<&Layout> for LayoutJson {
fn from(layout: &Layout) -> Self {
match layout {
Layout::Csv { nome, config, .. } => LayoutJson::Csv {
nome: nome.clone(),
delimitador: config.delimitador.to_string(),
encoding: config.encoding.clone(),
linha_cabecalho: config.linha_cabecalho,
indice_numero: config.indice_numero,
indice_serie: config.indice_serie,
indice_valor: config.indice_valor,
indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
},
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(),
aba: config.aba.clone(),
pos_numero: config.pos_numero.clone(),
pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
},
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
pub mod chave_serie;
+36
View File
@@ -0,0 +1,36 @@
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie + documento_tipo` é o identificador único de cada nota.
#[derive(Debug, Clone)]
pub struct Nota {
/// Número incremental da nota. Armazenado como u64.
pub numero: u64,
/// Série da nota (13 dígitos numéricos). Ex: "001", "1".
pub serie: String,
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
pub documento_tipo: Option<String>,
/// Valor monetário da nota (opcional).
pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
pub data: Option<NaiveDate>,
}
impl Nota {
pub fn new(
numero: u64,
serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
documento_tipo,
valor,
data,
}
}
}
+65
View File
@@ -0,0 +1,65 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Resultado intermediário da análise, antes de materializar os faltantes.
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)]
pub struct ResultadoPreAnalise {
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
/// Intervalo de sequência de uma série.
#[derive(Debug, Clone)]
pub struct IntervaloSerie {
pub minimo: u64,
pub maximo: u64,
pub contagem_faltantes: u64,
}
impl IntervaloSerie {
pub fn excede_limite(&self, limite: u64) -> bool {
self.contagem_faltantes > limite
}
}
/// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)]
pub struct ResultadoAnalise {
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
impl ResultadoAnalise {
/// Retorna true se não houver faltantes nem duplicatas.
pub fn sem_inconsistencias(&self) -> bool {
self.faltantes_por_serie.values().all(|v| v.is_empty())
&& self.duplicadas_por_serie.values().all(|v| v.is_empty())
}
/// Retorna o total de notas faltantes somando todas as séries.
pub fn total_faltantes(&self) -> usize {
self.faltantes_por_serie.values().map(|v| v.len()).sum()
}
/// Retorna o total de grupos de duplicatas somando todas as séries.
pub fn total_duplicatas(&self) -> usize {
self.duplicadas_por_serie.values().map(|v| v.len()).sum()
}
}
+58
View File
@@ -0,0 +1,58 @@
use crate::domain::errors::ErroSerie;
use regex::Regex;
use std::sync::OnceLock;
static REGEX_SERIE: OnceLock<Regex> = OnceLock::new();
fn regex_serie() -> &'static Regex {
REGEX_SERIE.get_or_init(|| Regex::new(r"^[0-9]{1,3}$").expect("Regex de série inválida"))
}
/// Valida e normaliza uma série.
/// Retorna `Ok(serie_normalizada)` ou `Err(ErroSerie)`.
pub fn validar_serie(s: &str) -> Result<String, ErroSerie> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(ErroSerie::Vazia);
}
if regex_serie().is_match(trimmed) {
Ok(trimmed.to_string())
} else {
Err(ErroSerie::Invalida(trimmed.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serie_valida_simples() {
assert!(validar_serie("1").is_ok());
assert!(validar_serie("01").is_ok());
assert!(validar_serie("001").is_ok());
assert!(validar_serie("999").is_ok());
}
#[test]
fn serie_com_espacos_e_valida() {
assert!(validar_serie(" 1 ").is_ok());
}
#[test]
fn serie_invalida_letras() {
assert!(validar_serie("ABC").is_err());
assert!(validar_serie("1A").is_err());
}
#[test]
fn serie_invalida_quatro_digitos() {
assert!(validar_serie("1234").is_err());
}
#[test]
fn serie_vazia() {
assert!(matches!(validar_serie(""), Err(ErroSerie::Vazia)));
assert!(matches!(validar_serie(" "), Err(ErroSerie::Vazia)));
}
}
+86
View File
@@ -0,0 +1,86 @@
use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
Invalida(String),
#[error("Série vazia")]
Vazia,
}
#[derive(Debug, Error, Clone)]
pub enum ErroValor {
#[error("Valor negativo não é permitido: '{0}'")]
Negativo(String),
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)]
pub enum ErroLayout {
#[error("Campo obrigatório ausente: '{0}'")]
CampoObrigatorioAusente(String),
#[error("JSON malformado: {0}")]
JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String),
}
#[derive(Debug, Error, Clone)]
pub enum ErroArquivo {
#[error("Arquivo muito grande ({0} bytes). Limite: 50 MB")]
TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String),
#[error("Erro de leitura: {0}")]
ErroLeitura(String),
}
/// Resumo consolidado de avisos para exibição em um único modal.
#[derive(Debug, Default, Clone)]
pub struct ResumoAvisos {
pub linhas_malformadas: usize,
pub numeros_invalidos: usize,
pub series_invalidas: usize,
pub valores_invalidos: usize,
pub detalhes: Vec<String>,
}
impl ResumoAvisos {
pub fn tem_avisos(&self) -> bool {
self.linhas_malformadas > 0
|| self.numeros_invalidos > 0
|| self.series_invalidas > 0
|| self.valores_invalidos > 0
}
pub fn linhas_para_exibir(&self) -> Vec<String> {
let mut linhas = Vec::new();
if self.linhas_malformadas > 0 {
linhas.push(format!(
"{} linhas descartadas por malformação",
self.linhas_malformadas
));
}
if self.numeros_invalidos > 0 {
linhas.push(format!(
"{} valores de Numero inválidos convertidos ou descartados",
self.numeros_invalidos
));
}
if self.series_invalidas > 0 {
linhas.push(format!(
"{} registros com Série inválida descartados",
self.series_invalidas
));
}
if self.valores_invalidos > 0 {
linhas.push(format!(
"{} valores monetários inválidos descartados",
self.valores_invalidos
));
}
linhas.extend(self.detalhes.clone());
linhas
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entities;
pub mod errors;
pub mod services;
+120
View File
@@ -0,0 +1,120 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas.
///
/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1;
}
// Manter apenas os que têm mais de uma ocorrência
contagem.retain(|_, count| *count > 1);
contagem
}
/// Agrupa as duplicidades por (serie, documento_tipo).
///
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas);
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie, documento_tipo), contagem) in raw {
let chave = ChaveSerie::new(serie, documento_tipo);
result.entry(chave).or_default().push((numero, contagem));
}
// Ordenar por numero dentro de cada grupo
for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota {
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]
fn sem_duplicatas() {
let notas = vec![nota(1, "001"), nota(2, "001"), nota(3, "001")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1);
}
#[test]
fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
}
#[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
}
#[test]
fn agrupamento_por_serie() {
let notas = vec![
nota(1, "001"),
nota(1, "001"),
nota(1, "002"),
nota(1, "002"),
nota(2, "001"),
nota(2, "001"),
];
let por_serie = duplicidades_por_serie(&notas);
let chave001 = ChaveSerie::new("001".to_string(), None);
let chave002 = ChaveSerie::new("002".to_string(), None);
let serie001 = por_serie.get(&chave001).unwrap();
assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2));
let serie002 = por_serie.get(&chave002).unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
}
+188
View File
@@ -0,0 +1,188 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::IntervaloSerie;
/// Limite de faltantes por série antes de solicitar confirmação do usuário (RF04).
pub const LIMITE_FALTANTES: u64 = 10_000;
/// Calcula o intervalo de faltantes de uma lista de notas de uma mesma série,
/// sem materializar a lista completa.
///
/// Retorna `None` se não há notas.
pub fn calcular_intervalo(notas: &[&Nota]) -> Option<IntervaloSerie> {
if notas.is_empty() {
return None;
}
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
numeros.sort_unstable();
numeros.dedup(); // Ignorar duplicatas no cálculo de sequência
let minimo = *numeros.first().unwrap();
let maximo = *numeros.last().unwrap();
// Contar faltantes de forma incremental
let mut faltantes: u64 = 0;
for w in numeros.windows(2) {
let a = w[0];
let b = w[1];
if b > a + 1 {
faltantes += b - a - 1;
}
}
Some(IntervaloSerie {
minimo,
maximo,
contagem_faltantes: faltantes,
})
}
/// Materializa a lista completa de números faltantes para uma série.
/// Deve ser chamado apenas após confirmação do usuário quando o intervalo
/// excede `LIMITE_FALTANTES`.
///
/// A lista é retornada em ordem crescente.
pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
if notas.is_empty() {
return Vec::new();
}
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
numeros.sort_unstable();
numeros.dedup();
if numeros.len() <= 1 {
return Vec::new();
}
let mut faltantes = Vec::new();
for w in numeros.windows(2) {
let a = w[0];
let b = w[1];
for faltante in (a + 1)..b {
faltantes.push(faltante);
}
}
faltantes
}
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
///
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
///
/// # Exemplo
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
if faltantes.is_empty() {
return Vec::new();
}
let mut grupos = Vec::new();
let mut inicio = faltantes[0];
let mut anterior = faltantes[0];
for &num in &faltantes[1..] {
if num != anterior + 1 {
grupos.push((inicio, anterior));
inicio = num;
}
anterior = num;
}
grupos.push((inicio, anterior));
grupos
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None, None)
}
#[test]
fn sem_faltantes() {
let notas = vec![nota(1), nota(2), nota(3)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn com_faltante_simples() {
let notas = vec![nota(1), nota(2), nota(3), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), vec![4u64]);
}
#[test]
fn com_multiplos_faltantes() {
let notas = vec![nota(1), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), vec![2u64, 3, 4]);
}
#[test]
fn serie_com_um_registro() {
let notas = vec![nota(7)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn vazio() {
let refs: Vec<&Nota> = vec![];
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn intervalo_correto() {
let notas = vec![nota(1), nota(2), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
let intervalo = calcular_intervalo(&refs).unwrap();
assert_eq!(intervalo.minimo, 1);
assert_eq!(intervalo.maximo, 5);
assert_eq!(intervalo.contagem_faltantes, 2);
}
#[test]
fn intervalo_excede_limite() {
let notas = vec![nota(1), nota(20_000)];
let refs: Vec<&Nota> = notas.iter().collect();
let intervalo = calcular_intervalo(&refs).unwrap();
assert!(intervalo.excede_limite(LIMITE_FALTANTES));
}
#[test]
fn duplicatas_ignoradas_no_calculo() {
// Duplicados não devem gerar faltantes falsos
let notas = vec![nota(1), nota(1), nota(2), nota(3)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn agrupar_vazio() {
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
}
#[test]
fn agrupar_isolados() {
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
}
#[test]
fn agrupar_um_intervalo() {
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
}
#[test]
fn agrupar_misto() {
assert_eq!(
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
vec![(1, 3), (5, 5), (8, 9)]
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod detector_duplicidade;
pub mod detector_sequencia;
pub mod parser_monetario;
+175
View File
@@ -0,0 +1,175 @@
use crate::domain::errors::ErroValor;
use rust_decimal::Decimal;
use std::str::FromStr;
/// Faz o parsing de uma string de valor monetário para `Decimal`,
/// suportando formatos brasileiro (1.000,00) e americano (1,000.00).
///
/// Algoritmo conforme RF06:
/// - Regra 1: contém ponto E vírgula → último separador é o decimal
/// - Regra 2: apenas um separador + 2 dígitos após → decimal; 3 dígitos → milhar
/// - Regra 3: sem separador → número inteiro
///
/// Rejeita valores negativos.
pub fn parse_valor(input: &str) -> Result<Decimal, ErroValor> {
let s = input.trim();
// Rejeitar negativos
if s.starts_with('-') {
return Err(ErroValor::Negativo(s.to_string()));
}
// Remover prefixos/sufixos comuns (ex: "R$", espaços internos, "BRL")
let s = s.trim_start_matches("R$").trim_start_matches("r$").trim();
let tem_ponto = s.contains('.');
let tem_virgula = s.contains(',');
let normalizado = match (tem_ponto, tem_virgula) {
// Regra 1: tem ambos — último separador é o decimal
(true, true) => {
let pos_ponto = s.rfind('.').unwrap();
let pos_virgula = s.rfind(',').unwrap();
if pos_ponto > pos_virgula {
// Formato americano: 1,000.00 → ponto é decimal
s.replace(',', "")
} else {
// Formato brasileiro: 1.000,00 → vírgula é decimal
s.replace('.', "").replace(',', ".")
}
}
// Regra 2: apenas ponto
(true, false) => {
let depois_do_ponto = &s[s.rfind('.').unwrap() + 1..];
match depois_do_ponto.len() {
3 => {
// 3 dígitos após ponto → separador de milhar (ex: 1.234)
s.replace('.', "")
}
_ => {
// 2 dígitos ou outro → decimal (ex: 1000.00)
s.to_string()
}
}
}
// Regra 2: apenas vírgula
(false, true) => {
let depois_da_virgula = &s[s.rfind(',').unwrap() + 1..];
match depois_da_virgula.len() {
3 => {
// 3 dígitos após vírgula → separador de milhar (ex: 1,234)
s.replace(',', "")
}
_ => {
// 2 dígitos ou outro → decimal (ex: 1000,00)
s.replace(',', ".")
}
}
}
// Regra 3: sem separador → inteiro
(false, false) => s.to_string(),
};
// Verificar se ainda há caracteres não numéricos (exceto ponto decimal)
if normalizado.chars().any(|c| !c.is_ascii_digit() && c != '.') {
return Err(ErroValor::NaoNumerico(input.to_string()));
}
Decimal::from_str(&normalizado).map_err(|_| ErroValor::NaoNumerico(input.to_string()))
}
/// Formata um `Decimal` para exibição no formato monetário brasileiro.
/// Ex: 1234.56 → "1.234,56"
pub fn formatar_valor_br(valor: &Decimal) -> String {
let s = format!("{:.2}", valor);
// Separar parte inteira e decimal
let partes: Vec<&str> = s.split('.').collect();
let inteira = partes[0];
let decimal = partes.get(1).copied().unwrap_or("00");
// Inserir pontos de milhar
let inteira_com_milhar = inserir_pontos_milhar(inteira);
format!("{},{}", inteira_com_milhar, decimal)
}
fn inserir_pontos_milhar(s: &str) -> String {
let digits: Vec<char> = s.chars().collect();
let len = digits.len();
let mut result = String::new();
for (i, &c) in digits.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push('.');
}
result.push(c);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn regra1_formato_br() {
assert_eq!(parse_valor("1.000,00").unwrap(), dec!(1000.00));
}
#[test]
fn regra1_formato_en() {
assert_eq!(parse_valor("1,000.00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_ponto_2_digitos() {
assert_eq!(parse_valor("1000.00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_virgula_2_digitos() {
assert_eq!(parse_valor("1000,00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_ponto_3_digitos_milhar() {
assert_eq!(parse_valor("1.234").unwrap(), dec!(1234));
}
#[test]
fn regra2_virgula_3_digitos_milhar() {
assert_eq!(parse_valor("1,234").unwrap(), dec!(1234));
}
#[test]
fn regra3_inteiro() {
assert_eq!(parse_valor("1000").unwrap(), dec!(1000));
}
#[test]
fn rejeitar_negativo() {
assert!(matches!(
parse_valor("-100,00"),
Err(ErroValor::Negativo(_))
));
}
#[test]
fn rejeitar_nao_numerico() {
assert!(matches!(parse_valor("abc"), Err(ErroValor::NaoNumerico(_))));
}
#[test]
fn formatar_milhar() {
assert_eq!(formatar_valor_br(&dec!(1234.56)), "1.234,56");
}
#[test]
fn formatar_sem_milhar() {
assert_eq!(formatar_valor_br(&dec!(100.00)), "100,00");
}
#[test]
fn formatar_grande() {
assert_eq!(formatar_valor_br(&dec!(1234567.89)), "1.234.567,89");
}
}
+126
View File
@@ -0,0 +1,126 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use encoding_rs::WINDOWS_1252;
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Resultado da leitura de um arquivo CSV.
pub struct ResultadoCsv {
/// Linhas de dados (já sem o cabeçalho), cada linha é um vetor de strings.
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lê um arquivo CSV e retorna as linhas de dados (sem o cabeçalho).
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `delimitador`: caractere delimitador (`,`, `;` ou `\t`)
/// - `encoding`: "utf-8" ou "windows-1252"
/// - `linha_cabecalho`: número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
pub fn ler_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
linha_cabecalho: usize,
) -> Result<ResultadoCsv, ErroArquivo> {
// Verificar tamanho
let metadata = std::fs::metadata(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
// Ler conteúdo bruto
let bytes = std::fs::read(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
// Decodificar encoding
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => {
String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
}
};
let mut avisos = ResumoAvisos::default();
let mut linhas_dados: Vec<Vec<String>> = Vec::new();
let mut numero_linha_arquivo: usize = 0;
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
for resultado in reader.records() {
numero_linha_arquivo += 1;
// Pular linhas antes ou na linha do cabeçalho
if linha_cabecalho > 0 && numero_linha_arquivo <= linha_cabecalho {
continue;
}
match resultado {
Ok(record) => {
let campos: Vec<String> = record.iter().map(|s| s.to_string()).collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s| s.trim().is_empty()) {
continue;
}
linhas_dados.push(campos);
}
Err(_) => {
avisos.linhas_malformadas += 1;
}
}
}
Ok(ResultadoCsv {
linhas: linhas_dados,
avisos,
})
}
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
n: usize,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
let bytes = std::fs::read(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
let linhas = reader
.records()
.take(n)
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
Ok(linhas)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
+188
View File
@@ -0,0 +1,188 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
use genpdf::{
elements::{Break, Paragraph},
fonts, style, Document, SimplePageDecorator,
};
use std::path::Path;
// Fontes embutidas no binário em tempo de compilação.
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
const FONT_REGULAR: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] =
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
/// Metadados do relatório.
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
/// Trait abstrata para geração de PDF.
/// Definida aqui para que o use case `exportar_pdf` dependa da abstração,
/// não da crate `genpdf` diretamente.
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
/// Implementação concreta usando `genpdf`.
pub struct GenpdfGenerator;
impl PdfGenerator for GenpdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
doc.set_minimal_conformance();
let mut decorator = SimplePageDecorator::new();
decorator.set_margins(10);
doc.set_page_decorator(decorator);
// Título
doc.push(
Paragraph::new("").styled_string(
"Relatório de Análise de Notas Fiscais",
style::Style::new().bold().with_font_size(16),
),
);
doc.push(Break::new(1));
// Metadados
doc.push(Paragraph::new(format!("Arquivo: {}", meta.nome_arquivo)));
if let Some(layout) = &meta.nome_layout {
doc.push(Paragraph::new(format!("Layout: {}", layout)));
}
doc.push(Paragraph::new(format!(
"Gerado em: {}",
meta.gerado_em.format("%d/%m/%Y %H:%M:%S")
)));
doc.push(Break::new(1));
// Ordenar chaves
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves_ordenadas.sort();
// Totais
doc.push(
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
);
doc.push(Paragraph::new(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
)));
for chave in &chaves_ordenadas {
let soma = &resultado.soma_por_serie[*chave];
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
chave.label(),
total,
formatar_valor_br(soma)
)));
}
doc.push(Break::new(1));
// Notas Faltantes
doc.push(
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
);
// Use faltantes keys for this section (may differ from soma keys if no values)
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves_faltantes.sort();
for chave in &chaves_faltantes {
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
Some(f) if !f.is_empty() => f,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
chave.label(),
faltantes.len()
)));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
}
doc.push(Break::new(1));
// Duplicatas
doc.push(
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
);
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves_dup.sort();
for chave in &chaves_dup {
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
Some(d) if !d.is_empty() => d,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
chave.label(),
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, chave.label(), count
)));
}
}
// Renderizar PDF
doc.render_to_file(caminho_saida)
.map_err(|e| format!("Erro ao gerar PDF: {}", e))?;
Ok(())
}
}
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
.map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
// italic e bold_italic reusam os dados de regular/bold respectivamente.
Ok(fonts::FontFamily {
italic: regular.clone(),
bold_italic: bold.clone(),
regular,
bold,
})
}
+54
View File
@@ -0,0 +1,54 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
/// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("comparador-notas").join("config.db")
}
/// Abre (ou cria) o banco de dados SQLite no caminho padrão.
///
/// Retorna a conexão aberta e pronta para uso, ou um erro descritivo.
/// Em caso de banco corrompido, renomeia para `.bak` e recria.
pub fn abrir_banco() -> Result<(Connection, bool), String> {
let caminho = caminho_banco();
abrir_banco_no_caminho(&caminho)
}
/// Versão testável que aceita um caminho explícito.
pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), String> {
// Criar diretório se não existir
if let Some(dir) = caminho.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Não foi possível criar diretório do banco: {}", e))?;
}
// Verificar se o arquivo existe e está corrompido
if caminho.exists() {
match Connection::open(caminho) {
Ok(conn) => {
// Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)),
Err(_e) => {
// Banco corrompido
drop(conn);
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
Err(_) => {
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
// Criar banco novo
let conn = Connection::open(caminho)
.map_err(|e| format!("Não foi possível criar banco de dados: {}", e))?;
Ok((conn, true)) // true = banco foi recriado (era corrompido)
}
@@ -0,0 +1,173 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result};
/// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
config.delimitador.to_string(),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
],
)?;
Ok(conn.last_insert_rowid())
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
],
)?;
Ok(conn.last_insert_rowid())
}
}
}
/// Atualiza um layout existente no banco.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout
.id()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("id ausente".to_string()))?;
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
WHERE id = ?10",
params![
nome,
config.delimitador.to_string(),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id,
],
)?;
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
WHERE id = ?8",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
id,
],
)?;
}
}
Ok(())
}
/// Lista todos os layouts salvos.
pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC",
)?;
let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row
.get::<_, Option<i64>>(8)?
.map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>(9)?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>(15)?
.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
pos_documento_tipo: row.get(16)?,
},
})
}
})?
.collect();
layouts
}
/// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
Ok(())
}
/// Verifica se já existe um layout com o nome fornecido.
pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1",
[nome],
|row| row.get(0),
)?;
Ok(count > 0)
}
+94
View File
@@ -0,0 +1,94 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
// Criar tabela de controle de versão se não existir
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);",
)?;
let versao_atual: i64 = conn
.query_row(
"SELECT versao FROM schema_version LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or(0);
if versao_atual < 1 {
migration_v1(conn)?;
}
if versao_atual < 2 {
migration_v2(conn)?;
}
if versao_atual < 3 {
migration_v3(conn)?;
}
if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
}
Ok(())
}
/// Migration v2: adicionar índice único em layouts.nome.
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
)?;
Ok(())
}
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
/// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch(
"UPDATE layouts
SET nome = nome || ' (' || id || ')'
WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);",
)?;
Ok(())
}
/// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);",
)?;
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod connection;
pub mod layout_repository;
pub mod migrations;
+235
View File
@@ -0,0 +1,235 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use calamine::{open_workbook_auto, Reader};
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Representa uma coordenada de célula (coluna base-0, linha base-0).
#[derive(Debug, Clone, Copy)]
pub struct Coordenada {
pub coluna: u32,
pub linha: u32,
}
/// Resultado da leitura de um arquivo XLSX.
pub struct ResultadoXlsx {
/// Linhas de dados, a partir da posição de início.
/// Cada linha é um vetor de strings (indexado pela posição da coluna de início).
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lista as abas disponíveis em um arquivo XLSX (ou XLS com extensão .xls mas conteúdo XLSX).
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?;
let workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec())
}
/// Lê os dados de uma aba XLSX (ou XLS com extensão .xls mas conteúdo XLSX) a partir de uma linha de início.
///
/// O formato real é detectado pelo conteúdo do arquivo (magic bytes), não pela extensão.
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `nome_aba`: nome da aba a ser lida
/// - `linha_inicio`: número da linha de início dos dados (base 1, ex: 3 para "B3")
pub fn ler_xlsx(
caminho: &Path,
nome_aba: &str,
linha_inicio: u32,
) -> Result<ResultadoXlsx, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
for (row_idx, row) in range.rows().enumerate() {
if row_idx < linha_inicio_base0 {
continue;
}
let campos: Vec<String> = row
.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) {
continue;
}
linhas.push(campos);
}
Ok(ResultadoXlsx { linhas, avisos })
}
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_xlsx(
caminho: &Path,
nome_aba: &str,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook = open_workbook_auto(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let linhas = range
.rows()
.take(5)
.map(|row| {
row.iter()
.map(|cell| {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
})
.collect()
})
.collect();
Ok(linhas)
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
///
/// Retorna `None` se a notação for inválida.
pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
let s = s.trim().to_uppercase();
if s.is_empty() {
return None;
}
let pos_numero = s.find(|c: char| c.is_ascii_digit())?;
let (letras, numeros) = s.split_at(pos_numero);
if letras.is_empty() || numeros.is_empty() {
return None;
}
// Converter letras para índice de coluna (base 0)
// A=0, B=1, ..., Z=25, AA=26, ...
let coluna = letras
.chars()
.try_fold(0u32, |acc, c| {
if c.is_ascii_uppercase() {
Some(acc * 26 + (c as u32 - 'A' as u32 + 1))
} else {
None
}
})?
.checked_sub(1)?;
let linha: u32 = numeros.parse().ok()?;
if linha == 0 {
return None;
}
Some(Coordenada { coluna, linha })
}
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata = std::fs::metadata(caminho)
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsear_b3() {
let coord = parsear_letra_linha("B3").unwrap();
assert_eq!(coord.coluna, 1); // B = coluna 1 (base 0)
assert_eq!(coord.linha, 3);
}
#[test]
fn parsear_a1() {
let coord = parsear_letra_linha("A1").unwrap();
assert_eq!(coord.coluna, 0);
assert_eq!(coord.linha, 1);
}
#[test]
fn parsear_z1() {
let coord = parsear_letra_linha("Z1").unwrap();
assert_eq!(coord.coluna, 25);
}
#[test]
fn parsear_aa1() {
let coord = parsear_letra_linha("AA1").unwrap();
assert_eq!(coord.coluna, 26);
}
#[test]
fn parsear_invalido() {
assert!(parsear_letra_linha("").is_none());
assert!(parsear_letra_linha("3B").is_none());
assert!(parsear_letra_linha("123").is_none());
}
#[test]
fn parsear_minusculo() {
let coord = parsear_letra_linha("b3").unwrap();
assert_eq!(coord.coluna, 1);
}
}
+25 -2
View File
@@ -1,3 +1,26 @@
fn main() { mod application;
println!("Hello, world!"); mod domain;
mod infrastructure;
mod ui;
use ui::app::App;
fn main() -> eframe::Result {
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("Comparador de Notas")
.with_inner_size([1024.0, 768.0])
.with_min_inner_size([800.0, 600.0]),
..Default::default()
};
eframe::run_native(
"Comparador de Notas",
native_options,
Box::new(|_cc| {
let mut app = App::default();
app.inicializar();
Ok(Box::new(app))
}),
)
} }
+702
View File
@@ -0,0 +1,702 @@
use crate::application::usecases::{
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
importar_arquivo::{importar_csv, importar_xlsx},
layouts::{excluir_layout, listar_layouts, salvar_layout},
};
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
errors::{ErroLayout, ResumoAvisos},
};
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use egui::Context;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::mpsc;
/// Estado global da aplicação.
pub enum EstadoApp {
/// Tela inicial: importar arquivo.
Importando,
/// Aguardando seleção de aba de arquivo XLSX.
SelecionandoAba { abas: Vec<String>, caminho: PathBuf },
/// Configuração de colunas após importar arquivo.
ConfigurandoColunas,
/// Resultado pronto para exibição.
ExibindoResultado(ResultadoAnalise),
/// Aguardando confirmação do usuário para expandir faltantes.
ConfirmandoIntervalo { pre: ResultadoPreAnalise },
/// Gerenciamento de layouts.
GerenciandoLayouts,
/// Análise em execução em background (thread separada).
Analisando,
}
/// Modal bloqueante a ser exibido sobre qualquer tela.
#[derive(Default)]
pub struct Modal {
pub visivel: bool,
pub titulo: String,
pub mensagem: String,
pub tipo: TipoModal,
/// Para modal de confirmação, a ação ao confirmar.
pub acao_confirmacao: Option<AcaoModal>,
/// Para modal com campo de texto (InputTexto).
pub input_texto: String,
}
#[derive(Default, Clone, PartialEq)]
pub enum TipoModal {
#[default]
Informacao,
Aviso,
Erro,
Confirmacao,
/// Modal com campo de texto para entrada do usuário.
InputTexto,
}
#[derive(Clone)]
pub enum AcaoModal {
ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64),
/// Sobrescrever layout existente — layout já carrega o id correto.
SobrescreverLayout(Layout),
ConfirmarNovaAnalise,
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
SalvarLayoutConfig,
}
/// Resultado enviado pela thread de análise de volta para a UI.
pub enum ResultadoPendente {
/// Análise concluída com sucesso.
Concluido {
resultado: ResultadoAnalise,
/// Avisos de importação (None quando vier do path de expansão).
avisos: Option<ResumoAvisos>,
/// Notas importadas (None quando o App já as possui).
notas: Option<Vec<Nota>>,
},
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, u64)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
/// Arquivo importado não continha notas válidas.
Vazio,
/// Erro durante importação ou análise.
Erro(String),
}
/// Struct principal da aplicação egui.
pub struct App {
pub estado: EstadoApp,
pub conn: Option<Connection>,
pub banco_foi_recriado: bool,
// Dados em memória do arquivo atual
pub notas_importadas: Vec<Nota>,
pub caminho_arquivo: Option<PathBuf>,
pub nome_arquivo: String,
// Configuração de layout atual
pub tipo_arquivo_atual: TipoArquivo,
pub layout_csv_atual: LayoutCsv,
pub layout_xlsx_atual: LayoutXlsx,
pub nome_layout_atual: String,
// Abas XLSX disponíveis (após seleção do arquivo)
pub abas_xlsx: Vec<String>,
// Layouts salvos no banco
pub layouts_salvos: Vec<Layout>,
// Modal
pub modal: Modal,
// Avisos da última importação
pub avisos_importacao: Option<ResumoAvisos>,
// Paginação (para tela de resultado)
pub pagina_faltantes: usize,
pub pagina_duplicatas: usize,
pub itens_por_pagina: usize,
// Pré-visualização das primeiras linhas do arquivo
pub preview_arquivo: Option<Vec<Vec<String>>>,
// Canal para receber resultado da análise em background
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
// Resultado anterior, preservado durante reanálise para restaurar em caso de falha
pub resultado_anterior: Option<ResultadoAnalise>,
}
impl Default for App {
fn default() -> Self {
Self {
estado: EstadoApp::Importando,
conn: None,
banco_foi_recriado: false,
notas_importadas: Vec::new(),
caminho_arquivo: None,
nome_arquivo: String::new(),
tipo_arquivo_atual: TipoArquivo::Csv,
layout_csv_atual: LayoutCsv::default(),
layout_xlsx_atual: LayoutXlsx::default(),
nome_layout_atual: String::new(),
abas_xlsx: Vec::new(),
layouts_salvos: Vec::new(),
modal: Modal::default(),
avisos_importacao: None,
pagina_faltantes: 0,
pagina_duplicatas: 0,
itens_por_pagina: 100,
preview_arquivo: None,
resultado_pendente: None,
resultado_anterior: None,
}
}
}
impl App {
/// Retorna um id numérico da tela atual para evitar borrow conflicts.
pub fn tela_atual(&self) -> u8 {
match &self.estado {
EstadoApp::Importando => 0,
EstadoApp::SelecionandoAba { .. } => 1,
EstadoApp::ConfigurandoColunas => 2,
EstadoApp::ExibindoResultado(_) => 3,
EstadoApp::ConfirmandoIntervalo { .. } => 4,
EstadoApp::GerenciandoLayouts => 5,
EstadoApp::Analisando => 6,
}
}
/// Inicializa o banco de dados e carrega os layouts salvos.
pub fn inicializar(&mut self) {
match abrir_banco() {
Ok((conn, recriado)) => {
self.banco_foi_recriado = recriado;
if let Err(e) = aplicar_migrations(&conn) {
self.exibir_erro(format!("Erro ao inicializar banco: {}", e));
return;
}
match listar_layouts(&conn) {
Ok(layouts) => self.layouts_salvos = layouts,
Err(e) => self.exibir_erro(format!("Erro ao carregar layouts: {}", e)),
}
self.conn = Some(conn);
if recriado {
self.exibir_aviso(
"Banco de dados corrompido",
"O banco de dados estava corrompido e foi recriado. Os layouts anteriores foram arquivados em config.db.bak.",
);
}
}
Err(e) => {
self.exibir_erro(format!("Erro crítico ao abrir banco de dados: {}", e));
}
}
}
pub fn exibir_erro(&mut self, msg: impl Into<String>) {
self.modal = Modal {
visivel: true,
titulo: "Erro".to_string(),
mensagem: msg.into(),
tipo: TipoModal::Erro,
acao_confirmacao: None,
input_texto: String::new(),
};
}
pub fn exibir_aviso(&mut self, titulo: impl Into<String>, msg: impl Into<String>) {
self.modal = Modal {
visivel: true,
titulo: titulo.into(),
mensagem: msg.into(),
tipo: TipoModal::Aviso,
acao_confirmacao: None,
input_texto: String::new(),
};
}
pub fn exibir_confirmacao(
&mut self,
titulo: impl Into<String>,
msg: impl Into<String>,
acao: AcaoModal,
) {
self.modal = Modal {
visivel: true,
titulo: titulo.into(),
mensagem: msg.into(),
tipo: TipoModal::Confirmacao,
acao_confirmacao: Some(acao),
input_texto: String::new(),
};
}
/// Recarrega a lista de layouts do banco.
/// Exibe modal com campo de texto para salvar o layout atual como novo preset.
pub fn exibir_modal_salvar_layout(&mut self) {
self.modal = Modal {
visivel: true,
titulo: "Salvar como layout...".to_string(),
mensagem: "Nome do layout:".to_string(),
tipo: TipoModal::InputTexto,
acao_confirmacao: Some(AcaoModal::SalvarLayoutConfig),
input_texto: self.nome_layout_atual.clone(),
};
}
pub fn recarregar_layouts(&mut self) {
if let Some(conn) = &self.conn {
match listar_layouts(conn) {
Ok(layouts) => self.layouts_salvos = layouts,
Err(e) => self.exibir_erro(format!("Erro ao carregar layouts: {}", e)),
}
}
}
/// Renderiza o modal bloqueante, se visível.
pub fn renderizar_modal(&mut self, ctx: &Context) {
if !self.modal.visivel {
return;
}
let titulo = self.modal.titulo.clone();
let mensagem = self.modal.mensagem.clone();
let tipo = self.modal.tipo.clone();
let acao = self.modal.acao_confirmacao.clone();
egui::Window::new(&titulo)
.collapsible(false)
.resizable(false)
.min_width(320.0)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.add_space(8.0);
ui.label(&mensagem);
if tipo == TipoModal::InputTexto {
ui.add_space(4.0);
let resp = ui.add(
egui::TextEdit::singleline(&mut self.modal.input_texto)
.desired_width(280.0)
.hint_text("Nome do layout"),
);
resp.request_focus();
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
if ui.button("✔ Confirmar").clicked() {
self.modal.visivel = false;
if let Some(acao) = acao.clone() {
self.executar_acao_modal(acao);
}
}
if ui.button("✖ Cancelar").clicked() {
self.modal.visivel = false;
self.modal.input_texto.clear();
}
} else {
if ui.button("OK").clicked() {
self.modal.visivel = false;
}
}
});
});
}
fn executar_acao_modal(&mut self, acao: AcaoModal) {
match acao {
AcaoModal::ConfirmarExpansaoFaltantes => {
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
std::mem::replace(&mut self.estado, EstadoApp::Analisando)
{
let notas = self.notas_importadas.clone();
let (tx, rx) = mpsc::channel();
self.resultado_pendente = Some(rx);
std::thread::spawn(move || {
let resultado = expandir_analise(pre, &notas);
let _ = tx.send(ResultadoPendente::Concluido {
resultado,
avisos: None,
notas: None,
});
});
}
}
AcaoModal::ConfirmarExclusaoLayout(id) => {
if let Some(conn) = &self.conn {
if let Err(e) = excluir_layout(conn, id) {
self.exibir_erro(e);
} else {
self.recarregar_layouts();
}
}
}
AcaoModal::SobrescreverLayout(layout) => {
if let Some(conn) = &self.conn {
match salvar_layout(conn, &layout) {
Ok(_) => {
self.recarregar_layouts();
}
Err(e) => {
self.exibir_erro(e.to_string());
}
}
}
}
AcaoModal::ConfirmarNovaAnalise => {
self.notas_importadas.clear();
self.preview_arquivo = None;
self.estado = EstadoApp::Importando;
}
AcaoModal::SalvarLayoutConfig => {
let nome = self.modal.input_texto.trim().to_string();
self.modal.input_texto.clear();
if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio.");
return;
}
let layout = match &self.tipo_arquivo_atual {
TipoArquivo::Csv => Layout::Csv {
id: None,
nome: nome.clone(),
config: self.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: None,
nome: nome.clone(),
config: self.layout_xlsx_atual.clone(),
},
};
let resultado = if let Some(conn) = &self.conn {
Some(salvar_layout(conn, &layout))
} else {
None
};
match resultado {
Some(Ok(_)) => {
self.nome_layout_atual = nome;
self.recarregar_layouts();
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
}
Some(Err(ErroLayout::NomeConflitante(nome_conflito))) => {
let id_existente = self
.layouts_salvos
.iter()
.find(|l| l.nome() == nome_conflito)
.and_then(|l| l.id());
if let Some(id) = id_existente {
let layout_com_id = match &self.tipo_arquivo_atual {
TipoArquivo::Csv => Layout::Csv {
id: Some(id),
nome: nome_conflito.clone(),
config: self.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: Some(id),
nome: nome_conflito.clone(),
config: self.layout_xlsx_atual.clone(),
},
};
self.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome_conflito
),
AcaoModal::SobrescreverLayout(layout_com_id),
);
}
}
Some(Err(e)) => self.exibir_erro(e.to_string()),
None => {}
}
}
}
}
/// Processa o resultado recebido do canal de análise em background.
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
match resultado {
ResultadoPendente::Concluido {
resultado,
avisos,
notas,
} => {
if let Some(n) = notas {
self.notas_importadas = n;
}
if let Some(av) = &avisos {
self.avisos_importacao = if av.tem_avisos() {
Some(av.clone())
} else {
None
};
}
self.resultado_anterior = None;
self.estado = EstadoApp::ExibindoResultado(resultado);
if let Some(av) = &self.avisos_importacao.clone() {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
self.exibir_aviso("Avisos de Importação", linhas);
}
}
}
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas,
avisos,
notas,
} => {
self.notas_importadas = notas;
self.avisos_importacao = if avisos.tem_avisos() {
Some(avisos)
} else {
None
};
let msg = series_excessivas
.iter()
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
})
.collect::<Vec<_>>()
.join("\n");
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
msg
),
AcaoModal::ConfirmarExpansaoFaltantes,
);
}
ResultadoPendente::Vazio => {
if let Some(resultado) = self.resultado_anterior.take() {
self.estado = EstadoApp::ExibindoResultado(resultado);
} else {
self.estado = EstadoApp::ConfigurandoColunas;
}
self.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
}
ResultadoPendente::Erro(e) => {
if let Some(resultado) = self.resultado_anterior.take() {
self.estado = EstadoApp::ExibindoResultado(resultado);
} else {
self.estado = EstadoApp::ConfigurandoColunas;
}
self.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
}
/// Reimporta o arquivo atual com o layout atual e executa análise.
/// Segue o mesmo padrão assíncrono de `executar_importacao` em configuracao_colunas.rs.
pub fn reimportar_e_analisar(&mut self, ctx: &Context) {
let caminho = match &self.caminho_arquivo {
Some(p) => p.clone(),
None => {
self.exibir_erro("Nenhum arquivo carregado.");
return;
}
};
// Preservar resultado atual para restaurar em caso de erro ou arquivo vazio
if let EstadoApp::ExibindoResultado(r) = &self.estado {
self.resultado_anterior = Some(r.clone());
}
let tipo = self.tipo_arquivo_atual.clone();
let layout_csv = self.layout_csv_atual.clone();
let layout_xlsx = self.layout_xlsx_atual.clone();
let (tx, rx) = mpsc::channel();
self.resultado_pendente = Some(rx);
self.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
TipoArquivo::Xlsx => {
importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string())
}
};
let res = match res_importacao {
Err(e) => ResultadoPendente::Erro(e),
Ok(importado) => {
if importado.notas.is_empty() {
ResultadoPendente::Vazio
} else {
let avisos = importado.avisos.clone();
let notas = importado.notas;
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
/// Executa a análise com as notas importadas.
pub fn executar_analise(&mut self) {
if self.notas_importadas.is_empty() {
self.exibir_aviso("Aviso", "Nenhuma nota importada para analisar.");
return;
}
let pre = pre_analisar(&self.notas_importadas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
let msg = excessivos
.iter()
.map(|(chave, count)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
chave.label(),
count
)
})
.collect::<Vec<_>>()
.join("\n");
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
self.exibir_confirmacao(
"Intervalo muito grande",
format!(
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
msg
),
acao,
);
} else {
let resultado = expandir_analise(pre, &self.notas_importadas);
self.estado = EstadoApp::ExibindoResultado(resultado);
}
}
}
impl App {
/// Renderiza o breadcrumb de etapas no topo (exceto na tela de layouts).
pub fn renderizar_breadcrumb(&self, ctx: &Context) {
let passo_ativo: usize = match self.tela_atual() {
0 | 1 => 1,
2 | 4 | 6 => 2,
3 => 3,
_ => return, // GerenciandoLayouts: sem breadcrumb
};
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
ui.add_space(4.0);
ui.horizontal(|ui| {
for (i, label) in ["Arquivo", "Colunas", "Resultado"].iter().enumerate() {
let n = i + 1;
let texto_completo = format!("{}. {}", n, label);
let texto = egui::RichText::new(texto_completo);
if n == passo_ativo {
ui.label(texto.strong());
} else if n < passo_ativo {
ui.label(texto);
} else {
ui.label(texto.weak());
}
if n < 3 {
ui.label(egui::RichText::new(" ").weak());
}
}
});
ui.add_space(4.0);
});
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
// Verificar resultado pendente da análise em background
if let Some(rx) = self.resultado_pendente.take() {
match rx.try_recv() {
Ok(resultado) => {
self.processar_resultado_pendente(resultado);
}
Err(mpsc::TryRecvError::Empty) => {
// Ainda processando: devolver receiver e solicitar repaint
self.resultado_pendente = Some(rx);
ctx.request_repaint();
}
Err(mpsc::TryRecvError::Disconnected) => {
self.estado = EstadoApp::ConfigurandoColunas;
self.exibir_erro("Erro interno: a análise foi interrompida inesperadamente.");
}
}
}
// Sempre renderizar modal por cima de tudo
self.renderizar_modal(ctx);
// Breadcrumb de etapas no topo
self.renderizar_breadcrumb(ctx);
// Determinar qual tela exibir sem borrar self.estado
let tela_id = self.tela_atual();
// Renderizar a tela atual
egui::CentralPanel::default().show(ctx, |ui| match tela_id {
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
6 => {
ui.centered_and_justified(|ui| {
ui.label(
egui::RichText::new("⏳ Analisando... aguarde.")
.size(22.0)
.strong(),
);
});
}
_ => {}
});
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod app;
pub mod screens;
+454
View File
@@ -0,0 +1,454 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
use crate::application::usecases::executar_analise::{
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
};
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
use egui::{Context, Ui};
/// Renderiza a tela de configuração de colunas.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Configuração de Colunas");
ui.add_space(8.0);
if let Some(caminho) = &app.caminho_arquivo.clone() {
ui.label(format!("Arquivo: {}", caminho.display()));
}
ui.add_space(8.0);
// Seletor de layout
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_config")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else {
&nome_layout_atual
})
.show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout {
if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone();
if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone();
match &layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
}
}
}
}
}
});
});
ui.add_space(12.0);
match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => renderizar_csv(ui, app),
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
}
// Pré-visualização do arquivo
if let Some(preview) = &app.preview_arquivo.clone() {
ui.add_space(8.0);
ui.separator();
ui.add_space(4.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
}
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
// Validação e botões de ação
let (valido, erros) = validar_config(app);
if !erros.is_empty() {
for erro in &erros {
ui.colored_label(egui::Color32::RED, format!("{}", erro));
}
ui.add_space(8.0);
}
ui.horizontal(|ui| {
if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando;
}
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
if ui.button("▶ Importar e Analisar").clicked() {
executar_importacao(app, ctx);
}
});
if valido && !app.notas_importadas.is_empty() {
if ui
.button("🔄 Reanalisar")
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
.clicked()
{
app.executar_analise();
}
}
if ui.button("💾 Salvar como layout...").clicked() {
app.exibir_modal_salvar_layout();
}
});
}
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
ui.group(|ui| {
ui.label("Configurações CSV");
ui.add_space(4.0);
// Delimitador
ui.horizontal(|ui| {
ui.label("Delimitador:");
let delim_str = match app.layout_csv_atual.delimitador {
',' => "Vírgula (,)",
';' => "Ponto e vírgula (;)",
'\t' => "Tabulação (Tab)",
_ => "Outro",
};
let mut delim_mudou = false;
egui::ComboBox::from_id_salt("combo_delimitador")
.selected_text(delim_str)
.show_ui(ui, |ui| {
if ui
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
.clicked()
{
app.layout_csv_atual.delimitador = ',';
delim_mudou = true;
}
if ui
.selectable_label(
app.layout_csv_atual.delimitador == ';',
"Ponto e vírgula (;)",
)
.clicked()
{
app.layout_csv_atual.delimitador = ';';
delim_mudou = true;
}
if ui
.selectable_label(
app.layout_csv_atual.delimitador == '\t',
"Tabulação (Tab)",
)
.clicked()
{
app.layout_csv_atual.delimitador = '\t';
delim_mudou = true;
}
});
if delim_mudou {
if let Some(caminho) = &app.caminho_arquivo.clone() {
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
).ok();
}
}
});
// Encoding
ui.horizontal(|ui| {
ui.label("Encoding:");
egui::ComboBox::from_id_salt("combo_encoding")
.selected_text(&app.layout_csv_atual.encoding)
.show_ui(ui, |ui| {
if ui
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
.clicked()
{
app.layout_csv_atual.encoding = "utf-8".to_string();
}
if ui
.selectable_label(
app.layout_csv_atual.encoding == "windows-1252",
"Windows-1252 (Latin-1)",
)
.clicked()
{
app.layout_csv_atual.encoding = "windows-1252".to_string();
}
});
});
// Linha cabeçalho
ui.horizontal(|ui| {
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
let mut val = app.layout_csv_atual.linha_cabecalho;
ui.add(egui::DragValue::new(&mut val).range(0..=100));
app.layout_csv_atual.linha_cabecalho = val;
});
});
ui.add_space(8.0);
ui.group(|ui| {
ui.label("Mapeamento de Colunas (índice base 0)");
ui.add_space(4.0);
campo_indice(
ui,
"Número (obrigatório):",
&mut app.layout_csv_atual.indice_numero,
);
campo_indice(
ui,
"Série (obrigatório):",
&mut app.layout_csv_atual.indice_serie,
);
campo_indice_opcional(
ui,
"Valor (opcional):",
&mut app.layout_csv_atual.indice_valor,
);
campo_indice_opcional(
ui,
"Data (opcional):",
&mut app.layout_csv_atual.indice_data,
);
campo_indice_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_csv_atual.indice_documento_tipo,
);
});
}
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
ui.group(|ui| {
ui.label("Configurações XLSX");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Aba:");
if app.abas_xlsx.is_empty() {
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
} else {
let aba_atual = app.layout_xlsx_atual.aba.clone();
egui::ComboBox::from_id_salt("combo_aba")
.selected_text(&aba_atual)
.show_ui(ui, |ui| {
for aba in &app.abas_xlsx.clone() {
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
app.layout_xlsx_atual.aba = aba.clone();
}
}
});
}
});
});
ui.add_space(8.0);
ui.group(|ui| {
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
ui.add_space(4.0);
campo_letra_linha(
ui,
"Número (obrigatório):",
&mut app.layout_xlsx_atual.pos_numero,
);
campo_letra_linha(
ui,
"Série (obrigatório):",
&mut app.layout_xlsx_atual.pos_serie,
);
campo_letra_linha_opcional(
ui,
"Valor (opcional):",
&mut app.layout_xlsx_atual.pos_valor,
);
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
campo_letra_linha_opcional(
ui,
"Tipo Documento (opcional):",
&mut app.layout_xlsx_atual.pos_documento_tipo,
);
});
}
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) {
ui.horizontal(|ui| {
ui.label(label);
ui.add(egui::DragValue::new(valor).range(0..=999usize));
});
}
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) {
ui.horizontal(|ui| {
let mut ativo = valor.is_some();
if ui.checkbox(&mut ativo, label).changed() {
*valor = if ativo { Some(0) } else { None };
}
if let Some(v) = valor {
ui.add(egui::DragValue::new(v).range(0..=999usize));
}
});
}
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) {
ui.horizontal(|ui| {
ui.label(label);
ui.text_edit_singleline(valor);
});
}
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) {
ui.horizontal(|ui| {
let mut ativo = valor.is_some();
if ui.checkbox(&mut ativo, label).changed() {
*valor = if ativo { Some(String::new()) } else { None };
}
if let Some(v) = valor {
ui.text_edit_singleline(v);
}
});
}
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
fn validar_config(app: &App) -> (bool, Vec<String>) {
let mut erros = Vec::new();
match &app.tipo_arquivo_atual {
TipoArquivo::Csv => {
let c = &app.layout_csv_atual;
// Verificar índices duplicados
let mut indices: Vec<(String, usize)> = vec![
("Numero".to_string(), c.indice_numero),
("Serie".to_string(), c.indice_serie),
];
if let Some(v) = c.indice_valor {
indices.push(("Valor".to_string(), v));
}
if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d));
}
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros);
}
TipoArquivo::Xlsx => {
let c = &app.layout_xlsx_atual;
if c.aba.trim().is_empty() {
erros.push("Selecione uma aba".to_string());
}
if c.pos_numero.trim().is_empty() {
erros.push("Posição do campo Numero é obrigatória".to_string());
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_numero)
.is_none()
{
erros.push(format!("Posição Numero inválida: '{}'", c.pos_numero));
}
if c.pos_serie.trim().is_empty() {
erros.push("Posição do campo Serie é obrigatória".to_string());
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_serie)
.is_none()
{
erros.push(format!("Posição Serie inválida: '{}'", c.pos_serie));
}
}
}
let valido = erros.is_empty();
(valido, erros)
}
fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
for i in 0..indices.len() {
for j in (i + 1)..indices.len() {
if indices[i].1 == indices[j].1 {
erros.push(format!(
"Campos '{}' e '{}' mapeados para o mesmo índice {}",
indices[i].0, indices[j].0, indices[i].1
));
}
}
}
}
fn executar_importacao(app: &mut App, ctx: &egui::Context) {
let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(),
None => return,
};
let tipo = app.tipo_arquivo_atual.clone();
let layout_csv = app.layout_csv_atual.clone();
let layout_xlsx = app.layout_xlsx_atual.clone();
let (tx, rx) = std::sync::mpsc::channel();
app.resultado_pendente = Some(rx);
app.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
// 1. Importar arquivo
let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
};
let res = match res_importacao {
Err(e) => ResultadoPendente::Erro(e),
Ok(importado) => {
if importado.notas.is_empty() {
ResultadoPendente::Vazio
} else {
let avisos = importado.avisos.clone();
let notas = importado.notas;
// 2. Pré-análise
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
// 3. Expandir faltantes
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
+328
View File
@@ -0,0 +1,328 @@
use crate::application::usecases::executar_analise::{
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
};
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
use egui::{Context, Ui};
use std::path::PathBuf;
/// Renderiza a tela de importação de arquivos.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Comparador de Notas — Importar Arquivo");
ui.add_space(16.0);
// --- Seleção de arquivo ---
ui.group(|ui| {
ui.label("Arquivo:");
ui.horizontal(|ui| {
let nome = if app.nome_arquivo.is_empty() {
"Nenhum arquivo selecionado".to_string()
} else {
app.nome_arquivo.clone()
};
ui.label(nome);
if ui.button("📂 Selecionar arquivo...").clicked() {
if let Some(caminho) = rfd::FileDialog::new()
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
.pick_file()
{
on_arquivo_selecionado(app, ctx, caminho);
}
}
});
});
ui.add_space(8.0);
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_import")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else {
&nome_layout_atual
})
.show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout {
if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone();
if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone();
aplicar_layout(app, &layout);
}
}
}
});
if ui.button("⚙ Gerenciar Layouts").clicked() {
app.estado = EstadoApp::GerenciandoLayouts;
}
});
ui.add_space(16.0);
if app.caminho_arquivo.is_some() {
if ui.button("▶ Configurar Colunas").clicked() {
app.estado = EstadoApp::ConfigurandoColunas;
}
}
}
/// Renderiza a tela de seleção de aba (XLSX).
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Selecionar Aba da Planilha");
ui.add_space(16.0);
let (abas, caminho) = match &app.estado {
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
_ => return,
};
ui.label(format!("Arquivo: {}", caminho.display()));
ui.add_space(8.0);
// --- Seleção de preset ---
let opcoes_layout: Vec<(i64, String)> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
.collect();
let nome_layout_atual = app.nome_layout_atual.clone();
if !opcoes_layout.is_empty() {
ui.horizontal(|ui| {
ui.label("Layout:");
egui::ComboBox::from_id_salt("combo_layouts_aba")
.selected_text(if nome_layout_atual.is_empty() {
"— Selecionar layout —"
} else {
&nome_layout_atual
})
.show_ui(ui, |ui| {
for (id, nome) in &opcoes_layout {
if ui
.selectable_label(nome_layout_atual == *nome, nome.as_str())
.clicked()
{
app.nome_layout_atual = nome.clone();
if let Some(layout) =
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
{
let layout = layout.clone();
aplicar_layout(app, &layout);
}
}
}
});
});
ui.add_space(8.0);
}
ui.label("Selecione a aba a processar:");
let aba_atual = app.layout_xlsx_atual.aba.clone();
for aba in &abas {
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
app.layout_xlsx_atual.aba = aba.clone();
// Gerar pré-visualização da aba selecionada
app.preview_arquivo =
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
}
}
// Pré-visualização da aba selecionada
if !app.layout_xlsx_atual.aba.is_empty() {
if let Some(preview) = &app.preview_arquivo {
ui.add_space(8.0);
crate::ui::screens::renderizar_tabela_preview(ui, preview);
}
}
ui.add_space(12.0);
if !app.layout_xlsx_atual.aba.is_empty() {
ui.horizontal(|ui| {
// Se há preset selecionado, oferecer processamento direto
let tem_preset = !app.nome_layout_atual.is_empty();
if tem_preset {
let caminho_clone = caminho.clone();
if ui.button("▶ Processar").clicked() {
app.nome_arquivo = caminho_clone
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
app.caminho_arquivo = Some(caminho_clone);
disparar_analise(app, ctx);
}
}
if ui.button("⚙ Configurar Colunas").clicked() {
app.nome_arquivo = caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
app.caminho_arquivo = Some(caminho);
app.estado = EstadoApp::ConfigurandoColunas;
}
});
}
if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando;
}
}
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
let extensao = caminho
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
app.nome_arquivo = caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match extensao.as_str() {
"csv" => {
app.tipo_arquivo_atual = TipoArquivo::Csv;
app.caminho_arquivo = Some(caminho.clone());
app.notas_importadas.clear();
// Gerar pré-visualização com o delimitador atual
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
&caminho,
app.layout_csv_atual.delimitador as u8,
&app.layout_csv_atual.encoding.clone(),
5,
)
.ok();
}
"xlsx" | "xls" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
match listar_abas_xlsx(&caminho) {
Ok(info) => {
app.notas_importadas.clear();
// Verificar se há preset XLSX ativo com aba compatível
let preset_aba = if !app.nome_layout_atual.is_empty() {
let aba = app.layout_xlsx_atual.aba.clone();
if !aba.is_empty() && info.abas.contains(&aba) {
Some(aba)
} else {
None
}
} else {
None
};
if let Some(aba) = preset_aba {
// Fluxo rápido: aba do preset existe → disparar análise direto
app.layout_xlsx_atual.aba = aba.clone();
app.caminho_arquivo = Some(caminho.clone());
app.abas_xlsx = info.abas;
app.preview_arquivo =
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
disparar_analise(app, ctx);
} else {
// Fluxo normal: exibir tela de seleção de aba
app.abas_xlsx = info.abas.clone();
app.estado = EstadoApp::SelecionandoAba {
abas: info.abas,
caminho: caminho.clone(),
};
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
}
}
}
_ => {
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
}
}
}
/// Dispara a análise assíncrona com o layout XLSX atual.
/// Usado tanto no fluxo rápido (preset com aba compatível) quanto no botão "Processar" da tela de aba.
fn disparar_analise(app: &mut App, ctx: &Context) {
let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(),
None => return,
};
let layout_xlsx = app.layout_xlsx_atual.clone();
let (tx, rx) = std::sync::mpsc::channel();
app.resultado_pendente = Some(rx);
app.estado = EstadoApp::Analisando;
ctx.request_repaint();
std::thread::spawn(move || {
let res_importacao = importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string());
let res = match res_importacao {
Err(e) => ResultadoPendente::Erro(e),
Ok(importado) => {
if importado.notas.is_empty() {
ResultadoPendente::Vazio
} else {
let avisos = importado.avisos.clone();
let notas = importado.notas;
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
if !excessivos.is_empty() {
ResultadoPendente::AguardandoConfirmacao {
pre,
series_excessivas: excessivos,
avisos,
notas,
}
} else {
let resultado = expandir_analise(pre, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
fn aplicar_layout(app: &mut App, layout: &Layout) {
match layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
}
}
+267
View File
@@ -0,0 +1,267 @@
use crate::application::usecases::layouts::{
exportar_layout_json, importar_layout_json, salvar_layout,
};
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
use crate::domain::errors::ErroLayout;
use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui};
/// Renderiza a tela de gerenciamento de layouts.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
ui.heading("Gerenciar Layouts");
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando;
}
});
ui.add_space(12.0);
ui.separator();
// Salvar layout atual
ui.add_space(8.0);
ui.group(|ui| {
ui.label("Salvar Layout Atual");
ui.horizontal(|ui| {
ui.label("Nome:");
ui.text_edit_singleline(&mut app.nome_layout_atual);
if ui.button("💾 Salvar").clicked() {
salvar_layout_atual(app);
}
});
});
ui.add_space(12.0);
ui.separator();
ui.add_space(8.0);
// Layouts CSV
let layouts_csv: Vec<_> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Csv)
.cloned()
.collect();
let layouts_xlsx: Vec<_> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
.cloned()
.collect();
egui::ScrollArea::vertical().show(ui, |ui| {
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
ui.add_space(12.0);
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
ui.add_space(16.0);
ui.separator();
ui.add_space(8.0);
// Importar de JSON
ui.horizontal(|ui| {
ui.label("Importar layout de arquivo JSON:");
if ui.button("📥 Importar JSON").clicked() {
if let Some(caminho) = rfd::FileDialog::new()
.add_filter("JSON", &["json"])
.pick_file()
{
match std::fs::read_to_string(&caminho) {
Ok(conteudo) => importar_json(app, &conteudo),
Err(e) => {
app.exibir_erro(format!("Erro ao ler arquivo JSON: {}", e));
}
}
}
}
});
});
}
fn renderizar_secao_layouts(
ui: &mut Ui,
_ctx: &Context,
app: &mut App,
titulo: &str,
layouts: &[Layout],
) {
ui.label(egui::RichText::new(titulo).strong());
ui.add_space(4.0);
if layouts.is_empty() {
ui.label("(nenhum layout salvo)");
return;
}
for layout in layouts {
ui.horizontal(|ui| {
ui.label(layout.nome());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Excluir
if let Some(id) = layout.id() {
if ui.button("🗑 Excluir").clicked() {
app.exibir_confirmacao(
"Confirmar exclusão",
format!("Deseja excluir o layout '{}'?", layout.nome()),
AcaoModal::ConfirmarExclusaoLayout(id),
);
}
// Exportar
if ui.button("📤 Exportar JSON").clicked() {
match exportar_layout_json(layout) {
Ok((conteudo, nome_sugerido)) => {
if let Some(caminho) = rfd::FileDialog::new()
.set_file_name(&nome_sugerido)
.add_filter("JSON", &["json"])
.save_file()
{
if let Err(e) = std::fs::write(&caminho, &conteudo) {
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
}
}
}
// Carregar
if ui.button("📂 Carregar").clicked() {
aplicar_layout(app, layout);
app.nome_layout_atual = layout.nome().to_string();
app.estado = EstadoApp::ConfigurandoColunas;
}
}
});
});
}
}
fn salvar_layout_atual(app: &mut App) {
if app.nome_layout_atual.trim().is_empty() {
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
return;
}
let layout = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: None,
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: None,
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_xlsx_atual.clone(),
},
};
if let Some(conn) = &app.conn {
match salvar_layout(conn, &layout) {
Ok(_) => {
app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
}
Err(ErroLayout::NomeConflitante(nome)) => {
let id_existente = app
.layouts_salvos
.iter()
.find(|l| l.nome() == nome)
.and_then(|l| l.id());
if let Some(id) = id_existente {
let layout_com_id = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: Some(id),
nome: nome.clone(),
config: app.layout_csv_atual.clone(),
},
TipoArquivo::Xlsx => Layout::Xlsx {
id: Some(id),
nome: nome.clone(),
config: app.layout_xlsx_atual.clone(),
},
};
app.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome
),
AcaoModal::SobrescreverLayout(layout_com_id),
);
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
}
}
}
}
fn importar_json(app: &mut App, conteudo: &str) {
if let Some(conn) = &app.conn {
match importar_layout_json(conn, conteudo, false, None) {
Ok(_) => {
app.recarregar_layouts();
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
}
Err(ErroLayout::NomeConflitante(nome)) => {
// Recriar o layout parseado para passá-lo no modal de confirmação.
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
.ok()
.and_then(|json_repr| Layout::try_from(json_repr).ok());
let id_existente = app
.layouts_salvos
.iter()
.find(|l| l.nome() == nome)
.and_then(|l| l.id());
match (parsed, id_existente) {
(Some(mut layout), Some(id)) => {
match &mut layout {
Layout::Csv { id: i, .. } => *i = Some(id),
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
app.exibir_confirmacao(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
nome
),
AcaoModal::SobrescreverLayout(layout),
);
}
_ => {
app.exibir_erro(format!(
"Conflito de nome: layout '{}' já existe.",
nome
));
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar layout: {}", e));
}
}
}
}
fn aplicar_layout(app: &mut App, layout: &Layout) {
match layout {
Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
}
}
+72
View File
@@ -0,0 +1,72 @@
pub mod configuracao_colunas;
pub mod import;
pub mod layouts;
pub mod resultado;
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
fn indice_para_letra(mut idx: usize) -> String {
let mut resultado = String::new();
loop {
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
if idx < 26 {
break;
}
idx = idx / 26 - 1;
}
resultado
}
/// Renderiza uma tabela simples de pré-visualização do arquivo.
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
/// seguida pelas linhas de dados.
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 0 {
return;
}
ui.label(
egui::RichText::new(format!(
"Pré-visualização ({} linha(s))",
linhas.len()
))
.small()
.weak(),
);
ui.add_space(2.0);
egui::ScrollArea::horizontal()
.id_salt("scroll_preview")
.max_height(160.0)
.show(ui, |ui| {
egui::Grid::new("tabela_preview")
.striped(true)
.spacing([8.0, 2.0])
.show(ui, |ui| {
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
for i in 0..num_colunas {
ui.label(
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
.strong()
.monospace(),
);
}
ui.end_row();
// Linhas de dados
for linha in linhas {
for col in 0..num_colunas {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let texto = if celula.chars().count() > 30 {
let truncado: String = celula.chars().take(30).collect();
format!("{}...", truncado)
} else {
celula.to_string()
};
ui.label(egui::RichText::new(texto).monospace().small());
}
ui.end_row();
}
});
});
}
+309
View File
@@ -0,0 +1,309 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
};
use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
/// Renderiza a tela de resultados.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
// Extrair resultado do estado (sem mover)
let resultado = match &app.estado {
EstadoApp::ExibindoResultado(r) => r.clone(),
_ => return,
};
ui.heading("Resultado da Análise");
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("< Nova Análise").clicked() {
app.exibir_confirmacao(
"Nova Análise",
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
AcaoModal::ConfirmarNovaAnalise,
);
}
if ui.button("⚙ Reconfigurar Colunas").clicked() {
app.estado = EstadoApp::ConfigurandoColunas;
return;
}
let pode_reanalisar = app.caminho_arquivo.is_some();
if ui
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
.clicked()
{
app.reimportar_e_analisar(ctx);
}
if ui.button("📄 Exportar PDF").clicked() {
exportar_para_pdf(app, &resultado);
}
});
ui.add_space(8.0);
// Controle de itens por página
ui.horizontal(|ui| {
ui.label("Itens por página:");
for &opcao in OPCOES_PAGINA {
if ui
.selectable_label(app.itens_por_pagina == opcao, opcao.to_string())
.clicked()
{
app.itens_por_pagina = opcao;
app.pagina_faltantes = 0;
app.pagina_duplicatas = 0;
}
}
});
ui.add_space(12.0);
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
// Faltantes
renderizar_faltantes(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Duplicatas
renderizar_duplicatas(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Totais
renderizar_totais(ui, &resultado);
});
}
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
ui.label(egui::RichText::new("Totais").heading().strong());
ui.add_space(4.0);
ui.label(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
));
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
chave.label(),
total_notas,
formatar_valor_br(soma)
));
}
}
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_faltantes = resultado.total_faltantes();
ui.label(
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
.heading()
.strong(),
);
ui.add_space(4.0);
if total_faltantes == 0 {
ui.label("✔ Nenhuma nota faltante.");
return;
}
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
if faltantes.is_empty() {
continue;
}
// Estatística de completude por série
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} faltante(s) — {}/{} notas ({:.1}% completo):",
chave.label(),
faltantes.len(),
total_notas,
total_esperado,
percentual,
));
if ui
.button("📋 Copiar")
.on_hover_text("Copiar todos os números faltantes")
.clicked()
{
let texto = faltantes
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
// Paginação (por faltante individual)
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_faltantes >= total_paginas {
app.pagina_faltantes = 0;
}
let inicio = app.pagina_faltantes * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
// Exibir grupos contíguos da página atual
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
if a == b {
ui.label(format!("{}", a));
} else {
ui.label(format!("{}{} ({} notas)", a, b, b - a + 1));
}
}
if total_paginas > 1 {
ui.horizontal(|ui| {
if ui.button("").clicked() && app.pagina_faltantes > 0 {
app.pagina_faltantes -= 1;
}
ui.label(format!(
"Página {} / {}",
app.pagina_faltantes + 1,
total_paginas
));
if ui.button("").clicked() && app.pagina_faltantes + 1 < total_paginas {
app.pagina_faltantes += 1;
}
});
}
}
}
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
let total_dup = resultado.total_duplicatas();
ui.label(
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
.heading()
.strong(),
);
ui.add_space(4.0);
if total_dup == 0 {
ui.label("✔ Nenhuma nota duplicada.");
return;
}
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() {
continue;
}
ui.horizontal(|ui| {
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
chave.label(),
duplicatas.len()
));
if ui
.button("📋 Copiar")
.on_hover_text("Copiar números duplicados")
.clicked()
{
let texto = duplicatas
.iter()
.map(|(n, c)| format!("{} ({}x)", n, c))
.collect::<Vec<_>>()
.join(", ");
ui.ctx().copy_text(texto);
}
});
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
if app.pagina_duplicatas >= total_paginas {
app.pagina_duplicatas = 0;
}
let inicio = app.pagina_duplicatas * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
for (numero, count) in &duplicatas[inicio..fim] {
ui.label(format!(
" • NF {} / Série {}{} ocorrências",
numero,
chave.label(),
count
));
}
if total_paginas > 1 {
ui.horizontal(|ui| {
if ui.button("").clicked() && app.pagina_duplicatas > 0 {
app.pagina_duplicatas -= 1;
}
ui.label(format!(
"Página {} / {}",
app.pagina_duplicatas + 1,
total_paginas
));
if ui.button("").clicked() && app.pagina_duplicatas + 1 < total_paginas {
app.pagina_duplicatas += 1;
}
});
}
}
}
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
if let Some(caminho) = rfd::FileDialog::new()
.set_file_name("relatorio.pdf")
.add_filter("PDF", &["pdf"])
.save_file()
{
let gerador = GenpdfGenerator;
let nome_layout = if app.nome_layout_atual.is_empty() {
None
} else {
Some(app.nome_layout_atual.as_str())
};
match exportar_pdf(
&gerador,
resultado,
&app.nome_arquivo,
nome_layout,
&caminho,
) {
Ok(_) => {
app.exibir_aviso(
"Sucesso",
format!("PDF exportado para: {}", caminho.display()),
);
}
Err(e) => {
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
}
}
}
}