From 2a21138bf8e176175543f0d18595f8b4cd95b9a8 Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Tue, 3 Mar 2026 23:28:55 -0300 Subject: [PATCH] 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. --- F03_PREVIEW_INTERPRETADO.md | 126 ----------- PRD.md | 405 ++++++++++++++++++++++++------------ src/ui/app.rs | 97 ++++++++- src/ui/screens/import.rs | 190 ++++++++++++++--- src/ui/screens/resultado.rs | 50 +++-- 5 files changed, 558 insertions(+), 310 deletions(-) delete mode 100644 F03_PREVIEW_INTERPRETADO.md diff --git a/F03_PREVIEW_INTERPRETADO.md b/F03_PREVIEW_INTERPRETADO.md deleted file mode 100644 index 2de337b..0000000 --- a/F03_PREVIEW_INTERPRETADO.md +++ /dev/null @@ -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>>` — `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, - pub serie: Result, - pub valor: Option>, - pub data: Option>, - pub documento_tipo: Option>, -} - -pub fn pre_visualizar_csv( - caminho: &Path, - layout: &LayoutCsv, - n_linhas: usize, -) -> Vec - -pub fn pre_visualizar_xlsx( - caminho: &Path, - layout: &LayoutXlsx, - n_linhas: usize, -) -> Vec -``` - -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>, -``` - -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 diff --git a/PRD.md b/PRD.md index e7bbdb5..0a29b46 100644 --- a/PRD.md +++ b/PRD.md @@ -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 --- @@ -98,16 +105,17 @@ 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. | -| 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. | +| Campo | Obrigatório | Descriçã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. 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. --- @@ -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: -* "32 linhas descartadas por mal formação" +* "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` | -| valor | `Option` | -| data | `Option` | +| Campo | Tipo | +| -------------- | ------------------- | +| numero | `u64` | +| serie | `String` | +| documento_tipo | `Option` | +| valor | `Option` | +| data | `Option` | + +**`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` | + +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` | +| duplicadas_por_serie | `HashMap>` | +| soma_total | `Decimal` | +| soma_por_serie | `HashMap` | +| total_por_serie | `HashMap` | **`ResultadoAnalise`** -| Campo | Tipo | -| -------------------- | -------------------------------------- | -| faltantes_por_serie | `HashMap>` | -| duplicadas_por_serie | `HashMap>` | -| soma_total | `Decimal` | -| soma_por_serie | `HashMap` | +| Campo | Tipo | +| -------------------- | ---------------------------------------- | +| faltantes_por_serie | `HashMap>` | +| duplicadas_por_serie | `HashMap>` | +| soma_total | `Decimal` | +| soma_por_serie | `HashMap` | +| total_por_serie | `HashMap` | > **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` 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 | -| `sqlite/layout_repository.rs` | CRUD de layouts via `rusqlite` | +| `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) | -| 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) | +| Campo | Tipo | Descrição | +| ---------------------- | ------- | ---------------------------------------------------------- | +| delimitador | texto | Caractere delimitador (`,`, `;`, `\t`) | +| encoding | texto | Encoding do arquivo (`utf-8` ou `windows-1252`) | +| linha_cabecalho | inteiro | Número da linha do cabeçalho (base 1). 0 = sem cabeçalho | +| indice_numero | inteiro | Índice da coluna Numero (base 0) | +| indice_serie | inteiro | Índice da coluna Serie (base 0) | +| indice_valor | inteiro | Índice da coluna Valor (base 0, nulo se ausente) | +| indice_data | inteiro | Índice da coluna Data (base 0, nulo se ausente) | +| indice_documento_tipo | inteiro | Índice da coluna TipoDocumento (base 0, nulo se ausente) | **Campos exclusivos de layouts XLSX:** -| Campo | Tipo | Descrição | -| -------------- | ------ | ---------------------------------------------------------------- | -| aba | texto | Nome ou índice da aba a ser processada | -| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) | -| pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) | -| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) | -| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) | +| Campo | Tipo | Descrição | +| ------------------ | ------ | ----------------------------------------------------------------- | +| aba | texto | Nome da aba a ser processada | +| pos_numero | texto | Posição inicial da coluna Numero no formato `LetraLinha` (ex: `D3`) | +| pos_serie | texto | Posição inicial da coluna Serie no formato `LetraLinha` (ex: `B3`) | +| pos_valor | texto | Posição inicial da coluna Valor (nulo se ausente) | +| pos_data | texto | Posição inicial da coluna Data (nulo se ausente) | +| pos_documento_tipo | texto | Posição inicial da coluna TipoDocumento (nulo se ausente) | ------------------------------------------------------------------------ @@ -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)`. --- diff --git a/src/ui/app.rs b/src/ui/app.rs index fa363b6..8c87662 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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>>, // Canal para receber resultado da análise em background pub resultado_pendente: Option>, + // Resultado anterior, preservado durante reanálise para restaurar em caso de falha + pub resultado_anterior: Option, } 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::>() @@ -484,16 +490,92 @@ impl App { ); } ResultadoPendente::Vazio => { - self.estado = EstadoApp::ConfigurandoColunas; + 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) => { - self.estado = EstadoApp::ConfigurandoColunas; + 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::>() @@ -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 { diff --git a/src/ui/screens/import.rs b/src/ui/screens/import.rs index 725d40d..ceace11 100644 --- a/src/ui/screens/import.rs +++ b/src/ui/screens/import.rs @@ -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,15 +157,32 @@ 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() { - 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; - } + ui.horizontal(|ui| { + // Se há preset selecionado, oferecer processamento direto + let tem_preset = !app.nome_layout_atual.is_empty(); + if tem_preset { + let caminho_clone = caminho.clone(); + if ui.button("▶ Processar").clicked() { + app.nome_arquivo = caminho_clone + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + app.caminho_arquivo = Some(caminho_clone); + disparar_analise(app, ctx); + } + } + + if ui.button("⚙ Configurar Colunas").clicked() { + app.nome_arquivo = caminho + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + app.caminho_arquivo = Some(caminho); + app.estado = EstadoApp::ConfigurandoColunas; + } + }); } if ui.button("< Voltar").clicked() { @@ -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.abas_xlsx = info.abas.clone(); - app.estado = EstadoApp::SelecionandoAba { - abas: info.abas, - caminho: caminho.clone(), - }; app.notas_importadas.clear(); + + // Verificar se há preset XLSX ativo com aba compatível + let preset_aba = if !app.nome_layout_atual.is_empty() { + let aba = app.layout_xlsx_atual.aba.clone(); + if !aba.is_empty() && info.abas.contains(&aba) { + Some(aba) + } else { + None + } + } else { + None + }; + + if let Some(aba) = preset_aba { + // Fluxo rápido: aba do preset existe → disparar análise direto + app.layout_xlsx_atual.aba = aba.clone(); + app.caminho_arquivo = Some(caminho.clone()); + app.abas_xlsx = info.abas; + app.preview_arquivo = + crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok(); + disparar_analise(app, ctx); + } else { + // Fluxo normal: exibir tela de seleção de aba + app.abas_xlsx = info.abas.clone(); + app.estado = EstadoApp::SelecionandoAba { + abas: info.abas, + caminho: caminho.clone(), + }; + } } Err(e) => { app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e)); @@ -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; } diff --git a/src/ui/screens/resultado.rs b/src/ui/screens/resultado.rs index dbcee38..adab075 100644 --- a/src/ui/screens/resultado.rs +++ b/src/ui/screens/resultado.rs @@ -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::>().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::>() + .join(", "); ui.ctx().copy_text(texto); } }); @@ -183,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) { let total_dup = resultado.total_duplicatas(); ui.label( - egui::RichText::new(format!( - "Notas Duplicadas ({} grupo(s))", - total_dup - )) - .heading() - .strong(), + egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup)) + .heading() + .strong(), ); ui.add_space(4.0); @@ -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));