feat: implement reimport and analysis functionality for XLSX files
- Added `reimportar_e_analisar` method to `App` struct to handle reimporting and analyzing files. - Preserved previous analysis results to restore in case of errors or empty imports. - Updated UI to include a button for reanalyzing the current file. - Enhanced file selection logic to allow direct processing if a compatible preset is selected. - Refactored import logic to streamline the analysis process for both CSV and XLSX files. - Improved user feedback with appropriate error and success messages during file operations.
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
# F-03 — Preview Interpretado de Colunas
|
||||
|
||||
> Análise feita em 03/03/2026. O preview bruto já existe; este documento descreve apenas o que falta.
|
||||
|
||||
## O que já existe
|
||||
|
||||
A tela de configuração de colunas (`src/ui/screens/configuracao_colunas.rs`) exibe as primeiras 5 linhas do arquivo como uma tabela de dados brutos, com cabeçalhos A(0), B(1), C(2)... O preview é atualizado quando o delimitador muda (CSV).
|
||||
|
||||
- `app.preview_arquivo: Option<Vec<Vec<String>>>` — `src/ui/app.rs:131`
|
||||
- `renderizar_tabela_preview()` — `src/ui/screens/mod.rs:22`
|
||||
- Recálculo ao mudar delimitador — `src/ui/screens/configuracao_colunas.rs:165`
|
||||
|
||||
## O que falta implementar
|
||||
|
||||
### 1. Struct `LinhaPreview`
|
||||
|
||||
Resultado do parse tentativo de cada linha usando o layout atual.
|
||||
|
||||
```rust
|
||||
// src/application/usecases/pre_visualizar.rs (arquivo novo)
|
||||
pub struct LinhaPreview {
|
||||
pub numero: Result<u64, String>,
|
||||
pub serie: Result<String, String>,
|
||||
pub valor: Option<Result<rust_decimal::Decimal, String>>,
|
||||
pub data: Option<Result<String, String>>,
|
||||
pub documento_tipo: Option<Result<String, String>>,
|
||||
}
|
||||
|
||||
pub fn pre_visualizar_csv(
|
||||
caminho: &Path,
|
||||
layout: &LayoutCsv,
|
||||
n_linhas: usize,
|
||||
) -> Vec<LinhaPreview>
|
||||
|
||||
pub fn pre_visualizar_xlsx(
|
||||
caminho: &Path,
|
||||
layout: &LayoutXlsx,
|
||||
n_linhas: usize,
|
||||
) -> Vec<LinhaPreview>
|
||||
```
|
||||
|
||||
Internamente, reutiliza a lógica de parse já existente em `importar_csv` / `importar_xlsx`, mas sem abortar na primeira falha — retorna `Err(mensagem)` por campo.
|
||||
|
||||
---
|
||||
|
||||
### 2. Estado no `App`
|
||||
|
||||
```rust
|
||||
// src/ui/app.rs
|
||||
pub preview_interpretado: Option<Vec<LinhaPreview>>,
|
||||
```
|
||||
|
||||
Recalculado sempre que qualquer campo de configuração muda (não só o delimitador). Gatilhos em `configuracao_colunas.rs`:
|
||||
|
||||
- Mudança de delimitador (já atualiza preview bruto; adicionar aqui)
|
||||
- Mudança de encoding
|
||||
- Mudança de qualquer `DragValue` de índice (via `.changed()`)
|
||||
- Mudança de qualquer `text_edit` de posição XLSX (via `.changed()`)
|
||||
|
||||
---
|
||||
|
||||
### 3. Widget `renderizar_tabela_preview_interpretado`
|
||||
|
||||
Substitui ou complementa `renderizar_tabela_preview` na tela de configuração. Exibe uma tabela com colunas fixas pelos campos mapeados (Número, Série, Valor, Data, Tipo), não pelas colunas do arquivo.
|
||||
|
||||
Comportamento por célula:
|
||||
- `Ok(v)` → texto verde ou neutro com o valor parseado
|
||||
- `Err(msg)` → fundo vermelho claro, texto com o erro curto (ex: `"não é número"`)
|
||||
- Campo opcional não mapeado → célula vazia/cinza
|
||||
|
||||
```
|
||||
| Número | Série | Valor | Data | Tipo |
|
||||
|--------|-------|----------|------------|------|
|
||||
| 1001 | 001 | 1.250,00 | 2024-01-05 | |
|
||||
| ✗ "abc"| 001 | 980,50 | 2024-01-06 | |
|
||||
| 1003 | 001 | ✗ "" | 2024-01-07 | |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Atualização reativa nos campos de índice
|
||||
|
||||
Atualmente, mudar um `DragValue` de índice não recalcula o preview. É necessário capturar `.changed()` em cada campo e disparar o recálculo.
|
||||
|
||||
Exemplo para CSV em `configuracao_colunas.rs`:
|
||||
|
||||
```rust
|
||||
let changed = campo_indice_rastreado(ui, "Número:", &mut app.layout_csv_atual.indice_numero);
|
||||
if changed {
|
||||
recalcular_preview_interpretado(app);
|
||||
}
|
||||
```
|
||||
|
||||
Alternativa mais simples: comparar o layout no início e no fim do frame e recalcular se diferente (evita modificar cada campo individualmente).
|
||||
|
||||
---
|
||||
|
||||
## Escopo de arquivos afetados
|
||||
|
||||
| Arquivo | Mudança |
|
||||
|---|---|
|
||||
| `src/application/usecases/pre_visualizar.rs` | Criar — lógica de parse tentativo |
|
||||
| `src/ui/app.rs` | Adicionar campo `preview_interpretado` |
|
||||
| `src/ui/screens/configuracao_colunas.rs` | Adicionar gatilhos de recálculo e chamar novo widget |
|
||||
| `src/ui/screens/mod.rs` | Adicionar `renderizar_tabela_preview_interpretado()` |
|
||||
|
||||
Não são necessárias novas dependências. O parse tentativo reutiliza funções já existentes nos readers.
|
||||
|
||||
---
|
||||
|
||||
## O que NÃO precisa mudar
|
||||
|
||||
- O preview bruto (`renderizar_tabela_preview`) pode ser mantido ou removido — a tabela interpretada é mais informativa.
|
||||
- Nenhuma mudança em domínio, banco ou PDF.
|
||||
- O fluxo de importação real não é alterado.
|
||||
|
||||
---
|
||||
|
||||
## Esforço reavaliado
|
||||
|
||||
O backlog estimava 4–6h. Com o preview bruto já existindo e a lógica de parse já implementada nos readers, o esforço real é de **2–3h**:
|
||||
|
||||
- 45min — `pre_visualizar.rs` (reutiliza lógica dos readers)
|
||||
- 30min — estado no `App` + gatilhos de recálculo
|
||||
- 1h — widget `renderizar_tabela_preview_interpretado`
|
||||
- 30min — testes e ajustes visuais
|
||||
@@ -1,8 +1,8 @@
|
||||
# PRD — Comparador de Notas
|
||||
|
||||
**Versão:** 1.6
|
||||
**Data:** 02/03/2026
|
||||
**Status:** Planejamento
|
||||
**Versão:** 1.7
|
||||
**Data:** 03/03/2026
|
||||
**Status:** Implementado (MVP)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
||||
* Notas faltantes
|
||||
* Notas duplicadas
|
||||
* Soma total dos valores
|
||||
* Agrupamento por série
|
||||
* Agrupamento por série e tipo de documento
|
||||
|
||||
## 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 duplicidades
|
||||
* 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
|
||||
* 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)
|
||||
* Carregar layouts salvos
|
||||
* Selecionar layout por menu dropdown
|
||||
* Excluir layouts
|
||||
* Exportar layout para JSON
|
||||
* Importar layout de JSON
|
||||
* Reanalisar arquivo sem reconfiguração (reimporta o mesmo arquivo com o layout atual)
|
||||
* Análise em background thread (UI não bloqueia durante importação e análise)
|
||||
|
||||
## 4.2 Não Incluído (MVP)
|
||||
|
||||
@@ -91,6 +97,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
||||
* Integração com banco de dados externo
|
||||
* Multiusuário
|
||||
* Acesso remoto
|
||||
* Exportação de resultado em CSV
|
||||
|
||||
---
|
||||
|
||||
@@ -99,15 +106,16 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
||||
O sistema trabalhará com os seguintes campos lógicos:
|
||||
|
||||
| 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`. |
|
||||
| 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. |
|
||||
| --------------- | ----------- | ------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| 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.
|
||||
|
||||
> **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 |
|
||||
| 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 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 o valor (opcional)
|
||||
* Qual índice representa a data (opcional)
|
||||
* Qual índice representa o tipo de documento (opcional)
|
||||
|
||||
### RF02.2 — Mapeamento XLSX (letra + linha)
|
||||
|
||||
O usuário define, no formato `LetraLinha`, a posição inicial de cada campo na planilha:
|
||||
|
||||
* Ex: `B3` indica coluna B a partir da linha 3
|
||||
* Os campos mapeáveis são os mesmos: Numero (obrigatório), Serie (obrigatório), Valor (opcional) 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.
|
||||
|
||||
### RF02.3 — Pré-visualização do Arquivo
|
||||
|
||||
A tela de configuração de colunas exibe as primeiras 5 linhas do arquivo com cabeçalho em notação de letras (A, B, C, ... com índice base-0 entre parênteses). A pré-visualização é atualizada automaticamente ao mudar o delimitador ou a aba selecionada.
|
||||
|
||||
### Comportamento de Memória
|
||||
|
||||
Após a importação, o arquivo permanece em memória e o usuário pode alterar o mapeamento de colunas e reprocessar sem selecionar o arquivo novamente. Ao importar um novo arquivo, os dados do arquivo anterior são descartados da memória.
|
||||
@@ -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:
|
||||
|
||||
* Sobrescrever o layout existente
|
||||
* Salvar com novo nome
|
||||
* Cancelar a importação
|
||||
|
||||
### 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:
|
||||
|
||||
* 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
|
||||
|
||||
### 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
|
||||
|
||||
@@ -251,26 +264,30 @@ Registros com o campo Numero igual a `0` devem ser descartados e reportados ao u
|
||||
|
||||
### Escopo da Detecção
|
||||
|
||||
Faltantes são detectados **por série**. Cada série possui sua própria sequência independente.
|
||||
Faltantes são detectados **por grupo `(Serie, TipoDocumento)`**. Cada grupo possui sua própria sequência independente.
|
||||
|
||||
### Série com Apenas um Registro
|
||||
|
||||
Se 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
2. Exibir aviso informando o intervalo calculado (ex: "Série 001: intervalo de 999.996 faltantes detectado")
|
||||
1. Interromper o processamento desse grupo
|
||||
2. Exibir aviso informando o intervalo calculado (ex: "Série 001 / NFE: intervalo de 999.996 faltantes detectado")
|
||||
3. Solicitar confirmação do usuário antes de continuar
|
||||
|
||||
Se o usuário confirmar, o sistema deve listar todos os faltantes normalmente, com paginação. Não há truncamento da lista após a confirmação.
|
||||
@@ -279,7 +296,7 @@ Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -294,11 +311,11 @@ Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
|
||||
|
||||
O sistema deve identificar registros duplicados.
|
||||
|
||||
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie** no arquivo importado.
|
||||
Um registro é considerado duplicado quando existe mais de uma ocorrência da mesma combinação **Numero + Serie + TipoDocumento** no arquivo importado.
|
||||
|
||||
### Exibição das Duplicatas
|
||||
|
||||
A lista de duplicatas exibe o identificador (Numero + Serie) 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
|
||||
|
||||
@@ -311,12 +328,14 @@ Todas as ocorrências de registros duplicados são incluídas na soma de valores
|
||||
O sistema deve calcular:
|
||||
|
||||
* Soma total
|
||||
* Soma por série
|
||||
* Soma por grupo `(Serie, TipoDocumento)`
|
||||
|
||||
### Formato de Valor Aceito
|
||||
|
||||
O sistema deve aceitar valores numéricos em formato brasileiro ou americano, detectando o formato automaticamente por registro seguindo o algoritmo abaixo.
|
||||
|
||||
O parser remove prefixos `R$` (maiúsculo ou minúsculo) e espaços antes do processamento.
|
||||
|
||||
#### Algoritmo de Parsing Monetário
|
||||
|
||||
**Regra 1 — Contém ambos ponto e vírgula:**
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
@@ -360,15 +379,17 @@ Todos os valores monetários são exibidos com **2 casas decimais fixas** no for
|
||||
|
||||
O sistema deve exibir:
|
||||
|
||||
* Lista de notas faltantes agrupadas por série
|
||||
* Lista de duplicadas agrupadas por série
|
||||
* Totais (soma total e soma por série)
|
||||
* Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
|
||||
* Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
|
||||
* Lista de duplicadas agrupadas por `(Serie, TipoDocumento)`
|
||||
* Totais (soma total e soma por grupo, com contagem de notas por grupo)
|
||||
* Botão de cópia rápida de listas para a área de transferência
|
||||
|
||||
### Organização dos Resultados
|
||||
|
||||
| Aspecto | Comportamento |
|
||||
| ------------- | ----------------------------------------------------- |
|
||||
| Agrupamento | Resultados sempre agrupados por 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 |
|
||||
| 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`.
|
||||
|
||||
As fontes do PDF (Liberation Sans) são embutidas no binário em tempo de compilação, eliminando dependência de fontes instaladas no sistema operacional.
|
||||
|
||||
O PDF deve conter:
|
||||
* Notas faltantes por série
|
||||
* Duplicatas por série
|
||||
* Totais por série e total geral
|
||||
* Notas faltantes por grupo `(Serie, TipoDocumento)`
|
||||
* Duplicatas por grupo
|
||||
* Totais por grupo e total geral
|
||||
|
||||
**Metadados do relatório:**
|
||||
* Nome do arquivo importado
|
||||
* Data e hora da geração
|
||||
* Nome do layout utilizado
|
||||
* 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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -451,6 +480,7 @@ Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltip
|
||||
* "32 linhas descartadas por malformação"
|
||||
* "12 valores de Numero inválidos convertidos ou descartados"
|
||||
* "5 registros com Série inválida descartados"
|
||||
* "3 valores monetários inválidos descartados"
|
||||
|
||||
Cada categoria de problema deve ser exibida como um item separado dentro do mesmo modal. Nunca devem ser abertos múltiplos modais sequenciais para a mesma operação de importação.
|
||||
|
||||
@@ -462,9 +492,16 @@ Fluxo principal:
|
||||
|
||||
1. Usuário abre o sistema
|
||||
2. Usuário importa planilha
|
||||
3. Usuário configura colunas
|
||||
4. Usuário executa análise
|
||||
5. Sistema exibe resultado
|
||||
3. (Para XLSX) Usuário seleciona aba
|
||||
4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
|
||||
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
|
||||
│ ├─ app.rs
|
||||
│ ├─ screens/
|
||||
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
|
||||
│ │ ├─ import.rs
|
||||
│ │ ├─ configuracao_colunas.rs
|
||||
│ │ ├─ layouts.rs
|
||||
@@ -493,20 +531,25 @@ src/
|
||||
├─ application/
|
||||
│ ├─ mod.rs
|
||||
│ ├─ usecases/
|
||||
│ │ ├─ mod.rs
|
||||
│ │ ├─ importar_arquivo.rs
|
||||
│ │ ├─ executar_analise.rs
|
||||
│ │ ├─ exportar_pdf.rs
|
||||
│ │ ├─ layouts.rs
|
||||
│
|
||||
├─ domain/
|
||||
│ ├─ mod.rs
|
||||
│ ├─ errors.rs
|
||||
│ ├─ entities/
|
||||
│ │ ├─ mod.rs
|
||||
│ │ ├─ nota.rs
|
||||
│ │ ├─ serie.rs
|
||||
│ │ ├─ chave_serie.rs
|
||||
│ │ ├─ layout.rs
|
||||
│ │ ├─ resultado_analise.rs
|
||||
│ │
|
||||
│ ├─ services/
|
||||
│ │ ├─ mod.rs
|
||||
│ │ ├─ detector_sequencia.rs
|
||||
│ │ ├─ detector_duplicidade.rs
|
||||
│ │ ├─ parser_monetario.rs
|
||||
@@ -533,47 +576,77 @@ Contém toda a lógica de negócio real.
|
||||
|
||||
**Não pode depender de:**
|
||||
|
||||
* egui
|
||||
* egui / eframe
|
||||
* rusqlite
|
||||
* calamine
|
||||
* csv
|
||||
* 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
|
||||
|
||||
**`Nota`**
|
||||
|
||||
| Campo | Tipo |
|
||||
| ------ | ----------------- |
|
||||
| -------------- | ------------------- |
|
||||
| numero | `u64` |
|
||||
| serie | `String` |
|
||||
| documento_tipo | `Option<String>` |
|
||||
| valor | `Option<Decimal>` |
|
||||
| data | `Option<NaiveDate>` |
|
||||
|
||||
**`ChaveSerie`**
|
||||
|
||||
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
|
||||
|
||||
| Campo | Tipo |
|
||||
| -------------- | ---------------- |
|
||||
| serie | `String` |
|
||||
| documento_tipo | `Option<String>` |
|
||||
|
||||
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
|
||||
|
||||
**`ResultadoPreAnalise`**
|
||||
|
||||
Resultado intermediário, antes de materializar os faltantes.
|
||||
|
||||
| Campo | Tipo |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
|
||||
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
|
||||
| soma_total | `Decimal` |
|
||||
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
|
||||
| total_por_serie | `HashMap<ChaveSerie, usize>` |
|
||||
|
||||
**`ResultadoAnalise`**
|
||||
|
||||
| Campo | Tipo |
|
||||
| -------------------- | -------------------------------------- |
|
||||
| faltantes_por_serie | `HashMap<String, Vec<u64>>` |
|
||||
| duplicadas_por_serie | `HashMap<String, Vec<(u64, usize)>>` |
|
||||
| -------------------- | ---------------------------------------- |
|
||||
| faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
|
||||
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
|
||||
| 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.
|
||||
|
||||
#### 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
|
||||
|
||||
`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`**
|
||||
|
||||
1. Recebe dados crus
|
||||
2. Chama `parser_monetario`
|
||||
3. Chama `detector_sequencia`
|
||||
4. Chama `detector_duplicidade`
|
||||
5. Monta `ResultadoAnalise`
|
||||
Expõe três funções:
|
||||
1. `pre_analisar(notas)` — calcula intervalos, duplicatas e somas sem expandir faltantes
|
||||
2. `series_com_intervalo_excessivo(pre)` — retorna grupos com contagem acima de `LIMITE_FALTANTES` (10.000)
|
||||
3. `expandir_analise(pre, notas)` — materializa a lista completa de faltantes após confirmação
|
||||
|
||||
**`exportar_pdf.rs`**
|
||||
|
||||
Depende 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 |
|
||||
| -------------------------------- | ----------------------------------------- |
|
||||
| `csv_reader.rs` | Leitura de arquivos CSV via `csv` |
|
||||
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine` |
|
||||
| `pdf_generator.rs` | Geração de PDF via `genpdf` |
|
||||
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite |
|
||||
| `sqlite/migrations.rs` | Aplicação de migrations de schema |
|
||||
| `csv_reader.rs` | Leitura de arquivos CSV via `csv`; `preview_csv` |
|
||||
| `xlsx_reader.rs` | Leitura de arquivos XLSX via `calamine`; `preview_xlsx`, `parsear_letra_linha`, `listar_abas` |
|
||||
| `pdf_generator.rs` | Trait `PdfGenerator` + implementação `GenpdfGenerator` via `genpdf`; fontes Liberation Sans embutidas no binário |
|
||||
| `sqlite/connection.rs` | Abertura e inicialização da conexão SQLite; tratamento de banco corrompido |
|
||||
| `sqlite/migrations.rs` | Aplicação de migrations de schema (versão atual: 3) |
|
||||
| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` |
|
||||
|
||||
Nada de infrastructure sobe para domain.
|
||||
|
||||
#### 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)
|
||||
@@ -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.
|
||||
|
||||
#### App (estado global)
|
||||
|
||||
`app.rs` contém o estado global da aplicação (`App`), o enum `EstadoApp`, os tipos `Modal`/`TipoModal`/`AcaoModal`, e a lógica de processamento de resultados assíncronos via `mpsc::channel`.
|
||||
|
||||
**Estados da aplicação:**
|
||||
|
||||
| Estado | Descrição |
|
||||
| ----------------------- | --------- |
|
||||
| `Importando` | Tela inicial: seleção de arquivo e layout |
|
||||
| `SelecionandoAba` | Aguardando seleção de aba XLSX |
|
||||
| `ConfigurandoColunas` | Mapeamento de colunas com pré-visualização |
|
||||
| `Analisando` | Análise em execução em background thread |
|
||||
| `ConfirmandoIntervalo` | Aguardando confirmação do usuário para expandir faltantes |
|
||||
| `ExibindoResultado` | Resultado pronto para exibição |
|
||||
| `GerenciandoLayouts` | Gerenciamento de layouts salvos |
|
||||
|
||||
#### Screens
|
||||
|
||||
| Arquivo | Responsabilidade |
|
||||
| ------------------------- | ----------------------------------------------------- |
|
||||
| `import.rs` | Seleção de arquivo e configurações de importação |
|
||||
| `configuracao_colunas.rs` | Mapeamento de colunas (RF02) |
|
||||
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir (RF08) |
|
||||
| `resultado.rs` | Exibição de resultados com paginação (RF07) |
|
||||
|
||||
> `configuracao_colunas.rs` e `layouts.rs` são mantidos separados porque tratam de responsabilidades distintas do RF02 e RF08, evitando que uma única screen acumule lógica de mapeamento de colunas e gerenciamento de persistência.
|
||||
| `mod.rs` | `renderizar_tabela_preview` e `indice_para_letra` |
|
||||
| `import.rs` | Seleção de arquivo, dropdown de layout, seleção de aba XLSX |
|
||||
| `configuracao_colunas.rs` | Mapeamento de colunas com pré-visualização (RF02) |
|
||||
| `layouts.rs` | Gerenciamento de layouts: salvar, carregar, excluir, exportar/importar JSON (RF08) |
|
||||
| `resultado.rs` | Exibição de resultados com paginação, intervalos contíguos, indicador de completude, botões copiar/reanalisar/exportar PDF (RF07) |
|
||||
|
||||
---
|
||||
|
||||
@@ -646,26 +740,34 @@ Infrastructure entra apenas quando necessário.
|
||||
|
||||
Exemplo real:
|
||||
|
||||
1. UI chama `importar_arquivo`
|
||||
1. UI chama `executar_importacao` (thread separada)
|
||||
2. Infrastructure lê CSV/XLSX
|
||||
3. Application transforma registros em entidades `Nota`
|
||||
4. Domain executa análise
|
||||
5. Application retorna `ResultadoAnalise`
|
||||
6. UI renderiza
|
||||
4. Domain executa pré-análise (`pre_analisar`)
|
||||
5. Se intervalo excessivo: UI solicita confirmação → Domain expande faltantes (`expandir_analise`)
|
||||
6. Application retorna `ResultadoAnalise` via `mpsc::channel`
|
||||
7. UI renderiza
|
||||
|
||||
---
|
||||
|
||||
## 9.4 Stack Tecnológica
|
||||
|
||||
| Camada | Tecnologia | Status |
|
||||
| ---------------- | ------------------ | -------------- |
|
||||
| Linguagem | Rust | Definido |
|
||||
| Framework de UI | egui | Definido |
|
||||
| SQLite | rusqlite | Definido |
|
||||
| Leitura de CSV | csv | Definido |
|
||||
| Leitura de XLSX | calamine | Definido |
|
||||
| Decimal fixo | rust_decimal | Definido |
|
||||
| Geração de PDF | genpdf | Definido |
|
||||
| Camada | Tecnologia | Versão |
|
||||
| ---------------- | ------------------ | ------- |
|
||||
| Linguagem | Rust | edition 2024 |
|
||||
| Framework de UI | egui + eframe | 0.31 |
|
||||
| Diálogos nativos | rfd | 0.15 |
|
||||
| SQLite | rusqlite (bundled) | 0.32 |
|
||||
| Leitura de CSV | csv | 1.3 |
|
||||
| Leitura de XLSX | calamine | 0.26 |
|
||||
| Decimal fixo | rust_decimal | 1.36 |
|
||||
| Geração de PDF | genpdf | 0.2 |
|
||||
| Datas | chrono | 0.4 |
|
||||
| Encoding | encoding_rs | 0.8 |
|
||||
| Caminhos de dados| dirs | 5 |
|
||||
| Erros tipados | thiserror | 2 |
|
||||
| Regex | regex | 1 |
|
||||
| Serialização | serde + serde_json | 1 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
@@ -741,31 +851,33 @@ Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configuraç
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
| ---------- | ------- | -------------------------------------- |
|
||||
| id | inteiro | Identificador único |
|
||||
| nome | texto | Nome do layout |
|
||||
| id | inteiro | Identificador único (auto-incremento) |
|
||||
| nome | texto | Nome do layout (único no banco) |
|
||||
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
|
||||
|
||||
**Campos exclusivos de layouts CSV:**
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
| ------------------ | ------ | -------------------------------------------------- |
|
||||
| ---------------------- | ------- | ---------------------------------------------------------- |
|
||||
| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) |
|
||||
| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) |
|
||||
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1) |
|
||||
| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho |
|
||||
| indice_numero | inteiro | Índice da coluna Numero (base 0) |
|
||||
| indice_serie | inteiro | Índice da coluna Serie (base 0) |
|
||||
| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) |
|
||||
| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) |
|
||||
| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) |
|
||||
|
||||
**Campos exclusivos de layouts XLSX:**
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
| -------------- | ------ | ---------------------------------------------------------------- |
|
||||
| aba | texto | Nome 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_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) |
|
||||
| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) |
|
||||
| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) |
|
||||
| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) |
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
@@ -785,15 +897,19 @@ Excluir layout
|
||||
|
||||
## 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:
|
||||
|
||||
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
|
||||
{
|
||||
"nome": "Layout Padrão CSV",
|
||||
"tipo": "csv",
|
||||
"nome": "Layout Padrão CSV",
|
||||
"delimitador": ";",
|
||||
"encoding": "utf-8",
|
||||
"linha_cabecalho": 1,
|
||||
"indice_numero": 3,
|
||||
"indice_serie": 1,
|
||||
"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
|
||||
{
|
||||
"nome": "Layout Padrão XLSX",
|
||||
"tipo": "xlsx",
|
||||
"nome": "Layout Padrão XLSX",
|
||||
"aba": "Plan1",
|
||||
"pos_numero": "D3",
|
||||
"pos_serie": "B3",
|
||||
"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
|
||||
@@ -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 XLSX com seleção de aba e posicionamento por `LetraLinha`
|
||||
* Detectar notas faltantes por série corretamente
|
||||
* Detectar duplicatas por série corretamente
|
||||
* Calcular soma total e soma por série corretamente
|
||||
* Exibir resultados agrupados por série com paginação
|
||||
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
|
||||
* Detectar duplicatas por grupo corretamente
|
||||
* Calcular soma total e soma por grupo corretamente
|
||||
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
|
||||
* Exibir indicador de completude por grupo
|
||||
* Exportar relatório de resultados para PDF
|
||||
* Salvar, carregar, selecionar e excluir layouts
|
||||
* Exportar e importar layouts via JSON
|
||||
* Exibir aviso de confirmação quando intervalo de faltantes exceder 10.000 por 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:
|
||||
|
||||
* Importar CSV e XLSX
|
||||
* Mapear colunas Numero, Serie, Valor e Data
|
||||
* Detectar notas faltantes por série
|
||||
* Detectar duplicatas por série
|
||||
* Calcular soma total e por série
|
||||
* Exibir resultados agrupados por série com paginação
|
||||
* Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
|
||||
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
|
||||
* Detectar duplicatas por grupo
|
||||
* Calcular soma total e por grupo
|
||||
* Exibir resultados agrupados por grupo com paginação e intervalos contíguos
|
||||
* Exportar relatório para PDF
|
||||
* Gerenciar layouts (salvar, carregar, excluir)
|
||||
* Exportar e importar layouts via JSON
|
||||
* Gerenciar layouts (salvar, carregar, excluir, exportar/importar JSON)
|
||||
* Reanalisar arquivo sem reconfiguração
|
||||
|
||||
---
|
||||
|
||||
# 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
|
||||
* Histórico de análises
|
||||
* Automação de importação (monitorar pasta)
|
||||
* Exportação para CSV
|
||||
* Multiusuário
|
||||
|
||||
---
|
||||
@@ -918,8 +1045,16 @@ Série
|
||||
|
||||
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
|
||||
|
||||
Ordem numérica crescente sem lacunas.
|
||||
Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
|
||||
|
||||
---
|
||||
|
||||
+88
-5
@@ -1,5 +1,6 @@
|
||||
use crate::application::usecases::{
|
||||
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
|
||||
importar_arquivo::{importar_csv, importar_xlsx},
|
||||
layouts::{excluir_layout, listar_layouts, salvar_layout},
|
||||
};
|
||||
use crate::domain::{
|
||||
@@ -131,6 +132,8 @@ pub struct App {
|
||||
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 {
|
||||
@@ -155,6 +158,7 @@ impl Default for App {
|
||||
itens_por_pagina: 100,
|
||||
preview_arquivo: None,
|
||||
resultado_pendente: None,
|
||||
resultado_anterior: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,6 +447,7 @@ impl App {
|
||||
None
|
||||
};
|
||||
}
|
||||
self.resultado_anterior = None;
|
||||
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||
if let Some(av) = &self.avisos_importacao.clone() {
|
||||
if av.tem_avisos() {
|
||||
@@ -468,7 +473,8 @@ impl App {
|
||||
.map(|(chave, count)| {
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
chave.label(), count
|
||||
chave.label(),
|
||||
count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -484,16 +490,92 @@ impl App {
|
||||
);
|
||||
}
|
||||
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.
|
||||
pub fn executar_analise(&mut self) {
|
||||
if self.notas_importadas.is_empty() {
|
||||
@@ -510,7 +592,8 @@ impl App {
|
||||
.map(|(chave, count)| {
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
chave.label(), count
|
||||
chave.label(),
|
||||
count
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -546,10 +629,10 @@ impl App {
|
||||
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate()
|
||||
{
|
||||
for (i, label) in ["Arquivo", "Colunas", "Resultado"].iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let texto = egui::RichText::new(*label);
|
||||
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 {
|
||||
|
||||
+151
-17
@@ -1,11 +1,14 @@
|
||||
use crate::application::usecases::importar_arquivo::listar_abas_xlsx;
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use crate::application::usecases::executar_analise::{
|
||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||
};
|
||||
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||
use egui::{Context, Ui};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Renderiza a tela de importação de arquivos.
|
||||
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||
ui.add_space(16.0);
|
||||
|
||||
@@ -25,7 +28,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
||||
.pick_file()
|
||||
{
|
||||
on_arquivo_selecionado(app, caminho);
|
||||
on_arquivo_selecionado(app, ctx, caminho);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -83,7 +86,7 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
}
|
||||
|
||||
/// 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.add_space(16.0);
|
||||
|
||||
@@ -94,6 +97,45 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// --- Seleção de preset ---
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
if !opcoes_layout.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_aba")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
aplicar_layout(app, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
ui.label("Selecione a aba a processar:");
|
||||
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
@@ -101,10 +143,8 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
// Gerar pré-visualização da aba selecionada
|
||||
app.preview_arquivo = crate::infrastructure::xlsx_reader::preview_xlsx(
|
||||
&caminho,
|
||||
aba,
|
||||
).ok();
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +157,24 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
if ui.button("▶ Configurar Colunas").clicked() {
|
||||
ui.horizontal(|ui| {
|
||||
// Se há preset selecionado, oferecer processamento direto
|
||||
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||
if tem_preset {
|
||||
let caminho_clone = caminho.clone();
|
||||
if ui.button("▶ Processar").clicked() {
|
||||
app.nome_arquivo = caminho_clone
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
app.caminho_arquivo = Some(caminho_clone);
|
||||
disparar_analise(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("⚙ Configurar Colunas").clicked() {
|
||||
app.nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
@@ -126,6 +182,7 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
app.caminho_arquivo = Some(caminho);
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ui.button("< Voltar").clicked() {
|
||||
@@ -133,7 +190,7 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
||||
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
|
||||
let extensao = caminho
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
@@ -156,18 +213,43 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
||||
app.layout_csv_atual.delimitador as u8,
|
||||
&app.layout_csv_atual.encoding.clone(),
|
||||
5,
|
||||
).ok();
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
"xlsx" | "xls" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
match listar_abas_xlsx(&caminho) {
|
||||
Ok(info) => {
|
||||
app.notas_importadas.clear();
|
||||
|
||||
// Verificar se há preset XLSX ativo com aba compatível
|
||||
let preset_aba = if !app.nome_layout_atual.is_empty() {
|
||||
let aba = app.layout_xlsx_atual.aba.clone();
|
||||
if !aba.is_empty() && info.abas.contains(&aba) {
|
||||
Some(aba)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(aba) = preset_aba {
|
||||
// Fluxo rápido: aba do preset existe → disparar análise direto
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
app.abas_xlsx = info.abas;
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
|
||||
disparar_analise(app, ctx);
|
||||
} else {
|
||||
// Fluxo normal: exibir tela de seleção de aba
|
||||
app.abas_xlsx = info.abas.clone();
|
||||
app.estado = EstadoApp::SelecionandoAba {
|
||||
abas: info.abas,
|
||||
caminho: caminho.clone(),
|
||||
};
|
||||
app.notas_importadas.clear();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||
@@ -180,13 +262,65 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
crate::domain::entities::layout::Layout::Csv { config, .. } => {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
}
|
||||
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
}
|
||||
|
||||
+34
-12
@@ -10,7 +10,7 @@ use egui::{Context, Ui};
|
||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||
|
||||
/// Renderiza a tela de resultados.
|
||||
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
// Extrair resultado do estado (sem mover)
|
||||
let resultado = match &app.estado {
|
||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||
@@ -34,6 +34,15 @@ pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pode_reanalisar = app.caminho_arquivo.is_some();
|
||||
if ui
|
||||
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
|
||||
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
|
||||
.clicked()
|
||||
{
|
||||
app.reimportar_e_analisar(ctx);
|
||||
}
|
||||
|
||||
if ui.button("📄 Exportar PDF").clicked() {
|
||||
exportar_para_pdf(app, &resultado);
|
||||
}
|
||||
@@ -138,8 +147,16 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
||||
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(", ");
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -183,10 +200,7 @@ fn renderizar_faltantes(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();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Notas Duplicadas ({} grupo(s))",
|
||||
total_dup
|
||||
))
|
||||
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
|
||||
.heading()
|
||||
.strong(),
|
||||
);
|
||||
@@ -212,7 +226,11 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
||||
chave.label(),
|
||||
duplicatas.len()
|
||||
));
|
||||
if ui.button("📋 Copiar").on_hover_text("Copiar números duplicados").clicked() {
|
||||
if ui
|
||||
.button("📋 Copiar")
|
||||
.on_hover_text("Copiar números duplicados")
|
||||
.clicked()
|
||||
{
|
||||
let texto = duplicatas
|
||||
.iter()
|
||||
.map(|(n, c)| format!("{} ({}x)", n, c))
|
||||
@@ -222,8 +240,7 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
||||
}
|
||||
});
|
||||
|
||||
let total_paginas =
|
||||
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
if app.pagina_duplicatas >= total_paginas {
|
||||
app.pagina_duplicatas = 0;
|
||||
}
|
||||
@@ -234,7 +251,9 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
||||
for (numero, count) in &duplicatas[inicio..fim] {
|
||||
ui.label(format!(
|
||||
" • NF {} / Série {} — {} ocorrências",
|
||||
numero, chave.label(), count
|
||||
numero,
|
||||
chave.label(),
|
||||
count
|
||||
));
|
||||
}
|
||||
|
||||
@@ -277,7 +296,10 @@ fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
||||
&caminho,
|
||||
) {
|
||||
Ok(_) => {
|
||||
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
|
||||
app.exibir_aviso(
|
||||
"Sucesso",
|
||||
format!("PDF exportado para: {}", caminho.display()),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||
|
||||
Reference in New Issue
Block a user