Compare commits
14
Commits
e64988a139
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a21138bf8 | ||
|
|
e7d72c7e01 | ||
|
|
b9052e073f | ||
|
|
2c96e99ccc | ||
|
|
a5605de6a3 | ||
|
|
0354ece935 | ||
|
|
2c08f237e9 | ||
|
|
dedb6a72c2 | ||
|
|
41c75e5ed1 | ||
|
|
0f728b9684 | ||
|
|
b2b91cda55 | ||
|
|
b0e216a6ff | ||
|
|
bbfa66979b | ||
|
|
f3e6fa313d |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cargo build)",
|
||||||
|
"Bash(xargs grep -l \"tipo\\\\|invoice\\\\|tipo.*documento\")",
|
||||||
|
"Bash(xargs wc -l)",
|
||||||
|
"Bash(xargs grep -l \"grupo\\\\|grouping\\\\|agrupa\")",
|
||||||
|
"Bash(cargo metadata --no-deps --format-version 1)",
|
||||||
|
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''name''], p[''version'']\\) for p in d[''packages'']]\")",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:api.github.com)",
|
||||||
|
"Bash(ls D:/PROJECTS/comparador-notas/*.md)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sqltools.connections": [
|
||||||
|
{
|
||||||
|
"previewLimit": 50,
|
||||||
|
"driver": "SQLite",
|
||||||
|
"database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db",
|
||||||
|
"name": "comparador-notas"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Backlog de Features — Comparador de Notas
|
||||||
|
|
||||||
|
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
|
||||||
|
|
||||||
|
## Alta Prioridade
|
||||||
|
|
||||||
|
### F-01 — Exportar Resultado em CSV.
|
||||||
|
|
||||||
|
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
|
||||||
|
|
||||||
|
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
|
||||||
|
- `faltantes.csv`: `serie,documento_tipo,numero`
|
||||||
|
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
|
||||||
|
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
|
||||||
|
- Botão na barra de ações da tela `resultado.rs`
|
||||||
|
- Dependência `csv` já está no `Cargo.toml`
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2–3h)
|
||||||
|
**Impacto:** Alto — elimina retrabalho manual
|
||||||
|
|
||||||
|
**Observações:**
|
||||||
|
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar)
|
||||||
|
|
||||||
|
**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura.
|
||||||
|
|
||||||
|
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader
|
||||||
|
- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024`
|
||||||
|
|
||||||
|
**Esforço estimado:** Baixo (30min)
|
||||||
|
**Impacto:** Médio — evita travamentos inesperados
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Média Prioridade
|
||||||
|
|
||||||
|
### F-03 — Preview de Colunas Antes da Análise (Isso já existe?)
|
||||||
|
|
||||||
|
**Problema:** Erros de mapeamento de colunas (índice errado, encoding incorreto) só são descobertos depois da importação, que pode falhar silenciosamente com "0 notas válidas".
|
||||||
|
|
||||||
|
**Solução:** Na tela de configuração de colunas, exibir uma tabela das primeiras 5 linhas mostrando como os campos serão interpretados, com highlight em células que falhariam o parse.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Função `pre_visualizar(caminho, layout, n_linhas: usize) -> Vec<LinhaPreview>`
|
||||||
|
- `LinhaPreview`: `{ numero: Result<u64>, serie: Result<String>, valor: Result<Decimal>, ... }`
|
||||||
|
- Widget de tabela em `configuracao_colunas.rs`
|
||||||
|
- Atualizar preview ao mudar qualquer campo de configuração (via `changed()`)
|
||||||
|
|
||||||
|
**Esforço estimado:** Alto (4–6h)
|
||||||
|
**Impacto:** Alto — reduz drasticamente "por que não importou nada?"
|
||||||
|
|
||||||
|
**Observações:**
|
||||||
|
Verificar se já existe no codigo, pois na tela de configuração do Layout ele exibe as 5 primeiras linhas do arquivo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-04 — Busca por Número na Tela de Resultado (Util)
|
||||||
|
|
||||||
|
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
|
||||||
|
|
||||||
|
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
|
||||||
|
- está **faltante** (aparece na lista de faltantes)
|
||||||
|
- está **duplicada** (aparece na lista de duplicatas)
|
||||||
|
- está **presente** (está nas notas importadas)
|
||||||
|
- **não encontrada** (fora do intervalo conhecido)
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Campo `filtro_numero: String` no `App`
|
||||||
|
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
|
||||||
|
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
|
||||||
|
- Banner de resultado no topo da tela
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2–3h)
|
||||||
|
**Impacto:** Médio — uso diário em auditorias pontuais
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario)
|
||||||
|
|
||||||
|
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
|
||||||
|
|
||||||
|
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
|
||||||
|
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
|
||||||
|
- Botão na barra de ações da tela `resultado.rs`
|
||||||
|
|
||||||
|
**Esforço estimado:** Baixo (1h)
|
||||||
|
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-06 — Auto-detecção de Delimitador CSV (Util)
|
||||||
|
|
||||||
|
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
|
||||||
|
|
||||||
|
**Solução:** Ao selecionar o arquivo CSV, amostrar as primeiras 5 linhas e contar ocorrências de cada delimitador candidato. Sugerir o mais frequente como padrão, mantendo a ComboBox editável.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Função `detectar_delimitador(primeiras_linhas: &[&str]) -> char`
|
||||||
|
- Chamar na transição para `ConfigurandoColunas` quando `TipoArquivo::Csv`
|
||||||
|
- Pré-selecionar na ComboBox sem impedir mudança manual
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2h)
|
||||||
|
**Impacto:** Baixo-Médio — reduz um passo de configuração na maioria dos casos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baixa Prioridade
|
||||||
|
|
||||||
|
### F-07 — Auto-detecção de Encoding CSV (Util)
|
||||||
|
|
||||||
|
**Problema:** Arquivos gerados por sistemas legados brasileiros frequentemente usam `windows-1252`, mas o padrão selecionado é `utf-8`. O usuário descobre o problema somente ao ver caracteres estranhos nos dados.
|
||||||
|
|
||||||
|
**Solução:** Tentar ler as primeiras linhas em `utf-8`; se houver erro de decodificação, sugerir automaticamente `windows-1252` com aviso visual na tela de configuração.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Função `detectar_encoding(caminho: &Path) -> String`
|
||||||
|
- Usar `encoding_rs` (já dependência) para tentar decode e verificar `had_errors`
|
||||||
|
- Aviso na tela de configuração: "Encoding detectado: windows-1252"
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2h)
|
||||||
|
**Impacto:** Baixo — problema raro, mas confuso quando ocorre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-09 — Paginação Automática de Faltantes no PDF (Util)
|
||||||
|
|
||||||
|
**Problema:** PDFs com muitos faltantes (ex: 5.000 números) geram um único bloco de texto muito longo, difícil de ler e imprimir.
|
||||||
|
|
||||||
|
**Solução:** No `pdf_generator.rs`, ao adicionar a lista de faltantes, agrupar em intervalos contíguos (usando a função `agrupar_contiguos` já implementada) e formatar como `100–104 (5 notas)`. Reduz drasticamente o tamanho do PDF e melhora legibilidade.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Importar `agrupar_contiguos` em `pdf_generator.rs`
|
||||||
|
- Substituir o `join(", ")` atual pelo formato de intervalos
|
||||||
|
- Sem novas dependências
|
||||||
|
|
||||||
|
**Esforço estimado:** Baixo (30min)
|
||||||
|
**Impacto:** Baixo-Médio — melhora legibilidade de PDFs com muitos faltantes
|
||||||
|
|
||||||
|
> **Nota:** Esta feature é especialmente fácil porque a lógica de agrupamento já existe — é só aplicar no PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
| ID | Feature | Esforço | Impacto | Prioridade |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
|
||||||
|
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
|
||||||
|
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
|
||||||
|
| F-04 | Busca por número no resultado | Médio | Médio | Média |
|
||||||
|
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
|
||||||
|
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
|
||||||
|
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
|
||||||
|
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
# Roteiro de Implementação — Comparador de Notas
|
|
||||||
|
|
||||||
**Versão:** 1.0
|
|
||||||
**Data:** 02/03/2026
|
|
||||||
**Baseado no PRD:** v1.6
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 0 — Fundação do Projeto
|
|
||||||
|
|
||||||
**Objetivo:** Estrutura compilável com todas as dependências.
|
|
||||||
|
|
||||||
### 0.1 Configurar `Cargo.toml`
|
|
||||||
|
|
||||||
Adicionar todas as dependências:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
eframe = "0.31"
|
|
||||||
egui = "0.31"
|
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
|
||||||
csv = "1.3"
|
|
||||||
calamine = "0.26"
|
|
||||||
rust_decimal = { version = "1.36", features = ["serde"] }
|
|
||||||
rust_decimal_macros = "1.36"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
genpdf = "0.2"
|
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
encoding_rs = "0.8"
|
|
||||||
dirs = "5"
|
|
||||||
thiserror = "2"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 0.2 Criar estrutura de pastas completa
|
|
||||||
|
|
||||||
Conforme definido na seção 9.1 do PRD:
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
main.rs
|
|
||||||
ui/
|
|
||||||
mod.rs
|
|
||||||
app.rs
|
|
||||||
screens/
|
|
||||||
import.rs
|
|
||||||
configuracao_colunas.rs
|
|
||||||
layouts.rs
|
|
||||||
resultado.rs
|
|
||||||
application/
|
|
||||||
mod.rs
|
|
||||||
usecases/
|
|
||||||
importar_arquivo.rs
|
|
||||||
executar_analise.rs
|
|
||||||
exportar_pdf.rs
|
|
||||||
domain/
|
|
||||||
mod.rs
|
|
||||||
errors.rs
|
|
||||||
entities/
|
|
||||||
nota.rs
|
|
||||||
serie.rs
|
|
||||||
layout.rs
|
|
||||||
resultado_analise.rs
|
|
||||||
services/
|
|
||||||
detector_sequencia.rs
|
|
||||||
detector_duplicidade.rs
|
|
||||||
parser_monetario.rs
|
|
||||||
infrastructure/
|
|
||||||
mod.rs
|
|
||||||
csv_reader.rs
|
|
||||||
xlsx_reader.rs
|
|
||||||
pdf_generator.rs
|
|
||||||
sqlite/
|
|
||||||
mod.rs
|
|
||||||
connection.rs
|
|
||||||
migrations.rs
|
|
||||||
layout_repository.rs
|
|
||||||
```
|
|
||||||
|
|
||||||
### 0.3 Criar todos os `mod.rs` com declarações vazias
|
|
||||||
|
|
||||||
Garantir que o projeto compila antes de começar a implementar.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 1 — Camada Domain (núcleo puro)
|
|
||||||
|
|
||||||
**Dependência:** Fase 0 concluída.
|
|
||||||
**Restrição:** Zero dependência de egui, rusqlite, calamine, csv ou genpdf. Apenas Rust puro + `rust_decimal` e `chrono`.
|
|
||||||
|
|
||||||
### 1.1 `domain/errors.rs`
|
|
||||||
|
|
||||||
Definir tipos de erro com `thiserror`:
|
|
||||||
|
|
||||||
- `ErroNumero` (Zero, NaoNumerico)
|
|
||||||
- `ErroSerie` (Invalida, Vazia)
|
|
||||||
- `ErroValor` (Negativo, NaoNumerico)
|
|
||||||
- `ErroLayout` (CampoObrigatorioAusente, JsonMalformado, NomeConflitante)
|
|
||||||
|
|
||||||
### 1.2 `domain/entities/nota.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Nota {
|
|
||||||
pub numero: u64,
|
|
||||||
pub serie: String,
|
|
||||||
pub valor: Option<Decimal>,
|
|
||||||
pub data: Option<NaiveDate>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.3 `domain/entities/serie.rs`
|
|
||||||
|
|
||||||
- Validação da regex `[0-9]{1,3}`
|
|
||||||
- Função `validar_serie(s: &str) -> Result<String, ErroSerie>`
|
|
||||||
|
|
||||||
### 1.4 `domain/entities/layout.rs`
|
|
||||||
|
|
||||||
- `enum TipoArquivo { Csv, Xlsx }`
|
|
||||||
- `struct LayoutCsv` com todos os campos da seção 10.4 do PRD
|
|
||||||
- `struct LayoutXlsx` com todos os campos da seção 10.4 do PRD
|
|
||||||
- `enum Layout { Csv(LayoutCsv), Xlsx(LayoutXlsx) }`
|
|
||||||
|
|
||||||
### 1.5 `domain/entities/resultado_analise.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct ResultadoPreAnalise {
|
|
||||||
// min, max, contagem_faltantes por série
|
|
||||||
pub intervalos_por_serie: HashMap<String, (u64, u64, usize)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ResultadoAnalise {
|
|
||||||
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
|
|
||||||
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
|
||||||
pub soma_total: Decimal,
|
|
||||||
pub soma_por_serie: HashMap<String, Decimal>,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.6 `domain/services/parser_monetario.rs`
|
|
||||||
|
|
||||||
Implementar exatamente o algoritmo da seção RF06:
|
|
||||||
|
|
||||||
| Regra | Condição | Comportamento |
|
|
||||||
|-------|----------|---------------|
|
|
||||||
| 1 | Contém ponto **e** vírgula | Último separador é o decimal |
|
|
||||||
| 2a | Apenas um separador + exatamente 2 dígitos após | Separador decimal |
|
|
||||||
| 2b | Apenas um separador + exatamente 3 dígitos após | Separador de milhar |
|
|
||||||
| 2c | Apenas um separador + outros casos | Separador decimal |
|
|
||||||
| 3 | Sem separador | Número inteiro |
|
|
||||||
|
|
||||||
- Rejeitar valores negativos (precedidos de `-`)
|
|
||||||
- Armazenar como `rust_decimal::Decimal`
|
|
||||||
|
|
||||||
> **Prioridade:** Testar exaustivamente com todos os exemplos da tabela do PRD antes de avançar.
|
|
||||||
|
|
||||||
### 1.7 `domain/services/detector_sequencia.rs`
|
|
||||||
|
|
||||||
- Recebe `Vec<&Nota>` de uma série
|
|
||||||
- Ordena por `numero` crescente
|
|
||||||
- Percorre **incrementalmente** (sem lista intermediária)
|
|
||||||
- Retorna `Vec<u64>` de faltantes
|
|
||||||
- Respeitar o limite de 10.000 registros faltantes (RFC04): retornar `ResultadoPreAnalise` antes de expandir
|
|
||||||
|
|
||||||
### 1.8 `domain/services/detector_duplicidade.rs`
|
|
||||||
|
|
||||||
- Recebe `Vec<&Nota>`
|
|
||||||
- Retorna `HashMap<(u64, String), usize>` com contagem por grupo
|
|
||||||
- Filtra apenas grupos com contagem > 1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 2 — Camada Infrastructure
|
|
||||||
|
|
||||||
**Dependência:** Fase 1 concluída.
|
|
||||||
|
|
||||||
### 2.1 `infrastructure/sqlite/connection.rs`
|
|
||||||
|
|
||||||
- Determinar caminho do banco conforme SO via `dirs::config_dir()`:
|
|
||||||
- Linux: `~/.config/comparador-notas/config.db`
|
|
||||||
- Windows: `%APPDATA%\comparador-notas\config.db`
|
|
||||||
- macOS: `~/Library/Application Support/comparador-notas/config.db`
|
|
||||||
- Criar diretório automaticamente se não existir
|
|
||||||
- Abrir conexão SQLite
|
|
||||||
- Tratamento de banco corrompido: renomear para `config.db.bak`, recriar banco vazio
|
|
||||||
|
|
||||||
### 2.2 `infrastructure/sqlite/migrations.rs`
|
|
||||||
|
|
||||||
- Tabela `schema_version` para controle de versão do schema
|
|
||||||
- Migration v1: criar tabela `layouts` com todos os campos da seção 10.4 do PRD
|
|
||||||
- Aplicar migrations automaticamente na inicialização
|
|
||||||
|
|
||||||
### 2.3 `infrastructure/sqlite/layout_repository.rs`
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn salvar(layout: &Layout) -> Result<i64>
|
|
||||||
fn listar() -> Result<Vec<Layout>>
|
|
||||||
fn buscar_por_id(id: i64) -> Result<Option<Layout>>
|
|
||||||
fn excluir(id: i64) -> Result<()>
|
|
||||||
fn existe_nome(nome: &str) -> Result<bool>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 `infrastructure/csv_reader.rs`
|
|
||||||
|
|
||||||
- Suporte a delimitadores: `,` `;` `\t`
|
|
||||||
- Suporte a encoding: UTF-8 e Windows-1252 (via `encoding_rs`)
|
|
||||||
- Respeitar linha de cabeçalho configurável (base 1)
|
|
||||||
- Ignorar linhas em branco silenciosamente
|
|
||||||
- Coletar linhas malformadas para relatório consolidado
|
|
||||||
- Validar limite de 50 MB antes de ler
|
|
||||||
- Retornar `Vec<Vec<String>>` (linhas × colunas)
|
|
||||||
|
|
||||||
### 2.5 `infrastructure/xlsx_reader.rs`
|
|
||||||
|
|
||||||
- `listar_abas(path) -> Vec<String>` — chamado imediatamente após seleção do arquivo
|
|
||||||
- Ler dados da aba selecionada a partir da posição `LetraLinha`
|
|
||||||
- Converter notação `LetraLinha` (ex: `B3`) para `(col_idx, row_idx)`
|
|
||||||
- Ignorar linhas em branco silenciosamente
|
|
||||||
- Validar limite de 50 MB
|
|
||||||
- Tratar arquivo corrompido retornando erro tipado
|
|
||||||
|
|
||||||
### 2.6 `infrastructure/pdf_generator.rs`
|
|
||||||
|
|
||||||
- Implementar trait `PdfGenerator` definida em `application/`
|
|
||||||
- Gerar PDF com `genpdf` contendo:
|
|
||||||
- Metadados: nome do arquivo importado, data/hora de geração, nome do layout
|
|
||||||
- Faltantes agrupados por série
|
|
||||||
- Duplicatas agrupadas por série
|
|
||||||
- Totais por série e total geral
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 3 — Camada Application
|
|
||||||
|
|
||||||
**Dependência:** Fases 1 e 2 concluídas.
|
|
||||||
|
|
||||||
### 3.1 `application/usecases/importar_arquivo.rs`
|
|
||||||
|
|
||||||
1. Validar tamanho do arquivo (recusar > 50 MB)
|
|
||||||
2. Detectar tipo (CSV ou XLSX)
|
|
||||||
3. Para XLSX: chamar `listar_abas()` e retornar lista para UI fazer a seleção
|
|
||||||
4. Chamar leitor adequado com configurações do layout
|
|
||||||
5. Mapear colunas e construir `Vec<Nota>`
|
|
||||||
6. Aplicar validações: regex de série, numero zero, valores inválidos
|
|
||||||
7. Retornar notas válidas + relatório de avisos consolidado (RF06/RNF06)
|
|
||||||
|
|
||||||
### 3.2 `application/usecases/executar_analise.rs`
|
|
||||||
|
|
||||||
1. Receber `Vec<Nota>`
|
|
||||||
2. Chamar `parser_monetario` para cada valor
|
|
||||||
3. Agrupar por série
|
|
||||||
4. Chamar `detector_sequencia` por série → gerar `ResultadoPreAnalise`
|
|
||||||
5. Se algum intervalo > 10.000: retornar `ResultadoPreAnalise` para UI solicitar confirmação
|
|
||||||
6. Após confirmação: expandir faltantes e montar `ResultadoAnalise`
|
|
||||||
7. Chamar `detector_duplicidade`
|
|
||||||
8. Calcular somas com `Decimal` (nunca `f64`)
|
|
||||||
9. Retornar `ResultadoAnalise`
|
|
||||||
|
|
||||||
### 3.3 `application/usecases/exportar_pdf.rs`
|
|
||||||
|
|
||||||
- Depende da trait `PdfGenerator` (não de `genpdf` diretamente)
|
|
||||||
- Recebe `ResultadoAnalise` + metadados do arquivo e layout
|
|
||||||
- Delega geração para a implementação concreta em infrastructure
|
|
||||||
|
|
||||||
### 3.4 Gerenciamento de layouts
|
|
||||||
|
|
||||||
- `salvar_layout` — valida campos obrigatórios antes de persistir
|
|
||||||
- `listar_layouts` — lista separada por tipo (CSV / XLSX)
|
|
||||||
- `carregar_layout` — busca por id
|
|
||||||
- `excluir_layout` — remove do banco
|
|
||||||
- `exportar_layout_json` — serializa com `serde_json`, sugere nome do arquivo
|
|
||||||
- `importar_layout_json` — desserializa + valida campos obrigatórios + verifica conflito de nome
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 4 — Camada UI (egui/eframe)
|
|
||||||
|
|
||||||
**Dependência:** Fase 3 concluída.
|
|
||||||
**Regra:** Nenhuma regra de negócio, parsing ou cálculo dentro da UI.
|
|
||||||
|
|
||||||
### 4.1 `ui/app.rs` — Estrutura principal
|
|
||||||
|
|
||||||
```rust
|
|
||||||
enum EstadoApp {
|
|
||||||
Importando,
|
|
||||||
ConfigurandoColunas,
|
|
||||||
SelecionandoAba(Vec<String>), // lista de abas XLSX
|
|
||||||
Analisando,
|
|
||||||
ConfirmandoIntervalo(ResultadoPreAnalise),
|
|
||||||
ExibindoResultado(ResultadoAnalise),
|
|
||||||
GerenciandoLayouts,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- Gerenciamento de modais/popups bloqueantes (RNF06)
|
|
||||||
- Struct `App` com estado compartilhado e referência ao banco SQLite
|
|
||||||
|
|
||||||
### 4.2 `ui/screens/import.rs`
|
|
||||||
|
|
||||||
- Botão "Selecionar arquivo" com diálogo nativo
|
|
||||||
- Exibir nome do arquivo selecionado
|
|
||||||
- Para XLSX: exibir dropdown de seleção de aba **imediatamente** após seleção do arquivo
|
|
||||||
- Dropdown de layouts salvos para carregar configuração existente
|
|
||||||
- Botão "Gerenciar Layouts" → navegar para `layouts.rs`
|
|
||||||
|
|
||||||
### 4.3 `ui/screens/configuracao_colunas.rs`
|
|
||||||
|
|
||||||
- Para CSV: campos numéricos para índice de cada coluna (base 0) + delimitador + encoding + linha cabeçalho
|
|
||||||
- Para XLSX: campos de texto para posição `LetraLinha` de cada coluna
|
|
||||||
- Validação em tempo real: índices duplicados, campos obrigatórios ausentes
|
|
||||||
- Bloqueio do botão "Executar Análise" quando configuração inválida
|
|
||||||
- Botão "Executar Análise"
|
|
||||||
|
|
||||||
### 4.4 `ui/screens/layouts.rs`
|
|
||||||
|
|
||||||
- Listar layouts salvos separados por tipo (CSV / XLSX)
|
|
||||||
- Campo de nome + botão "Salvar layout atual"
|
|
||||||
- Botão "Excluir" com modal de confirmação
|
|
||||||
- Botão "Exportar para JSON" → diálogo de salvar com nome sugerido baseado no nome do layout
|
|
||||||
- Botão "Importar de JSON" → diálogo de abertura
|
|
||||||
- Modal de conflito de nome ao importar: "Sobrescrever" ou "Salvar com novo nome"
|
|
||||||
|
|
||||||
### 4.5 `ui/screens/resultado.rs`
|
|
||||||
|
|
||||||
- Seções: **Faltantes** | **Duplicatas** | **Totais**
|
|
||||||
- Agrupamento por série em todas as seções
|
|
||||||
- Paginação com dropdown: 50 / 100 / 200 / 1000 itens por página
|
|
||||||
- Valores monetários formatados em pt-BR (ex: `1.234,56`)
|
|
||||||
- Números de notas sem zeros à esquerda (ex: `0001` exibido como `1`)
|
|
||||||
- Botão "Exportar PDF" → chamar use case `exportar_pdf`
|
|
||||||
|
|
||||||
### 4.6 Modais globais (RNF06)
|
|
||||||
|
|
||||||
| Modal | Trigger |
|
|
||||||
|-------|---------|
|
|
||||||
| Erro genérico | Qualquer erro com mensagem + botão OK |
|
|
||||||
| Aviso consolidado | Final de importação com lista de categorias de problema |
|
|
||||||
| Confirmação de intervalo | Faltantes > 10.000 por série |
|
|
||||||
| Confirmação de exclusão | Excluir layout |
|
|
||||||
| Conflito de nome | Importar JSON com nome existente |
|
|
||||||
| Banco corrompido | Inicialização com `config.db` inválido |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 5 — Testes
|
|
||||||
|
|
||||||
**Dependência:** Fases 1–4 concluídas (testes unitários podem ser escritos junto com cada fase).
|
|
||||||
|
|
||||||
### 5.1 Testes unitários — Domain
|
|
||||||
|
|
||||||
- `parser_monetario`: todos os casos da tabela do PRD + casos extremos
|
|
||||||
- `detector_sequencia`: sequência completa, com faltantes, série com um único registro, intervalos > 10.000
|
|
||||||
- `detector_duplicidade`: sem duplicatas, com duplicatas, múltiplas séries
|
|
||||||
- Validação de série (regex `[0-9]{1,3}`): válidos e inválidos
|
|
||||||
- Validação de numero zero: deve ser descartado
|
|
||||||
|
|
||||||
### 5.2 Testes de integração — Infrastructure
|
|
||||||
|
|
||||||
- Leitura CSV: UTF-8, Windows-1252, diferentes delimitadores, linhas malformadas, arquivo > 50 MB
|
|
||||||
- Leitura XLSX: aba correta, posição `LetraLinha`, arquivo corrompido, limite de tamanho
|
|
||||||
- SQLite: migrations, CRUD de layouts, banco corrompido → recriação
|
|
||||||
|
|
||||||
### 5.3 Testes de integração — Application
|
|
||||||
|
|
||||||
- Fluxo completo CSV → análise → resultado
|
|
||||||
- Fluxo completo XLSX → análise → resultado
|
|
||||||
- Importação de layout JSON: válido, malformado, conflito de nome com sobrescrita, conflito com renomeação
|
|
||||||
- Intervalo > 10.000: verificar que `ResultadoPreAnalise` é retornado antes da expansão
|
|
||||||
|
|
||||||
### 5.4 Testes manuais de UI
|
|
||||||
|
|
||||||
- Todas as mensagens em PT-BR
|
|
||||||
- Formatação monetária correta (pt-BR)
|
|
||||||
- Paginação funcionando
|
|
||||||
- Geração do PDF com metadados corretos
|
|
||||||
- Fluxo XLSX: aba exibida imediatamente após seleção do arquivo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fase 6 — Polimento e Empacotamento
|
|
||||||
|
|
||||||
### 6.1 Strings e idioma
|
|
||||||
|
|
||||||
- Revisar todas as mensagens de erro/aviso para PT-BR (RNF05)
|
|
||||||
- Garantir que nenhuma string de erro interna vaze para a UI como texto bruto
|
|
||||||
|
|
||||||
### 6.2 Performance
|
|
||||||
|
|
||||||
- Validar com arquivo de 100.000 registros (RNF03)
|
|
||||||
- Confirmar que detecção de faltantes é incremental (sem lista intermediária antes da confirmação)
|
|
||||||
- Medir tempo de importação e análise
|
|
||||||
|
|
||||||
### 6.3 Build multiplataforma
|
|
||||||
|
|
||||||
- Linux: validar caminho `~/.config/comparador-notas/config.db`
|
|
||||||
- Windows: validar caminho `%APPDATA%\comparador-notas\config.db`
|
|
||||||
- macOS: validar caminho `~/Library/Application Support/comparador-notas/config.db`
|
|
||||||
|
|
||||||
### 6.4 Empacotamento
|
|
||||||
|
|
||||||
- Configurar ícone da aplicação
|
|
||||||
- Build de release otimizado: `cargo build --release`
|
|
||||||
- Testar executável standalone (sem instalação, sem dependências externas)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ordem de Execução Recomendada
|
|
||||||
|
|
||||||
```
|
|
||||||
Fase 0 → Fase 1 (com testes unitários) → Fase 2.1/2.2/2.3 → Fase 2.4/2.5
|
|
||||||
→ Fase 3.1/3.2 → Fase 4.1/4.2/4.3/4.5 → Fase 2.6/3.3 → Fase 3.4/4.4
|
|
||||||
→ Fase 5 → Fase 6
|
|
||||||
```
|
|
||||||
|
|
||||||
> O núcleo de domínio deve estar **totalmente testado** antes de qualquer integração. O `parser_monetario` e o `detector_sequencia` são os componentes mais críticos do sistema.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependências entre Componentes
|
|
||||||
|
|
||||||
```
|
|
||||||
domain/errors.rs
|
|
||||||
└─ domain/entities/ (nota, serie, layout, resultado_analise)
|
|
||||||
└─ domain/services/ (parser_monetario, detector_sequencia, detector_duplicidade)
|
|
||||||
└─ application/usecases/ (importar_arquivo, executar_analise, exportar_pdf)
|
|
||||||
└─ infrastructure/ (csv_reader, xlsx_reader, sqlite/, pdf_generator)
|
|
||||||
└─ ui/ (app, screens/)
|
|
||||||
```
|
|
||||||
|
|
||||||
Nenhuma camada pode importar de uma camada acima dela na hierarquia.
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# PRD — Comparador de Notas
|
# PRD — Comparador de Notas
|
||||||
|
|
||||||
**Versão:** 1.6
|
**Versão:** 1.7
|
||||||
**Data:** 02/03/2026
|
**Data:** 03/03/2026
|
||||||
**Status:** Planejamento
|
**Status:** Implementado (MVP)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
* Notas faltantes
|
* Notas faltantes
|
||||||
* Notas duplicadas
|
* Notas duplicadas
|
||||||
* Soma total dos valores
|
* Soma total dos valores
|
||||||
* Agrupamento por série
|
* Agrupamento por série e tipo de documento
|
||||||
|
|
||||||
## 3.2 Objetivos Secundários
|
## 3.2 Objetivos Secundários
|
||||||
|
|
||||||
@@ -75,15 +75,21 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
* Detecção de quebras de sequência
|
* Detecção de quebras de sequência
|
||||||
* Detecção de duplicidades
|
* Detecção de duplicidades
|
||||||
* Soma de valores
|
* Soma de valores
|
||||||
* Agrupamento por série
|
* Agrupamento por série e tipo de documento
|
||||||
|
* Pré-visualização das primeiras linhas do arquivo na tela de configuração
|
||||||
* Relatório visual com paginação
|
* Relatório visual com paginação
|
||||||
* Exportação de relatório para PDF
|
* Exibição de faltantes agrupados em intervalos contíguos (ex: `10–50 (41 notas)`)
|
||||||
|
* Indicador de completude por série (ex: `48/50 notas — 96,0% completo`)
|
||||||
|
* Botão de cópia rápida de listas de faltantes/duplicatas para área de transferência
|
||||||
|
* Exportação de relatório para PDF (fontes Liberation Sans embutidas no binário)
|
||||||
* Salvar layouts personalizados (exclusivos por tipo de arquivo)
|
* 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
|
* Exportar layout para JSON
|
||||||
* Importar layout de JSON
|
* Importar layout de JSON
|
||||||
|
* Reanalisar arquivo sem reconfiguração (reimporta o mesmo arquivo com o layout atual)
|
||||||
|
* Análise em background thread (UI não bloqueia durante importação e análise)
|
||||||
|
|
||||||
## 4.2 Não Incluído (MVP)
|
## 4.2 Não Incluído (MVP)
|
||||||
|
|
||||||
@@ -91,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
* Integração com banco de dados externo
|
* Integração com banco de dados externo
|
||||||
* Multiusuário
|
* Multiusuário
|
||||||
* Acesso remoto
|
* Acesso remoto
|
||||||
|
* Exportação de resultado em CSV
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -98,16 +105,17 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
|
|
||||||
O sistema trabalhará com os seguintes campos lógicos:
|
O sistema trabalhará com os seguintes campos lógicos:
|
||||||
|
|
||||||
| Campo | Obrigatório | Descrição |
|
| Campo | Obrigatório | Descrição |
|
||||||
| ------ | ----------- | ------------------------------------------------------------------------- |
|
| --------------- | ----------- | ------------------------------------------------------------------------- |
|
||||||
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único. Armazenado internamente como `u64`. |
|
| Numero | Sim | Número incremental da nota. Armazenado internamente como `u64`. |
|
||||||
| 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. |
|
| 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. Armazenado internamente como decimal fixo (`rust_decimal`). |
|
| Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). |
|
||||||
| 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. |
|
| Data | Não | Data de emissão da nota. Exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo. Formatos aceitos: `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. |
|
||||||
|
| TipoDocumento | Não | Tipo do documento (ex: `NFE`, `NFCE`). Quando mapeado, compõe a chave de agrupamento junto com a Série. Qualquer string não vazia é aceita. |
|
||||||
|
|
||||||
Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
|
Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
|
||||||
|
|
||||||
> **Identificador único de uma nota:** combinação de `Numero + Serie`. Duplicidade e sequência são sempre avaliadas dentro da mesma série.
|
> **Identificador único de uma nota:** combinação de `Numero + Serie + TipoDocumento`. Duplicidade e sequência são sempre avaliadas dentro do mesmo grupo `(Serie, TipoDocumento)`. Quando `TipoDocumento` não é mapeado, o agrupamento é feito somente por `Serie` (retrocompatível).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,7 +134,7 @@ O sistema deve permitir importar arquivos:
|
|||||||
| ----------------- | ----------------------------------------------------------------------------- |
|
| ----------------- | ----------------------------------------------------------------------------- |
|
||||||
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
|
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
|
||||||
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
|
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
|
||||||
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.) |
|
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.). 0 = sem cabeçalho |
|
||||||
| Linhas em branco | Devem ser ignoradas silenciosamente |
|
| Linhas em branco | Devem ser ignoradas silenciosamente |
|
||||||
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
|
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
|
||||||
|
|
||||||
@@ -160,16 +168,21 @@ O usuário define, via **índice numérico** (posição da coluna, base 0), qual
|
|||||||
* Qual índice representa a série (obrigatório)
|
* Qual índice representa a série (obrigatório)
|
||||||
* Qual índice representa o valor (opcional)
|
* Qual índice representa o valor (opcional)
|
||||||
* Qual índice representa a data (opcional)
|
* Qual índice representa a data (opcional)
|
||||||
|
* Qual índice representa o tipo de documento (opcional)
|
||||||
|
|
||||||
### RF02.2 — Mapeamento XLSX (letra + linha)
|
### RF02.2 — Mapeamento XLSX (letra + linha)
|
||||||
|
|
||||||
O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha:
|
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
|
* 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)
|
* Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional), Data (opcional) e TipoDocumento (opcional)
|
||||||
|
|
||||||
> A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior.
|
> A linha informada é a **linha de início dos dados** (não o cabeçalho). O cabeçalho, se existir, é a linha imediatamente anterior.
|
||||||
|
|
||||||
|
### RF02.3 — Pré-visualização do Arquivo
|
||||||
|
|
||||||
|
A tela de configuração de colunas exibe as primeiras 5 linhas do arquivo com cabeçalho em notação de letras (A, B, C, ... com índice base-0 entre parênteses). A pré-visualização é atualizada automaticamente ao mudar o delimitador ou a aba selecionada.
|
||||||
|
|
||||||
### Comportamento de Memória
|
### 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.
|
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.
|
||||||
@@ -207,7 +220,7 @@ Cada layout é exclusivo de um tipo de arquivo: **CSV** ou **XLSX**. Um layout C
|
|||||||
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:
|
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
|
* Sobrescrever o layout existente
|
||||||
* Salvar com novo nome
|
* Cancelar a importação
|
||||||
|
|
||||||
### Erros na Importação de Layout JSON
|
### Erros na Importação de Layout JSON
|
||||||
|
|
||||||
@@ -227,14 +240,14 @@ Não há limite no número de layouts que podem ser armazenados.
|
|||||||
|
|
||||||
O sistema deve:
|
O sistema deve:
|
||||||
|
|
||||||
* Ordenar os registros por número dentro de cada série
|
* Ordenar os registros por número dentro de cada grupo `(Serie, TipoDocumento)`
|
||||||
* Detectar números faltantes na sequência
|
* Detectar números faltantes na sequência
|
||||||
|
|
||||||
### Regra de Detecção
|
### Regra de Detecção
|
||||||
|
|
||||||
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada série. Qualquer número ausente nesse intervalo é considerado faltante.
|
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada grupo. Qualquer número ausente nesse intervalo é considerado faltante.
|
||||||
|
|
||||||
Exemplo: série 001 contém os números `0001, 0002, 0003, 0005` → `0004` está faltando.
|
Exemplo: série 001 / NFE contém os números `0001, 0002, 0003, 0005` → `0004` está faltando.
|
||||||
|
|
||||||
### Tratamento de Valores Não Numéricos
|
### Tratamento de Valores Não Numéricos
|
||||||
|
|
||||||
@@ -251,26 +264,30 @@ Registros com o campo Numero igual a `0` devem ser descartados e reportados ao u
|
|||||||
|
|
||||||
### Escopo da Detecção
|
### Escopo da Detecção
|
||||||
|
|
||||||
Faltantes são detectados **por série**. Cada série possui sua própria sequência independente.
|
Faltantes são detectados **por grupo `(Serie, TipoDocumento)`**. Cada grupo possui sua própria sequência independente.
|
||||||
|
|
||||||
### Série com Apenas um Registro
|
### 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.
|
Se um grupo contiver apenas um registro, o intervalo de sequência é `numero..numero`. Não há faltantes nesse caso. O grupo é processado e exibido normalmente.
|
||||||
|
|
||||||
### Ordenação
|
### 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.
|
A ordenação dos registros dentro de cada grupo é sempre **numérica crescente**, independentemente do formato original do campo Numero no arquivo de entrada.
|
||||||
|
|
||||||
### Exibição do Campo Numero
|
### 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.
|
O campo Numero é armazenado e processado como inteiro. Na exibição (listas de faltantes, duplicatas e relatório PDF), o número é exibido **sem zeros à esquerda** (ex: `0001` é exibido como `1`). O formato de exibição não altera a lógica de detecção ou ordenação.
|
||||||
|
|
||||||
|
### Agrupamento de Faltantes Contíguos
|
||||||
|
|
||||||
|
Na tela de resultado, faltantes consecutivos são exibidos agrupados em intervalos (ex: `10–50 (41 notas)`) para facilitar a leitura. Faltantes isolados são exibidos individualmente (ex: `• 75`).
|
||||||
|
|
||||||
### Proteção contra Intervalos Anormalmente Grandes
|
### Proteção contra Intervalos Anormalmente Grandes
|
||||||
|
|
||||||
Se o intervalo de faltantes de qualquer série exceder **10.000 registros**, o sistema deve:
|
Se o intervalo de faltantes de qualquer grupo exceder **10.000 registros**, o sistema deve:
|
||||||
|
|
||||||
1. Interromper o processamento dessa série
|
1. Interromper o processamento desse grupo
|
||||||
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: intervalo de 999.996 faltantes detectado")
|
2. Exibir aviso informando o intervalo calculado (ex: "Série 001 / NFE: intervalo de 999.996 faltantes detectado")
|
||||||
3. Solicitar confirmação do usuário antes de continuar
|
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.
|
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.
|
||||||
@@ -279,7 +296,7 @@ Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam
|
|||||||
|
|
||||||
### Tratamento de Série com Valor Inválido
|
### 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.
|
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:
|
Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável:
|
||||||
|
|
||||||
@@ -294,11 +311,11 @@ Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
|
|||||||
|
|
||||||
O sistema deve identificar registros duplicados.
|
O sistema deve identificar registros duplicados.
|
||||||
|
|
||||||
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie** no arquivo importado.
|
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie + TipoDocumento** no arquivo importado.
|
||||||
|
|
||||||
### Exibição das Duplicatas
|
### 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.
|
A lista de duplicatas exibe o identificador (`Numero + Serie + TipoDocumento`) e a contagem de ocorrências por grupo. Exemplo: `NF 0004 / Série 001 / NFE — 3 ocorrências`. As ocorrências individuais não são listadas separadamente.
|
||||||
|
|
||||||
### Impacto na Soma de Valores
|
### Impacto na Soma de Valores
|
||||||
|
|
||||||
@@ -311,12 +328,14 @@ Todas as ocorrências de registros duplicados são incluídas na soma de valores
|
|||||||
O sistema deve calcular:
|
O sistema deve calcular:
|
||||||
|
|
||||||
* Soma total
|
* Soma total
|
||||||
* Soma por série
|
* Soma por grupo `(Serie, TipoDocumento)`
|
||||||
|
|
||||||
### Formato de Valor Aceito
|
### Formato de Valor Aceito
|
||||||
|
|
||||||
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
|
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
|
||||||
|
|
||||||
|
O parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
|
||||||
|
|
||||||
#### Algoritmo de Parsing Monetário
|
#### Algoritmo de Parsing Monetário
|
||||||
|
|
||||||
**Regra 1 — Contém ambos ponto e vírgula:**
|
**Regra 1 — Contém ambos ponto e vírgula:**
|
||||||
@@ -348,7 +367,7 @@ Valores negativos (precedidos de `-`) devem ser rejeitados e reportados ao usuá
|
|||||||
|
|
||||||
Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário.
|
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`.
|
> **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por grupo devem ser calculadas inteiramente em `Decimal`.
|
||||||
|
|
||||||
### Formato de Exibição de Valores
|
### Formato de Exibição de Valores
|
||||||
|
|
||||||
@@ -360,15 +379,17 @@ Todos os valores monetários são exibidos com **2 casas decimais fixas** no for
|
|||||||
|
|
||||||
O sistema deve exibir:
|
O sistema deve exibir:
|
||||||
|
|
||||||
* Lista de notas faltantes agrupadas por série
|
* Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
|
||||||
* Lista de duplicadas agrupadas por série
|
* Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
|
||||||
* Totais (soma total e soma por série)
|
* Lista de duplicadas agrupadas por `(Serie, TipoDocumento)`
|
||||||
|
* Totais (soma total e soma por grupo, com contagem de notas por grupo)
|
||||||
|
* Botão de cópia rápida de listas para a área de transferência
|
||||||
|
|
||||||
### Organização dos Resultados
|
### Organização dos Resultados
|
||||||
|
|
||||||
| Aspecto | Comportamento |
|
| Aspecto | Comportamento |
|
||||||
| ------------- | ----------------------------------------------------- |
|
| ------------- | ----------------------------------------------------- |
|
||||||
| Agrupamento | Resultados sempre agrupados por série |
|
| Agrupamento | Resultados sempre agrupados por `(Serie, TipoDocumento)`. Quando TipoDocumento não está mapeado, o label do grupo exibe apenas a Série |
|
||||||
| Listas longas | Paginação — o usuário navega entre páginas de resultados |
|
| 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 |
|
| Itens por página | Selecionável via dropdown com as opções: 50, 100, 200, 1000 |
|
||||||
|
|
||||||
@@ -376,15 +397,21 @@ O sistema deve exibir:
|
|||||||
|
|
||||||
O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`.
|
O sistema deve permitir exportar o relatório de resultados para **PDF** utilizando a biblioteca `genpdf`.
|
||||||
|
|
||||||
|
As fontes do PDF (Liberation Sans) são embutidas no binário em tempo de compilação, eliminando dependência de fontes instaladas no sistema operacional.
|
||||||
|
|
||||||
O PDF deve conter:
|
O PDF deve conter:
|
||||||
* Notas faltantes por série
|
* Notas faltantes por grupo `(Serie, TipoDocumento)`
|
||||||
* Duplicatas por série
|
* Duplicatas por grupo
|
||||||
* Totais por série e total geral
|
* Totais por grupo e total geral
|
||||||
|
|
||||||
**Metadados do relatório:**
|
**Metadados do relatório:**
|
||||||
* Nome do arquivo importado
|
* Nome do arquivo importado
|
||||||
* Data e hora da geração
|
* Data e hora da geração
|
||||||
* Nome do layout utilizado
|
* Nome do layout utilizado (se houver)
|
||||||
|
|
||||||
|
### RF07.2 — Reanalisar Arquivo
|
||||||
|
|
||||||
|
O sistema deve permitir reimportar o mesmo arquivo do disco com o layout atual e executar a análise novamente, sem nenhuma interação adicional. A operação é executada em background thread para não bloquear a interface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -424,6 +451,8 @@ O sistema deve suportar no mínimo:
|
|||||||
|
|
||||||
100.000 registros por arquivo
|
100.000 registros por arquivo
|
||||||
|
|
||||||
|
A análise é executada em uma thread separada (background) para não bloquear a interface gráfica durante o processamento.
|
||||||
|
|
||||||
> **Nota de implementação:** A detecção de faltantes deve ser implementada de forma **incremental** — ordenar a lista e percorrer comparando elementos consecutivos — evitando a geração de listas intermediárias completas antes da confirmação do usuário. Isso garante consumo de memória proporcional aos dados reais, não ao intervalo.
|
> **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -448,9 +477,10 @@ Todas as mensagens de erro e aviso devem ser exibidas em **modal/popup bloqueant
|
|||||||
|
|
||||||
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:
|
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"
|
* "32 linhas descartadas por malformação"
|
||||||
* "12 valores de Numero inválidos convertidos ou descartados"
|
* "12 valores de Numero inválidos convertidos ou descartados"
|
||||||
* "5 registros com Série inválida descartados"
|
* "5 registros com Série inválida descartados"
|
||||||
|
* "3 valores monetários inválidos descartados"
|
||||||
|
|
||||||
Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação.
|
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.
|
||||||
|
|
||||||
@@ -462,9 +492,16 @@ Fluxo principal:
|
|||||||
|
|
||||||
1. Usuário abre o sistema
|
1. Usuário abre o sistema
|
||||||
2. Usuário importa planilha
|
2. Usuário importa planilha
|
||||||
3. Usuário configura colunas
|
3. (Para XLSX) Usuário seleciona aba
|
||||||
4. Usuário executa análise
|
4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
|
||||||
5. Sistema exibe resultado
|
5. Usuário executa análise (processamento em background)
|
||||||
|
6. Sistema exibe resultado
|
||||||
|
|
||||||
|
Fluxo alternativo — layout salvo:
|
||||||
|
|
||||||
|
1. Usuário abre o sistema
|
||||||
|
2. Usuário seleciona layout no dropdown
|
||||||
|
3. Usuário importa planilha → análise é disparada automaticamente
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -485,6 +522,7 @@ src/
|
|||||||
│ ├─ mod.rs
|
│ ├─ mod.rs
|
||||||
│ ├─ app.rs
|
│ ├─ app.rs
|
||||||
│ ├─ screens/
|
│ ├─ screens/
|
||||||
|
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
|
||||||
│ │ ├─ import.rs
|
│ │ ├─ import.rs
|
||||||
│ │ ├─ configuracao_colunas.rs
|
│ │ ├─ configuracao_colunas.rs
|
||||||
│ │ ├─ layouts.rs
|
│ │ ├─ layouts.rs
|
||||||
@@ -493,20 +531,25 @@ src/
|
|||||||
├─ application/
|
├─ application/
|
||||||
│ ├─ mod.rs
|
│ ├─ mod.rs
|
||||||
│ ├─ usecases/
|
│ ├─ usecases/
|
||||||
|
│ │ ├─ mod.rs
|
||||||
│ │ ├─ importar_arquivo.rs
|
│ │ ├─ importar_arquivo.rs
|
||||||
│ │ ├─ executar_analise.rs
|
│ │ ├─ executar_analise.rs
|
||||||
│ │ ├─ exportar_pdf.rs
|
│ │ ├─ exportar_pdf.rs
|
||||||
|
│ │ ├─ layouts.rs
|
||||||
│
|
│
|
||||||
├─ domain/
|
├─ domain/
|
||||||
│ ├─ mod.rs
|
│ ├─ mod.rs
|
||||||
│ ├─ errors.rs
|
│ ├─ errors.rs
|
||||||
│ ├─ entities/
|
│ ├─ entities/
|
||||||
|
│ │ ├─ mod.rs
|
||||||
│ │ ├─ nota.rs
|
│ │ ├─ nota.rs
|
||||||
│ │ ├─ serie.rs
|
│ │ ├─ serie.rs
|
||||||
|
│ │ ├─ chave_serie.rs
|
||||||
│ │ ├─ layout.rs
|
│ │ ├─ layout.rs
|
||||||
│ │ ├─ resultado_analise.rs
|
│ │ ├─ resultado_analise.rs
|
||||||
│ │
|
│ │
|
||||||
│ ├─ services/
|
│ ├─ services/
|
||||||
|
│ │ ├─ mod.rs
|
||||||
│ │ ├─ detector_sequencia.rs
|
│ │ ├─ detector_sequencia.rs
|
||||||
│ │ ├─ detector_duplicidade.rs
|
│ │ ├─ detector_duplicidade.rs
|
||||||
│ │ ├─ parser_monetario.rs
|
│ │ ├─ parser_monetario.rs
|
||||||
@@ -533,47 +576,77 @@ Contém toda a lógica de negócio real.
|
|||||||
|
|
||||||
**Não pode depender de:**
|
**Não pode depender de:**
|
||||||
|
|
||||||
* egui
|
* egui / eframe
|
||||||
* rusqlite
|
* rusqlite
|
||||||
* calamine
|
* calamine
|
||||||
* csv
|
* csv
|
||||||
* genpdf
|
* genpdf
|
||||||
|
|
||||||
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`).
|
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`) e utilitários de erros (`thiserror`, `regex`).
|
||||||
|
|
||||||
#### Entidades
|
#### Entidades
|
||||||
|
|
||||||
**`Nota`**
|
**`Nota`**
|
||||||
|
|
||||||
| Campo | Tipo |
|
| Campo | Tipo |
|
||||||
| ------ | ----------------- |
|
| -------------- | ------------------- |
|
||||||
| numero | `u64` |
|
| numero | `u64` |
|
||||||
| serie | `String` |
|
| serie | `String` |
|
||||||
| valor | `Option<Decimal>` |
|
| documento_tipo | `Option<String>` |
|
||||||
| data | `Option<NaiveDate>` |
|
| valor | `Option<Decimal>` |
|
||||||
|
| data | `Option<NaiveDate>` |
|
||||||
|
|
||||||
|
**`ChaveSerie`**
|
||||||
|
|
||||||
|
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
|
||||||
|
|
||||||
|
| Campo | Tipo |
|
||||||
|
| -------------- | ---------------- |
|
||||||
|
| serie | `String` |
|
||||||
|
| documento_tipo | `Option<String>` |
|
||||||
|
|
||||||
|
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
|
||||||
|
|
||||||
|
**`ResultadoPreAnalise`**
|
||||||
|
|
||||||
|
Resultado intermediário, antes de materializar os faltantes.
|
||||||
|
|
||||||
|
| Campo | Tipo |
|
||||||
|
| -------------------- | ---------------------------------------- |
|
||||||
|
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
|
||||||
|
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
|
||||||
|
| soma_total | `Decimal` |
|
||||||
|
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
|
||||||
|
| total_por_serie | `HashMap<ChaveSerie, usize>` |
|
||||||
|
|
||||||
**`ResultadoAnalise`**
|
**`ResultadoAnalise`**
|
||||||
|
|
||||||
| Campo | Tipo |
|
| Campo | Tipo |
|
||||||
| -------------------- | -------------------------------------- |
|
| -------------------- | ---------------------------------------- |
|
||||||
| faltantes_por_serie | `HashMap<String, Vec<u64>>` |
|
| faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
|
||||||
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` |
|
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
|
||||||
| soma_total | `Decimal` |
|
| soma_total | `Decimal` |
|
||||||
| soma_por_serie | `HashMap<String, Decimal>` |
|
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
|
||||||
|
| total_por_serie | `HashMap<ChaveSerie, usize>` |
|
||||||
|
|
||||||
> **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas.
|
> **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
|
#### Services
|
||||||
|
|
||||||
`detector_sequencia` — recebe `Vec<Nota>` agrupadas por série, retorna faltantes.
|
`detector_sequencia` — recebe `Vec<&Nota>` agrupadas por `ChaveSerie`, retorna faltantes. Funções:
|
||||||
|
- `calcular_intervalo` — retorna `IntervaloSerie` sem materializar a lista completa
|
||||||
|
- `detectar_faltantes` — materializa a lista completa após confirmação
|
||||||
|
- `agrupar_contiguos` — agrupa uma lista ordenada de faltantes em pares `(inicio, fim)` para exibição compacta
|
||||||
|
|
||||||
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie)`.
|
`detector_duplicidade` — retorna mapa de contagem por `(numero, serie, documento_tipo)`.
|
||||||
|
|
||||||
`parser_monetario` — implementa exatamente o algoritmo definido no RF06.
|
`parser_monetario` — implementa exatamente o algoritmo definido no RF06. Também expõe `formatar_valor_br` para exibição no formato `1.234,56`.
|
||||||
|
|
||||||
#### Erros
|
#### 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.
|
`domain/errors.rs` define os erros do domínio de forma tipada (ex: `ErroSerie::Invalida`, `ErroNumero::Zero`, `ErroValor::Negativo`, `ErroLayout::NomeConflitante`, `ErroArquivo::TamanhoExcedido`). Nenhuma camada deve propagar `String` livre como erro de domínio.
|
||||||
|
|
||||||
|
`ResumoAvisos` consolida contagens de linhas malformadas, números inválidos, séries inválidas e valores inválidos para exibição em um único modal ao final da importação.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -585,15 +658,25 @@ Conhece o domain. O domain não conhece o application.
|
|||||||
|
|
||||||
**`executar_analise.rs`**
|
**`executar_analise.rs`**
|
||||||
|
|
||||||
1. Recebe dados crus
|
Expõe três funções:
|
||||||
2. Chama `parser_monetario`
|
1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
|
||||||
3. Chama `detector_sequencia`
|
2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
|
||||||
4. Chama `detector_duplicidade`
|
3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
|
||||||
5. Monta `ResultadoAnalise`
|
|
||||||
|
|
||||||
**`exportar_pdf.rs`**
|
**`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`.
|
Depende da trait abstrata `PdfGenerator` definida em `infrastructure/pdf_generator.rs`. Isso evita que o application dependa diretamente de `genpdf`.
|
||||||
|
|
||||||
|
**`importar_arquivo.rs`**
|
||||||
|
|
||||||
|
Expõe:
|
||||||
|
- `importar_csv(caminho, config)` — lê CSV e mapeia para notas
|
||||||
|
- `importar_xlsx(caminho, config)` — lê XLSX e mapeia para notas
|
||||||
|
- `listar_abas_xlsx(caminho)` — lista abas antes de configurar
|
||||||
|
|
||||||
|
**`layouts.rs`**
|
||||||
|
|
||||||
|
Expõe operações de CRUD e import/export de layouts: `salvar_layout`, `listar_layouts`, `excluir_layout`, `exportar_layout_json`, `importar_layout_json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -603,19 +686,15 @@ Implementa leitores, persistência e geração de arquivos.
|
|||||||
|
|
||||||
| Arquivo | Responsabilidade |
|
| Arquivo | Responsabilidade |
|
||||||
| -------------------------------- | ----------------------------------------- |
|
| -------------------------------- | ----------------------------------------- |
|
||||||
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` |
|
| `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
|
||||||
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` |
|
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
|
||||||
| `pdf_generator.rs` | Geração de PDF via `genpdf` |
|
| `pdf_generator.rs` | Trait `PdfGenerator` + implementação `GenpdfGenerator` via `genpdf`; fontes Liberation Sans embutidas no binário |
|
||||||
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite |
|
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite; tratamento de banco corrompido |
|
||||||
| `sqlite/migrations.rs` | Aplicação de migrations de schema |
|
| `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
|
||||||
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
|
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
|
||||||
|
|
||||||
Nada de infrastructure sobe para domain.
|
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)
|
### UI (interface)
|
||||||
@@ -624,16 +703,31 @@ Apenas coleta input, chama use cases e renderiza resultado.
|
|||||||
|
|
||||||
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
|
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
|
||||||
|
|
||||||
|
#### App (estado global)
|
||||||
|
|
||||||
|
`app.rs` contém o estado global da aplicação (`App`), o enum `EstadoApp`, os tipos `Modal`/`TipoModal`/`AcaoModal`, e a lógica de processamento de resultados assíncronos via `mpsc::channel`.
|
||||||
|
|
||||||
|
**Estados da aplicação:**
|
||||||
|
|
||||||
|
| Estado | Descrição |
|
||||||
|
| ----------------------- | --------- |
|
||||||
|
| `Importando` | Tela inicial: seleção de arquivo e layout |
|
||||||
|
| `SelecionandoAba` | Aguardando seleção de aba XLSX |
|
||||||
|
| `ConfigurandoColunas` | Mapeamento de colunas com pré-visualização |
|
||||||
|
| `Analisando` | Análise em execução em background thread |
|
||||||
|
| `ConfirmandoIntervalo` | Aguardando confirmação do usuário para expandir faltantes |
|
||||||
|
| `ExibindoResultado` | Resultado pronto para exibição |
|
||||||
|
| `GerenciandoLayouts` | Gerenciamento de layouts salvos |
|
||||||
|
|
||||||
#### Screens
|
#### Screens
|
||||||
|
|
||||||
| Arquivo | Responsabilidade |
|
| Arquivo | Responsabilidade |
|
||||||
| ------------------------- | ----------------------------------------------------- |
|
| ------------------------- | ----------------------------------------------------- |
|
||||||
| `import.rs` | Seleção de arquivo e configurações de importação |
|
| `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
|
||||||
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) |
|
| `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
|
||||||
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) |
|
| `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
|
||||||
| `resultado.rs` | Exibição de resultados com paginação (RF07) |
|
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir, exportar/importar JSON (RF08) |
|
||||||
|
| `resultado.rs` | Exibição de resultados com paginação, intervalos contíguos, indicador de completude, botões copiar/reanalisar/exportar PDF (RF07) |
|
||||||
> `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -646,26 +740,34 @@ Infrastructure entra apenas quando necessário.
|
|||||||
|
|
||||||
Exemplo real:
|
Exemplo real:
|
||||||
|
|
||||||
1. UI chama `importar_arquivo`
|
1. UI chama `executar_importacao` (thread separada)
|
||||||
2. Infrastructure lê CSV/XLSX
|
2. Infrastructure lê CSV/XLSX
|
||||||
3. Application transforma registros em entidades `Nota`
|
3. Application transforma registros em entidades `Nota`
|
||||||
4. Domain executa análise
|
4. Domain executa pré-análise (`pre_analisar`)
|
||||||
5. Application retorna `ResultadoAnalise`
|
5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
|
||||||
6. UI renderiza
|
6. Application retorna `ResultadoAnalise` via `mpsc::channel`
|
||||||
|
7. UI renderiza
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9.4 Stack Tecnológica
|
## 9.4 Stack Tecnológica
|
||||||
|
|
||||||
| Camada | Tecnologia | Status |
|
| Camada | Tecnologia | Versão |
|
||||||
| ---------------- | ------------------ | -------------- |
|
| ---------------- | ------------------ | ------- |
|
||||||
| Linguagem | Rust | Definido |
|
| Linguagem | Rust | edition 2024 |
|
||||||
| Framework de UI | egui | Definido |
|
| Framework de UI | egui + eframe | 0.31 |
|
||||||
| SQLite | rusqlite | Definido |
|
| Diálogos nativos | rfd | 0.15 |
|
||||||
| Leitura de CSV | csv | Definido |
|
| SQLite | rusqlite (bundled) | 0.32 |
|
||||||
| Leitura de XLSX | calamine | Definido |
|
| Leitura de CSV | csv | 1.3 |
|
||||||
| Decimal fixo | rust_decimal | Definido |
|
| Leitura de XLSX | calamine | 0.26 |
|
||||||
| Geração de PDF | genpdf | Definido |
|
| Decimal fixo | rust_decimal | 1.36 |
|
||||||
|
| Geração de PDF | genpdf | 0.2 |
|
||||||
|
| Datas | chrono | 0.4 |
|
||||||
|
| Encoding | encoding_rs | 0.8 |
|
||||||
|
| Caminhos de dados| dirs | 5 |
|
||||||
|
| Erros tipados | thiserror | 2 |
|
||||||
|
| Regex | regex | 1 |
|
||||||
|
| Serialização | serde + serde_json | 1 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -723,6 +825,14 @@ Os layouts salvos anteriormente serão perdidos neste cenário. O arquivo `confi
|
|||||||
|
|
||||||
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.
|
O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco.
|
||||||
|
|
||||||
|
**Versão atual do schema: 3**
|
||||||
|
|
||||||
|
| Versão | Alteração |
|
||||||
|
| ------ | --------- |
|
||||||
|
| 1 | Criação da tabela `layouts` |
|
||||||
|
| 2 | Índice único em `layouts.nome`; renomeia duplicatas com sufixo `(id)` |
|
||||||
|
| 3 | Adição das colunas `indice_documento_tipo` (CSV) e `pos_documento_tipo` (XLSX) |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
## 10.3 Dados Armazenados
|
## 10.3 Dados Armazenados
|
||||||
@@ -741,31 +851,33 @@ Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configuraç
|
|||||||
|
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
| ---------- | ------- | -------------------------------------- |
|
| ---------- | ------- | -------------------------------------- |
|
||||||
| id | inteiro | Identificador único |
|
| id | inteiro | Identificador único (auto-incremento) |
|
||||||
| nome | texto | Nome do layout |
|
| nome | texto | Nome do layout (único no banco) |
|
||||||
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
|
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
|
||||||
|
|
||||||
**Campos exclusivos de layouts CSV:**
|
**Campos exclusivos de layouts CSV:**
|
||||||
|
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
| ------------------ | ------ | -------------------------------------------------- |
|
| ---------------------- | ------- | ---------------------------------------------------------- |
|
||||||
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
|
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
|
||||||
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
|
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
|
||||||
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1) |
|
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho |
|
||||||
| indice_numero | inteiro | Índice da coluna Numero (base 0) |
|
| indice_numero | inteiro | Índice da coluna Numero (base 0) |
|
||||||
| indice_serie | inteiro | Índice da coluna Serie (base 0) |
|
| indice_serie | inteiro | Índice da coluna Serie (base 0) |
|
||||||
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
|
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
|
||||||
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
|
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
|
||||||
|
| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
|
||||||
|
|
||||||
**Campos exclusivos de layouts XLSX:**
|
**Campos exclusivos de layouts XLSX:**
|
||||||
|
|
||||||
| Campo | Tipo | Descrição |
|
| Campo | Tipo | Descrição |
|
||||||
| -------------- | ------ | ---------------------------------------------------------------- |
|
| ------------------ | ------ | ----------------------------------------------------------------- |
|
||||||
| aba | texto | Nome ou índice da aba a ser processada |
|
| aba | texto | Nome da aba a ser processada |
|
||||||
| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) |
|
| 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_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_valor | texto | Posição inicial da coluna Valor (nulo se ausente) |
|
||||||
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
|
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
|
||||||
|
| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) |
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -785,15 +897,19 @@ Excluir layout
|
|||||||
|
|
||||||
## 10.6 Interface do Usuário
|
## 10.6 Interface do Usuário
|
||||||
|
|
||||||
Os layouts salvos devem ser exibidos em um menu dropdown.
|
Os layouts salvos devem ser exibidos em um menu dropdown, filtrado pelo tipo de arquivo atual (CSV ou XLSX).
|
||||||
|
|
||||||
O usuário deve poder:
|
O usuário deve poder:
|
||||||
|
|
||||||
Selecionar layout existente
|
Selecionar layout existente
|
||||||
|
|
||||||
Criar novo layout
|
Criar novo layout (via modal com campo de texto ou via tela de gerenciamento)
|
||||||
|
|
||||||
Excluir layout
|
Excluir layout (com confirmação)
|
||||||
|
|
||||||
|
Exportar layout para JSON
|
||||||
|
|
||||||
|
Importar layout de JSON (com tratamento de conflito de nome)
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -835,15 +951,16 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"nome": "Layout Padrão CSV",
|
|
||||||
"tipo": "csv",
|
"tipo": "csv",
|
||||||
|
"nome": "Layout Padrão CSV",
|
||||||
"delimitador": ";",
|
"delimitador": ";",
|
||||||
"encoding": "utf-8",
|
"encoding": "utf-8",
|
||||||
"linha_cabecalho": 1,
|
"linha_cabecalho": 1,
|
||||||
"indice_numero": 3,
|
"indice_numero": 3,
|
||||||
"indice_serie": 1,
|
"indice_serie": 1,
|
||||||
"indice_valor": 5,
|
"indice_valor": 5,
|
||||||
"indice_data": null
|
"indice_data": null,
|
||||||
|
"indice_documento_tipo": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -851,16 +968,19 @@ O campo `tipo` define qual conjunto de configurações está presente no arquivo
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"nome": "Layout Padrão XLSX",
|
|
||||||
"tipo": "xlsx",
|
"tipo": "xlsx",
|
||||||
|
"nome": "Layout Padrão XLSX",
|
||||||
"aba": "Plan1",
|
"aba": "Plan1",
|
||||||
"pos_numero": "D3",
|
"pos_numero": "D3",
|
||||||
"pos_serie": "B3",
|
"pos_serie": "B3",
|
||||||
"pos_valor": "F3",
|
"pos_valor": "F3",
|
||||||
"pos_data": null
|
"pos_data": null,
|
||||||
|
"pos_documento_tipo": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nota:** o campo `tipo` é usado como tag de discriminante pelo `serde` (`#[serde(tag = "tipo")]`). O campo `indice_documento_tipo` / `pos_documento_tipo` usa `#[serde(default)]` para retrocompatibilidade com arquivos JSON exportados antes da versão 1.7.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 12. Critérios de Aceite
|
# 12. Critérios de Aceite
|
||||||
@@ -869,14 +989,17 @@ O sistema será considerado funcional quando:
|
|||||||
|
|
||||||
* Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
|
* Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
|
||||||
* Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
|
* Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
|
||||||
* Detectar notas faltantes por série corretamente
|
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
|
||||||
* Detectar duplicatas por série corretamente
|
* Detectar duplicatas por grupo corretamente
|
||||||
* Calcular soma total e soma por série corretamente
|
* Calcular soma total e soma por grupo corretamente
|
||||||
* Exibir resultados agrupados por série com paginação
|
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
|
||||||
|
* Exibir indicador de completude por grupo
|
||||||
* Exportar relatório de resultados para PDF
|
* Exportar relatório de resultados para PDF
|
||||||
* Salvar, carregar, selecionar e excluir layouts
|
* Salvar, carregar, selecionar e excluir layouts
|
||||||
* Exportar e importar layouts via JSON
|
* Exportar e importar layouts via JSON
|
||||||
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por série
|
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por grupo
|
||||||
|
* Executar análise em background sem bloquear a interface
|
||||||
|
* Exibir pré-visualização das primeiras 5 linhas do arquivo na tela de configuração
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -885,25 +1008,29 @@ O sistema será considerado funcional quando:
|
|||||||
O MVP inclui o escopo completo descrito neste PRD:
|
O MVP inclui o escopo completo descrito neste PRD:
|
||||||
|
|
||||||
* Importar CSV e XLSX
|
* Importar CSV e XLSX
|
||||||
* Mapear colunas Numero, Serie, Valor e Data
|
* Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
|
||||||
* Detectar notas faltantes por série
|
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
|
||||||
* Detectar duplicatas por série
|
* Detectar duplicatas por grupo
|
||||||
* Calcular soma total e por série
|
* Calcular soma total e por grupo
|
||||||
* Exibir resultados agrupados por série com paginação
|
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
|
||||||
* Exportar relatório para PDF
|
* Exportar relatório para PDF
|
||||||
* Gerenciar layouts (salvar, carregar, excluir)
|
* Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
|
||||||
* Exportar e importar layouts via JSON
|
* Reanalisar arquivo sem reconfiguração
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 14. Evoluções Futuras
|
# 14. Evoluções Futuras
|
||||||
|
|
||||||
Possíveis melhorias:
|
Possíveis melhorias (ver `FEATURES_BACKLOG.md` para detalhes):
|
||||||
|
|
||||||
|
* Exportação de resultado em CSV (`faltantes.csv`, `duplicatas.csv`)
|
||||||
|
* Busca por número na tela de resultado
|
||||||
|
* Auto-detecção de delimitador CSV
|
||||||
|
* Auto-detecção de encoding CSV
|
||||||
|
* Agrupamento de faltantes como intervalos no PDF
|
||||||
* Integração com ERP
|
* Integração com ERP
|
||||||
* Histórico de análises
|
* Histórico de análises
|
||||||
* Automação de importação (monitorar pasta)
|
* Automação de importação (monitorar pasta)
|
||||||
* Exportação para CSV
|
|
||||||
* Multiusuário
|
* Multiusuário
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -918,8 +1045,16 @@ Série
|
|||||||
|
|
||||||
Agrupador independente de sequência.
|
Agrupador independente de sequência.
|
||||||
|
|
||||||
|
TipoDocumento
|
||||||
|
|
||||||
|
Subtipo de documento dentro de uma série (ex: NFE, NFCE). Quando mapeado, compõe a chave de agrupamento junto com a Série.
|
||||||
|
|
||||||
|
ChaveSerie
|
||||||
|
|
||||||
|
Chave composta `(Serie, TipoDocumento)` que identifica um grupo de notas para fins de detecção de sequência, duplicidade e cálculo de somas.
|
||||||
|
|
||||||
Sequência
|
Sequência
|
||||||
|
|
||||||
Ordem numérica crescente sem lacunas.
|
Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
# Sugestões de Melhoria — Fluxo da Interface
|
||||||
|
|
||||||
|
**Data:** 02/03/2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Indicador de progresso das etapas
|
||||||
|
|
||||||
|
O fluxo possui 3–4 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
|
||||||
|
|
||||||
|
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
|
||||||
|
|
||||||
|
```
|
||||||
|
① Arquivo ② Colunas ③ Resultado
|
||||||
|
```
|
||||||
|
|
||||||
|
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Unificar importação e configuração de colunas em um único painel
|
||||||
|
|
||||||
|
Atualmente o caminho é:
|
||||||
|
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
|
||||||
|
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
|
||||||
|
|
||||||
|
São 3 ações separadas para chegar à análise.
|
||||||
|
|
||||||
|
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Pré-visualização das primeiras linhas do arquivo
|
||||||
|
|
||||||
|
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
|
||||||
|
|
||||||
|
**Sugestão:** Exibir as primeiras 3–5 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Confirmação ao clicar em "Nova Análise"
|
||||||
|
|
||||||
|
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
|
||||||
|
|
||||||
|
**Sugestão:** Exibir modal de confirmação com a mensagem:
|
||||||
|
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
|
||||||
|
|
||||||
|
Botões: **Confirmar** | **Cancelar**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Reorganizar seções do resultado
|
||||||
|
|
||||||
|
A ordem atual das seções em `resultado.rs` é:
|
||||||
|
|
||||||
|
```
|
||||||
|
Totais → Faltantes → Duplicatas
|
||||||
|
```
|
||||||
|
|
||||||
|
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
|
||||||
|
|
||||||
|
**Sugestão:** Inverter para:
|
||||||
|
|
||||||
|
```
|
||||||
|
Faltantes → Duplicatas → Totais
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso coloca a informação mais relevante no topo da tela.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Seletor de layout também na tela de configuração de colunas
|
||||||
|
|
||||||
|
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
|
||||||
|
|
||||||
|
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Botão "Salvar como layout..." na tela de configuração
|
||||||
|
|
||||||
|
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
|
||||||
|
|
||||||
|
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Feedback visual durante a análise
|
||||||
|
|
||||||
|
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
|
||||||
|
|
||||||
|
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
|
||||||
|
|
||||||
|
```
|
||||||
|
⏳ Analisando... aguarde.
|
||||||
|
```
|
||||||
|
|
||||||
|
Mesmo sem progresso percentual, já elimina a percepção de travamento.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo de Impacto
|
||||||
|
|
||||||
|
| # | Sugestão | Impacto UX | Esforço estimado |
|
||||||
|
|---|----------|------------|-----------------|
|
||||||
|
| 1 | Breadcrumb de etapas | Médio | Baixo |
|
||||||
|
| 2 | Unificar importação + configuração | Alto | Médio |
|
||||||
|
| 3 | Pré-visualização do arquivo | Alto | Médio |
|
||||||
|
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
|
||||||
|
| 5 | Reordenar seções do resultado | Médio | Baixo |
|
||||||
|
| 6 | Seletor de layout em configuração | Médio | Baixo |
|
||||||
|
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
|
||||||
|
| 8 | Feedback durante análise | Alto | Baixo |
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,11 +1,12 @@
|
|||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::{
|
entities::{
|
||||||
|
chave_serie::ChaveSerie,
|
||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
detector_duplicidade::duplicidades_por_serie,
|
detector_duplicidade::duplicidades_por_serie,
|
||||||
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
|
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -15,30 +16,31 @@ use std::collections::HashMap;
|
|||||||
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
|
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
|
||||||
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
|
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
|
||||||
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
||||||
// Agrupar por série
|
// Agrupar por (serie, documento_tipo)
|
||||||
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
|
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
por_serie.entry(nota.serie.clone()).or_default().push(nota);
|
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
|
||||||
|
por_chave.entry(chave).or_default().push(nota);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut intervalos_por_serie = HashMap::new();
|
let mut intervalos_por_serie = HashMap::new();
|
||||||
let mut soma_total = Decimal::ZERO;
|
let mut soma_total = Decimal::ZERO;
|
||||||
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
|
let mut soma_por_serie: HashMap<ChaveSerie, Decimal> = HashMap::new();
|
||||||
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
|
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
|
||||||
|
|
||||||
for (serie, notas_serie) in &por_serie {
|
for (chave, notas_grupo) in &por_chave {
|
||||||
// Somar valores
|
// Somar valores
|
||||||
for nota in notas_serie.iter() {
|
for nota in notas_grupo.iter() {
|
||||||
if let Some(v) = nota.valor {
|
if let Some(v) = nota.valor {
|
||||||
soma_total += v;
|
soma_total += v;
|
||||||
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
|
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
|
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
|
||||||
|
|
||||||
// Calcular intervalo de faltantes
|
// Calcular intervalo de faltantes
|
||||||
if let Some(intervalo) = calcular_intervalo(notas_serie) {
|
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
|
||||||
intervalos_por_serie.insert(serie.clone(), intervalo);
|
intervalos_por_serie.insert(chave.clone(), intervalo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,31 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica se alguma série excede o limite de faltantes.
|
/// Verifica se algum grupo excede o limite de faltantes.
|
||||||
/// Retorna lista de séries que precisam de confirmação.
|
/// Retorna lista de chaves que precisam de confirmação.
|
||||||
pub fn series_com_intervalo_excessivo(
|
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
|
||||||
pre: &ResultadoPreAnalise,
|
|
||||||
) -> Vec<(String, u64)> {
|
|
||||||
pre.intervalos_por_serie
|
pre.intervalos_por_serie
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||||
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
|
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
|
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
|
||||||
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
|
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
|
||||||
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
|
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
|
||||||
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
|
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
por_serie.entry(nota.serie.clone()).or_default().push(nota);
|
let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone());
|
||||||
|
por_chave.entry(chave).or_default().push(nota);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut faltantes_por_serie = HashMap::new();
|
let mut faltantes_por_serie = HashMap::new();
|
||||||
|
|
||||||
for (serie, notas_serie) in &por_serie {
|
for (chave, notas_grupo) in &por_chave {
|
||||||
let faltantes = detectar_faltantes(notas_serie);
|
let faltantes = detectar_faltantes(notas_grupo);
|
||||||
faltantes_por_serie.insert(serie.clone(), faltantes);
|
faltantes_por_serie.insert(chave.clone(), faltantes);
|
||||||
}
|
}
|
||||||
|
|
||||||
ResultadoAnalise {
|
ResultadoAnalise {
|
||||||
@@ -89,21 +90,24 @@ pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
use rust_decimal_macros::dec;
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
/// Executa análise completa sem verificar limites (atalho para testes).
|
||||||
|
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
|
||||||
|
let pre = pre_analisar(notas);
|
||||||
|
expandir_analise(pre, notas)
|
||||||
|
}
|
||||||
|
|
||||||
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
|
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), valor, None)
|
Nota::new(numero, serie.to_string(), None, valor, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
|
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -114,7 +118,8 @@ mod tests {
|
|||||||
nota(4, "001", Some(dec!(50.00))),
|
nota(4, "001", Some(dec!(50.00))),
|
||||||
];
|
];
|
||||||
let resultado = executar_analise(¬as);
|
let resultado = executar_analise(¬as);
|
||||||
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]);
|
let chave = ChaveSerie::new("001".to_string(), None);
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]);
|
||||||
assert_eq!(resultado.soma_total, dec!(350.00));
|
assert_eq!(resultado.soma_total, dec!(350.00));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,19 +132,35 @@ mod tests {
|
|||||||
nota(2, "002", None),
|
nota(2, "002", None),
|
||||||
];
|
];
|
||||||
let resultado = executar_analise(¬as);
|
let resultado = executar_analise(¬as);
|
||||||
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
|
let chave001 = ChaveSerie::new("001".to_string(), None);
|
||||||
assert!(resultado.faltantes_por_serie["002"].is_empty());
|
let chave002 = ChaveSerie::new("002".to_string(), None);
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]);
|
||||||
|
assert!(resultado.faltantes_por_serie[&chave002].is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pre_analise_detecta_intervalo_excessivo() {
|
fn pre_analise_detecta_intervalo_excessivo() {
|
||||||
let notas = vec![
|
let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
|
||||||
nota(1, "001", None),
|
|
||||||
nota(20_000, "001", None),
|
|
||||||
];
|
|
||||||
let pre = pre_analisar(¬as);
|
let pre = pre_analisar(¬as);
|
||||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
assert_eq!(excessivos.len(), 1);
|
assert_eq!(excessivos.len(), 1);
|
||||||
assert_eq!(excessivos[0].0, "001");
|
assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn analise_nfe_nfce_mesma_serie_grupos_separados() {
|
||||||
|
// NFE série 001: 1, 3 → faltante 2
|
||||||
|
// NFCE série 001: 2, 5 → faltante 3 e 4
|
||||||
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(3, "001", "NFE"),
|
||||||
|
nota_com_tipo(2, "001", "NFCE"),
|
||||||
|
nota_com_tipo(5, "001", "NFCE"),
|
||||||
|
];
|
||||||
|
let resultado = executar_analise(¬as);
|
||||||
|
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
|
||||||
|
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
|
||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
use crate::domain::entities::serie::validar_serie;
|
use crate::domain::entities::serie::validar_serie;
|
||||||
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
||||||
@@ -24,7 +24,10 @@ pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
|
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
|
||||||
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> {
|
pub fn importar_csv(
|
||||||
|
caminho: &Path,
|
||||||
|
config: &LayoutCsv,
|
||||||
|
) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||||
let resultado = csv_reader::ler_csv(
|
let resultado = csv_reader::ler_csv(
|
||||||
caminho,
|
caminho,
|
||||||
config.delimitador as u8,
|
config.delimitador as u8,
|
||||||
@@ -37,6 +40,7 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
|
|||||||
&resultado.linhas,
|
&resultado.linhas,
|
||||||
config.indice_numero,
|
config.indice_numero,
|
||||||
config.indice_serie,
|
config.indice_serie,
|
||||||
|
config.indice_documento_tipo,
|
||||||
config.indice_valor,
|
config.indice_valor,
|
||||||
config.indice_data,
|
config.indice_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
@@ -46,17 +50,21 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
|
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
|
||||||
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> {
|
pub fn importar_xlsx(
|
||||||
|
caminho: &Path,
|
||||||
|
config: &LayoutXlsx,
|
||||||
|
) -> Result<ResultadoImportacao, ErroArquivo> {
|
||||||
// Determinar linha de início a partir de qualquer campo mapeado
|
// Determinar linha de início a partir de qualquer campo mapeado
|
||||||
let linha_inicio = {
|
let linha_inicio = {
|
||||||
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero)
|
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
|
||||||
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
|
ErroArquivo::ErroLeitura(format!(
|
||||||
"Posição de Numero inválida: '{}'", config.pos_numero
|
"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
|
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)
|
coord_num.linha.min(coord_ser.linha)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,12 +87,18 @@ pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImp
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
||||||
.map(|c| c.coluna as usize);
|
.map(|c| c.coluna as usize);
|
||||||
|
let col_documento_tipo = config
|
||||||
|
.pos_documento_tipo
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
|
||||||
|
.map(|c| c.coluna as usize);
|
||||||
|
|
||||||
let mut avisos = resultado.avisos;
|
let mut avisos = resultado.avisos;
|
||||||
let notas = mapear_linhas_para_notas(
|
let notas = mapear_linhas_para_notas(
|
||||||
&resultado.linhas,
|
&resultado.linhas,
|
||||||
col_numero,
|
col_numero,
|
||||||
col_serie,
|
col_serie,
|
||||||
|
col_documento_tipo,
|
||||||
col_valor,
|
col_valor,
|
||||||
col_data,
|
col_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
@@ -99,6 +113,7 @@ fn mapear_linhas_para_notas(
|
|||||||
linhas: &[Vec<String>],
|
linhas: &[Vec<String>],
|
||||||
idx_numero: usize,
|
idx_numero: usize,
|
||||||
idx_serie: usize,
|
idx_serie: usize,
|
||||||
|
idx_documento_tipo: Option<usize>,
|
||||||
idx_valor: Option<usize>,
|
idx_valor: Option<usize>,
|
||||||
idx_data: Option<usize>,
|
idx_data: Option<usize>,
|
||||||
avisos: &mut ResumoAvisos,
|
avisos: &mut ResumoAvisos,
|
||||||
@@ -113,7 +128,9 @@ fn mapear_linhas_para_notas(
|
|||||||
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
|
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
|
||||||
_ => {
|
_ => {
|
||||||
avisos.numeros_invalidos += 1;
|
avisos.numeros_invalidos += 1;
|
||||||
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num));
|
avisos
|
||||||
|
.detalhes
|
||||||
|
.push(format!("Linha {}: campo Numero ausente", linha_num));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -123,7 +140,9 @@ fn mapear_linhas_para_notas(
|
|||||||
Ok(n) => n,
|
Ok(n) => n,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
avisos.numeros_invalidos += 1;
|
avisos.numeros_invalidos += 1;
|
||||||
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg));
|
avisos
|
||||||
|
.detalhes
|
||||||
|
.push(format!("Linha {}: {}", linha_num, msg));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -133,7 +152,9 @@ fn mapear_linhas_para_notas(
|
|||||||
Some(s) => s.trim().to_string(),
|
Some(s) => s.trim().to_string(),
|
||||||
None => {
|
None => {
|
||||||
avisos.series_invalidas += 1;
|
avisos.series_invalidas += 1;
|
||||||
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num));
|
avisos
|
||||||
|
.detalhes
|
||||||
|
.push(format!("Linha {}: campo Serie ausente", linha_num));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -182,7 +203,17 @@ fn mapear_linhas_para_notas(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
notas.push(Nota::new(numero, serie, valor, data));
|
// Extrair tipo de documento (opcional) — qualquer string não vazia
|
||||||
|
let documento_tipo = if let Some(idx) = idx_documento_tipo {
|
||||||
|
match linha.get(idx) {
|
||||||
|
Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
notas
|
notas
|
||||||
|
|||||||
@@ -2,24 +2,31 @@ use crate::domain::{
|
|||||||
entities::layout::{Layout, LayoutJson},
|
entities::layout::{Layout, LayoutJson},
|
||||||
errors::ErroLayout,
|
errors::ErroLayout,
|
||||||
};
|
};
|
||||||
use crate::infrastructure::sqlite::{layout_repository, migrations};
|
use crate::infrastructure::sqlite::layout_repository;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
/// Salva um layout no banco de dados.
|
/// Salva um layout no banco de dados.
|
||||||
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
|
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
|
||||||
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String> {
|
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
|
||||||
|
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayout> {
|
||||||
// Validar campos obrigatórios
|
// Validar campos obrigatórios
|
||||||
if layout.nome().trim().is_empty() {
|
if layout.nome().trim().is_empty() {
|
||||||
return Err("Nome do layout não pode ser vazio".to_string());
|
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(id) = layout.id() {
|
if let Some(id) = layout.id() {
|
||||||
layout_repository::atualizar(conn, layout)
|
layout_repository::atualizar(conn, layout)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
} else {
|
} else {
|
||||||
|
let nome = layout.nome().to_string();
|
||||||
|
let existe = layout_repository::existe_nome(conn, &nome)
|
||||||
|
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||||
|
if existe {
|
||||||
|
return Err(ErroLayout::NomeConflitante(nome));
|
||||||
|
}
|
||||||
layout_repository::salvar(conn, layout)
|
layout_repository::salvar(conn, layout)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,11 +35,6 @@ pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
|
|||||||
layout_repository::listar(conn).map_err(|e| e.to_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.
|
/// Exclui um layout pelo id.
|
||||||
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
|
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
|
||||||
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
|
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
|
||||||
@@ -61,8 +63,8 @@ pub fn importar_layout_json(
|
|||||||
novo_nome: Option<&str>,
|
novo_nome: Option<&str>,
|
||||||
) -> Result<i64, ErroLayout> {
|
) -> Result<i64, ErroLayout> {
|
||||||
// Deserializar
|
// Deserializar
|
||||||
let json_repr: LayoutJson = serde_json::from_str(json)
|
let json_repr: LayoutJson =
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||||
|
|
||||||
let mut layout = Layout::try_from(json_repr)?;
|
let mut layout = Layout::try_from(json_repr)?;
|
||||||
|
|
||||||
@@ -105,6 +107,5 @@ pub fn importar_layout_json(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inserir novo
|
// Inserir novo
|
||||||
layout_repository::salvar(conn, &layout)
|
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
||||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/// Chave composta que identifica um grupo de notas fiscais.
|
||||||
|
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
|
||||||
|
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
|
||||||
|
/// somente por série (retrocompatível).
|
||||||
|
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub struct ChaveSerie {
|
||||||
|
pub serie: String,
|
||||||
|
pub documento_tipo: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChaveSerie {
|
||||||
|
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
serie,
|
||||||
|
documento_tipo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match &self.documento_tipo {
|
||||||
|
Some(tipo) => format!("{} / {}", self.serie, tipo),
|
||||||
|
None => self.serie.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ pub struct LayoutCsv {
|
|||||||
pub indice_valor: Option<usize>,
|
pub indice_valor: Option<usize>,
|
||||||
/// Índice da coluna Data (base 0, None se não mapeado)
|
/// Índice da coluna Data (base 0, None se não mapeado)
|
||||||
pub indice_data: Option<usize>,
|
pub indice_data: Option<usize>,
|
||||||
|
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
|
||||||
|
pub indice_documento_tipo: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LayoutCsv {
|
impl Default for LayoutCsv {
|
||||||
@@ -46,6 +48,7 @@ impl Default for LayoutCsv {
|
|||||||
indice_serie: 1,
|
indice_serie: 1,
|
||||||
indice_valor: None,
|
indice_valor: None,
|
||||||
indice_data: None,
|
indice_data: None,
|
||||||
|
indice_documento_tipo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,8 @@ pub struct LayoutXlsx {
|
|||||||
pub pos_valor: Option<String>,
|
pub pos_valor: Option<String>,
|
||||||
/// Posição inicial da coluna Data (None se não mapeado)
|
/// Posição inicial da coluna Data (None se não mapeado)
|
||||||
pub pos_data: Option<String>,
|
pub pos_data: Option<String>,
|
||||||
|
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
|
||||||
|
pub pos_documento_tipo: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for LayoutXlsx {
|
impl Default for LayoutXlsx {
|
||||||
@@ -73,6 +78,7 @@ impl Default for LayoutXlsx {
|
|||||||
pos_serie: String::new(),
|
pos_serie: String::new(),
|
||||||
pos_valor: None,
|
pos_valor: None,
|
||||||
pos_data: None,
|
pos_data: None,
|
||||||
|
pos_documento_tipo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +135,8 @@ pub enum LayoutJson {
|
|||||||
indice_serie: usize,
|
indice_serie: usize,
|
||||||
indice_valor: Option<usize>,
|
indice_valor: Option<usize>,
|
||||||
indice_data: Option<usize>,
|
indice_data: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
indice_documento_tipo: Option<usize>,
|
||||||
},
|
},
|
||||||
Xlsx {
|
Xlsx {
|
||||||
nome: String,
|
nome: String,
|
||||||
@@ -137,6 +145,8 @@ pub enum LayoutJson {
|
|||||||
pos_serie: String,
|
pos_serie: String,
|
||||||
pos_valor: Option<String>,
|
pos_valor: Option<String>,
|
||||||
pos_data: Option<String>,
|
pos_data: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pos_documento_tipo: Option<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +164,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
indice_serie,
|
indice_serie,
|
||||||
indice_valor,
|
indice_valor,
|
||||||
indice_data,
|
indice_data,
|
||||||
|
indice_documento_tipo,
|
||||||
} => {
|
} => {
|
||||||
if nome.trim().is_empty() {
|
if nome.trim().is_empty() {
|
||||||
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
@@ -176,6 +187,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
indice_serie,
|
indice_serie,
|
||||||
indice_valor,
|
indice_valor,
|
||||||
indice_data,
|
indice_data,
|
||||||
|
indice_documento_tipo,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -186,6 +198,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
pos_serie,
|
pos_serie,
|
||||||
pos_valor,
|
pos_valor,
|
||||||
pos_data,
|
pos_data,
|
||||||
|
pos_documento_tipo,
|
||||||
} => {
|
} => {
|
||||||
if nome.trim().is_empty() {
|
if nome.trim().is_empty() {
|
||||||
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
@@ -211,6 +224,7 @@ impl TryFrom<LayoutJson> for Layout {
|
|||||||
pos_serie,
|
pos_serie,
|
||||||
pos_valor,
|
pos_valor,
|
||||||
pos_data,
|
pos_data,
|
||||||
|
pos_documento_tipo,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson {
|
|||||||
indice_serie: config.indice_serie,
|
indice_serie: config.indice_serie,
|
||||||
indice_valor: config.indice_valor,
|
indice_valor: config.indice_valor,
|
||||||
indice_data: config.indice_data,
|
indice_data: config.indice_data,
|
||||||
|
indice_documento_tipo: config.indice_documento_tipo,
|
||||||
},
|
},
|
||||||
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
|
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
|
||||||
nome: nome.clone(),
|
nome: nome.clone(),
|
||||||
@@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson {
|
|||||||
pos_serie: config.pos_serie.clone(),
|
pos_serie: config.pos_serie.clone(),
|
||||||
pos_valor: config.pos_valor.clone(),
|
pos_valor: config.pos_valor.clone(),
|
||||||
pos_data: config.pos_data.clone(),
|
pos_data: config.pos_data.clone(),
|
||||||
|
pos_documento_tipo: config.pos_documento_tipo.clone(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod layout;
|
|||||||
pub mod nota;
|
pub mod nota;
|
||||||
pub mod resultado_analise;
|
pub mod resultado_analise;
|
||||||
pub mod serie;
|
pub mod serie;
|
||||||
|
pub mod chave_serie;
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ use chrono::NaiveDate;
|
|||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
/// Representa uma nota fiscal com seus campos lógicos.
|
/// Representa uma nota fiscal com seus campos lógicos.
|
||||||
/// `numero + serie` é o identificador único de cada nota.
|
/// `numero + serie + documento_tipo` é o identificador único de cada nota.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Nota {
|
pub struct Nota {
|
||||||
/// Número incremental da nota. Armazenado como u64.
|
/// Número incremental da nota. Armazenado como u64.
|
||||||
pub numero: u64,
|
pub numero: u64,
|
||||||
/// Série da nota (1–3 dígitos numéricos). Ex: "001", "1".
|
/// Série da nota (1–3 dígitos numéricos). Ex: "001", "1".
|
||||||
pub serie: String,
|
pub serie: String,
|
||||||
|
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
|
||||||
|
pub documento_tipo: Option<String>,
|
||||||
/// Valor monetário da nota (opcional).
|
/// Valor monetário da nota (opcional).
|
||||||
pub valor: Option<Decimal>,
|
pub valor: Option<Decimal>,
|
||||||
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
|
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
|
||||||
@@ -19,12 +21,14 @@ impl Nota {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
numero: u64,
|
numero: u64,
|
||||||
serie: String,
|
serie: String,
|
||||||
|
documento_tipo: Option<String>,
|
||||||
valor: Option<Decimal>,
|
valor: Option<Decimal>,
|
||||||
data: Option<NaiveDate>,
|
data: Option<NaiveDate>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
numero,
|
numero,
|
||||||
serie,
|
serie,
|
||||||
|
documento_tipo,
|
||||||
valor,
|
valor,
|
||||||
data,
|
data,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -5,15 +6,15 @@ use std::collections::HashMap;
|
|||||||
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
|
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResultadoPreAnalise {
|
pub struct ResultadoPreAnalise {
|
||||||
/// Mapa série → (minimo, maximo, contagem_faltantes)
|
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
|
||||||
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
|
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
|
||||||
/// Duplicatas já processadas (não dependem dos faltantes)
|
/// Duplicatas já processadas (não dependem dos faltantes)
|
||||||
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||||
/// Somas já calculadas
|
/// Somas já calculadas
|
||||||
pub soma_total: Decimal,
|
pub soma_total: Decimal,
|
||||||
pub soma_por_serie: HashMap<String, Decimal>,
|
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||||
/// Total de notas processadas por série
|
/// Total de notas processadas por (serie, documento_tipo)
|
||||||
pub total_por_serie: HashMap<String, usize>,
|
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Intervalo de sequência de uma série.
|
/// Intervalo de sequência de uma série.
|
||||||
@@ -33,16 +34,16 @@ impl IntervaloSerie {
|
|||||||
/// Resultado completo da análise, com a lista materializada de faltantes.
|
/// Resultado completo da análise, com a lista materializada de faltantes.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ResultadoAnalise {
|
pub struct ResultadoAnalise {
|
||||||
/// Números faltantes por série (ordenados crescentemente)
|
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
|
||||||
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
|
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
|
||||||
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
|
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
|
||||||
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||||
/// Soma total de todos os valores
|
/// Soma total de todos os valores
|
||||||
pub soma_total: Decimal,
|
pub soma_total: Decimal,
|
||||||
/// Soma por série
|
/// Soma por (serie, documento_tipo)
|
||||||
pub soma_por_serie: HashMap<String, Decimal>,
|
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||||
/// Total de notas processadas por série
|
/// Total de notas processadas por (serie, documento_tipo)
|
||||||
pub total_por_serie: HashMap<String, usize>,
|
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResultadoAnalise {
|
impl ResultadoAnalise {
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
use thiserror::Error;
|
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)]
|
#[derive(Debug, Error, Clone)]
|
||||||
pub enum ErroSerie {
|
pub enum ErroSerie {
|
||||||
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
|
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
|
||||||
@@ -40,52 +32,10 @@ pub enum ErroArquivo {
|
|||||||
TamanhoExcedido(u64),
|
TamanhoExcedido(u64),
|
||||||
#[error("Arquivo corrompido ou ilegível: {0}")]
|
#[error("Arquivo corrompido ou ilegível: {0}")]
|
||||||
Corrompido(String),
|
Corrompido(String),
|
||||||
#[error("Formato não suportado: {0}")]
|
|
||||||
FormatoNaoSuportado(String),
|
|
||||||
#[error("Erro de leitura: {0}")]
|
#[error("Erro de leitura: {0}")]
|
||||||
ErroLeitura(String),
|
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.
|
/// Resumo consolidado de avisos para exibição em um único modal.
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct ResumoAvisos {
|
pub struct ResumoAvisos {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Detecta registros duplicados em uma lista de notas.
|
/// Detecta registros duplicados em uma lista de notas.
|
||||||
///
|
///
|
||||||
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
|
/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências,
|
||||||
/// contendo apenas grupos com mais de uma ocorrência.
|
/// contendo apenas grupos com mais de uma ocorrência.
|
||||||
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
|
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
|
||||||
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
|
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
|
||||||
|
|
||||||
for nota in notas {
|
for nota in notas {
|
||||||
*contagem
|
*contagem
|
||||||
.entry((nota.numero, nota.serie.clone()))
|
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
|
||||||
.or_insert(0) += 1;
|
.or_insert(0) += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
|
|||||||
contagem
|
contagem
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Agrupa as duplicidades por série.
|
/// Agrupa as duplicidades por (serie, documento_tipo).
|
||||||
///
|
///
|
||||||
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
|
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
|
||||||
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
|
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
|
||||||
let raw = detectar_duplicidades(notas);
|
let raw = detectar_duplicidades(notas);
|
||||||
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
|
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
|
||||||
|
|
||||||
for ((numero, serie), contagem) in raw {
|
for ((numero, serie, documento_tipo), contagem) in raw {
|
||||||
result.entry(serie).or_default().push((numero, contagem));
|
let chave = ChaveSerie::new(serie, documento_tipo);
|
||||||
|
result.entry(chave).or_default().push((numero, contagem));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ordenar por numero dentro de cada série
|
// Ordenar por numero dentro de cada grupo
|
||||||
for lista in result.values_mut() {
|
for lista in result.values_mut() {
|
||||||
lista.sort_by_key(|(num, _)| *num);
|
lista.sort_by_key(|(num, _)| *num);
|
||||||
}
|
}
|
||||||
@@ -44,7 +46,11 @@ mod tests {
|
|||||||
use crate::domain::entities::nota::Nota;
|
use crate::domain::entities::nota::Nota;
|
||||||
|
|
||||||
fn nota(numero: u64, serie: &str) -> Nota {
|
fn nota(numero: u64, serie: &str) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), None, None)
|
Nota::new(numero, serie.to_string(), None, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
|
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -58,7 +64,7 @@ mod tests {
|
|||||||
fn com_duplicata_simples() {
|
fn com_duplicata_simples() {
|
||||||
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
|
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2));
|
assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
|
||||||
assert_eq!(dup.len(), 1);
|
assert_eq!(dup.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,17 +72,30 @@ mod tests {
|
|||||||
fn duplicata_multiplas_ocorrencias() {
|
fn duplicata_multiplas_ocorrencias() {
|
||||||
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
|
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3));
|
assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
|
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
|
||||||
// Número 1 em séries diferentes não é duplicata
|
|
||||||
let notas = vec![nota(1, "001"), nota(1, "002")];
|
let notas = vec![nota(1, "001"), nota(1, "002")];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert!(dup.is_empty());
|
assert!(dup.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
||||||
|
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert!(dup.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
|
||||||
|
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn agrupamento_por_serie() {
|
fn agrupamento_por_serie() {
|
||||||
let notas = vec![
|
let notas = vec![
|
||||||
@@ -88,13 +107,13 @@ mod tests {
|
|||||||
nota(2, "001"),
|
nota(2, "001"),
|
||||||
];
|
];
|
||||||
let por_serie = duplicidades_por_serie(¬as);
|
let por_serie = duplicidades_por_serie(¬as);
|
||||||
// Série 001 deve ter notas 1 e 2 duplicadas
|
let chave001 = ChaveSerie::new("001".to_string(), None);
|
||||||
let serie001 = por_serie.get("001").unwrap();
|
let chave002 = ChaveSerie::new("002".to_string(), None);
|
||||||
|
let serie001 = por_serie.get(&chave001).unwrap();
|
||||||
assert_eq!(serie001.len(), 2);
|
assert_eq!(serie001.len(), 2);
|
||||||
assert_eq!(serie001[0], (1, 2));
|
assert_eq!(serie001[0], (1, 2));
|
||||||
assert_eq!(serie001[1], (2, 2));
|
assert_eq!(serie001[1], (2, 2));
|
||||||
// Série 002 deve ter nota 1 duplicada
|
let serie002 = por_serie.get(&chave002).unwrap();
|
||||||
let serie002 = por_serie.get("002").unwrap();
|
|
||||||
assert_eq!(serie002.len(), 1);
|
assert_eq!(serie002.len(), 1);
|
||||||
assert_eq!(serie002[0], (1, 2));
|
assert_eq!(serie002[0], (1, 2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
|
|||||||
faltantes
|
faltantes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos.
|
||||||
|
///
|
||||||
|
/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
|
||||||
|
///
|
||||||
|
/// # Exemplo
|
||||||
|
/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
|
||||||
|
pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> {
|
||||||
|
if faltantes.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut grupos = Vec::new();
|
||||||
|
let mut inicio = faltantes[0];
|
||||||
|
let mut anterior = faltantes[0];
|
||||||
|
|
||||||
|
for &num in &faltantes[1..] {
|
||||||
|
if num != anterior + 1 {
|
||||||
|
grupos.push((inicio, anterior));
|
||||||
|
inicio = num;
|
||||||
|
}
|
||||||
|
anterior = num;
|
||||||
|
}
|
||||||
|
grupos.push((inicio, anterior));
|
||||||
|
grupos
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -74,7 +100,7 @@ mod tests {
|
|||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
fn nota(numero: u64) -> Nota {
|
fn nota(numero: u64) -> Nota {
|
||||||
Nota::new(numero, "001".to_string(), None, None)
|
Nota::new(numero, "001".to_string(), None, None, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -136,4 +162,27 @@ mod tests {
|
|||||||
let refs: Vec<&Nota> = notas.iter().collect();
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_vazio() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_isolados() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_um_intervalo() {
|
||||||
|
assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupar_misto() {
|
||||||
|
assert_eq!(
|
||||||
|
agrupar_contiguos(&[1, 2, 3, 5, 8, 9]),
|
||||||
|
vec![(1, 3), (5, 5), (8, 9)]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,3 +88,39 @@ pub fn ler_csv(
|
|||||||
avisos,
|
avisos,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
|
||||||
|
/// Usado exclusivamente para pré-visualização na UI.
|
||||||
|
pub fn preview_csv(
|
||||||
|
caminho: &Path,
|
||||||
|
delimitador: u8,
|
||||||
|
encoding: &str,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
|
let bytes = std::fs::read(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
|
|
||||||
|
let conteudo = match encoding.to_lowercase().as_str() {
|
||||||
|
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
||||||
|
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
_ => String::from_utf8(bytes)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.delimiter(delimitador)
|
||||||
|
.has_headers(false)
|
||||||
|
.flexible(true)
|
||||||
|
.from_reader(conteudo.as_bytes());
|
||||||
|
|
||||||
|
let linhas = reader
|
||||||
|
.records()
|
||||||
|
.take(n)
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.map(|r| r.iter().map(|s| s.to_string()).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(linhas)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||||
use crate::domain::services::parser_monetario::formatar_valor_br;
|
use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||||
use chrono::{DateTime, Local};
|
use chrono::{DateTime, Local};
|
||||||
use genpdf::{
|
use genpdf::{
|
||||||
elements::{Break, Paragraph},
|
elements::{Break, Paragraph},
|
||||||
fonts, style, Document, Element, SimplePageDecorator,
|
fonts, style, Document, SimplePageDecorator,
|
||||||
};
|
};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
// Fontes embutidas no binário em tempo de compilação.
|
||||||
|
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
|
||||||
|
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
|
||||||
|
const FONT_REGULAR: &[u8] =
|
||||||
|
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||||
|
const FONT_BOLD: &[u8] =
|
||||||
|
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||||
|
|
||||||
/// Metadados do relatório.
|
/// Metadados do relatório.
|
||||||
pub struct MetadadosRelatorio {
|
pub struct MetadadosRelatorio {
|
||||||
pub nome_arquivo: String,
|
pub nome_arquivo: String,
|
||||||
@@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
meta: &MetadadosRelatorio,
|
meta: &MetadadosRelatorio,
|
||||||
caminho_saida: &Path,
|
caminho_saida: &Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Carregar fonte do sistema (DejaVu Sans)
|
let font_family = carregar_fonte_familia()?;
|
||||||
let font_family = carregar_fonte_sistema()
|
|
||||||
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
|
|
||||||
|
|
||||||
let mut doc = Document::new(font_family);
|
let mut doc = Document::new(font_family);
|
||||||
doc.set_title("Relatório — Comparador de Notas");
|
doc.set_title("Relatório — Comparador de Notas");
|
||||||
@@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
)));
|
)));
|
||||||
doc.push(Break::new(1));
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Ordenar chaves
|
||||||
|
let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||||
|
chaves_ordenadas.sort();
|
||||||
|
|
||||||
// Totais
|
// Totais
|
||||||
doc.push(
|
doc.push(
|
||||||
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||||
@@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
formatar_valor_br(&resultado.soma_total)
|
formatar_valor_br(&resultado.soma_total)
|
||||||
)));
|
)));
|
||||||
|
|
||||||
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
for chave in &chaves_ordenadas {
|
||||||
series_ordenadas.sort();
|
let soma = &resultado.soma_por_serie[*chave];
|
||||||
|
let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0);
|
||||||
for serie in &series_ordenadas {
|
|
||||||
let soma = &resultado.soma_por_serie[*serie];
|
|
||||||
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} nota(s) — R$ {}",
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
serie,
|
chave.label(),
|
||||||
total,
|
total,
|
||||||
formatar_valor_br(soma)
|
formatar_valor_br(soma)
|
||||||
)));
|
)));
|
||||||
@@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
);
|
);
|
||||||
|
|
||||||
for serie in &series_ordenadas {
|
// Use faltantes keys for this section (may differ from soma keys if no values)
|
||||||
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
|
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
|
chaves_faltantes.sort();
|
||||||
|
|
||||||
|
for chave in &chaves_faltantes {
|
||||||
|
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
|
||||||
Some(f) if !f.is_empty() => f,
|
Some(f) if !f.is_empty() => f,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} faltante(s)",
|
" Série {}: {} faltante(s)",
|
||||||
serie,
|
chave.label(),
|
||||||
faltantes.len()
|
faltantes.len()
|
||||||
)));
|
)));
|
||||||
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||||
@@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
);
|
);
|
||||||
|
|
||||||
for serie in &series_ordenadas {
|
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
|
chaves_dup.sort();
|
||||||
|
|
||||||
|
for chave in &chaves_dup {
|
||||||
|
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
|
||||||
Some(d) if !d.is_empty() => d,
|
Some(d) if !d.is_empty() => d,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" Série {}: {} grupo(s) duplicado(s)",
|
" Série {}: {} grupo(s) duplicado(s)",
|
||||||
serie,
|
chave.label(),
|
||||||
duplicatas.len()
|
duplicatas.len()
|
||||||
)));
|
)));
|
||||||
for (numero, count) in duplicatas {
|
for (numero, count) in duplicatas {
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" NF {} / Série {} — {} ocorrências",
|
" NF {} / Série {} — {} ocorrências",
|
||||||
numero, serie, count
|
numero, chave.label(), count
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenta carregar fontes DejaVu Sans do sistema.
|
/// Constrói a família de fontes a partir dos bytes embutidos no binário.
|
||||||
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
|
/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar
|
||||||
// Caminhos comuns no Linux, Windows e macOS
|
/// fontes do sistema (Arial ~993 KB/variante, sem subsetting).
|
||||||
let candidatos_regular = [
|
fn carregar_fonte_familia() -> Result<fonts::FontFamily<fonts::FontData>, String> {
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None)
|
||||||
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
.map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?;
|
||||||
"C:\\Windows\\Fonts\\arial.ttf",
|
let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None)
|
||||||
"/Library/Fonts/Arial.ttf",
|
.map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?;
|
||||||
];
|
|
||||||
let candidatos_bold = [
|
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
||||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
||||||
"C:\\Windows\\Fonts\\arialbd.ttf",
|
|
||||||
"/Library/Fonts/Arial Bold.ttf",
|
|
||||||
];
|
|
||||||
|
|
||||||
let regular_path = candidatos_regular
|
|
||||||
.iter()
|
|
||||||
.find(|p| std::path::Path::new(p).exists())
|
|
||||||
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
|
|
||||||
|
|
||||||
let bold_path = candidatos_bold
|
|
||||||
.iter()
|
|
||||||
.find(|p| std::path::Path::new(p).exists())
|
|
||||||
.unwrap_or(regular_path);
|
|
||||||
|
|
||||||
let regular = fonts::FontData::load(regular_path, None)
|
|
||||||
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
|
|
||||||
let bold = fonts::FontData::load(bold_path, None)
|
|
||||||
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
|
|
||||||
|
|
||||||
|
// genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico,
|
||||||
|
// italic e bold_italic reusam os dados de regular/bold respectivamente.
|
||||||
Ok(fonts::FontFamily {
|
Ok(fonts::FontFamily {
|
||||||
regular: regular.clone(),
|
|
||||||
bold,
|
|
||||||
italic: regular.clone(),
|
italic: regular.clone(),
|
||||||
bold_italic: regular,
|
bold_italic: bold.clone(),
|
||||||
|
regular,
|
||||||
|
bold,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Determina o caminho do banco de dados conforme o sistema operacional.
|
/// Determina o caminho do banco de dados conforme o sistema operacional.
|
||||||
pub fn caminho_banco() -> PathBuf {
|
pub fn caminho_banco() -> PathBuf {
|
||||||
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
@@ -33,7 +31,7 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
|
|||||||
// Testar se o banco funciona com uma query simples
|
// Testar se o banco funciona com uma query simples
|
||||||
match conn.execute_batch("SELECT 1;") {
|
match conn.execute_batch("SELECT 1;") {
|
||||||
Ok(_) => return Ok((conn, false)),
|
Ok(_) => return Ok((conn, false)),
|
||||||
Err(e) => {
|
Err(_e) => {
|
||||||
// Banco corrompido
|
// Banco corrompido
|
||||||
drop(conn);
|
drop(conn);
|
||||||
let bak = caminho.with_extension("db.bak");
|
let bak = caminho.with_extension("db.bak");
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO layouts
|
"INSERT INTO layouts
|
||||||
(nome, tipo, delimitador, encoding, linha_cabecalho,
|
(nome, tipo, delimitador, encoding, linha_cabecalho,
|
||||||
indice_numero, indice_serie, indice_valor, indice_data)
|
indice_numero, indice_serie, indice_valor, indice_data,
|
||||||
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
indice_documento_tipo)
|
||||||
|
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
config.delimitador.to_string(),
|
||||||
@@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
config.indice_serie as i64,
|
config.indice_serie as i64,
|
||||||
config.indice_valor.map(|v| v as i64),
|
config.indice_valor.map(|v| v as i64),
|
||||||
config.indice_data.map(|v| v as i64),
|
config.indice_data.map(|v| v as i64),
|
||||||
|
config.indice_documento_tipo.map(|v| v as i64),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
@@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
Layout::Xlsx { nome, config, .. } => {
|
Layout::Xlsx { nome, config, .. } => {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO layouts
|
"INSERT INTO layouts
|
||||||
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
|
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
|
||||||
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
|
pos_documento_tipo)
|
||||||
|
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.aba,
|
config.aba,
|
||||||
@@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
|||||||
config.pos_serie,
|
config.pos_serie,
|
||||||
config.pos_valor,
|
config.pos_valor,
|
||||||
config.pos_data,
|
config.pos_data,
|
||||||
|
config.pos_documento_tipo,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
@@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, delimitador = ?2, encoding = ?3,
|
nome = ?1, delimitador = ?2, encoding = ?3,
|
||||||
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
||||||
indice_valor = ?7, indice_data = ?8
|
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
|
||||||
WHERE id = ?9",
|
WHERE id = ?10",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.delimitador.to_string(),
|
config.delimitador.to_string(),
|
||||||
@@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
config.indice_serie as i64,
|
config.indice_serie as i64,
|
||||||
config.indice_valor.map(|v| v as i64),
|
config.indice_valor.map(|v| v as i64),
|
||||||
config.indice_data.map(|v| v as i64),
|
config.indice_data.map(|v| v as i64),
|
||||||
|
config.indice_documento_tipo.map(|v| v as i64),
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE layouts SET
|
"UPDATE layouts SET
|
||||||
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
||||||
pos_valor = ?5, pos_data = ?6
|
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7
|
||||||
WHERE id = ?7",
|
WHERE id = ?8",
|
||||||
params![
|
params![
|
||||||
nome,
|
nome,
|
||||||
config.aba,
|
config.aba,
|
||||||
@@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
|||||||
config.pos_serie,
|
config.pos_serie,
|
||||||
config.pos_valor,
|
config.pos_valor,
|
||||||
config.pos_data,
|
config.pos_data,
|
||||||
|
config.pos_documento_tipo,
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
@@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
"SELECT id, nome, tipo,
|
"SELECT id, nome, tipo,
|
||||||
delimitador, encoding, linha_cabecalho,
|
delimitador, encoding, linha_cabecalho,
|
||||||
indice_numero, indice_serie, indice_valor, indice_data,
|
indice_numero, indice_serie, indice_valor, indice_data,
|
||||||
aba, pos_numero, pos_serie, pos_valor, pos_data
|
aba, pos_numero, pos_serie, pos_valor, pos_data,
|
||||||
|
indice_documento_tipo, pos_documento_tipo
|
||||||
FROM layouts ORDER BY nome ASC",
|
FROM layouts ORDER BY nome ASC",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
indice_data: row
|
indice_data: row
|
||||||
.get::<_, Option<i64>>(9)?
|
.get::<_, Option<i64>>(9)?
|
||||||
.map(|v| v as usize),
|
.map(|v| v as usize),
|
||||||
|
indice_documento_tipo: row
|
||||||
|
.get::<_, Option<i64>>(15)?
|
||||||
|
.map(|v| v as usize),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
pos_serie: row.get(12)?,
|
pos_serie: row.get(12)?,
|
||||||
pos_valor: row.get(13)?,
|
pos_valor: row.get(13)?,
|
||||||
pos_data: row.get(14)?,
|
pos_data: row.get(14)?,
|
||||||
|
pos_documento_tipo: row.get(16)?,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -145,55 +156,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
layouts
|
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.
|
/// Remove um layout pelo id.
|
||||||
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
|
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
|
||||||
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
|
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
|
|
||||||
/// Versão atual do schema do banco de dados.
|
/// Versão atual do schema do banco de dados.
|
||||||
const VERSAO_SCHEMA_ATUAL: i64 = 1;
|
const VERSAO_SCHEMA_ATUAL: i64 = 3;
|
||||||
|
|
||||||
/// Aplica todas as migrations necessárias para atualizar o banco
|
/// Aplica todas as migrations necessárias para atualizar o banco
|
||||||
/// para a versão mais recente.
|
/// para a versão mais recente.
|
||||||
@@ -23,13 +23,45 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
|||||||
|
|
||||||
if versao_atual < 1 {
|
if versao_atual < 1 {
|
||||||
migration_v1(conn)?;
|
migration_v1(conn)?;
|
||||||
if versao_atual == 0 {
|
}
|
||||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
|
if versao_atual < 2 {
|
||||||
} else {
|
migration_v2(conn)?;
|
||||||
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
|
}
|
||||||
}
|
if versao_atual < 3 {
|
||||||
|
migration_v3(conn)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if versao_atual == 0 {
|
||||||
|
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
|
||||||
|
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
|
||||||
|
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migration v2: adicionar índice único em layouts.nome.
|
||||||
|
|
||||||
|
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
||||||
|
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
||||||
|
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
|
||||||
|
/// em bancos que já possuem nomes repetidos.
|
||||||
|
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"UPDATE layouts
|
||||||
|
SET nome = nome || ' (' || id || ')'
|
||||||
|
WHERE id NOT IN (
|
||||||
|
SELECT MIN(id) FROM layouts GROUP BY nome
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);",
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
||||||
use calamine::{open_workbook, Reader, Xlsx};
|
use calamine::{open_workbook_auto, Reader};
|
||||||
use std::fs::File;
|
|
||||||
use std::io::BufReader;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
|
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
@@ -22,17 +20,19 @@ pub struct ResultadoXlsx {
|
|||||||
pub avisos: ResumoAvisos,
|
pub avisos: ResumoAvisos,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lista as abas disponíveis em um arquivo XLSX.
|
/// Lista as abas disponíveis em um arquivo XLSX (ou XLS com extensão .xls mas conteúdo XLSX).
|
||||||
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
||||||
verificar_tamanho(caminho)?;
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
let workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
|
let workbook = open_workbook_auto(caminho)
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
Ok(workbook.sheet_names().to_vec())
|
Ok(workbook.sheet_names().to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lê os dados de uma aba XLSX a partir de uma linha de início.
|
/// Lê os dados de uma aba XLSX (ou XLS com extensão .xls mas conteúdo XLSX) a partir de uma linha de início.
|
||||||
|
///
|
||||||
|
/// O formato real é detectado pelo conteúdo do arquivo (magic bytes), não pela extensão.
|
||||||
///
|
///
|
||||||
/// # Parâmetros
|
/// # Parâmetros
|
||||||
/// - `caminho`: caminho do arquivo
|
/// - `caminho`: caminho do arquivo
|
||||||
@@ -45,14 +45,14 @@ pub fn ler_xlsx(
|
|||||||
) -> Result<ResultadoXlsx, ErroArquivo> {
|
) -> Result<ResultadoXlsx, ErroArquivo> {
|
||||||
verificar_tamanho(caminho)?;
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
let mut workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
|
let mut workbook = open_workbook_auto(caminho)
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
let range: calamine::Range<calamine::Data> = workbook
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
.worksheet_range(nome_aba)
|
.worksheet_range(nome_aba)
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
let mut avisos = ResumoAvisos::default();
|
let avisos = ResumoAvisos::default();
|
||||||
let mut linhas: Vec<Vec<String>> = Vec::new();
|
let mut linhas: Vec<Vec<String>> = Vec::new();
|
||||||
|
|
||||||
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
|
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
|
||||||
@@ -97,6 +97,53 @@ pub fn ler_xlsx(
|
|||||||
Ok(ResultadoXlsx { linhas, avisos })
|
Ok(ResultadoXlsx { linhas, avisos })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
|
||||||
|
/// Usado exclusivamente para pré-visualização na UI.
|
||||||
|
pub fn preview_xlsx(
|
||||||
|
caminho: &Path,
|
||||||
|
nome_aba: &str,
|
||||||
|
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
|
let mut workbook = open_workbook_auto(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
|
.worksheet_range(nome_aba)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let linhas = range
|
||||||
|
.rows()
|
||||||
|
.take(5)
|
||||||
|
.map(|row| {
|
||||||
|
row.iter()
|
||||||
|
.map(|cell| {
|
||||||
|
use calamine::Data;
|
||||||
|
match cell {
|
||||||
|
Data::Empty => String::new(),
|
||||||
|
Data::String(s) => s.clone(),
|
||||||
|
Data::Float(f) => {
|
||||||
|
if f.fract() == 0.0 {
|
||||||
|
format!("{}", *f as i64)
|
||||||
|
} else {
|
||||||
|
format!("{}", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::Int(i) => i.to_string(),
|
||||||
|
Data::Bool(b) => b.to_string(),
|
||||||
|
Data::DateTime(dt) => dt.to_string(),
|
||||||
|
Data::DateTimeIso(s) => s.clone(),
|
||||||
|
Data::DurationIso(s) => s.clone(),
|
||||||
|
Data::Error(_) => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(linhas)
|
||||||
|
}
|
||||||
|
|
||||||
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
|
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
|
||||||
///
|
///
|
||||||
/// Retorna `None` se a notação for inválida.
|
/// Retorna `None` se a notação for inválida.
|
||||||
|
|||||||
+398
-36
@@ -1,26 +1,22 @@
|
|||||||
use crate::application::usecases::{
|
use crate::application::usecases::{
|
||||||
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
|
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
|
||||||
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao},
|
importar_arquivo::{importar_csv, importar_xlsx},
|
||||||
layouts::{
|
layouts::{excluir_layout, listar_layouts, salvar_layout},
|
||||||
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
|
|
||||||
listar_layouts, salvar_layout,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::{
|
entities::{
|
||||||
|
chave_serie::ChaveSerie,
|
||||||
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
|
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
|
||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
},
|
},
|
||||||
errors::ResumoAvisos,
|
errors::{ErroLayout, ResumoAvisos},
|
||||||
};
|
|
||||||
use crate::infrastructure::{
|
|
||||||
pdf_generator::GenpdfGenerator,
|
|
||||||
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
|
|
||||||
};
|
};
|
||||||
|
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||||
use egui::Context;
|
use egui::Context;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
/// Estado global da aplicação.
|
/// Estado global da aplicação.
|
||||||
pub enum EstadoApp {
|
pub enum EstadoApp {
|
||||||
@@ -33,12 +29,11 @@ pub enum EstadoApp {
|
|||||||
/// Resultado pronto para exibição.
|
/// Resultado pronto para exibição.
|
||||||
ExibindoResultado(ResultadoAnalise),
|
ExibindoResultado(ResultadoAnalise),
|
||||||
/// Aguardando confirmação do usuário para expandir faltantes.
|
/// Aguardando confirmação do usuário para expandir faltantes.
|
||||||
ConfirmandoIntervalo {
|
ConfirmandoIntervalo { pre: ResultadoPreAnalise },
|
||||||
pre: ResultadoPreAnalise,
|
|
||||||
series_excessivas: Vec<(String, u64)>,
|
|
||||||
},
|
|
||||||
/// Gerenciamento de layouts.
|
/// Gerenciamento de layouts.
|
||||||
GerenciandoLayouts,
|
GerenciandoLayouts,
|
||||||
|
/// Análise em execução em background (thread separada).
|
||||||
|
Analisando,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
||||||
@@ -50,6 +45,8 @@ pub struct Modal {
|
|||||||
pub tipo: TipoModal,
|
pub tipo: TipoModal,
|
||||||
/// Para modal de confirmação, a ação ao confirmar.
|
/// Para modal de confirmação, a ação ao confirmar.
|
||||||
pub acao_confirmacao: Option<AcaoModal>,
|
pub acao_confirmacao: Option<AcaoModal>,
|
||||||
|
/// Para modal com campo de texto (InputTexto).
|
||||||
|
pub input_texto: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, PartialEq)]
|
#[derive(Default, Clone, PartialEq)]
|
||||||
@@ -59,13 +56,42 @@ pub enum TipoModal {
|
|||||||
Aviso,
|
Aviso,
|
||||||
Erro,
|
Erro,
|
||||||
Confirmacao,
|
Confirmacao,
|
||||||
|
/// Modal com campo de texto para entrada do usuário.
|
||||||
|
InputTexto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum AcaoModal {
|
pub enum AcaoModal {
|
||||||
ConfirmarExpansaoFaltantes,
|
ConfirmarExpansaoFaltantes,
|
||||||
ConfirmarExclusaoLayout(i64),
|
ConfirmarExclusaoLayout(i64),
|
||||||
SobrescreverLayout,
|
/// Sobrescrever layout existente — layout já carrega o id correto.
|
||||||
|
SobrescreverLayout(Layout),
|
||||||
|
ConfirmarNovaAnalise,
|
||||||
|
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
|
||||||
|
SalvarLayoutConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado enviado pela thread de análise de volta para a UI.
|
||||||
|
pub enum ResultadoPendente {
|
||||||
|
/// Análise concluída com sucesso.
|
||||||
|
Concluido {
|
||||||
|
resultado: ResultadoAnalise,
|
||||||
|
/// Avisos de importação (None quando vier do path de expansão).
|
||||||
|
avisos: Option<ResumoAvisos>,
|
||||||
|
/// Notas importadas (None quando o App já as possui).
|
||||||
|
notas: Option<Vec<Nota>>,
|
||||||
|
},
|
||||||
|
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||||
|
AguardandoConfirmacao {
|
||||||
|
pre: ResultadoPreAnalise,
|
||||||
|
series_excessivas: Vec<(ChaveSerie, u64)>,
|
||||||
|
avisos: ResumoAvisos,
|
||||||
|
notas: Vec<Nota>,
|
||||||
|
},
|
||||||
|
/// Arquivo importado não continha notas válidas.
|
||||||
|
Vazio,
|
||||||
|
/// Erro durante importação ou análise.
|
||||||
|
Erro(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Struct principal da aplicação egui.
|
/// Struct principal da aplicação egui.
|
||||||
@@ -101,6 +127,13 @@ pub struct App {
|
|||||||
pub pagina_faltantes: usize,
|
pub pagina_faltantes: usize,
|
||||||
pub pagina_duplicatas: usize,
|
pub pagina_duplicatas: usize,
|
||||||
pub itens_por_pagina: usize,
|
pub itens_por_pagina: usize,
|
||||||
|
|
||||||
|
// Pré-visualização das primeiras linhas do arquivo
|
||||||
|
pub preview_arquivo: Option<Vec<Vec<String>>>,
|
||||||
|
// Canal para receber resultado da análise em background
|
||||||
|
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
|
||||||
|
// Resultado anterior, preservado durante reanálise para restaurar em caso de falha
|
||||||
|
pub resultado_anterior: Option<ResultadoAnalise>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
@@ -123,6 +156,9 @@ impl Default for App {
|
|||||||
pagina_faltantes: 0,
|
pagina_faltantes: 0,
|
||||||
pagina_duplicatas: 0,
|
pagina_duplicatas: 0,
|
||||||
itens_por_pagina: 100,
|
itens_por_pagina: 100,
|
||||||
|
preview_arquivo: None,
|
||||||
|
resultado_pendente: None,
|
||||||
|
resultado_anterior: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +173,7 @@ impl App {
|
|||||||
EstadoApp::ExibindoResultado(_) => 3,
|
EstadoApp::ExibindoResultado(_) => 3,
|
||||||
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
||||||
EstadoApp::GerenciandoLayouts => 5,
|
EstadoApp::GerenciandoLayouts => 5,
|
||||||
|
EstadoApp::Analisando => 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +212,7 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Erro,
|
tipo: TipoModal::Erro,
|
||||||
acao_confirmacao: None,
|
acao_confirmacao: None,
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +223,7 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Aviso,
|
tipo: TipoModal::Aviso,
|
||||||
acao_confirmacao: None,
|
acao_confirmacao: None,
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +239,23 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Confirmacao,
|
tipo: TipoModal::Confirmacao,
|
||||||
acao_confirmacao: Some(acao),
|
acao_confirmacao: Some(acao),
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recarrega a lista de layouts do banco.
|
/// Recarrega a lista de layouts do banco.
|
||||||
|
/// Exibe modal com campo de texto para salvar o layout atual como novo preset.
|
||||||
|
pub fn exibir_modal_salvar_layout(&mut self) {
|
||||||
|
self.modal = Modal {
|
||||||
|
visivel: true,
|
||||||
|
titulo: "Salvar como layout...".to_string(),
|
||||||
|
mensagem: "Nome do layout:".to_string(),
|
||||||
|
tipo: TipoModal::InputTexto,
|
||||||
|
acao_confirmacao: Some(AcaoModal::SalvarLayoutConfig),
|
||||||
|
input_texto: self.nome_layout_atual.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub fn recarregar_layouts(&mut self) {
|
pub fn recarregar_layouts(&mut self) {
|
||||||
if let Some(conn) = &self.conn {
|
if let Some(conn) = &self.conn {
|
||||||
match listar_layouts(conn) {
|
match listar_layouts(conn) {
|
||||||
@@ -227,14 +279,26 @@ impl App {
|
|||||||
egui::Window::new(&titulo)
|
egui::Window::new(&titulo)
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
|
.min_width(320.0)
|
||||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.label(&mensagem);
|
ui.label(&mensagem);
|
||||||
|
|
||||||
|
if tipo == TipoModal::InputTexto {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.modal.input_texto)
|
||||||
|
.desired_width(280.0)
|
||||||
|
.hint_text("Nome do layout"),
|
||||||
|
);
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if tipo == TipoModal::Confirmacao {
|
if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
|
||||||
if ui.button("✔ Confirmar").clicked() {
|
if ui.button("✔ Confirmar").clicked() {
|
||||||
self.modal.visivel = false;
|
self.modal.visivel = false;
|
||||||
if let Some(acao) = acao.clone() {
|
if let Some(acao) = acao.clone() {
|
||||||
@@ -243,6 +307,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
if ui.button("✖ Cancelar").clicked() {
|
if ui.button("✖ Cancelar").clicked() {
|
||||||
self.modal.visivel = false;
|
self.modal.visivel = false;
|
||||||
|
self.modal.input_texto.clear();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ui.button("OK").clicked() {
|
if ui.button("OK").clicked() {
|
||||||
@@ -256,12 +321,20 @@ impl App {
|
|||||||
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
||||||
match acao {
|
match acao {
|
||||||
AcaoModal::ConfirmarExpansaoFaltantes => {
|
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||||
// Retirar pre-análise do estado e expandir
|
|
||||||
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
|
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
|
||||||
std::mem::replace(&mut self.estado, EstadoApp::Importando)
|
std::mem::replace(&mut self.estado, EstadoApp::Analisando)
|
||||||
{
|
{
|
||||||
let resultado = expandir_analise(pre, &self.notas_importadas);
|
let notas = self.notas_importadas.clone();
|
||||||
self.estado = EstadoApp::ExibindoResultado(resultado);
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.resultado_pendente = Some(rx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let resultado = expandir_analise(pre, ¬as);
|
||||||
|
let _ = tx.send(ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos: None,
|
||||||
|
notas: None,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
||||||
@@ -273,10 +346,236 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
|
AcaoModal::SobrescreverLayout(layout) => {
|
||||||
|
if let Some(conn) = &self.conn {
|
||||||
|
match salvar_layout(conn, &layout) {
|
||||||
|
Ok(_) => {
|
||||||
|
self.recarregar_layouts();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.exibir_erro(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcaoModal::ConfirmarNovaAnalise => {
|
||||||
|
self.notas_importadas.clear();
|
||||||
|
self.preview_arquivo = None;
|
||||||
|
self.estado = EstadoApp::Importando;
|
||||||
|
}
|
||||||
|
AcaoModal::SalvarLayoutConfig => {
|
||||||
|
let nome = self.modal.input_texto.trim().to_string();
|
||||||
|
self.modal.input_texto.clear();
|
||||||
|
if nome.is_empty() {
|
||||||
|
self.exibir_erro("O nome do layout não pode ser vazio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let layout = match &self.tipo_arquivo_atual {
|
||||||
|
TipoArquivo::Csv => Layout::Csv {
|
||||||
|
id: None,
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: self.layout_csv_atual.clone(),
|
||||||
|
},
|
||||||
|
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||||
|
id: None,
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: self.layout_xlsx_atual.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resultado = if let Some(conn) = &self.conn {
|
||||||
|
Some(salvar_layout(conn, &layout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
match resultado {
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
self.nome_layout_atual = nome;
|
||||||
|
self.recarregar_layouts();
|
||||||
|
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
|
||||||
|
}
|
||||||
|
Some(Err(ErroLayout::NomeConflitante(nome_conflito))) => {
|
||||||
|
let id_existente = self
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.nome() == nome_conflito)
|
||||||
|
.and_then(|l| l.id());
|
||||||
|
if let Some(id) = id_existente {
|
||||||
|
let layout_com_id = match &self.tipo_arquivo_atual {
|
||||||
|
TipoArquivo::Csv => Layout::Csv {
|
||||||
|
id: Some(id),
|
||||||
|
nome: nome_conflito.clone(),
|
||||||
|
config: self.layout_csv_atual.clone(),
|
||||||
|
},
|
||||||
|
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||||
|
id: Some(id),
|
||||||
|
nome: nome_conflito.clone(),
|
||||||
|
config: self.layout_xlsx_atual.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
self.exibir_confirmacao(
|
||||||
|
"Conflito de nome",
|
||||||
|
format!(
|
||||||
|
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||||
|
nome_conflito
|
||||||
|
),
|
||||||
|
AcaoModal::SobrescreverLayout(layout_com_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Err(e)) => self.exibir_erro(e.to_string()),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Processa o resultado recebido do canal de análise em background.
|
||||||
|
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
|
||||||
|
match resultado {
|
||||||
|
ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
} => {
|
||||||
|
if let Some(n) = notas {
|
||||||
|
self.notas_importadas = n;
|
||||||
|
}
|
||||||
|
if let Some(av) = &avisos {
|
||||||
|
self.avisos_importacao = if av.tem_avisos() {
|
||||||
|
Some(av.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
self.resultado_anterior = None;
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
if let Some(av) = &self.avisos_importacao.clone() {
|
||||||
|
if av.tem_avisos() {
|
||||||
|
let linhas = av.linhas_para_exibir().join("\n");
|
||||||
|
self.exibir_aviso("Avisos de Importação", linhas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResultadoPendente::AguardandoConfirmacao {
|
||||||
|
pre,
|
||||||
|
series_excessivas,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
} => {
|
||||||
|
self.notas_importadas = notas;
|
||||||
|
self.avisos_importacao = if avisos.tem_avisos() {
|
||||||
|
Some(avisos)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let msg = series_excessivas
|
||||||
|
.iter()
|
||||||
|
.map(|(chave, count)| {
|
||||||
|
format!(
|
||||||
|
"Série {}: intervalo de {} faltantes detectado",
|
||||||
|
chave.label(),
|
||||||
|
count
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
|
||||||
|
self.exibir_confirmacao(
|
||||||
|
"Intervalo muito grande",
|
||||||
|
format!(
|
||||||
|
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
|
||||||
|
msg
|
||||||
|
),
|
||||||
|
AcaoModal::ConfirmarExpansaoFaltantes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ResultadoPendente::Vazio => {
|
||||||
|
if let Some(resultado) = self.resultado_anterior.take() {
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
} else {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
self.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
||||||
|
}
|
||||||
|
ResultadoPendente::Erro(e) => {
|
||||||
|
if let Some(resultado) = self.resultado_anterior.take() {
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
} else {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
self.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reimporta o arquivo atual com o layout atual e executa análise.
|
||||||
|
/// Segue o mesmo padrão assíncrono de `executar_importacao` em configuracao_colunas.rs.
|
||||||
|
pub fn reimportar_e_analisar(&mut self, ctx: &Context) {
|
||||||
|
let caminho = match &self.caminho_arquivo {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
self.exibir_erro("Nenhum arquivo carregado.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preservar resultado atual para restaurar em caso de erro ou arquivo vazio
|
||||||
|
if let EstadoApp::ExibindoResultado(r) = &self.estado {
|
||||||
|
self.resultado_anterior = Some(r.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let tipo = self.tipo_arquivo_atual.clone();
|
||||||
|
let layout_csv = self.layout_csv_atual.clone();
|
||||||
|
let layout_xlsx = self.layout_xlsx_atual.clone();
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.resultado_pendente = Some(rx);
|
||||||
|
self.estado = EstadoApp::Analisando;
|
||||||
|
ctx.request_repaint();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let res_importacao = match tipo {
|
||||||
|
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
||||||
|
TipoArquivo::Xlsx => {
|
||||||
|
importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = match res_importacao {
|
||||||
|
Err(e) => ResultadoPendente::Erro(e),
|
||||||
|
Ok(importado) => {
|
||||||
|
if importado.notas.is_empty() {
|
||||||
|
ResultadoPendente::Vazio
|
||||||
|
} else {
|
||||||
|
let avisos = importado.avisos.clone();
|
||||||
|
let notas = importado.notas;
|
||||||
|
|
||||||
|
let pre = pre_analisar(¬as);
|
||||||
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
|
|
||||||
|
if !excessivos.is_empty() {
|
||||||
|
ResultadoPendente::AguardandoConfirmacao {
|
||||||
|
pre,
|
||||||
|
series_excessivas: excessivos,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let resultado = expandir_analise(pre, ¬as);
|
||||||
|
ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos: Some(avisos),
|
||||||
|
notas: Some(notas),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Executa a análise com as notas importadas.
|
/// Executa a análise com as notas importadas.
|
||||||
pub fn executar_analise(&mut self) {
|
pub fn executar_analise(&mut self) {
|
||||||
if self.notas_importadas.is_empty() {
|
if self.notas_importadas.is_empty() {
|
||||||
@@ -290,17 +589,18 @@ impl App {
|
|||||||
if !excessivos.is_empty() {
|
if !excessivos.is_empty() {
|
||||||
let msg = excessivos
|
let msg = excessivos
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(serie, count)| {
|
.map(|(chave, count)| {
|
||||||
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
|
format!(
|
||||||
|
"Série {}: intervalo de {} faltantes detectado",
|
||||||
|
chave.label(),
|
||||||
|
count
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
|
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
|
||||||
self.estado = EstadoApp::ConfirmandoIntervalo {
|
self.estado = EstadoApp::ConfirmandoIntervalo { pre };
|
||||||
pre,
|
|
||||||
series_excessivas: excessivos,
|
|
||||||
};
|
|
||||||
self.exibir_confirmacao(
|
self.exibir_confirmacao(
|
||||||
"Intervalo muito grande",
|
"Intervalo muito grande",
|
||||||
format!(
|
format!(
|
||||||
@@ -316,25 +616,87 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Renderiza o breadcrumb de etapas no topo (exceto na tela de layouts).
|
||||||
|
pub fn renderizar_breadcrumb(&self, ctx: &Context) {
|
||||||
|
let passo_ativo: usize = match self.tela_atual() {
|
||||||
|
0 | 1 => 1,
|
||||||
|
2 | 4 | 6 => 2,
|
||||||
|
3 => 3,
|
||||||
|
_ => return, // GerenciandoLayouts: sem breadcrumb
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (i, label) in ["Arquivo", "Colunas", "Resultado"].iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
let texto_completo = format!("{}. {}", n, label);
|
||||||
|
let texto = egui::RichText::new(texto_completo);
|
||||||
|
if n == passo_ativo {
|
||||||
|
ui.label(texto.strong());
|
||||||
|
} else if n < passo_ativo {
|
||||||
|
ui.label(texto);
|
||||||
|
} else {
|
||||||
|
ui.label(texto.weak());
|
||||||
|
}
|
||||||
|
if n < 3 {
|
||||||
|
ui.label(egui::RichText::new(" ›").weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
||||||
|
// Verificar resultado pendente da análise em background
|
||||||
|
if let Some(rx) = self.resultado_pendente.take() {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(resultado) => {
|
||||||
|
self.processar_resultado_pendente(resultado);
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => {
|
||||||
|
// Ainda processando: devolver receiver e solicitar repaint
|
||||||
|
self.resultado_pendente = Some(rx);
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
self.exibir_erro("Erro interno: a análise foi interrompida inesperadamente.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sempre renderizar modal por cima de tudo
|
// Sempre renderizar modal por cima de tudo
|
||||||
self.renderizar_modal(ctx);
|
self.renderizar_modal(ctx);
|
||||||
|
|
||||||
|
// Breadcrumb de etapas no topo
|
||||||
|
self.renderizar_breadcrumb(ctx);
|
||||||
|
|
||||||
// Determinar qual tela exibir sem borrar self.estado
|
// Determinar qual tela exibir sem borrar self.estado
|
||||||
let tela_id = self.tela_atual();
|
let tela_id = self.tela_atual();
|
||||||
|
|
||||||
// Renderizar a tela atual
|
// Renderizar a tela atual
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
egui::CentralPanel::default().show(ctx, |ui| match tela_id {
|
||||||
match tela_id {
|
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
|
||||||
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
|
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
|
||||||
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
|
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||||
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
||||||
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||||
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
|
||||||
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
|
6 => {
|
||||||
_ => {}
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("⏳ Analisando... aguarde.")
|
||||||
|
.size(22.0)
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||||
use crate::domain::entities::layout::TipoArquivo;
|
use crate::application::usecases::executar_analise::{
|
||||||
use crate::ui::app::{App, EstadoApp};
|
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||||
|
};
|
||||||
|
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||||
|
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
/// Renderiza a tela de configuração de colunas.
|
/// Renderiza a tela de configuração de colunas.
|
||||||
@@ -12,6 +15,51 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Seletor de layout
|
||||||
|
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||||
|
let opcoes_layout: Vec<(i64, String)> = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.tipo() == tipo_atual)
|
||||||
|
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||||
|
.collect();
|
||||||
|
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Layout:");
|
||||||
|
egui::ComboBox::from_id_salt("combo_layouts_config")
|
||||||
|
.selected_text(if nome_layout_atual.is_empty() {
|
||||||
|
"— Selecionar layout —"
|
||||||
|
} else {
|
||||||
|
&nome_layout_atual
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (id, nome) in &opcoes_layout {
|
||||||
|
if ui
|
||||||
|
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.nome_layout_atual = nome.clone();
|
||||||
|
if let Some(layout) =
|
||||||
|
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||||
|
{
|
||||||
|
let layout = layout.clone();
|
||||||
|
match &layout {
|
||||||
|
Layout::Csv { config, .. } => {
|
||||||
|
app.layout_csv_atual = config.clone();
|
||||||
|
}
|
||||||
|
Layout::Xlsx { config, .. } => {
|
||||||
|
app.layout_xlsx_atual = config.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
match app.tipo_arquivo_atual.clone() {
|
match app.tipo_arquivo_atual.clone() {
|
||||||
@@ -19,6 +67,14 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pré-visualização do arquivo
|
||||||
|
if let Some(preview) = &app.preview_arquivo.clone() {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(4.0);
|
||||||
|
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||||
|
}
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
@@ -34,15 +90,29 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("← Voltar").clicked() {
|
if ui.button("< Voltar").clicked() {
|
||||||
app.estado = EstadoApp::Importando;
|
app.estado = EstadoApp::Importando;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||||
if ui.button("▶ Importar e Analisar").clicked() {
|
if ui.button("▶ Importar e Analisar").clicked() {
|
||||||
executar_importacao(app);
|
executar_importacao(app, ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if valido && !app.notas_importadas.is_empty() {
|
||||||
|
if ui
|
||||||
|
.button("🔄 Reanalisar")
|
||||||
|
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.executar_analise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("💾 Salvar como layout...").clicked() {
|
||||||
|
app.exibir_modal_salvar_layout();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,19 +130,48 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
'\t' => "Tabulação (Tab)",
|
'\t' => "Tabulação (Tab)",
|
||||||
_ => "Outro",
|
_ => "Outro",
|
||||||
};
|
};
|
||||||
|
let mut delim_mudou = false;
|
||||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||||
.selected_text(delim_str)
|
.selected_text(delim_str)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
if ui.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)").clicked() {
|
if ui
|
||||||
|
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
app.layout_csv_atual.delimitador = ',';
|
app.layout_csv_atual.delimitador = ',';
|
||||||
|
delim_mudou = true;
|
||||||
}
|
}
|
||||||
if ui.selectable_label(app.layout_csv_atual.delimitador == ';', "Ponto e vírgula (;)").clicked() {
|
if ui
|
||||||
|
.selectable_label(
|
||||||
|
app.layout_csv_atual.delimitador == ';',
|
||||||
|
"Ponto e vírgula (;)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
app.layout_csv_atual.delimitador = ';';
|
app.layout_csv_atual.delimitador = ';';
|
||||||
|
delim_mudou = true;
|
||||||
}
|
}
|
||||||
if ui.selectable_label(app.layout_csv_atual.delimitador == '\t', "Tabulação (Tab)").clicked() {
|
if ui
|
||||||
|
.selectable_label(
|
||||||
|
app.layout_csv_atual.delimitador == '\t',
|
||||||
|
"Tabulação (Tab)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
app.layout_csv_atual.delimitador = '\t';
|
app.layout_csv_atual.delimitador = '\t';
|
||||||
|
delim_mudou = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if delim_mudou {
|
||||||
|
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||||
|
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||||
|
caminho,
|
||||||
|
app.layout_csv_atual.delimitador as u8,
|
||||||
|
&app.layout_csv_atual.encoding.clone(),
|
||||||
|
5,
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Encoding
|
// Encoding
|
||||||
@@ -81,10 +180,19 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
egui::ComboBox::from_id_salt("combo_encoding")
|
egui::ComboBox::from_id_salt("combo_encoding")
|
||||||
.selected_text(&app.layout_csv_atual.encoding)
|
.selected_text(&app.layout_csv_atual.encoding)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
if ui.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8").clicked() {
|
if ui
|
||||||
|
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
app.layout_csv_atual.encoding = "utf-8".to_string();
|
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() {
|
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();
|
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -104,11 +212,32 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
ui.label("Mapeamento de Colunas (índice base 0)");
|
ui.label("Mapeamento de Colunas (índice base 0)");
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
campo_indice(ui, "Número (obrigatório):", &mut app.layout_csv_atual.indice_numero);
|
campo_indice(
|
||||||
campo_indice(ui, "Série (obrigatório):", &mut app.layout_csv_atual.indice_serie);
|
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(
|
||||||
campo_indice_opcional(ui, "Data (opcional):", &mut app.layout_csv_atual.indice_data);
|
ui,
|
||||||
|
"Valor (opcional):",
|
||||||
|
&mut app.layout_csv_atual.indice_valor,
|
||||||
|
);
|
||||||
|
campo_indice_opcional(
|
||||||
|
ui,
|
||||||
|
"Data (opcional):",
|
||||||
|
&mut app.layout_csv_atual.indice_data,
|
||||||
|
);
|
||||||
|
campo_indice_opcional(
|
||||||
|
ui,
|
||||||
|
"Tipo Documento (opcional):",
|
||||||
|
&mut app.layout_csv_atual.indice_documento_tipo,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +270,27 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
|||||||
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
campo_letra_linha(ui, "Número (obrigatório):", &mut app.layout_xlsx_atual.pos_numero);
|
campo_letra_linha(
|
||||||
campo_letra_linha(ui, "Série (obrigatório):", &mut app.layout_xlsx_atual.pos_serie);
|
ui,
|
||||||
campo_letra_linha_opcional(ui, "Valor (opcional):", &mut app.layout_xlsx_atual.pos_valor);
|
"Número (obrigatório):",
|
||||||
|
&mut app.layout_xlsx_atual.pos_numero,
|
||||||
|
);
|
||||||
|
campo_letra_linha(
|
||||||
|
ui,
|
||||||
|
"Série (obrigatório):",
|
||||||
|
&mut app.layout_xlsx_atual.pos_serie,
|
||||||
|
);
|
||||||
|
campo_letra_linha_opcional(
|
||||||
|
ui,
|
||||||
|
"Valor (opcional):",
|
||||||
|
&mut app.layout_xlsx_atual.pos_valor,
|
||||||
|
);
|
||||||
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
||||||
|
campo_letra_linha_opcional(
|
||||||
|
ui,
|
||||||
|
"Tipo Documento (opcional):",
|
||||||
|
&mut app.layout_xlsx_atual.pos_documento_tipo,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
|
|||||||
if let Some(d) = c.indice_data {
|
if let Some(d) = c.indice_data {
|
||||||
indices.push(("Data".to_string(), d));
|
indices.push(("Data".to_string(), d));
|
||||||
}
|
}
|
||||||
|
if let Some(t) = c.indice_documento_tipo {
|
||||||
|
indices.push(("Tipo Documento".to_string(), t));
|
||||||
|
}
|
||||||
verificar_duplicados(&indices, &mut erros);
|
verificar_duplicados(&indices, &mut erros);
|
||||||
}
|
}
|
||||||
TipoArquivo::Xlsx => {
|
TipoArquivo::Xlsx => {
|
||||||
@@ -245,51 +394,61 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn executar_importacao(app: &mut App) {
|
fn executar_importacao(app: &mut App, ctx: &egui::Context) {
|
||||||
let caminho = match &app.caminho_arquivo {
|
let caminho = match &app.caminho_arquivo {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let resultado = match app.tipo_arquivo_atual.clone() {
|
let tipo = app.tipo_arquivo_atual.clone();
|
||||||
TipoArquivo::Csv => {
|
let layout_csv = app.layout_csv_atual.clone();
|
||||||
importar_csv(&caminho, &app.layout_csv_atual)
|
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
TipoArquivo::Xlsx => {
|
|
||||||
importar_xlsx(&caminho, &app.layout_xlsx_atual)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match resultado {
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
Ok(res) => {
|
app.resultado_pendente = Some(rx);
|
||||||
if res.notas.is_empty() {
|
app.estado = EstadoApp::Analisando;
|
||||||
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
ctx.request_repaint();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let avisos = res.avisos.clone();
|
std::thread::spawn(move || {
|
||||||
app.notas_importadas = res.notas;
|
// 1. Importar arquivo
|
||||||
app.avisos_importacao = if avisos.tem_avisos() {
|
let res_importacao = match tipo {
|
||||||
Some(avisos.clone())
|
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
||||||
} else {
|
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
||||||
None
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Executar análise
|
let res = match res_importacao {
|
||||||
app.executar_analise();
|
Err(e) => ResultadoPendente::Erro(e),
|
||||||
|
Ok(importado) => {
|
||||||
|
if importado.notas.is_empty() {
|
||||||
|
ResultadoPendente::Vazio
|
||||||
|
} else {
|
||||||
|
let avisos = importado.avisos.clone();
|
||||||
|
let notas = importado.notas;
|
||||||
|
|
||||||
// Exibir avisos consolidados após análise
|
// 2. Pré-análise
|
||||||
if let Some(av) = &app.avisos_importacao {
|
let pre = pre_analisar(¬as);
|
||||||
if av.tem_avisos() {
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
let linhas = av.linhas_para_exibir().join("\n");
|
|
||||||
app.exibir_aviso("Avisos de Importação", linhas);
|
if !excessivos.is_empty() {
|
||||||
|
ResultadoPendente::AguardandoConfirmacao {
|
||||||
|
pre,
|
||||||
|
series_excessivas: excessivos,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 3. Expandir faltantes
|
||||||
|
let resultado = expandir_analise(pre, ¬as);
|
||||||
|
ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos: Some(avisos),
|
||||||
|
notas: Some(notas),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
let _ = tx.send(res);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-30
@@ -1,11 +1,14 @@
|
|||||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx};
|
use crate::application::usecases::executar_analise::{
|
||||||
use crate::domain::entities::layout::TipoArquivo;
|
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||||
use crate::ui::app::{App, EstadoApp};
|
};
|
||||||
|
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
|
||||||
|
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||||
|
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// Renderiza a tela de importação de arquivos.
|
/// Renderiza a tela de importação de arquivos.
|
||||||
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
|
|
||||||
@@ -22,10 +25,10 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
if ui.button("📂 Selecionar arquivo...").clicked() {
|
if ui.button("📂 Selecionar arquivo...").clicked() {
|
||||||
if let Some(caminho) = rfd::FileDialog::new()
|
if let Some(caminho) = rfd::FileDialog::new()
|
||||||
.add_filter("Planilhas", &["csv", "xlsx"])
|
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
||||||
.pick_file()
|
.pick_file()
|
||||||
{
|
{
|
||||||
on_arquivo_selecionado(app, caminho);
|
on_arquivo_selecionado(app, ctx, caminho);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -53,9 +56,14 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
|||||||
})
|
})
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
for (id, nome) in &opcoes_layout {
|
for (id, nome) in &opcoes_layout {
|
||||||
if ui.selectable_label(nome_layout_atual == *nome, nome.as_str()).clicked() {
|
if ui
|
||||||
|
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
app.nome_layout_atual = nome.clone();
|
app.nome_layout_atual = nome.clone();
|
||||||
if let Some(layout) = app.layouts_salvos.iter().find(|l| l.id() == Some(*id)) {
|
if let Some(layout) =
|
||||||
|
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||||
|
{
|
||||||
let layout = layout.clone();
|
let layout = layout.clone();
|
||||||
aplicar_layout(app, &layout);
|
aplicar_layout(app, &layout);
|
||||||
}
|
}
|
||||||
@@ -78,7 +86,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Renderiza a tela de seleção de aba (XLSX).
|
/// Renderiza a tela de seleção de aba (XLSX).
|
||||||
pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||||
ui.heading("Selecionar Aba da Planilha");
|
ui.heading("Selecionar Aba da Planilha");
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
|
|
||||||
@@ -89,33 +97,100 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// --- Seleção de preset ---
|
||||||
|
let opcoes_layout: Vec<(i64, String)> = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||||
|
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||||
|
.collect();
|
||||||
|
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||||
|
|
||||||
|
if !opcoes_layout.is_empty() {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Layout:");
|
||||||
|
egui::ComboBox::from_id_salt("combo_layouts_aba")
|
||||||
|
.selected_text(if nome_layout_atual.is_empty() {
|
||||||
|
"— Selecionar layout —"
|
||||||
|
} else {
|
||||||
|
&nome_layout_atual
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (id, nome) in &opcoes_layout {
|
||||||
|
if ui
|
||||||
|
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.nome_layout_atual = nome.clone();
|
||||||
|
if let Some(layout) =
|
||||||
|
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||||
|
{
|
||||||
|
let layout = layout.clone();
|
||||||
|
aplicar_layout(app, &layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
ui.label("Selecione a aba a processar:");
|
ui.label("Selecione a aba a processar:");
|
||||||
|
|
||||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||||
for aba in &abas {
|
for aba in &abas {
|
||||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||||
app.layout_xlsx_atual.aba = aba.clone();
|
app.layout_xlsx_atual.aba = aba.clone();
|
||||||
|
// Gerar pré-visualização da aba selecionada
|
||||||
|
app.preview_arquivo =
|
||||||
|
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pré-visualização da aba selecionada
|
||||||
|
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||||
|
if let Some(preview) = &app.preview_arquivo {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||||
if ui.button("▶ Configurar Colunas").clicked() {
|
ui.horizontal(|ui| {
|
||||||
app.nome_arquivo = caminho
|
// Se há preset selecionado, oferecer processamento direto
|
||||||
.file_name()
|
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
if tem_preset {
|
||||||
.unwrap_or_default();
|
let caminho_clone = caminho.clone();
|
||||||
app.caminho_arquivo = Some(caminho);
|
if ui.button("▶ Processar").clicked() {
|
||||||
app.estado = EstadoApp::ConfigurandoColunas;
|
app.nome_arquivo = caminho_clone
|
||||||
}
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
app.caminho_arquivo = Some(caminho_clone);
|
||||||
|
disparar_analise(app, ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("⚙ Configurar Colunas").clicked() {
|
||||||
|
app.nome_arquivo = caminho
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
app.caminho_arquivo = Some(caminho);
|
||||||
|
app.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if ui.button("← Voltar").clicked() {
|
if ui.button("< Voltar").clicked() {
|
||||||
app.estado = EstadoApp::Importando;
|
app.estado = EstadoApp::Importando;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
|
||||||
let extensao = caminho
|
let extensao = caminho
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|e| e.to_str())
|
.and_then(|e| e.to_str())
|
||||||
@@ -130,19 +205,51 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
|||||||
match extensao.as_str() {
|
match extensao.as_str() {
|
||||||
"csv" => {
|
"csv" => {
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
app.caminho_arquivo = Some(caminho);
|
app.caminho_arquivo = Some(caminho.clone());
|
||||||
app.notas_importadas.clear();
|
app.notas_importadas.clear();
|
||||||
|
// Gerar pré-visualização com o delimitador atual
|
||||||
|
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||||
|
&caminho,
|
||||||
|
app.layout_csv_atual.delimitador as u8,
|
||||||
|
&app.layout_csv_atual.encoding.clone(),
|
||||||
|
5,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
"xlsx" => {
|
"xlsx" | "xls" => {
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
match listar_abas_xlsx(&caminho) {
|
match listar_abas_xlsx(&caminho) {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
app.abas_xlsx = info.abas.clone();
|
|
||||||
app.estado = EstadoApp::SelecionandoAba {
|
|
||||||
abas: info.abas,
|
|
||||||
caminho: caminho.clone(),
|
|
||||||
};
|
|
||||||
app.notas_importadas.clear();
|
app.notas_importadas.clear();
|
||||||
|
|
||||||
|
// Verificar se há preset XLSX ativo com aba compatível
|
||||||
|
let preset_aba = if !app.nome_layout_atual.is_empty() {
|
||||||
|
let aba = app.layout_xlsx_atual.aba.clone();
|
||||||
|
if !aba.is_empty() && info.abas.contains(&aba) {
|
||||||
|
Some(aba)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(aba) = preset_aba {
|
||||||
|
// Fluxo rápido: aba do preset existe → disparar análise direto
|
||||||
|
app.layout_xlsx_atual.aba = aba.clone();
|
||||||
|
app.caminho_arquivo = Some(caminho.clone());
|
||||||
|
app.abas_xlsx = info.abas;
|
||||||
|
app.preview_arquivo =
|
||||||
|
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
|
||||||
|
disparar_analise(app, ctx);
|
||||||
|
} else {
|
||||||
|
// Fluxo normal: exibir tela de seleção de aba
|
||||||
|
app.abas_xlsx = info.abas.clone();
|
||||||
|
app.estado = EstadoApp::SelecionandoAba {
|
||||||
|
abas: info.abas,
|
||||||
|
caminho: caminho.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||||
@@ -150,18 +257,70 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
app.exibir_erro("Formato de arquivo não suportado. Use CSV ou XLSX.");
|
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
|
/// Dispara a análise assíncrona com o layout XLSX atual.
|
||||||
|
/// Usado tanto no fluxo rápido (preset com aba compatível) quanto no botão "Processar" da tela de aba.
|
||||||
|
fn disparar_analise(app: &mut App, ctx: &Context) {
|
||||||
|
let caminho = match &app.caminho_arquivo {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
app.resultado_pendente = Some(rx);
|
||||||
|
app.estado = EstadoApp::Analisando;
|
||||||
|
ctx.request_repaint();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let res_importacao = importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string());
|
||||||
|
|
||||||
|
let res = match res_importacao {
|
||||||
|
Err(e) => ResultadoPendente::Erro(e),
|
||||||
|
Ok(importado) => {
|
||||||
|
if importado.notas.is_empty() {
|
||||||
|
ResultadoPendente::Vazio
|
||||||
|
} else {
|
||||||
|
let avisos = importado.avisos.clone();
|
||||||
|
let notas = importado.notas;
|
||||||
|
|
||||||
|
let pre = pre_analisar(¬as);
|
||||||
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
|
|
||||||
|
if !excessivos.is_empty() {
|
||||||
|
ResultadoPendente::AguardandoConfirmacao {
|
||||||
|
pre,
|
||||||
|
series_excessivas: excessivos,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let resultado = expandir_analise(pre, ¬as);
|
||||||
|
ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos: Some(avisos),
|
||||||
|
notas: Some(notas),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
||||||
match layout {
|
match layout {
|
||||||
crate::domain::entities::layout::Layout::Csv { config, .. } => {
|
Layout::Csv { config, .. } => {
|
||||||
app.layout_csv_atual = config.clone();
|
app.layout_csv_atual = config.clone();
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
}
|
}
|
||||||
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
|
Layout::Xlsx { config, .. } => {
|
||||||
app.layout_xlsx_atual = config.clone();
|
app.layout_xlsx_atual = config.clone();
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-12
@@ -1,7 +1,7 @@
|
|||||||
use crate::application::usecases::layouts::{
|
use crate::application::usecases::layouts::{
|
||||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||||
};
|
};
|
||||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo};
|
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
|
||||||
use crate::domain::errors::ErroLayout;
|
use crate::domain::errors::ErroLayout;
|
||||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
@@ -12,7 +12,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("← Voltar").clicked() {
|
if ui.button("< Voltar").clicked() {
|
||||||
app.estado = EstadoApp::Importando;
|
app.estado = EstadoApp::Importando;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,7 +83,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
fn renderizar_secao_layouts(
|
fn renderizar_secao_layouts(
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
ctx: &Context,
|
_ctx: &Context,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
titulo: &str,
|
titulo: &str,
|
||||||
layouts: &[Layout],
|
layouts: &[Layout],
|
||||||
@@ -167,6 +167,35 @@ fn salvar_layout_atual(app: &mut App) {
|
|||||||
app.recarregar_layouts();
|
app.recarregar_layouts();
|
||||||
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
||||||
}
|
}
|
||||||
|
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||||
|
let id_existente = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.nome() == nome)
|
||||||
|
.and_then(|l| l.id());
|
||||||
|
if let Some(id) = id_existente {
|
||||||
|
let layout_com_id = match app.tipo_arquivo_atual.clone() {
|
||||||
|
TipoArquivo::Csv => Layout::Csv {
|
||||||
|
id: Some(id),
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: app.layout_csv_atual.clone(),
|
||||||
|
},
|
||||||
|
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||||
|
id: Some(id),
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: app.layout_xlsx_atual.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
app.exibir_confirmacao(
|
||||||
|
"Conflito de nome",
|
||||||
|
format!(
|
||||||
|
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||||
|
nome
|
||||||
|
),
|
||||||
|
AcaoModal::SobrescreverLayout(layout_com_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
||||||
}
|
}
|
||||||
@@ -182,15 +211,40 @@ fn importar_json(app: &mut App, conteudo: &str) {
|
|||||||
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
||||||
}
|
}
|
||||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||||
// Exibir opções: sobrescrever ou cancelar
|
// Recriar o layout parseado para passá-lo no modal de confirmação.
|
||||||
app.exibir_aviso(
|
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
|
||||||
"Conflito de nome",
|
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
|
||||||
format!(
|
.ok()
|
||||||
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
|
.and_then(|json_repr| Layout::try_from(json_repr).ok());
|
||||||
nome
|
|
||||||
),
|
let id_existente = app
|
||||||
);
|
.layouts_salvos
|
||||||
// TODO: implementar fluxo completo de sobrescrever com entrada de novo nome
|
.iter()
|
||||||
|
.find(|l| l.nome() == nome)
|
||||||
|
.and_then(|l| l.id());
|
||||||
|
|
||||||
|
match (parsed, id_existente) {
|
||||||
|
(Some(mut layout), Some(id)) => {
|
||||||
|
match &mut layout {
|
||||||
|
Layout::Csv { id: i, .. } => *i = Some(id),
|
||||||
|
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
||||||
|
}
|
||||||
|
app.exibir_confirmacao(
|
||||||
|
"Conflito de nome",
|
||||||
|
format!(
|
||||||
|
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||||
|
nome
|
||||||
|
),
|
||||||
|
AcaoModal::SobrescreverLayout(layout),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.exibir_erro(format!(
|
||||||
|
"Conflito de nome: layout '{}' já existe.",
|
||||||
|
nome
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
||||||
|
|||||||
@@ -2,3 +2,71 @@ pub mod configuracao_colunas;
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod layouts;
|
pub mod layouts;
|
||||||
pub mod resultado;
|
pub mod resultado;
|
||||||
|
|
||||||
|
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
|
||||||
|
fn indice_para_letra(mut idx: usize) -> String {
|
||||||
|
let mut resultado = String::new();
|
||||||
|
loop {
|
||||||
|
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||||
|
if idx < 26 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx = idx / 26 - 1;
|
||||||
|
}
|
||||||
|
resultado
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza uma tabela simples de pré-visualização do arquivo.
|
||||||
|
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
|
||||||
|
/// seguida pelas linhas de dados.
|
||||||
|
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
|
||||||
|
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||||
|
if num_colunas == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"Pré-visualização ({} linha(s))",
|
||||||
|
linhas.len()
|
||||||
|
))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.add_space(2.0);
|
||||||
|
|
||||||
|
egui::ScrollArea::horizontal()
|
||||||
|
.id_salt("scroll_preview")
|
||||||
|
.max_height(160.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
egui::Grid::new("tabela_preview")
|
||||||
|
.striped(true)
|
||||||
|
.spacing([8.0, 2.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
|
||||||
|
for i in 0..num_colunas {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
|
||||||
|
.strong()
|
||||||
|
.monospace(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
// Linhas de dados
|
||||||
|
for linha in linhas {
|
||||||
|
for col in 0..num_colunas {
|
||||||
|
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let texto = if celula.chars().count() > 30 {
|
||||||
|
let truncado: String = celula.chars().take(30).collect();
|
||||||
|
format!("{}...", truncado)
|
||||||
|
} else {
|
||||||
|
celula.to_string()
|
||||||
|
};
|
||||||
|
ui.label(egui::RichText::new(texto).monospace().small());
|
||||||
|
}
|
||||||
|
ui.end_row();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+106
-56
@@ -1,10 +1,10 @@
|
|||||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::resultado_analise::ResultadoAnalise,
|
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
||||||
services::parser_monetario::formatar_valor_br,
|
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
||||||
};
|
};
|
||||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||||
use crate::ui::app::{App, EstadoApp};
|
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||||
@@ -21,10 +21,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("← Nova Análise").clicked() {
|
if ui.button("< Nova Análise").clicked() {
|
||||||
app.estado = EstadoApp::Importando;
|
app.exibir_confirmacao(
|
||||||
app.notas_importadas.clear();
|
"Nova Análise",
|
||||||
return;
|
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
|
||||||
|
AcaoModal::ConfirmarNovaAnalise,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||||
@@ -32,6 +34,15 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pode_reanalisar = app.caminho_arquivo.is_some();
|
||||||
|
if ui
|
||||||
|
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
|
||||||
|
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.reimportar_e_analisar(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if ui.button("📄 Exportar PDF").clicked() {
|
if ui.button("📄 Exportar PDF").clicked() {
|
||||||
exportar_para_pdf(app, &resultado);
|
exportar_para_pdf(app, &resultado);
|
||||||
}
|
}
|
||||||
@@ -58,12 +69,6 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
// Totais
|
|
||||||
renderizar_totais(ui, &resultado);
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
// Faltantes
|
// Faltantes
|
||||||
renderizar_faltantes(ui, app, &resultado);
|
renderizar_faltantes(ui, app, &resultado);
|
||||||
|
|
||||||
@@ -72,6 +77,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
// Duplicatas
|
// Duplicatas
|
||||||
renderizar_duplicatas(ui, app, &resultado);
|
renderizar_duplicatas(ui, app, &resultado);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Totais
|
||||||
|
renderizar_totais(ui, &resultado);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,15 +95,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
|||||||
formatar_valor_br(&resultado.soma_total)
|
formatar_valor_br(&resultado.soma_total)
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let soma = &resultado.soma_por_serie[serie];
|
let soma = &resultado.soma_por_serie[chave];
|
||||||
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
" Série {}: {} nota(s) — R$ {}",
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
serie,
|
chave.label(),
|
||||||
total_notas,
|
total_notas,
|
||||||
formatar_valor_br(soma)
|
formatar_valor_br(soma)
|
||||||
));
|
));
|
||||||
@@ -102,12 +113,9 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
|||||||
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
let total_faltantes = resultado.total_faltantes();
|
let total_faltantes = resultado.total_faltantes();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
|
||||||
"Notas Faltantes ({} total)",
|
.heading()
|
||||||
total_faltantes
|
.strong(),
|
||||||
))
|
|
||||||
.heading()
|
|
||||||
.strong(),
|
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
@@ -116,22 +124,44 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let faltantes = &resultado.faltantes_por_serie[serie];
|
let faltantes = &resultado.faltantes_por_serie[chave];
|
||||||
if faltantes.is_empty() {
|
if faltantes.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.label(format!(
|
// Estatística de completude por série
|
||||||
"Série {} — {} faltante(s):",
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
serie,
|
let total_esperado = total_notas + faltantes.len();
|
||||||
faltantes.len()
|
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
|
||||||
));
|
|
||||||
|
|
||||||
// Paginação
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!(
|
||||||
|
"Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):",
|
||||||
|
chave.label(),
|
||||||
|
faltantes.len(),
|
||||||
|
total_notas,
|
||||||
|
total_esperado,
|
||||||
|
percentual,
|
||||||
|
));
|
||||||
|
if ui
|
||||||
|
.button("📋 Copiar")
|
||||||
|
.on_hover_text("Copiar todos os números faltantes")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
let texto = faltantes
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
ui.ctx().copy_text(texto);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paginação (por faltante individual)
|
||||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
if app.pagina_faltantes >= total_paginas {
|
if app.pagina_faltantes >= total_paginas {
|
||||||
app.pagina_faltantes = 0;
|
app.pagina_faltantes = 0;
|
||||||
@@ -140,8 +170,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
|||||||
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
||||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||||
|
|
||||||
for numero in &faltantes[inicio..fim] {
|
// Exibir grupos contíguos da página atual
|
||||||
ui.label(format!(" • {}", numero));
|
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
||||||
|
if a == b {
|
||||||
|
ui.label(format!(" • {}", a));
|
||||||
|
} else {
|
||||||
|
ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if total_paginas > 1 {
|
if total_paginas > 1 {
|
||||||
@@ -165,12 +200,9 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
|||||||
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
let total_dup = resultado.total_duplicatas();
|
let total_dup = resultado.total_duplicatas();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
|
||||||
"Notas Duplicadas ({} grupo(s))",
|
.heading()
|
||||||
total_dup
|
.strong(),
|
||||||
))
|
|
||||||
.heading()
|
|
||||||
.strong(),
|
|
||||||
);
|
);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
|
|
||||||
@@ -179,23 +211,36 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
series.sort();
|
chaves.sort();
|
||||||
|
|
||||||
for serie in series {
|
for chave in chaves {
|
||||||
let duplicatas = &resultado.duplicadas_por_serie[serie];
|
let duplicatas = &resultado.duplicadas_por_serie[chave];
|
||||||
if duplicatas.is_empty() {
|
if duplicatas.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.label(format!(
|
ui.horizontal(|ui| {
|
||||||
"Série {} — {} grupo(s) duplicado(s):",
|
ui.label(format!(
|
||||||
serie,
|
"Série {} — {} grupo(s) duplicado(s):",
|
||||||
duplicatas.len()
|
chave.label(),
|
||||||
));
|
duplicatas.len()
|
||||||
|
));
|
||||||
|
if ui
|
||||||
|
.button("📋 Copiar")
|
||||||
|
.on_hover_text("Copiar números duplicados")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
let texto = duplicatas
|
||||||
|
.iter()
|
||||||
|
.map(|(n, c)| format!("{} ({}x)", n, c))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
ui.ctx().copy_text(texto);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let total_paginas =
|
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
|
||||||
if app.pagina_duplicatas >= total_paginas {
|
if app.pagina_duplicatas >= total_paginas {
|
||||||
app.pagina_duplicatas = 0;
|
app.pagina_duplicatas = 0;
|
||||||
}
|
}
|
||||||
@@ -206,7 +251,9 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
|||||||
for (numero, count) in &duplicatas[inicio..fim] {
|
for (numero, count) in &duplicatas[inicio..fim] {
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
" • NF {} / Série {} — {} ocorrências",
|
" • NF {} / Série {} — {} ocorrências",
|
||||||
numero, serie, count
|
numero,
|
||||||
|
chave.label(),
|
||||||
|
count
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +296,10 @@ fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
|||||||
&caminho,
|
&caminho,
|
||||||
) {
|
) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
|
app.exibir_aviso(
|
||||||
|
"Sucesso",
|
||||||
|
format!("PDF exportado para: {}", caminho.display()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||||
|
|||||||
Reference in New Issue
Block a user