Compare commits

..
4 Commits
Author SHA1 Message Date
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
38 changed files with 10098 additions and 69 deletions
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"
+429
View File
@@ -0,0 +1,429 @@
# Roteiro de Implementação — Comparador de Notas
**Versão:** 1.0
**Data:** 02/03/2026
**Baseado no PRD:** v1.6
---
## Fase 0 — Fundação do Projeto
**Objetivo:** Estrutura compilável com todas as dependências.
### 0.1 Configurar `Cargo.toml`
Adicionar todas as dependências:
```toml
[dependencies]
eframe = "0.31"
egui = "0.31"
rusqlite = { version = "0.32", features = ["bundled"] }
csv = "1.3"
calamine = "0.26"
rust_decimal = { version = "1.36", features = ["serde"] }
rust_decimal_macros = "1.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
genpdf = "0.2"
chrono = { version = "0.4", features = ["serde"] }
encoding_rs = "0.8"
dirs = "5"
thiserror = "2"
```
### 0.2 Criar estrutura de pastas completa
Conforme definido na seção 9.1 do PRD:
```
src/
main.rs
ui/
mod.rs
app.rs
screens/
import.rs
configuracao_colunas.rs
layouts.rs
resultado.rs
application/
mod.rs
usecases/
importar_arquivo.rs
executar_analise.rs
exportar_pdf.rs
domain/
mod.rs
errors.rs
entities/
nota.rs
serie.rs
layout.rs
resultado_analise.rs
services/
detector_sequencia.rs
detector_duplicidade.rs
parser_monetario.rs
infrastructure/
mod.rs
csv_reader.rs
xlsx_reader.rs
pdf_generator.rs
sqlite/
mod.rs
connection.rs
migrations.rs
layout_repository.rs
```
### 0.3 Criar todos os `mod.rs` com declarações vazias
Garantir que o projeto compila antes de começar a implementar.
---
## Fase 1 — Camada Domain (núcleo puro)
**Dependência:** Fase 0 concluída.
**Restrição:** Zero dependência de egui, rusqlite, calamine, csv ou genpdf. Apenas Rust puro + `rust_decimal` e `chrono`.
### 1.1 `domain/errors.rs`
Definir tipos de erro com `thiserror`:
- `ErroNumero` (Zero, NaoNumerico)
- `ErroSerie` (Invalida, Vazia)
- `ErroValor` (Negativo, NaoNumerico)
- `ErroLayout` (CampoObrigatorioAusente, JsonMalformado, NomeConflitante)
### 1.2 `domain/entities/nota.rs`
```rust
pub struct Nota {
pub numero: u64,
pub serie: String,
pub valor: Option<Decimal>,
pub data: Option<NaiveDate>,
}
```
### 1.3 `domain/entities/serie.rs`
- Validação da regex `[0-9]{1,3}`
- Função `validar_serie(s: &str) -> Result<String, ErroSerie>`
### 1.4 `domain/entities/layout.rs`
- `enum TipoArquivo { Csv, Xlsx }`
- `struct LayoutCsv` com todos os campos da seção 10.4 do PRD
- `struct LayoutXlsx` com todos os campos da seção 10.4 do PRD
- `enum Layout { Csv(LayoutCsv), Xlsx(LayoutXlsx) }`
### 1.5 `domain/entities/resultado_analise.rs`
```rust
pub struct ResultadoPreAnalise {
// min, max, contagem_faltantes por série
pub intervalos_por_serie: HashMap<String, (u64, u64, usize)>,
}
pub struct ResultadoAnalise {
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
}
```
### 1.6 `domain/services/parser_monetario.rs`
Implementar exatamente o algoritmo da seção RF06:
| Regra | Condição | Comportamento |
|-------|----------|---------------|
| 1 | Contém ponto **e** vírgula | Último separador é o decimal |
| 2a | Apenas um separador + exatamente 2 dígitos após | Separador decimal |
| 2b | Apenas um separador + exatamente 3 dígitos após | Separador de milhar |
| 2c | Apenas um separador + outros casos | Separador decimal |
| 3 | Sem separador | Número inteiro |
- Rejeitar valores negativos (precedidos de `-`)
- Armazenar como `rust_decimal::Decimal`
> **Prioridade:** Testar exaustivamente com todos os exemplos da tabela do PRD antes de avançar.
### 1.7 `domain/services/detector_sequencia.rs`
- Recebe `Vec<&Nota>` de uma série
- Ordena por `numero` crescente
- Percorre **incrementalmente** (sem lista intermediária)
- Retorna `Vec<u64>` de faltantes
- Respeitar o limite de 10.000 registros faltantes (RFC04): retornar `ResultadoPreAnalise` antes de expandir
### 1.8 `domain/services/detector_duplicidade.rs`
- Recebe `Vec<&Nota>`
- Retorna `HashMap<(u64, String), usize>` com contagem por grupo
- Filtra apenas grupos com contagem > 1
---
## Fase 2 — Camada Infrastructure
**Dependência:** Fase 1 concluída.
### 2.1 `infrastructure/sqlite/connection.rs`
- Determinar caminho do banco conforme SO via `dirs::config_dir()`:
- Linux: `~/.config/comparador-notas/config.db`
- Windows: `%APPDATA%\comparador-notas\config.db`
- macOS: `~/Library/Application Support/comparador-notas/config.db`
- Criar diretório automaticamente se não existir
- Abrir conexão SQLite
- Tratamento de banco corrompido: renomear para `config.db.bak`, recriar banco vazio
### 2.2 `infrastructure/sqlite/migrations.rs`
- Tabela `schema_version` para controle de versão do schema
- Migration v1: criar tabela `layouts` com todos os campos da seção 10.4 do PRD
- Aplicar migrations automaticamente na inicialização
### 2.3 `infrastructure/sqlite/layout_repository.rs`
```rust
fn salvar(layout: &Layout) -> Result<i64>
fn listar() -> Result<Vec<Layout>>
fn buscar_por_id(id: i64) -> Result<Option<Layout>>
fn excluir(id: i64) -> Result<()>
fn existe_nome(nome: &str) -> Result<bool>
```
### 2.4 `infrastructure/csv_reader.rs`
- Suporte a delimitadores: `,` `;` `\t`
- Suporte a encoding: UTF-8 e Windows-1252 (via `encoding_rs`)
- Respeitar linha de cabeçalho configurável (base 1)
- Ignorar linhas em branco silenciosamente
- Coletar linhas malformadas para relatório consolidado
- Validar limite de 50 MB antes de ler
- Retornar `Vec<Vec<String>>` (linhas × colunas)
### 2.5 `infrastructure/xlsx_reader.rs`
- `listar_abas(path) -> Vec<String>` — chamado imediatamente após seleção do arquivo
- Ler dados da aba selecionada a partir da posição `LetraLinha`
- Converter notação `LetraLinha` (ex: `B3`) para `(col_idx, row_idx)`
- Ignorar linhas em branco silenciosamente
- Validar limite de 50 MB
- Tratar arquivo corrompido retornando erro tipado
### 2.6 `infrastructure/pdf_generator.rs`
- Implementar trait `PdfGenerator` definida em `application/`
- Gerar PDF com `genpdf` contendo:
- Metadados: nome do arquivo importado, data/hora de geração, nome do layout
- Faltantes agrupados por série
- Duplicatas agrupadas por série
- Totais por série e total geral
---
## Fase 3 — Camada Application
**Dependência:** Fases 1 e 2 concluídas.
### 3.1 `application/usecases/importar_arquivo.rs`
1. Validar tamanho do arquivo (recusar > 50 MB)
2. Detectar tipo (CSV ou XLSX)
3. Para XLSX: chamar `listar_abas()` e retornar lista para UI fazer a seleção
4. Chamar leitor adequado com configurações do layout
5. Mapear colunas e construir `Vec<Nota>`
6. Aplicar validações: regex de série, numero zero, valores inválidos
7. Retornar notas válidas + relatório de avisos consolidado (RF06/RNF06)
### 3.2 `application/usecases/executar_analise.rs`
1. Receber `Vec<Nota>`
2. Chamar `parser_monetario` para cada valor
3. Agrupar por série
4. Chamar `detector_sequencia` por série → gerar `ResultadoPreAnalise`
5. Se algum intervalo > 10.000: retornar `ResultadoPreAnalise` para UI solicitar confirmação
6. Após confirmação: expandir faltantes e montar `ResultadoAnalise`
7. Chamar `detector_duplicidade`
8. Calcular somas com `Decimal` (nunca `f64`)
9. Retornar `ResultadoAnalise`
### 3.3 `application/usecases/exportar_pdf.rs`
- Depende da trait `PdfGenerator` (não de `genpdf` diretamente)
- Recebe `ResultadoAnalise` + metadados do arquivo e layout
- Delega geração para a implementação concreta em infrastructure
### 3.4 Gerenciamento de layouts
- `salvar_layout` — valida campos obrigatórios antes de persistir
- `listar_layouts` — lista separada por tipo (CSV / XLSX)
- `carregar_layout` — busca por id
- `excluir_layout` — remove do banco
- `exportar_layout_json` — serializa com `serde_json`, sugere nome do arquivo
- `importar_layout_json` — desserializa + valida campos obrigatórios + verifica conflito de nome
---
## Fase 4 — Camada UI (egui/eframe)
**Dependência:** Fase 3 concluída.
**Regra:** Nenhuma regra de negócio, parsing ou cálculo dentro da UI.
### 4.1 `ui/app.rs` — Estrutura principal
```rust
enum EstadoApp {
Importando,
ConfigurandoColunas,
SelecionandoAba(Vec<String>), // lista de abas XLSX
Analisando,
ConfirmandoIntervalo(ResultadoPreAnalise),
ExibindoResultado(ResultadoAnalise),
GerenciandoLayouts,
}
```
- Gerenciamento de modais/popups bloqueantes (RNF06)
- Struct `App` com estado compartilhado e referência ao banco SQLite
### 4.2 `ui/screens/import.rs`
- Botão "Selecionar arquivo" com diálogo nativo
- Exibir nome do arquivo selecionado
- Para XLSX: exibir dropdown de seleção de aba **imediatamente** após seleção do arquivo
- Dropdown de layouts salvos para carregar configuração existente
- Botão "Gerenciar Layouts" → navegar para `layouts.rs`
### 4.3 `ui/screens/configuracao_colunas.rs`
- Para CSV: campos numéricos para índice de cada coluna (base 0) + delimitador + encoding + linha cabeçalho
- Para XLSX: campos de texto para posição `LetraLinha` de cada coluna
- Validação em tempo real: índices duplicados, campos obrigatórios ausentes
- Bloqueio do botão "Executar Análise" quando configuração inválida
- Botão "Executar Análise"
### 4.4 `ui/screens/layouts.rs`
- Listar layouts salvos separados por tipo (CSV / XLSX)
- Campo de nome + botão "Salvar layout atual"
- Botão "Excluir" com modal de confirmação
- Botão "Exportar para JSON" → diálogo de salvar com nome sugerido baseado no nome do layout
- Botão "Importar de JSON" → diálogo de abertura
- Modal de conflito de nome ao importar: "Sobrescrever" ou "Salvar com novo nome"
### 4.5 `ui/screens/resultado.rs`
- Seções: **Faltantes** | **Duplicatas** | **Totais**
- Agrupamento por série em todas as seções
- Paginação com dropdown: 50 / 100 / 200 / 1000 itens por página
- Valores monetários formatados em pt-BR (ex: `1.234,56`)
- Números de notas sem zeros à esquerda (ex: `0001` exibido como `1`)
- Botão "Exportar PDF" → chamar use case `exportar_pdf`
### 4.6 Modais globais (RNF06)
| Modal | Trigger |
|-------|---------|
| Erro genérico | Qualquer erro com mensagem + botão OK |
| Aviso consolidado | Final de importação com lista de categorias de problema |
| Confirmação de intervalo | Faltantes > 10.000 por série |
| Confirmação de exclusão | Excluir layout |
| Conflito de nome | Importar JSON com nome existente |
| Banco corrompido | Inicialização com `config.db` inválido |
---
## Fase 5 — Testes
**Dependência:** Fases 14 concluídas (testes unitários podem ser escritos junto com cada fase).
### 5.1 Testes unitários — Domain
- `parser_monetario`: todos os casos da tabela do PRD + casos extremos
- `detector_sequencia`: sequência completa, com faltantes, série com um único registro, intervalos > 10.000
- `detector_duplicidade`: sem duplicatas, com duplicatas, múltiplas séries
- Validação de série (regex `[0-9]{1,3}`): válidos e inválidos
- Validação de numero zero: deve ser descartado
### 5.2 Testes de integração — Infrastructure
- Leitura CSV: UTF-8, Windows-1252, diferentes delimitadores, linhas malformadas, arquivo > 50 MB
- Leitura XLSX: aba correta, posição `LetraLinha`, arquivo corrompido, limite de tamanho
- SQLite: migrations, CRUD de layouts, banco corrompido → recriação
### 5.3 Testes de integração — Application
- Fluxo completo CSV → análise → resultado
- Fluxo completo XLSX → análise → resultado
- Importação de layout JSON: válido, malformado, conflito de nome com sobrescrita, conflito com renomeação
- Intervalo > 10.000: verificar que `ResultadoPreAnalise` é retornado antes da expansão
### 5.4 Testes manuais de UI
- Todas as mensagens em PT-BR
- Formatação monetária correta (pt-BR)
- Paginação funcionando
- Geração do PDF com metadados corretos
- Fluxo XLSX: aba exibida imediatamente após seleção do arquivo
---
## Fase 6 — Polimento e Empacotamento
### 6.1 Strings e idioma
- Revisar todas as mensagens de erro/aviso para PT-BR (RNF05)
- Garantir que nenhuma string de erro interna vaze para a UI como texto bruto
### 6.2 Performance
- Validar com arquivo de 100.000 registros (RNF03)
- Confirmar que detecção de faltantes é incremental (sem lista intermediária antes da confirmação)
- Medir tempo de importação e análise
### 6.3 Build multiplataforma
- Linux: validar caminho `~/.config/comparador-notas/config.db`
- Windows: validar caminho `%APPDATA%\comparador-notas\config.db`
- macOS: validar caminho `~/Library/Application Support/comparador-notas/config.db`
### 6.4 Empacotamento
- Configurar ícone da aplicação
- Build de release otimizado: `cargo build --release`
- Testar executável standalone (sem instalação, sem dependências externas)
---
## Ordem de Execução Recomendada
```
Fase 0 → Fase 1 (com testes unitários) → Fase 2.1/2.2/2.3 → Fase 2.4/2.5
→ Fase 3.1/3.2 → Fase 4.1/4.2/4.3/4.5 → Fase 2.6/3.3 → Fase 3.4/4.4
→ Fase 5 → Fase 6
```
> O núcleo de domínio deve estar **totalmente testado** antes de qualquer integração. O `parser_monetario` e o `detector_sequencia` são os componentes mais críticos do sistema.
---
## Dependências entre Componentes
```
domain/errors.rs
└─ domain/entities/ (nota, serie, layout, resultado_analise)
└─ domain/services/ (parser_monetario, detector_sequencia, detector_duplicidade)
└─ application/usecases/ (importar_arquivo, executar_analise, exportar_pdf)
└─ infrastructure/ (csv_reader, xlsx_reader, sqlite/, pdf_generator)
└─ ui/ (app, screens/)
```
Nenhuma camada pode importar de uma camada acima dela na hierarquia.
+500 -64
View File
@@ -1,6 +1,6 @@
# PRD — Comparador de Notas # PRD — Comparador de Notas
**Versão:** 1.1 **Versão:** 1.6
**Data:** 02/03/2026 **Data:** 02/03/2026
**Status:** Planejamento **Status:** Planejamento
@@ -71,16 +71,19 @@ 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
* Relatório visual * Relatório visual com paginação
* Salvar layouts personalizados * Exportação de relatório para PDF
* 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
## 4.2 Não Incluído (MVP) ## 4.2 Não Incluído (MVP)
@@ -97,12 +100,12 @@ 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. Junto com a Série forma o identificador único. 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. Quando mapeado, exibido como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. |
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`. Duplicidade e sequência são sempre avaliadas dentro da mesma série.
@@ -127,30 +130,58 @@ O sistema deve permitir importar arquivos:
| 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)
### 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) e Data (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.
### 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,6 +198,29 @@ 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
* Salvar com novo nome
### 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
@@ -191,10 +245,49 @@ 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 série**. Cada série possui sua própria sequência independente.
### Série com Apenas um Registro
Se uma série contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. A série é processada e exibida normalmente.
### Ordenação
A ordenação dos registros dentro de cada série é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada.
### 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.
### Proteção contra Intervalos Anormalmente Grandes
Se o intervalo de faltantes de qualquer série exceder **10.000 registros**, o sistema deve:
1. Interromper o processamento dessa série
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: 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.
--- ---
## RF05 — Detecção de Duplicidade ## RF05 — Detecção de Duplicidade
@@ -203,6 +296,14 @@ 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** no arquivo importado.
### Exibição das Duplicatas
A lista de duplicatas exibe o identificador (Numero + Serie) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 — 3 ocorrências`. As ocorrências individuais não são listadas separadamente.
### 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.
--- ---
## RF06 — Soma de Valores ## RF06 — Soma de Valores
@@ -212,15 +313,78 @@ O sistema deve calcular:
* Soma total * Soma total
* Soma por série * Soma por série
### 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.
#### 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 série 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`).
--- ---
## RF07 — Exibição de Resultados ## RF07 — Exibição de Resultados
O sistema deve exibir: O sistema deve exibir:
* Lista de notas faltantes * Lista de notas faltantes agrupadas por série
* Lista de duplicadas * Lista de duplicadas agrupadas por série
* Totais * Totais (soma total e soma por série)
### Organização dos Resultados
| Aspecto | Comportamento |
| ------------- | ----------------------------------------------------- |
| Agrupamento | Resultados sempre agrupados por 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`.
O PDF deve conter:
* Notas faltantes por série
* Duplicatas por série
* Totais por série e total geral
**Metadados do relatório:**
* Nome do arquivo importado
* Data e hora da geração
* Nome do layout utilizado
--- ---
@@ -260,6 +424,8 @@ O sistema deve suportar no mínimo:
100.000 registros por arquivo 100.000 registros por arquivo
> **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,6 +434,28 @@ 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 mal formação"
* "12 valores de Numero inválidos convertidos ou descartados"
* "5 registros com Série inválida 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:
@@ -280,22 +468,194 @@ Fluxo principal:
--- ---
# 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
```
src/
├─ main.rs
├─ ui/
│ ├─ mod.rs
│ ├─ app.rs
│ ├─ screens/
│ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs
│ │ ├─ resultado.rs
├─ application/
│ ├─ mod.rs
│ ├─ usecases/
│ │ ├─ importar_arquivo.rs
│ │ ├─ executar_analise.rs
│ │ ├─ exportar_pdf.rs
├─ domain/
│ ├─ mod.rs
│ ├─ errors.rs
│ ├─ entities/
│ │ ├─ nota.rs
│ │ ├─ serie.rs
│ │ ├─ layout.rs
│ │ ├─ resultado_analise.rs
│ │
│ ├─ services/
│ │ ├─ detector_sequencia.rs
│ │ ├─ detector_duplicidade.rs
│ │ ├─ parser_monetario.rs
├─ infrastructure/
│ ├─ mod.rs
│ ├─ csv_reader.rs
│ ├─ xlsx_reader.rs
│ ├─ pdf_generator.rs
│ ├─ sqlite/
│ │ ├─ mod.rs
│ │ ├─ connection.rs
│ │ ├─ migrations.rs
│ │ ├─ layout_repository.rs
```
---
## 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
* rusqlite
* calamine
* csv
* genpdf
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`).
#### Entidades
**`Nota`**
| Campo | Tipo |
| ------ | ----------------- |
| numero | `u64` |
| serie | `String` |
| valor | `Option<Decimal>` |
| data | `Option<NaiveDate>` |
**`ResultadoAnalise`**
| Campo | Tipo |
| -------------------- | -------------------------------------- |
| faltantes_por_serie | `HashMap<String, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<String, Decimal>` |
> **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 série, retorna faltantes.
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie)`.
`parser_monetario` — implementa exatamente o algoritmo definido no RF06.
#### Erros
`domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`). Nenhuma camada deve propagar `String` livre como erro de domínio.
---
### Application (orquestração)
Coordenam o fluxo entre domain e infrastructure.
Conhece o domain. O domain não conhece o application.
**`executar_analise.rs`**
1. Recebe dados crus
2. Chama `parser_monetario`
3. Chama `detector_sequencia`
4. Chama `detector_duplicidade`
5. Monta `ResultadoAnalise`
**`exportar_pdf.rs`**
Depende de uma trait abstrata (`PdfGenerator`) definida no próprio módulo application. A implementação concreta fica em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`.
---
### Infrastructure (implementações concretas)
Implementa leitores, persistência e geração de arquivos.
| Arquivo | Responsabilidade |
| -------------------------------- | ----------------------------------------- |
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` |
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` |
| `pdf_generator.rs` | Geração de PDF via `genpdf` |
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite |
| `sqlite/migrations.rs` | Aplicação de migrations de schema |
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
Nada de infrastructure sobe para domain.
#### Por que `layout_repository.rs` dentro de `sqlite/`
Manter o repositório dentro de `sqlite/` concentra todos os artefatos SQLite em um único módulo. Se futuramente o sistema armazenar histórico de análises ou configurações do usuário (RF14/10.8), novos repositórios são adicionados no mesmo lugar sem dispersão.
---
### UI (interface)
Apenas coleta input, chama use cases e renderiza resultado.
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
#### Screens
| Arquivo | Responsabilidade |
| ------------------------- | ----------------------------------------------------- |
| `import.rs` | Seleção de arquivo e configurações de importação |
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) |
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) |
| `resultado.rs` | Exibição de resultados com paginação (RF07) |
> `configuracao_colunas.rs` e `layouts.rs` são mantidos separados porque tratam de responsabilidades distintas do RF02 e RF08, evitando que uma única screen acumule lógica de mapeamento de colunas e gerenciamento de persistência.
---
## 9.3 Fluxo de Execução
```
UI → Application → Domain
Infrastructure entra apenas quando necessário.
```
Exemplo real:
1. UI chama `importar_arquivo`
2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota`
4. Domain executa análise
5. Application retorna `ResultadoAnalise`
6. UI renderiza
---
## 9.4 Stack Tecnológica
| Camada | Tecnologia | Status | | Camada | Tecnologia | Status |
| ---------------- | ------------------ | -------------- | | ---------------- | ------------------ | -------------- |
@@ -304,6 +664,8 @@ Sem dependências externas obrigatórias.
| SQLite | rusqlite | Definido | | SQLite | rusqlite | Definido |
| Leitura de CSV | csv | Definido | | Leitura de CSV | csv | Definido |
| Leitura de XLSX | calamine | Definido | | Leitura de XLSX | calamine | Definido |
| Decimal fixo | rust_decimal | Definido |
| Geração de PDF | genpdf | Definido |
--- ---
@@ -332,12 +694,34 @@ 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.
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -351,16 +735,37 @@ 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 |
coluna_serie texto Nome da coluna da série | nome | texto | Nome do layout |
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) |
| 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) |
**Campos exclusivos de layouts XLSX:**
| Campo | Tipo | Descrição |
| -------------- | ------ | ---------------------------------------------------------------- |
| aba | texto | Nome ou índice 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) |
------------------------------------------------------------------------ ------------------------------------------------------------------------
@@ -424,15 +829,35 @@ 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", "nome": "Layout Padrão CSV",
"coluna_numero": "Nota", "tipo": "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
}
```
**Exemplo — layout XLSX:**
```json
{
"nome": "Layout Padrão XLSX",
"tipo": "xlsx",
"aba": "Plan1",
"pos_numero": "D3",
"pos_serie": "B3",
"pos_valor": "F3",
"pos_data": null
} }
``` ```
@@ -442,21 +867,32 @@ Exemplo de arquivo JSON exportado:
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 série corretamente
* Permitir configuração * Detectar duplicatas por série corretamente
* Calcular soma total e soma por série corretamente
* Exibir resultados agrupados por série com paginação
* 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 série
--- ---
# 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 e Data
* Detectar faltantes * Detectar notas faltantes por série
* Exibir resultado * Detectar duplicatas por série
* Calcular soma total e por série
* Exibir resultados agrupados por série com paginação
* Exportar relatório para PDF
* Gerenciar layouts (salvar, carregar, excluir)
* Exportar e importar layouts via JSON
--- ---
@@ -464,11 +900,11 @@ Escopo mínimo:
Possíveis melhorias: Possíveis melhorias:
* Exportação de relatório
* Exportação 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)
* Exportação para CSV
* Multiusuário
--- ---
+2
View File
@@ -0,0 +1,2 @@
[tools]
rust = "latest"
+1
View File
@@ -0,0 +1 @@
pub mod usecases;
@@ -0,0 +1,145 @@
use crate::domain::{
entities::{
nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
},
services::{
detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_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 série
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
}
let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie {
// Somar valores
for nota in notas_serie.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) {
intervalos_por_serie.insert(serie.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 alguma série excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação.
pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
.collect()
}
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
}
let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie {
let faltantes = detectar_faltantes(notas_serie);
faltantes_por_serie.insert(serie.clone(), faltantes);
}
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,
}
}
/// Executa análise completa sem verificar limites (use case simplificado).
/// Útil quando o caller já confirmou ou sabe que não há intervalos excessivos.
pub fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec;
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, 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);
assert_eq!(resultado.faltantes_por_serie["001"], 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);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
assert!(resultado.faltantes_por_serie["002"].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, "001");
}
}
+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,230 @@
use crate::domain::entities::layout::{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_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 mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
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_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
};
notas.push(Nota::new(numero, serie, 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
}
+110
View File
@@ -0,0 +1,110 @@
use crate::domain::{
entities::layout::{Layout, LayoutJson},
errors::ErroLayout,
};
use crate::infrastructure::sqlite::{layout_repository, migrations};
use rusqlite::Connection;
/// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String> {
// Validar campos obrigatórios
if layout.nome().trim().is_empty() {
return Err("Nome do layout não pode ser vazio".to_string());
}
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout)
.map_err(|e| e.to_string())?;
Ok(id)
} else {
layout_repository::salvar(conn, layout)
.map_err(|e| 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())
}
/// Carrega um layout pelo id.
pub fn carregar_layout(conn: &Connection, id: i64) -> Result<Option<Layout>, String> {
layout_repository::buscar_por_id(conn, id).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id.
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;
+244
View File
@@ -0,0 +1,244 @@
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>,
}
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,
}
}
}
/// 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>,
}
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,
}
}
}
/// 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>,
},
Xlsx {
nome: String,
aba: String,
pos_numero: String,
pos_serie: String,
pos_valor: Option<String>,
pos_data: 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,
} => {
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,
},
})
}
LayoutJson::Xlsx {
nome,
aba,
pos_numero,
pos_serie,
pos_valor,
pos_data,
} => {
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,
},
})
}
}
}
}
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,
},
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(),
},
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
+32
View File
@@ -0,0 +1,32 @@
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos.
/// `numero + serie` é 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,
/// 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,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
valor,
data,
}
}
}
+64
View File
@@ -0,0 +1,64 @@
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 série → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
/// Somas já calculadas
pub soma_total: Decimal,
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
}
/// 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 série (ordenados crescentemente)
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por série
pub soma_por_serie: HashMap<String, Decimal>,
/// Total de notas processadas por série
pub total_por_serie: HashMap<String, usize>,
}
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)));
}
}
+136
View File
@@ -0,0 +1,136 @@
use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroNumero {
#[error("Número zero não é válido para uma nota fiscal")]
Zero,
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)]
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("Formato não suportado: {0}")]
FormatoNaoSuportado(String),
#[error("Erro de leitura: {0}")]
ErroLeitura(String),
}
#[derive(Debug, Error)]
pub enum ErroAnalise {
#[error("Intervalo de faltantes muito grande para a série '{serie}': {intervalo} registros")]
IntervaloMuitoGrande { serie: String, intervalo: u64 },
#[error("Campo obrigatório não mapeado: {0}")]
CampoObrigatorioNaoMapeado(String),
#[error("Índice de coluna inválido: campo '{campo}', índice {indice}")]
IndiceInvalido { campo: String, indice: usize },
#[error("Dois campos mapeados para o mesmo índice: {0}")]
IndicesDuplicados(String),
}
/// Aviso coletado durante a importação, para ser exibido de forma consolidada ao usuário.
#[derive(Debug, Clone)]
pub struct AvisoImportacao {
pub categoria: CategoriaAviso,
pub mensagem: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CategoriaAviso {
LinhaMalformada,
NumeroInvalido,
SerieInvalida,
ValorInvalido,
RegistroDescartado,
}
impl std::fmt::Display for CategoriaAviso {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CategoriaAviso::LinhaMalformada => write!(f, "Linhas malformadas"),
CategoriaAviso::NumeroInvalido => write!(f, "Valores de Numero inválidos"),
CategoriaAviso::SerieInvalida => write!(f, "Registros com Série inválida"),
CategoriaAviso::ValorInvalido => write!(f, "Valores monetários inválidos"),
CategoriaAviso::RegistroDescartado => write!(f, "Registros descartados"),
}
}
}
/// Resumo consolidado de avisos para exibição em um único modal.
#[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;
+101
View File
@@ -0,0 +1,101 @@
use crate::domain::entities::nota::Nota;
use std::collections::HashMap;
/// Detecta registros duplicados em uma lista de notas.
///
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.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 série.
///
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas);
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
for ((numero, serie), contagem) in raw {
result.entry(serie).or_default().push((numero, contagem));
}
// Ordenar por numero dentro de cada série
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)
}
#[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())), 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())), Some(&3));
}
#[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
// Número 1 em séries diferentes não é duplicata
let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn 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);
// Série 001 deve ter notas 1 e 2 duplicadas
let serie001 = por_serie.get("001").unwrap();
assert_eq!(serie001.len(), 2);
assert_eq!(serie001[0], (1, 2));
assert_eq!(serie001[1], (2, 2));
// Série 002 deve ter nota 1 duplicada
let serie002 = por_serie.get("002").unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
}
+139
View File
@@ -0,0 +1,139 @@
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
}
#[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)
}
#[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());
}
}
+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");
}
}
+90
View File
@@ -0,0 +1,90 @@
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,
})
}
+4
View File
@@ -0,0 +1,4 @@
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
+193
View File
@@ -0,0 +1,193 @@
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, Element, SimplePageDecorator,
};
use std::path::Path;
/// 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> {
// Carregar fonte do sistema (DejaVu Sans)
let font_family = carregar_fonte_sistema()
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
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));
// 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)
)));
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
series_ordenadas.sort();
for serie in &series_ordenadas {
let soma = &resultado.soma_por_serie[*serie];
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
doc.push(Paragraph::new(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
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)),
);
for serie in &series_ordenadas {
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
Some(f) if !f.is_empty() => f,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
serie,
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)),
);
for serie in &series_ordenadas {
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
Some(d) if !d.is_empty() => d,
_ => {
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
serie,
duplicatas.len()
)));
for (numero, count) in duplicatas {
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências",
numero, serie, count
)));
}
}
// Renderizar PDF
doc.render_to_file(caminho_saida)
.map_err(|e| format!("Erro ao gerar PDF: {}", e))?;
Ok(())
}
}
/// Tenta carregar fontes DejaVu Sans do sistema.
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
// Caminhos comuns no Linux, Windows e macOS
let candidatos_regular = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/Library/Fonts/Arial.ttf",
];
let candidatos_bold = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\arialbd.ttf",
"/Library/Fonts/Arial Bold.ttf",
];
let regular_path = candidatos_regular
.iter()
.find(|p| std::path::Path::new(p).exists())
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
let bold_path = candidatos_bold
.iter()
.find(|p| std::path::Path::new(p).exists())
.unwrap_or(regular_path);
let regular = fonts::FontData::load(regular_path, None)
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
let bold = fonts::FontData::load(bold_path, None)
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
Ok(fonts::FontFamily {
regular: regular.clone(),
bold,
italic: regular.clone(),
bold_italic: regular,
})
}
+56
View File
@@ -0,0 +1,56 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// 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,211 @@
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)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
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),
],
)?;
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)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
],
)?;
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
WHERE id = ?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),
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
WHERE id = ?7",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
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
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),
},
})
} 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)?,
},
})
}
})?
.collect();
layouts
}
/// Busca um layout pelo id.
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data
FROM layouts WHERE id = ?1",
)?;
let mut results = stmt.query_map([id], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
},
})
}
})?;
results.next().transpose()
}
/// Remove um layout pelo id.
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)
}
+62
View File
@@ -0,0 +1,62 @@
use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 1;
/// 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 == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
} else {
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
}
}
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;
+188
View File
@@ -0,0 +1,188 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use calamine::{open_workbook, Reader, Xlsx};
use std::fs::File;
use std::io::BufReader;
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.
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?;
let workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec())
}
/// Lê os dados de uma aba XLSX a partir de uma linha de início.
///
/// # 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: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(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 mut 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 })
}
/// 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))
}),
)
} }
+340
View File
@@ -0,0 +1,340 @@
use crate::application::usecases::{
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao},
layouts::{
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
listar_layouts, salvar_layout,
},
};
use crate::domain::{
entities::{
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
errors::ResumoAvisos,
};
use crate::infrastructure::{
pdf_generator::GenpdfGenerator,
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
};
use egui::Context;
use rusqlite::Connection;
use std::path::PathBuf;
/// 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,
series_excessivas: Vec<(String, u64)>,
},
/// Gerenciamento de layouts.
GerenciandoLayouts,
}
/// 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>,
}
#[derive(Default, Clone, PartialEq)]
pub enum TipoModal {
#[default]
Informacao,
Aviso,
Erro,
Confirmacao,
}
#[derive(Clone)]
pub enum AcaoModal {
ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64),
SobrescreverLayout,
}
/// 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,
}
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,
}
}
}
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,
}
}
/// 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,
};
}
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,
};
}
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),
};
}
/// Recarrega a lista de layouts do banco.
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)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.add_space(8.0);
ui.label(&mensagem);
ui.add_space(12.0);
ui.horizontal(|ui| {
if tipo == TipoModal::Confirmacao {
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;
}
} else {
if ui.button("OK").clicked() {
self.modal.visivel = false;
}
}
});
});
}
fn executar_acao_modal(&mut self, acao: AcaoModal) {
match acao {
AcaoModal::ConfirmarExpansaoFaltantes => {
// Retirar pre-análise do estado e expandir
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
std::mem::replace(&mut self.estado, EstadoApp::Importando)
{
let resultado = expandir_analise(pre, &self.notas_importadas);
self.estado = EstadoApp::ExibindoResultado(resultado);
}
}
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 => {} // Handled inline in layouts screen
}
}
/// 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(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
})
.collect::<Vec<_>>()
.join("\n");
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
self.estado = EstadoApp::ConfirmandoIntervalo {
pre,
series_excessivas: excessivos,
};
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 eframe::App for App {
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
// Sempre renderizar modal por cima de tudo
self.renderizar_modal(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),
_ => {}
}
});
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod app;
pub mod screens;
+295
View File
@@ -0,0 +1,295 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::{App, EstadoApp};
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(12.0);
match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => renderizar_csv(ui, app),
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
}
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);
}
});
});
}
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",
};
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 = ',';
}
if ui.selectable_label(app.layout_csv_atual.delimitador == ';', "Ponto e vírgula (;)").clicked() {
app.layout_csv_atual.delimitador = ';';
}
if ui.selectable_label(app.layout_csv_atual.delimitador == '\t', "Tabulação (Tab)").clicked() {
app.layout_csv_atual.delimitador = '\t';
}
});
});
// 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);
});
}
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);
});
}
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));
}
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) {
let caminho = match &app.caminho_arquivo {
Some(p) => p.clone(),
None => return,
};
let resultado = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => {
importar_csv(&caminho, &app.layout_csv_atual)
.map_err(|e| e.to_string())
}
TipoArquivo::Xlsx => {
importar_xlsx(&caminho, &app.layout_xlsx_atual)
.map_err(|e| e.to_string())
}
};
match resultado {
Ok(res) => {
if res.notas.is_empty() {
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
return;
}
let avisos = res.avisos.clone();
app.notas_importadas = res.notas;
app.avisos_importacao = if avisos.tem_avisos() {
Some(avisos.clone())
} else {
None
};
// Executar análise
app.executar_analise();
// Exibir avisos consolidados após análise
if let Some(av) = &app.avisos_importacao {
if av.tem_avisos() {
let linhas = av.linhas_para_exibir().join("\n");
app.exibir_aviso("Avisos de Importação", linhas);
}
}
}
Err(e) => {
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
}
}
}
+169
View File
@@ -0,0 +1,169 @@
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx};
use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::{App, EstadoApp};
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"])
.pick_file()
{
on_arquivo_selecionado(app, 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);
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();
}
}
ui.add_space(12.0);
if !app.layout_xlsx_atual.aba.is_empty() {
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, 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);
app.notas_importadas.clear();
}
"xlsx" => {
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
match listar_abas_xlsx(&caminho) {
Ok(info) => {
app.abas_xlsx = info.abas.clone();
app.estado = EstadoApp::SelecionandoAba {
abas: info.abas,
caminho: caminho.clone(),
};
app.notas_importadas.clear();
}
Err(e) => {
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
}
}
}
_ => {
app.exibir_erro("Formato de arquivo não suportado. Use CSV ou XLSX.");
}
}
}
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
match layout {
crate::domain::entities::layout::Layout::Csv { config, .. } => {
app.layout_csv_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Csv;
}
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
app.layout_xlsx_atual = config.clone();
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
}
}
}
+213
View File
@@ -0,0 +1,213 @@
use crate::application::usecases::layouts::{
exportar_layout_json, importar_layout_json, salvar_layout,
};
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, 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(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)) => {
// Exibir opções: sobrescrever ou cancelar
app.exibir_aviso(
"Conflito de nome",
format!(
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
nome
),
);
// TODO: implementar fluxo completo de sobrescrever com entrada de novo 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;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod configuracao_colunas;
pub mod import;
pub mod layouts;
pub mod resultado;
+259
View File
@@ -0,0 +1,259 @@
use crate::application::usecases::exportar_pdf::exportar_pdf;
use crate::domain::{
entities::resultado_analise::ResultadoAnalise,
services::parser_monetario::formatar_valor_br,
};
use crate::infrastructure::pdf_generator::GenpdfGenerator;
use crate::ui::app::{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.estado = EstadoApp::Importando;
app.notas_importadas.clear();
return;
}
if ui.button("⚙ Reconfigurar Colunas").clicked() {
app.estado = EstadoApp::ConfigurandoColunas;
return;
}
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| {
// Totais
renderizar_totais(ui, &resultado);
ui.add_space(12.0);
ui.separator();
// Faltantes
renderizar_faltantes(ui, app, &resultado);
ui.add_space(12.0);
ui.separator();
// Duplicatas
renderizar_duplicatas(ui, app, &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 series: Vec<&String> = resultado.soma_por_serie.keys().collect();
series.sort();
for serie in series {
let soma = &resultado.soma_por_serie[serie];
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
ui.label(format!(
" Série {}: {} nota(s) — R$ {}",
serie,
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 series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
series.sort();
for serie in series {
let faltantes = &resultado.faltantes_por_serie[serie];
if faltantes.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} faltante(s):",
serie,
faltantes.len()
));
// Paginação
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());
for numero in &faltantes[inicio..fim] {
ui.label(format!("{}", numero));
}
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 series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
series.sort();
for serie in series {
let duplicatas = &resultado.duplicadas_por_serie[serie];
if duplicatas.is_empty() {
continue;
}
ui.label(format!(
"Série {}{} grupo(s) duplicado(s):",
serie,
duplicatas.len()
));
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, serie, 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));
}
}
}
}