diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8a6e3d0..c652360 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,14 @@ { "permissions": { "allow": [ - "Bash(cargo build)" + "Bash(cargo build)", + "Bash(xargs grep -l \"tipo\\\\|invoice\\\\|tipo.*documento\")", + "Bash(xargs wc -l)", + "Bash(xargs grep -l \"grupo\\\\|grouping\\\\|agrupa\")", + "Bash(cargo metadata --no-deps --format-version 1)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''name''], p[''version'']\\) for p in d[''packages'']]\")", + "WebFetch(domain:github.com)", + "WebFetch(domain:api.github.com)" ] } } diff --git a/ANALISE_DUPLICIDADE_LAYOUT.md b/ANALISE_DUPLICIDADE_LAYOUT.md deleted file mode 100644 index 036c8bf..0000000 --- a/ANALISE_DUPLICIDADE_LAYOUT.md +++ /dev/null @@ -1,184 +0,0 @@ -# Análise — Duplicidade de Layouts ao Salvar - -**Data:** 03/03/2026 -**Status:** Análise / Proposta de Correção - ---- - -## 1. Problema Identificado - -Ao clicar em **"💾 Salvar"** na tela de gerenciamento de layouts, o sistema permite salvar um layout com um nome já existente, criando entradas duplicadas no banco sem qualquer aviso ou confirmação ao usuário. - -O PRD (RF03) exige que, ao existir conflito de nome, o sistema pergunte ao usuário se deseja **sobrescrever** o existente ou **salvar com novo nome**. Esse comportamento está **implementado apenas no fluxo de importação JSON**, mas está **ausente no fluxo de salvar layout atual**. - ---- - -## 2. Raiz do Problema — Análise por Camada - -### 2.1 Camada Infrastructure — `sqlite/migrations.rs` - -A tabela `layouts` foi criada na `migration_v1` **sem nenhuma constraint `UNIQUE` no campo `nome`**: - -```sql --- migration_v1 (src/infrastructure/sqlite/migrations.rs) -CREATE TABLE IF NOT EXISTS layouts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - nome TEXT NOT NULL, -- ← sem UNIQUE - tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), - ... -); -``` - -O banco de dados não impede, por si só, a inserção de registros com o mesmo nome. - ---- - -### 2.2 Camada Infrastructure — `sqlite/layout_repository.rs` - -A função `salvar` executa um `INSERT` direto, sem qualquer verificação prévia: - -```rust -// src/infrastructure/sqlite/layout_repository.rs — fn salvar() -conn.execute( - "INSERT INTO layouts (nome, tipo, ...) VALUES (?1, 'csv', ...)", - params![nome, ...], -)?; -``` - -Existe a função `existe_nome` no mesmo arquivo, que verifica duplicidade por consulta, mas ela **não é chamada** pelo fluxo de salvar layout atual — apenas pelo fluxo de importação JSON. - ---- - -### 2.3 Camada Application — `usecases/layouts.rs` - -A função `salvar_layout` só verifica se o `id` está presente: - -```rust -// src/application/usecases/layouts.rs — fn salvar_layout() -if let Some(id) = layout.id() { - layout_repository::atualizar(conn, layout) // atualiza se tem id -} else { - layout_repository::salvar(conn, layout) // insere direto sem checar nome -} -``` - -A verificação de nome duplicado (`existe_nome`) **está implementada em `importar_layout_json`**, mas **não foi replicada** em `salvar_layout`. - ---- - -### 2.4 Camada UI — `ui/screens/layouts.rs` - -A função `salvar_layout_atual` monta o layout sempre com `id: None` e chama `salvar_layout` sem tratar o caso de conflito de nome: - -```rust -// src/ui/screens/layouts.rs — fn salvar_layout_atual() -let layout = match app.tipo_arquivo_atual.clone() { - TipoArquivo::Csv => Layout::Csv { - id: None, // ← sempre None, nunca verifica se nome já existe - nome: app.nome_layout_atual.trim().to_string(), - config: app.layout_csv_atual.clone(), - }, - ... -}; - -match salvar_layout(conn, &layout) { - Ok(_) => { ... } // ← não há tratamento para conflito de nome - Err(e) => { ... } -} -``` - -O fluxo de resolução de conflito (sobrescrever / salvar com novo nome) **existe apenas em `importar_json`**, que trata `ErroLayout::NomeConflitante`, mas mesmo nesse caso o tratamento está incompleto (há um `TODO` no código). - ---- - -## 3. Resumo dos Pontos Falhos - -| # | Localização | Problema | -|---|-------------|----------| -| 1 | `sqlite/migrations.rs` — `migration_v1` | Nenhum `UNIQUE` constraint em `nome` na tabela `layouts` | -| 2 | `usecases/layouts.rs` — `salvar_layout` | Não consulta `existe_nome` antes de inserir | -| 3 | `domain/errors.rs` | Falta variante de erro para duplicidade no fluxo de salvar (apenas existe em importar JSON) | -| 4 | `ui/screens/layouts.rs` — `salvar_layout_atual` | Não trata conflito de nome; não exibe opção de sobrescrever ou renomear | -| 5 | `ui/screens/layouts.rs` — `importar_json` | Tratamento de `NomeConflitante` incompleto (TODO pendente) | - ---- - -## 4. Proposta de Correção - -### 4.1 Migration v2 — Adicionar `UNIQUE` em `nome` - -**Arquivo:** `src/infrastructure/sqlite/migrations.rs` - -Adicionar uma segunda migration que cria uma `UNIQUE` constraint na coluna `nome`. -Atualizar `VERSAO_SCHEMA_ATUAL` de `1` para `2` e chamar `migration_v2` no bloco condicional de aplicação de migrations. - -``` --- migration_v2 -CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome); -``` - -> Usar um índice único em vez de recriar a tabela é mais seguro, pois preserva dados já existentes. Bancos que já possuem duplicatas precisariam de tratamento antes da migração (ex.: renomear automaticamente os conflitantes com sufixo numérico). - ---- - -### 4.2 Retornar Erro Tipado de Conflito em `salvar_layout` - -**Arquivo:** `src/application/usecases/layouts.rs` — função `salvar_layout` - -Antes da inserção, verificar se já existe layout com o mesmo nome usando `layout_repository::existe_nome`. Retornar um erro tipado (ex.: `ErroLayout::NomeConflitante`) em vez de uma `String` quando o nome já existir e nenhum `id` foi fornecido. - -Fluxo esperado: - -``` -1. layout.id() é None (novo layout) -2. Consultar existe_nome(conn, layout.nome()) -3. Se existir → retornar Err(ErroLayout::NomeConflitante(nome)) -4. Se não existir → prosseguir com INSERT -``` - -A assinatura da função deverá mudar de `Result` para `Result` para que o chamador consiga distinguir o tipo de erro. - ---- - -### 4.3 Tratar Conflito na UI — Fluxo de Sobrescrever ou Renomear - -**Arquivo:** `src/ui/screens/layouts.rs` — função `salvar_layout_atual` - -Ao receber `Err(ErroLayout::NomeConflitante(nome))`, a UI deve exibir um modal de confirmação com duas opções: - -- **Sobrescrever** — buscar o `id` do layout existente pelo nome, montar o layout com esse `id` e chamar `salvar_layout` novamente (o use case irá para o branch `atualizar`) -- **Cancelar** — descartar a operação sem salvar - -> A opção "Salvar com novo nome" pode ser considerada, porém aumenta a complexidade da tela. Para a correção mínima, sobrescrever ou cancelar já atende ao PRD. - -O estado da `App` pode precisar de um campo intermediário para guardar o layout pendente de confirmação entre frames do egui, ou se o sistema já possui um mecanismo de `AcaoModal`, criar uma variante `AcaoModal::ConfirmarSobrescritaLayout(Layout)` e processar no handler de ações do modal. - ---- - -### 4.4 Completar o `TODO` em `importar_json` - -**Arquivo:** `src/ui/screens/layouts.rs` — função `importar_json` - -O bloco `Err(ErroLayout::NomeConflitante(nome))` contém um `TODO` que não implementa o fluxo completo. Após a correção do item 4.3, a mesma lógica de modal de confirmação deve ser reutilizada aqui, chamando `importar_layout_json` com `sobrescrever_se_existir: true` quando o usuário confirmar. - ---- - -## 5. Ordem de Implementação Sugerida - -``` -1. migration_v2 (constraint UNIQUE) ............ infrastructure/sqlite/migrations.rs -2. Erro tipado em salvar_layout ................. application/usecases/layouts.rs -3. Modal de conflito na UI (salvar atual) ....... ui/screens/layouts.rs -4. Modal de conflito na UI (importar JSON) ...... ui/screens/layouts.rs (completar TODO) -``` - ---- - -## 6. Impacto da Correção - -| Aspecto | Antes | Depois | -|---------|-------|--------| -| Layouts duplicados no banco | Possível | Bloqueado pelo banco (UNIQUE) | -| Feedback ao usuário | Nenhum — salva silenciosamente | Modal com escolha: sobrescrever ou cancelar | -| Conformidade com PRD (RF03) | Parcial (apenas importação JSON) | Completa em todos os fluxos | -| Importação JSON com conflito | TODO incompleto | Resolvido com mesma lógica | diff --git a/FEATURES_BACKLOG.md b/FEATURES_BACKLOG.md new file mode 100644 index 0000000..108c9e9 --- /dev/null +++ b/FEATURES_BACKLOG.md @@ -0,0 +1,166 @@ +# Backlog de Features — Comparador de Notas + +> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado. + +## Alta Prioridade + +### F-01 — Exportar Resultado em CSV. + +**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF. + +**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções): +- `faltantes.csv`: `serie,documento_tipo,numero` +- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias` + +**Escopo técnico:** +- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)` +- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade) +- Botão na barra de ações da tela `resultado.rs` +- Dependência `csv` já está no `Cargo.toml` + +**Esforço estimado:** Médio (2–3h) +**Impacto:** Alto — elimina retrabalho manual + +**Observações:** +Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função. + +--- + +### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar) + +**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura. + +**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite. + +**Escopo técnico:** +- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader +- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024` + +**Esforço estimado:** Baixo (30min) +**Impacto:** Médio — evita travamentos inesperados + +--- + +## Média Prioridade + +### F-03 — Preview de Colunas Antes da Análise (Isso já existe?) + +**Problema:** Erros de mapeamento de colunas (índice errado, encoding incorreto) só são descobertos depois da importação, que pode falhar silenciosamente com "0 notas válidas". + +**Solução:** Na tela de configuração de colunas, exibir uma tabela das primeiras 5 linhas mostrando como os campos serão interpretados, com highlight em células que falhariam o parse. + +**Escopo técnico:** +- Função `pre_visualizar(caminho, layout, n_linhas: usize) -> Vec` +- `LinhaPreview`: `{ numero: Result, serie: Result, valor: Result, ... }` +- Widget de tabela em `configuracao_colunas.rs` +- Atualizar preview ao mudar qualquer campo de configuração (via `changed()`) + +**Esforço estimado:** Alto (4–6h) +**Impacto:** Alto — reduz drasticamente "por que não importou nada?" + +**Observações:** +Verificar se já existe no codigo, pois na tela de configuração do Layout ele exibe as 5 primeiras linhas do arquivo. + +--- + +### F-04 — Busca por Número na Tela de Resultado (Util) + +**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado. + +**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota: +- está **faltante** (aparece na lista de faltantes) +- está **duplicada** (aparece na lista de duplicatas) +- está **presente** (está nas notas importadas) +- **não encontrada** (fora do intervalo conhecido) + +**Escopo técnico:** +- Campo `filtro_numero: String` no `App` +- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie` +- Para "presente": busca em `app.notas_importadas` (já mantido em memória) +- Banner de resultado no topo da tela + +**Esforço estimado:** Médio (2–3h) +**Impacto:** Médio — uso diário em auditorias pontuais + +--- + +### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario) + +**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar). + +**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional. + +**Escopo técnico:** +- Persistir `caminho_arquivo_atual: Option` no `App` (já existe parcialmente como `nome_arquivo`) +- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais +- Botão na barra de ações da tela `resultado.rs` + +**Esforço estimado:** Baixo (1h) +**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar + +--- + +### F-06 — Auto-detecção de Delimitador CSV (Util) + +**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam. + +**Solução:** Ao selecionar o arquivo CSV, amostrar as primeiras 5 linhas e contar ocorrências de cada delimitador candidato. Sugerir o mais frequente como padrão, mantendo a ComboBox editável. + +**Escopo técnico:** +- Função `detectar_delimitador(primeiras_linhas: &[&str]) -> char` +- Chamar na transição para `ConfigurandoColunas` quando `TipoArquivo::Csv` +- Pré-selecionar na ComboBox sem impedir mudança manual + +**Esforço estimado:** Médio (2h) +**Impacto:** Baixo-Médio — reduz um passo de configuração na maioria dos casos + +--- + +## Baixa Prioridade + +### F-07 — Auto-detecção de Encoding CSV (Util) + +**Problema:** Arquivos gerados por sistemas legados brasileiros frequentemente usam `windows-1252`, mas o padrão selecionado é `utf-8`. O usuário descobre o problema somente ao ver caracteres estranhos nos dados. + +**Solução:** Tentar ler as primeiras linhas em `utf-8`; se houver erro de decodificação, sugerir automaticamente `windows-1252` com aviso visual na tela de configuração. + +**Escopo técnico:** +- Função `detectar_encoding(caminho: &Path) -> String` +- Usar `encoding_rs` (já dependência) para tentar decode e verificar `had_errors` +- Aviso na tela de configuração: "Encoding detectado: windows-1252" + +**Esforço estimado:** Médio (2h) +**Impacto:** Baixo — problema raro, mas confuso quando ocorre + +--- + +### F-09 — Paginação Automática de Faltantes no PDF (Util) + +**Problema:** PDFs com muitos faltantes (ex: 5.000 números) geram um único bloco de texto muito longo, difícil de ler e imprimir. + +**Solução:** No `pdf_generator.rs`, ao adicionar a lista de faltantes, agrupar em intervalos contíguos (usando a função `agrupar_contiguos` já implementada) e formatar como `100–104 (5 notas)`. Reduz drasticamente o tamanho do PDF e melhora legibilidade. + +**Escopo técnico:** +- Importar `agrupar_contiguos` em `pdf_generator.rs` +- Substituir o `join(", ")` atual pelo formato de intervalos +- Sem novas dependências + +**Esforço estimado:** Baixo (30min) +**Impacto:** Baixo-Médio — melhora legibilidade de PDFs com muitos faltantes + +> **Nota:** Esta feature é especialmente fácil porque a lógica de agrupamento já existe — é só aplicar no PDF. + +--- + +## Resumo + +| ID | Feature | Esforço | Impacto | Prioridade | +|---|---|---|---|---| +| F-01 | Exportar resultado em CSV | Médio | Alto | Alta | +| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta | +| F-03 | Preview de colunas antes da análise | Alto | Alto | Média | +| F-04 | Busca por número no resultado | Médio | Médio | Média | +| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média | +| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média | +| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa | +| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | diff --git a/IMPLEMENTACAO.md b/IMPLEMENTACAO.md deleted file mode 100644 index 753df8f..0000000 --- a/IMPLEMENTACAO.md +++ /dev/null @@ -1,429 +0,0 @@ -# Roteiro de Implementação — Comparador de Notas - -**Versão:** 1.0 -**Data:** 02/03/2026 -**Baseado no PRD:** v1.6 - ---- - -## Fase 0 — Fundação do Projeto - -**Objetivo:** Estrutura compilável com todas as dependências. - -### 0.1 Configurar `Cargo.toml` - -Adicionar todas as dependências: - -```toml -[dependencies] -eframe = "0.31" -egui = "0.31" -rusqlite = { version = "0.32", features = ["bundled"] } -csv = "1.3" -calamine = "0.26" -rust_decimal = { version = "1.36", features = ["serde"] } -rust_decimal_macros = "1.36" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -genpdf = "0.2" -chrono = { version = "0.4", features = ["serde"] } -encoding_rs = "0.8" -dirs = "5" -thiserror = "2" -``` - -### 0.2 Criar estrutura de pastas completa - -Conforme definido na seção 9.1 do PRD: - -``` -src/ - main.rs - ui/ - mod.rs - app.rs - screens/ - import.rs - configuracao_colunas.rs - layouts.rs - resultado.rs - application/ - mod.rs - usecases/ - importar_arquivo.rs - executar_analise.rs - exportar_pdf.rs - domain/ - mod.rs - errors.rs - entities/ - nota.rs - serie.rs - layout.rs - resultado_analise.rs - services/ - detector_sequencia.rs - detector_duplicidade.rs - parser_monetario.rs - infrastructure/ - mod.rs - csv_reader.rs - xlsx_reader.rs - pdf_generator.rs - sqlite/ - mod.rs - connection.rs - migrations.rs - layout_repository.rs -``` - -### 0.3 Criar todos os `mod.rs` com declarações vazias - -Garantir que o projeto compila antes de começar a implementar. - ---- - -## Fase 1 — Camada Domain (núcleo puro) - -**Dependência:** Fase 0 concluída. -**Restrição:** Zero dependência de egui, rusqlite, calamine, csv ou genpdf. Apenas Rust puro + `rust_decimal` e `chrono`. - -### 1.1 `domain/errors.rs` - -Definir tipos de erro com `thiserror`: - -- `ErroNumero` (Zero, NaoNumerico) -- `ErroSerie` (Invalida, Vazia) -- `ErroValor` (Negativo, NaoNumerico) -- `ErroLayout` (CampoObrigatorioAusente, JsonMalformado, NomeConflitante) - -### 1.2 `domain/entities/nota.rs` - -```rust -pub struct Nota { - pub numero: u64, - pub serie: String, - pub valor: Option, - pub data: Option, -} -``` - -### 1.3 `domain/entities/serie.rs` - -- Validação da regex `[0-9]{1,3}` -- Função `validar_serie(s: &str) -> Result` - -### 1.4 `domain/entities/layout.rs` - -- `enum TipoArquivo { Csv, Xlsx }` -- `struct LayoutCsv` com todos os campos da seção 10.4 do PRD -- `struct LayoutXlsx` com todos os campos da seção 10.4 do PRD -- `enum Layout { Csv(LayoutCsv), Xlsx(LayoutXlsx) }` - -### 1.5 `domain/entities/resultado_analise.rs` - -```rust -pub struct ResultadoPreAnalise { - // min, max, contagem_faltantes por série - pub intervalos_por_serie: HashMap, -} - -pub struct ResultadoAnalise { - pub faltantes_por_serie: HashMap>, - pub duplicadas_por_serie: HashMap>, - pub soma_total: Decimal, - pub soma_por_serie: HashMap, -} -``` - -### 1.6 `domain/services/parser_monetario.rs` - -Implementar exatamente o algoritmo da seção RF06: - -| Regra | Condição | Comportamento | -|-------|----------|---------------| -| 1 | Contém ponto **e** vírgula | Último separador é o decimal | -| 2a | Apenas um separador + exatamente 2 dígitos após | Separador decimal | -| 2b | Apenas um separador + exatamente 3 dígitos após | Separador de milhar | -| 2c | Apenas um separador + outros casos | Separador decimal | -| 3 | Sem separador | Número inteiro | - -- Rejeitar valores negativos (precedidos de `-`) -- Armazenar como `rust_decimal::Decimal` - -> **Prioridade:** Testar exaustivamente com todos os exemplos da tabela do PRD antes de avançar. - -### 1.7 `domain/services/detector_sequencia.rs` - -- Recebe `Vec<&Nota>` de uma série -- Ordena por `numero` crescente -- Percorre **incrementalmente** (sem lista intermediária) -- Retorna `Vec` de faltantes -- Respeitar o limite de 10.000 registros faltantes (RFC04): retornar `ResultadoPreAnalise` antes de expandir - -### 1.8 `domain/services/detector_duplicidade.rs` - -- Recebe `Vec<&Nota>` -- Retorna `HashMap<(u64, String), usize>` com contagem por grupo -- Filtra apenas grupos com contagem > 1 - ---- - -## Fase 2 — Camada Infrastructure - -**Dependência:** Fase 1 concluída. - -### 2.1 `infrastructure/sqlite/connection.rs` - -- Determinar caminho do banco conforme SO via `dirs::config_dir()`: - - Linux: `~/.config/comparador-notas/config.db` - - Windows: `%APPDATA%\comparador-notas\config.db` - - macOS: `~/Library/Application Support/comparador-notas/config.db` -- Criar diretório automaticamente se não existir -- Abrir conexão SQLite -- Tratamento de banco corrompido: renomear para `config.db.bak`, recriar banco vazio - -### 2.2 `infrastructure/sqlite/migrations.rs` - -- Tabela `schema_version` para controle de versão do schema -- Migration v1: criar tabela `layouts` com todos os campos da seção 10.4 do PRD -- Aplicar migrations automaticamente na inicialização - -### 2.3 `infrastructure/sqlite/layout_repository.rs` - -```rust -fn salvar(layout: &Layout) -> Result -fn listar() -> Result> -fn buscar_por_id(id: i64) -> Result> -fn excluir(id: i64) -> Result<()> -fn existe_nome(nome: &str) -> Result -``` - -### 2.4 `infrastructure/csv_reader.rs` - -- Suporte a delimitadores: `,` `;` `\t` -- Suporte a encoding: UTF-8 e Windows-1252 (via `encoding_rs`) -- Respeitar linha de cabeçalho configurável (base 1) -- Ignorar linhas em branco silenciosamente -- Coletar linhas malformadas para relatório consolidado -- Validar limite de 50 MB antes de ler -- Retornar `Vec>` (linhas × colunas) - -### 2.5 `infrastructure/xlsx_reader.rs` - -- `listar_abas(path) -> Vec` — chamado imediatamente após seleção do arquivo -- Ler dados da aba selecionada a partir da posição `LetraLinha` -- Converter notação `LetraLinha` (ex: `B3`) para `(col_idx, row_idx)` -- Ignorar linhas em branco silenciosamente -- Validar limite de 50 MB -- Tratar arquivo corrompido retornando erro tipado - -### 2.6 `infrastructure/pdf_generator.rs` - -- Implementar trait `PdfGenerator` definida em `application/` -- Gerar PDF com `genpdf` contendo: - - Metadados: nome do arquivo importado, data/hora de geração, nome do layout - - Faltantes agrupados por série - - Duplicatas agrupadas por série - - Totais por série e total geral - ---- - -## Fase 3 — Camada Application - -**Dependência:** Fases 1 e 2 concluídas. - -### 3.1 `application/usecases/importar_arquivo.rs` - -1. Validar tamanho do arquivo (recusar > 50 MB) -2. Detectar tipo (CSV ou XLSX) -3. Para XLSX: chamar `listar_abas()` e retornar lista para UI fazer a seleção -4. Chamar leitor adequado com configurações do layout -5. Mapear colunas e construir `Vec` -6. Aplicar validações: regex de série, numero zero, valores inválidos -7. Retornar notas válidas + relatório de avisos consolidado (RF06/RNF06) - -### 3.2 `application/usecases/executar_analise.rs` - -1. Receber `Vec` -2. Chamar `parser_monetario` para cada valor -3. Agrupar por série -4. Chamar `detector_sequencia` por série → gerar `ResultadoPreAnalise` -5. Se algum intervalo > 10.000: retornar `ResultadoPreAnalise` para UI solicitar confirmação -6. Após confirmação: expandir faltantes e montar `ResultadoAnalise` -7. Chamar `detector_duplicidade` -8. Calcular somas com `Decimal` (nunca `f64`) -9. Retornar `ResultadoAnalise` - -### 3.3 `application/usecases/exportar_pdf.rs` - -- Depende da trait `PdfGenerator` (não de `genpdf` diretamente) -- Recebe `ResultadoAnalise` + metadados do arquivo e layout -- Delega geração para a implementação concreta em infrastructure - -### 3.4 Gerenciamento de layouts - -- `salvar_layout` — valida campos obrigatórios antes de persistir -- `listar_layouts` — lista separada por tipo (CSV / XLSX) -- `carregar_layout` — busca por id -- `excluir_layout` — remove do banco -- `exportar_layout_json` — serializa com `serde_json`, sugere nome do arquivo -- `importar_layout_json` — desserializa + valida campos obrigatórios + verifica conflito de nome - ---- - -## Fase 4 — Camada UI (egui/eframe) - -**Dependência:** Fase 3 concluída. -**Regra:** Nenhuma regra de negócio, parsing ou cálculo dentro da UI. - -### 4.1 `ui/app.rs` — Estrutura principal - -```rust -enum EstadoApp { - Importando, - ConfigurandoColunas, - SelecionandoAba(Vec), // lista de abas XLSX - Analisando, - ConfirmandoIntervalo(ResultadoPreAnalise), - ExibindoResultado(ResultadoAnalise), - GerenciandoLayouts, -} -``` - -- Gerenciamento de modais/popups bloqueantes (RNF06) -- Struct `App` com estado compartilhado e referência ao banco SQLite - -### 4.2 `ui/screens/import.rs` - -- Botão "Selecionar arquivo" com diálogo nativo -- Exibir nome do arquivo selecionado -- Para XLSX: exibir dropdown de seleção de aba **imediatamente** após seleção do arquivo -- Dropdown de layouts salvos para carregar configuração existente -- Botão "Gerenciar Layouts" → navegar para `layouts.rs` - -### 4.3 `ui/screens/configuracao_colunas.rs` - -- Para CSV: campos numéricos para índice de cada coluna (base 0) + delimitador + encoding + linha cabeçalho -- Para XLSX: campos de texto para posição `LetraLinha` de cada coluna -- Validação em tempo real: índices duplicados, campos obrigatórios ausentes -- Bloqueio do botão "Executar Análise" quando configuração inválida -- Botão "Executar Análise" - -### 4.4 `ui/screens/layouts.rs` - -- Listar layouts salvos separados por tipo (CSV / XLSX) -- Campo de nome + botão "Salvar layout atual" -- Botão "Excluir" com modal de confirmação -- Botão "Exportar para JSON" → diálogo de salvar com nome sugerido baseado no nome do layout -- Botão "Importar de JSON" → diálogo de abertura -- Modal de conflito de nome ao importar: "Sobrescrever" ou "Salvar com novo nome" - -### 4.5 `ui/screens/resultado.rs` - -- Seções: **Faltantes** | **Duplicatas** | **Totais** -- Agrupamento por série em todas as seções -- Paginação com dropdown: 50 / 100 / 200 / 1000 itens por página -- Valores monetários formatados em pt-BR (ex: `1.234,56`) -- Números de notas sem zeros à esquerda (ex: `0001` exibido como `1`) -- Botão "Exportar PDF" → chamar use case `exportar_pdf` - -### 4.6 Modais globais (RNF06) - -| Modal | Trigger | -|-------|---------| -| Erro genérico | Qualquer erro com mensagem + botão OK | -| Aviso consolidado | Final de importação com lista de categorias de problema | -| Confirmação de intervalo | Faltantes > 10.000 por série | -| Confirmação de exclusão | Excluir layout | -| Conflito de nome | Importar JSON com nome existente | -| Banco corrompido | Inicialização com `config.db` inválido | - ---- - -## Fase 5 — Testes - -**Dependência:** Fases 1–4 concluídas (testes unitários podem ser escritos junto com cada fase). - -### 5.1 Testes unitários — Domain - -- `parser_monetario`: todos os casos da tabela do PRD + casos extremos -- `detector_sequencia`: sequência completa, com faltantes, série com um único registro, intervalos > 10.000 -- `detector_duplicidade`: sem duplicatas, com duplicatas, múltiplas séries -- Validação de série (regex `[0-9]{1,3}`): válidos e inválidos -- Validação de numero zero: deve ser descartado - -### 5.2 Testes de integração — Infrastructure - -- Leitura CSV: UTF-8, Windows-1252, diferentes delimitadores, linhas malformadas, arquivo > 50 MB -- Leitura XLSX: aba correta, posição `LetraLinha`, arquivo corrompido, limite de tamanho -- SQLite: migrations, CRUD de layouts, banco corrompido → recriação - -### 5.3 Testes de integração — Application - -- Fluxo completo CSV → análise → resultado -- Fluxo completo XLSX → análise → resultado -- Importação de layout JSON: válido, malformado, conflito de nome com sobrescrita, conflito com renomeação -- Intervalo > 10.000: verificar que `ResultadoPreAnalise` é retornado antes da expansão - -### 5.4 Testes manuais de UI - -- Todas as mensagens em PT-BR -- Formatação monetária correta (pt-BR) -- Paginação funcionando -- Geração do PDF com metadados corretos -- Fluxo XLSX: aba exibida imediatamente após seleção do arquivo - ---- - -## Fase 6 — Polimento e Empacotamento - -### 6.1 Strings e idioma - -- Revisar todas as mensagens de erro/aviso para PT-BR (RNF05) -- Garantir que nenhuma string de erro interna vaze para a UI como texto bruto - -### 6.2 Performance - -- Validar com arquivo de 100.000 registros (RNF03) -- Confirmar que detecção de faltantes é incremental (sem lista intermediária antes da confirmação) -- Medir tempo de importação e análise - -### 6.3 Build multiplataforma - -- Linux: validar caminho `~/.config/comparador-notas/config.db` -- Windows: validar caminho `%APPDATA%\comparador-notas\config.db` -- macOS: validar caminho `~/Library/Application Support/comparador-notas/config.db` - -### 6.4 Empacotamento - -- Configurar ícone da aplicação -- Build de release otimizado: `cargo build --release` -- Testar executável standalone (sem instalação, sem dependências externas) - ---- - -## Ordem de Execução Recomendada - -``` -Fase 0 → Fase 1 (com testes unitários) → Fase 2.1/2.2/2.3 → Fase 2.4/2.5 - → Fase 3.1/3.2 → Fase 4.1/4.2/4.3/4.5 → Fase 2.6/3.3 → Fase 3.4/4.4 - → Fase 5 → Fase 6 -``` - -> O núcleo de domínio deve estar **totalmente testado** antes de qualquer integração. O `parser_monetario` e o `detector_sequencia` são os componentes mais críticos do sistema. - ---- - -## Dependências entre Componentes - -``` -domain/errors.rs - └─ domain/entities/ (nota, serie, layout, resultado_analise) - └─ domain/services/ (parser_monetario, detector_sequencia, detector_duplicidade) - └─ application/usecases/ (importar_arquivo, executar_analise, exportar_pdf) - └─ infrastructure/ (csv_reader, xlsx_reader, sqlite/, pdf_generator) - └─ ui/ (app, screens/) -``` - -Nenhuma camada pode importar de uma camada acima dela na hierarquia. diff --git a/assets/fonts/LiberationSans-Bold.ttf b/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..dc5d57f Binary files /dev/null and b/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/assets/fonts/LiberationSans-Regular.ttf b/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..e633985 Binary files /dev/null and b/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/application/usecases/executar_analise.rs b/src/application/usecases/executar_analise.rs index 9fb5ee8..919ea62 100644 --- a/src/application/usecases/executar_analise.rs +++ b/src/application/usecases/executar_analise.rs @@ -1,5 +1,6 @@ use crate::domain::{ entities::{ + chave_serie::ChaveSerie, nota::Nota, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, }, @@ -15,30 +16,31 @@ use std::collections::HashMap; /// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e, /// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`. pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise { - // Agrupar por série - let mut por_serie: HashMap> = HashMap::new(); + // Agrupar por (serie, documento_tipo) + let mut por_chave: HashMap> = HashMap::new(); for nota in notas { - por_serie.entry(nota.serie.clone()).or_default().push(nota); + let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone()); + por_chave.entry(chave).or_default().push(nota); } let mut intervalos_por_serie = HashMap::new(); let mut soma_total = Decimal::ZERO; - let mut soma_por_serie: HashMap = HashMap::new(); - let mut total_por_serie: HashMap = HashMap::new(); + let mut soma_por_serie: HashMap = HashMap::new(); + let mut total_por_serie: HashMap = HashMap::new(); - for (serie, notas_serie) in &por_serie { + for (chave, notas_grupo) in &por_chave { // Somar valores - for nota in notas_serie.iter() { + for nota in notas_grupo.iter() { if let Some(v) = nota.valor { soma_total += v; - *soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v; + *soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v; } } - *total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len(); + *total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len(); // Calcular intervalo de faltantes - if let Some(intervalo) = calcular_intervalo(notas_serie) { - intervalos_por_serie.insert(serie.clone(), intervalo); + if let Some(intervalo) = calcular_intervalo(notas_grupo) { + intervalos_por_serie.insert(chave.clone(), intervalo); } } @@ -53,29 +55,30 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise { } } -/// Verifica se alguma série excede o limite de faltantes. -/// Retorna lista de séries que precisam de confirmação. -pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(String, u64)> { +/// Verifica se algum grupo excede o limite de faltantes. +/// Retorna lista de chaves que precisam de confirmação. +pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> { pre.intervalos_por_serie .iter() .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) - .map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes)) + .map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes)) .collect() } /// Expande a pré-análise para o resultado completo, materializando a lista de faltantes. /// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite). pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise { - let mut por_serie: HashMap> = HashMap::new(); + let mut por_chave: HashMap> = HashMap::new(); for nota in notas { - por_serie.entry(nota.serie.clone()).or_default().push(nota); + let chave = ChaveSerie::new(nota.serie.clone(), nota.documento_tipo.clone()); + por_chave.entry(chave).or_default().push(nota); } let mut faltantes_por_serie = HashMap::new(); - for (serie, notas_serie) in &por_serie { - let faltantes = detectar_faltantes(notas_serie); - faltantes_por_serie.insert(serie.clone(), faltantes); + for (chave, notas_grupo) in &por_chave { + let faltantes = detectar_faltantes(notas_grupo); + faltantes_por_serie.insert(chave.clone(), faltantes); } ResultadoAnalise { @@ -100,7 +103,11 @@ mod tests { } fn nota(numero: u64, serie: &str, valor: Option) -> Nota { - Nota::new(numero, serie.to_string(), valor, None) + Nota::new(numero, serie.to_string(), None, valor, None) + } + + fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota { + Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None) } #[test] @@ -111,7 +118,8 @@ mod tests { nota(4, "001", Some(dec!(50.00))), ]; let resultado = executar_analise(¬as); - assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]); + let chave = ChaveSerie::new("001".to_string(), None); + assert_eq!(resultado.faltantes_por_serie[&chave], vec![3u64]); assert_eq!(resultado.soma_total, dec!(350.00)); } @@ -124,8 +132,10 @@ mod tests { nota(2, "002", None), ]; let resultado = executar_analise(¬as); - assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]); - assert!(resultado.faltantes_por_serie["002"].is_empty()); + let chave001 = ChaveSerie::new("001".to_string(), None); + let chave002 = ChaveSerie::new("002".to_string(), None); + assert_eq!(resultado.faltantes_por_serie[&chave001], vec![2u64]); + assert!(resultado.faltantes_por_serie[&chave002].is_empty()); } #[test] @@ -134,6 +144,23 @@ mod tests { let pre = pre_analisar(¬as); let excessivos = series_com_intervalo_excessivo(&pre); assert_eq!(excessivos.len(), 1); - assert_eq!(excessivos[0].0, "001"); + assert_eq!(excessivos[0].0, ChaveSerie::new("001".to_string(), None)); + } + + #[test] + fn analise_nfe_nfce_mesma_serie_grupos_separados() { + // NFE série 001: 1, 3 → faltante 2 + // NFCE série 001: 2, 5 → faltante 3 e 4 + let notas = vec![ + nota_com_tipo(1, "001", "NFE"), + nota_com_tipo(3, "001", "NFE"), + nota_com_tipo(2, "001", "NFCE"), + nota_com_tipo(5, "001", "NFCE"), + ]; + let resultado = executar_analise(¬as); + let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string())); + let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string())); + assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]); + assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]); } } diff --git a/src/application/usecases/importar_arquivo.rs b/src/application/usecases/importar_arquivo.rs index def8fed..1a7b847 100644 --- a/src/application/usecases/importar_arquivo.rs +++ b/src/application/usecases/importar_arquivo.rs @@ -40,6 +40,7 @@ pub fn importar_csv( &resultado.linhas, config.indice_numero, config.indice_serie, + config.indice_documento_tipo, config.indice_valor, config.indice_data, &mut avisos, @@ -86,12 +87,18 @@ pub fn importar_xlsx( .as_ref() .and_then(|p| xlsx_reader::parsear_letra_linha(p)) .map(|c| c.coluna as usize); + let col_documento_tipo = config + .pos_documento_tipo + .as_ref() + .and_then(|p| xlsx_reader::parsear_letra_linha(p)) + .map(|c| c.coluna as usize); let mut avisos = resultado.avisos; let notas = mapear_linhas_para_notas( &resultado.linhas, col_numero, col_serie, + col_documento_tipo, col_valor, col_data, &mut avisos, @@ -106,6 +113,7 @@ fn mapear_linhas_para_notas( linhas: &[Vec], idx_numero: usize, idx_serie: usize, + idx_documento_tipo: Option, idx_valor: Option, idx_data: Option, avisos: &mut ResumoAvisos, @@ -195,7 +203,17 @@ fn mapear_linhas_para_notas( None }; - notas.push(Nota::new(numero, serie, valor, data)); + // Extrair tipo de documento (opcional) — qualquer string não vazia + let documento_tipo = if let Some(idx) = idx_documento_tipo { + match linha.get(idx) { + Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()), + _ => None, + } + } else { + None + }; + + notas.push(Nota::new(numero, serie, documento_tipo, valor, data)); } notas diff --git a/src/domain/entities/chave_serie.rs b/src/domain/entities/chave_serie.rs new file mode 100644 index 0000000..122d3a7 --- /dev/null +++ b/src/domain/entities/chave_serie.rs @@ -0,0 +1,26 @@ +/// Chave composta que identifica um grupo de notas fiscais. +/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais. +/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento +/// somente por série (retrocompatível). +#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub struct ChaveSerie { + pub serie: String, + pub documento_tipo: Option, +} + +impl ChaveSerie { + pub fn new(serie: String, documento_tipo: Option) -> Self { + Self { + serie, + documento_tipo, + } + } + + /// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente. + pub fn label(&self) -> String { + match &self.documento_tipo { + Some(tipo) => format!("{} / {}", self.serie, tipo), + None => self.serie.clone(), + } + } +} diff --git a/src/domain/entities/layout.rs b/src/domain/entities/layout.rs index 4ea9c94..9e342fa 100644 --- a/src/domain/entities/layout.rs +++ b/src/domain/entities/layout.rs @@ -34,6 +34,8 @@ pub struct LayoutCsv { pub indice_valor: Option, /// Índice da coluna Data (base 0, None se não mapeado) pub indice_data: Option, + /// Índice da coluna Tipo Documento (base 0, None se não mapeado) + pub indice_documento_tipo: Option, } impl Default for LayoutCsv { @@ -46,6 +48,7 @@ impl Default for LayoutCsv { indice_serie: 1, indice_valor: None, indice_data: None, + indice_documento_tipo: None, } } } @@ -63,6 +66,8 @@ pub struct LayoutXlsx { pub pos_valor: Option, /// Posição inicial da coluna Data (None se não mapeado) pub pos_data: Option, + /// Posição inicial da coluna Tipo Documento (None se não mapeado) + pub pos_documento_tipo: Option, } impl Default for LayoutXlsx { @@ -73,6 +78,7 @@ impl Default for LayoutXlsx { pos_serie: String::new(), pos_valor: None, pos_data: None, + pos_documento_tipo: None, } } } @@ -129,6 +135,8 @@ pub enum LayoutJson { indice_serie: usize, indice_valor: Option, indice_data: Option, + #[serde(default)] + indice_documento_tipo: Option, }, Xlsx { nome: String, @@ -137,6 +145,8 @@ pub enum LayoutJson { pos_serie: String, pos_valor: Option, pos_data: Option, + #[serde(default)] + pos_documento_tipo: Option, }, } @@ -154,6 +164,7 @@ impl TryFrom for Layout { indice_serie, indice_valor, indice_data, + indice_documento_tipo, } => { if nome.trim().is_empty() { return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( @@ -176,6 +187,7 @@ impl TryFrom for Layout { indice_serie, indice_valor, indice_data, + indice_documento_tipo, }, }) } @@ -186,6 +198,7 @@ impl TryFrom for Layout { pos_serie, pos_valor, pos_data, + pos_documento_tipo, } => { if nome.trim().is_empty() { return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente( @@ -211,6 +224,7 @@ impl TryFrom for Layout { pos_serie, pos_valor, pos_data, + pos_documento_tipo, }, }) } @@ -230,6 +244,7 @@ impl From<&Layout> for LayoutJson { indice_serie: config.indice_serie, indice_valor: config.indice_valor, indice_data: config.indice_data, + indice_documento_tipo: config.indice_documento_tipo, }, Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx { nome: nome.clone(), @@ -238,6 +253,7 @@ impl From<&Layout> for LayoutJson { pos_serie: config.pos_serie.clone(), pos_valor: config.pos_valor.clone(), pos_data: config.pos_data.clone(), + pos_documento_tipo: config.pos_documento_tipo.clone(), }, } } diff --git a/src/domain/entities/mod.rs b/src/domain/entities/mod.rs index ba12ded..5f5e86e 100644 --- a/src/domain/entities/mod.rs +++ b/src/domain/entities/mod.rs @@ -2,3 +2,4 @@ pub mod layout; pub mod nota; pub mod resultado_analise; pub mod serie; +pub mod chave_serie; diff --git a/src/domain/entities/nota.rs b/src/domain/entities/nota.rs index 9937e1f..4e805e6 100644 --- a/src/domain/entities/nota.rs +++ b/src/domain/entities/nota.rs @@ -2,13 +2,15 @@ use chrono::NaiveDate; use rust_decimal::Decimal; /// Representa uma nota fiscal com seus campos lógicos. -/// `numero + serie` é o identificador único de cada nota. +/// `numero + serie + documento_tipo` é o identificador único de cada nota. #[derive(Debug, Clone)] pub struct Nota { /// Número incremental da nota. Armazenado como u64. pub numero: u64, /// Série da nota (1–3 dígitos numéricos). Ex: "001", "1". pub serie: String, + /// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado. + pub documento_tipo: Option, /// Valor monetário da nota (opcional). pub valor: Option, /// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras). @@ -19,12 +21,14 @@ impl Nota { pub fn new( numero: u64, serie: String, + documento_tipo: Option, valor: Option, data: Option, ) -> Self { Self { numero, serie, + documento_tipo, valor, data, } diff --git a/src/domain/entities/resultado_analise.rs b/src/domain/entities/resultado_analise.rs index 93815cd..1bc074c 100644 --- a/src/domain/entities/resultado_analise.rs +++ b/src/domain/entities/resultado_analise.rs @@ -1,3 +1,4 @@ +use crate::domain::entities::chave_serie::ChaveSerie; use rust_decimal::Decimal; use std::collections::HashMap; @@ -5,15 +6,15 @@ use std::collections::HashMap; /// Usado para verificar se algum intervalo excede 10.000 registros (RF04). #[derive(Debug, Clone)] pub struct ResultadoPreAnalise { - /// Mapa série → (minimo, maximo, contagem_faltantes) - pub intervalos_por_serie: HashMap, + /// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes) + pub intervalos_por_serie: HashMap, /// Duplicatas já processadas (não dependem dos faltantes) - pub duplicadas_por_serie: HashMap>, + pub duplicadas_por_serie: HashMap>, /// Somas já calculadas pub soma_total: Decimal, - pub soma_por_serie: HashMap, - /// Total de notas processadas por série - pub total_por_serie: HashMap, + pub soma_por_serie: HashMap, + /// Total de notas processadas por (serie, documento_tipo) + pub total_por_serie: HashMap, } /// Intervalo de sequência de uma série. @@ -33,16 +34,16 @@ impl IntervaloSerie { /// Resultado completo da análise, com a lista materializada de faltantes. #[derive(Debug, Clone)] pub struct ResultadoAnalise { - /// Números faltantes por série (ordenados crescentemente) - pub faltantes_por_serie: HashMap>, - /// Duplicatas: mapa série → [(numero, contagem_ocorrencias)] - pub duplicadas_por_serie: HashMap>, + /// Números faltantes por (serie, documento_tipo) (ordenados crescentemente) + pub faltantes_por_serie: HashMap>, + /// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)] + pub duplicadas_por_serie: HashMap>, /// Soma total de todos os valores pub soma_total: Decimal, - /// Soma por série - pub soma_por_serie: HashMap, - /// Total de notas processadas por série - pub total_por_serie: HashMap, + /// Soma por (serie, documento_tipo) + pub soma_por_serie: HashMap, + /// Total de notas processadas por (serie, documento_tipo) + pub total_por_serie: HashMap, } impl ResultadoAnalise { diff --git a/src/domain/services/detector_duplicidade.rs b/src/domain/services/detector_duplicidade.rs index 3a05893..f82035e 100644 --- a/src/domain/services/detector_duplicidade.rs +++ b/src/domain/services/detector_duplicidade.rs @@ -1,16 +1,17 @@ +use crate::domain::entities::chave_serie::ChaveSerie; use crate::domain::entities::nota::Nota; use std::collections::HashMap; /// Detecta registros duplicados em uma lista de notas. /// -/// Retorna um mapa (numero, serie) → contagem de ocorrências, +/// Retorna um mapa (numero, serie, documento_tipo) → contagem de ocorrências, /// contendo apenas grupos com mais de uma ocorrência. -pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> { - let mut contagem: HashMap<(u64, String), usize> = HashMap::new(); +pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option), usize> { + let mut contagem: HashMap<(u64, String, Option), usize> = HashMap::new(); for nota in notas { *contagem - .entry((nota.numero, nota.serie.clone())) + .entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone())) .or_insert(0) += 1; } @@ -19,18 +20,19 @@ pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> { contagem } -/// Agrupa as duplicidades por série. +/// Agrupa as duplicidades por (serie, documento_tipo). /// -/// Retorna HashMap>, ordenado por numero crescente. -pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap> { +/// Retorna HashMap>, ordenado por numero crescente. +pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap> { let raw = detectar_duplicidades(notas); - let mut result: HashMap> = HashMap::new(); + let mut result: HashMap> = HashMap::new(); - for ((numero, serie), contagem) in raw { - result.entry(serie).or_default().push((numero, contagem)); + for ((numero, serie, documento_tipo), contagem) in raw { + let chave = ChaveSerie::new(serie, documento_tipo); + result.entry(chave).or_default().push((numero, contagem)); } - // Ordenar por numero dentro de cada série + // Ordenar por numero dentro de cada grupo for lista in result.values_mut() { lista.sort_by_key(|(num, _)| *num); } @@ -44,7 +46,11 @@ mod tests { use crate::domain::entities::nota::Nota; fn nota(numero: u64, serie: &str) -> Nota { - Nota::new(numero, serie.to_string(), None, None) + Nota::new(numero, serie.to_string(), None, None, None) + } + + fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota { + Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None) } #[test] @@ -58,7 +64,7 @@ mod tests { fn com_duplicata_simples() { let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")]; let dup = detectar_duplicidades(¬as); - assert_eq!(dup.get(&(1, "001".to_string())), Some(&2)); + assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2)); assert_eq!(dup.len(), 1); } @@ -66,17 +72,30 @@ mod tests { fn duplicata_multiplas_ocorrencias() { let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")]; let dup = detectar_duplicidades(¬as); - assert_eq!(dup.get(&(4, "001".to_string())), Some(&3)); + assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3)); } #[test] fn mesmo_numero_series_diferentes_nao_e_duplicata() { - // Número 1 em séries diferentes não é duplicata let notas = vec![nota(1, "001"), nota(1, "002")]; let dup = detectar_duplicidades(¬as); assert!(dup.is_empty()); } + #[test] + fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() { + let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")]; + let dup = detectar_duplicidades(¬as); + assert!(dup.is_empty()); + } + + #[test] + fn mesmo_numero_serie_tipo_igual_e_duplicata() { + let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")]; + let dup = detectar_duplicidades(¬as); + assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2)); + } + #[test] fn agrupamento_por_serie() { let notas = vec![ @@ -88,13 +107,13 @@ mod tests { nota(2, "001"), ]; let por_serie = duplicidades_por_serie(¬as); - // Série 001 deve ter notas 1 e 2 duplicadas - let serie001 = por_serie.get("001").unwrap(); + let chave001 = ChaveSerie::new("001".to_string(), None); + let chave002 = ChaveSerie::new("002".to_string(), None); + let serie001 = por_serie.get(&chave001).unwrap(); assert_eq!(serie001.len(), 2); assert_eq!(serie001[0], (1, 2)); assert_eq!(serie001[1], (2, 2)); - // Série 002 deve ter nota 1 duplicada - let serie002 = por_serie.get("002").unwrap(); + let serie002 = por_serie.get(&chave002).unwrap(); assert_eq!(serie002.len(), 1); assert_eq!(serie002[0], (1, 2)); } diff --git a/src/domain/services/detector_sequencia.rs b/src/domain/services/detector_sequencia.rs index ebae722..6c980c9 100644 --- a/src/domain/services/detector_sequencia.rs +++ b/src/domain/services/detector_sequencia.rs @@ -67,6 +67,32 @@ pub fn detectar_faltantes(notas: &[&Nota]) -> Vec { faltantes } +/// Agrupa uma lista **ordenada** de faltantes em intervalos contíguos. +/// +/// Retorna pares `(inicio, fim)`. Números isolados têm `inicio == fim`. +/// +/// # Exemplo +/// `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]` +pub fn agrupar_contiguos(faltantes: &[u64]) -> Vec<(u64, u64)> { + if faltantes.is_empty() { + return Vec::new(); + } + + let mut grupos = Vec::new(); + let mut inicio = faltantes[0]; + let mut anterior = faltantes[0]; + + for &num in &faltantes[1..] { + if num != anterior + 1 { + grupos.push((inicio, anterior)); + inicio = num; + } + anterior = num; + } + grupos.push((inicio, anterior)); + grupos +} + #[cfg(test)] mod tests { use super::*; @@ -74,7 +100,7 @@ mod tests { use rust_decimal::Decimal; fn nota(numero: u64) -> Nota { - Nota::new(numero, "001".to_string(), None, None) + Nota::new(numero, "001".to_string(), None, None, None) } #[test] @@ -136,4 +162,27 @@ mod tests { let refs: Vec<&Nota> = notas.iter().collect(); assert_eq!(detectar_faltantes(&refs), Vec::::new()); } + + #[test] + fn agrupar_vazio() { + assert_eq!(agrupar_contiguos(&[]), Vec::<(u64, u64)>::new()); + } + + #[test] + fn agrupar_isolados() { + assert_eq!(agrupar_contiguos(&[1, 3, 5]), vec![(1, 1), (3, 3), (5, 5)]); + } + + #[test] + fn agrupar_um_intervalo() { + assert_eq!(agrupar_contiguos(&[1, 2, 3]), vec![(1, 3)]); + } + + #[test] + fn agrupar_misto() { + assert_eq!( + agrupar_contiguos(&[1, 2, 3, 5, 8, 9]), + vec![(1, 3), (5, 5), (8, 9)] + ); + } } diff --git a/src/infrastructure/pdf_generator.rs b/src/infrastructure/pdf_generator.rs index d47578c..fa8de1b 100644 --- a/src/infrastructure/pdf_generator.rs +++ b/src/infrastructure/pdf_generator.rs @@ -1,3 +1,4 @@ +use crate::domain::entities::chave_serie::ChaveSerie; use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::services::parser_monetario::formatar_valor_br; use chrono::{DateTime, Local}; @@ -7,6 +8,14 @@ use genpdf::{ }; use std::path::Path; +// Fontes embutidas no binário em tempo de compilação. +// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada), +// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados. +const FONT_REGULAR: &[u8] = + include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf"); +const FONT_BOLD: &[u8] = + include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf"); + /// Metadados do relatório. pub struct MetadadosRelatorio { pub nome_arquivo: String, @@ -36,9 +45,7 @@ impl PdfGenerator for GenpdfGenerator { meta: &MetadadosRelatorio, caminho_saida: &Path, ) -> Result<(), String> { - // Carregar fonte do sistema (DejaVu Sans) - let font_family = carregar_fonte_sistema() - .map_err(|e| format!("Erro ao carregar fontes: {}", e))?; + let font_family = carregar_fonte_familia()?; let mut doc = Document::new(font_family); doc.set_title("Relatório — Comparador de Notas"); @@ -68,6 +75,10 @@ impl PdfGenerator for GenpdfGenerator { ))); doc.push(Break::new(1)); + // Ordenar chaves + let mut chaves_ordenadas: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect(); + chaves_ordenadas.sort(); + // Totais doc.push( Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)), @@ -77,15 +88,12 @@ impl PdfGenerator for GenpdfGenerator { formatar_valor_br(&resultado.soma_total) ))); - let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect(); - series_ordenadas.sort(); - - for serie in &series_ordenadas { - let soma = &resultado.soma_por_serie[*serie]; - let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0); + for chave in &chaves_ordenadas { + let soma = &resultado.soma_por_serie[*chave]; + let total = resultado.total_por_serie.get(*chave).copied().unwrap_or(0); doc.push(Paragraph::new(format!( " Série {}: {} nota(s) — R$ {}", - serie, + chave.label(), total, formatar_valor_br(soma) ))); @@ -98,18 +106,22 @@ impl PdfGenerator for GenpdfGenerator { Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)), ); - for serie in &series_ordenadas { - let faltantes = match resultado.faltantes_por_serie.get(*serie) { + // Use faltantes keys for this section (may differ from soma keys if no values) + let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect(); + chaves_faltantes.sort(); + + for chave in &chaves_faltantes { + let faltantes = match resultado.faltantes_por_serie.get(*chave) { Some(f) if !f.is_empty() => f, _ => { - doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie))); + doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label()))); continue; } }; doc.push(Paragraph::new(format!( " Série {}: {} faltante(s)", - serie, + chave.label(), faltantes.len() ))); let numeros: Vec = faltantes.iter().map(|n| n.to_string()).collect(); @@ -123,24 +135,27 @@ impl PdfGenerator for GenpdfGenerator { Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)), ); - for serie in &series_ordenadas { - let duplicatas = match resultado.duplicadas_por_serie.get(*serie) { + let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect(); + chaves_dup.sort(); + + for chave in &chaves_dup { + let duplicatas = match resultado.duplicadas_por_serie.get(*chave) { Some(d) if !d.is_empty() => d, _ => { - doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie))); + doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label()))); continue; } }; doc.push(Paragraph::new(format!( " Série {}: {} grupo(s) duplicado(s)", - serie, + chave.label(), duplicatas.len() ))); for (numero, count) in duplicatas { doc.push(Paragraph::new(format!( " NF {} / Série {} — {} ocorrências", - numero, serie, count + numero, chave.label(), count ))); } } @@ -153,41 +168,21 @@ impl PdfGenerator for GenpdfGenerator { } } -/// Tenta carregar fontes DejaVu Sans do sistema. -fn carregar_fonte_sistema() -> Result, String> { - // Caminhos comuns no Linux, Windows e macOS - let candidatos_regular = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "C:\\Windows\\Fonts\\arial.ttf", - "/Library/Fonts/Arial.ttf", - ]; - let candidatos_bold = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", - "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", - "C:\\Windows\\Fonts\\arialbd.ttf", - "/Library/Fonts/Arial Bold.ttf", - ]; - - let regular_path = candidatos_regular - .iter() - .find(|p| std::path::Path::new(p).exists()) - .ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?; - - let bold_path = candidatos_bold - .iter() - .find(|p| std::path::Path::new(p).exists()) - .unwrap_or(regular_path); - - let regular = fonts::FontData::load(regular_path, None) - .map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?; - let bold = fonts::FontData::load(bold_path, None) - .map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?; +/// Constrói a família de fontes a partir dos bytes embutidos no binário. +/// Usa Liberation Sans (open-source, ~402 KB/variante) em vez de carregar +/// fontes do sistema (Arial ~993 KB/variante, sem subsetting). +fn carregar_fonte_familia() -> Result, String> { + let regular = fonts::FontData::new(FONT_REGULAR.to_vec(), None) + .map_err(|e| format!("Erro ao inicializar fonte regular: {}", e))?; + let bold = fonts::FontData::new(FONT_BOLD.to_vec(), None) + .map_err(|e| format!("Erro ao inicializar fonte bold: {}", e))?; + // genpdf exige os 4 slots do FontFamily. Como o relatório nunca usa itálico, + // italic e bold_italic reusam os dados de regular/bold respectivamente. Ok(fonts::FontFamily { - regular: regular.clone(), - bold, italic: regular.clone(), - bold_italic: regular, + bold_italic: bold.clone(), + regular, + bold, }) } diff --git a/src/infrastructure/sqlite/layout_repository.rs b/src/infrastructure/sqlite/layout_repository.rs index 385aae5..ae1d7d1 100644 --- a/src/infrastructure/sqlite/layout_repository.rs +++ b/src/infrastructure/sqlite/layout_repository.rs @@ -8,8 +8,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { conn.execute( "INSERT INTO layouts (nome, tipo, delimitador, encoding, linha_cabecalho, - indice_numero, indice_serie, indice_valor, indice_data) - VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + indice_numero, indice_serie, indice_valor, indice_data, + indice_documento_tipo) + VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ nome, config.delimitador.to_string(), @@ -19,6 +20,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { config.indice_serie as i64, config.indice_valor.map(|v| v as i64), config.indice_data.map(|v| v as i64), + config.indice_documento_tipo.map(|v| v as i64), ], )?; Ok(conn.last_insert_rowid()) @@ -26,8 +28,9 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { Layout::Xlsx { nome, config, .. } => { conn.execute( "INSERT INTO layouts - (nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data) - VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)", + (nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data, + pos_documento_tipo) + VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)", params![ nome, config.aba, @@ -35,6 +38,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result { config.pos_serie, config.pos_valor, config.pos_data, + config.pos_documento_tipo, ], )?; Ok(conn.last_insert_rowid()) @@ -54,8 +58,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { "UPDATE layouts SET nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, - indice_valor = ?7, indice_data = ?8 - WHERE id = ?9", + indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9 + WHERE id = ?10", params![ nome, config.delimitador.to_string(), @@ -65,6 +69,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { config.indice_serie as i64, config.indice_valor.map(|v| v as i64), config.indice_data.map(|v| v as i64), + config.indice_documento_tipo.map(|v| v as i64), id, ], )?; @@ -73,8 +78,8 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { conn.execute( "UPDATE layouts SET nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, - pos_valor = ?5, pos_data = ?6 - WHERE id = ?7", + pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7 + WHERE id = ?8", params![ nome, config.aba, @@ -82,6 +87,7 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { config.pos_serie, config.pos_valor, config.pos_data, + config.pos_documento_tipo, id, ], )?; @@ -96,7 +102,8 @@ pub fn listar(conn: &Connection) -> Result> { "SELECT id, nome, tipo, delimitador, encoding, linha_cabecalho, indice_numero, indice_serie, indice_valor, indice_data, - aba, pos_numero, pos_serie, pos_valor, pos_data + aba, pos_numero, pos_serie, pos_valor, pos_data, + indice_documento_tipo, pos_documento_tipo FROM layouts ORDER BY nome ASC", )?; @@ -124,6 +131,9 @@ pub fn listar(conn: &Connection) -> Result> { indice_data: row .get::<_, Option>(9)? .map(|v| v as usize), + indice_documento_tipo: row + .get::<_, Option>(15)? + .map(|v| v as usize), }, }) } else { @@ -136,6 +146,7 @@ pub fn listar(conn: &Connection) -> Result> { pos_serie: row.get(12)?, pos_valor: row.get(13)?, pos_data: row.get(14)?, + pos_documento_tipo: row.get(16)?, }, }) } diff --git a/src/infrastructure/sqlite/migrations.rs b/src/infrastructure/sqlite/migrations.rs index 7b4446f..4510dd9 100644 --- a/src/infrastructure/sqlite/migrations.rs +++ b/src/infrastructure/sqlite/migrations.rs @@ -1,7 +1,7 @@ use rusqlite::{Connection, Result}; /// Versão atual do schema do banco de dados. -const VERSAO_SCHEMA_ATUAL: i64 = 2; +const VERSAO_SCHEMA_ATUAL: i64 = 3; /// Aplica todas as migrations necessárias para atualizar o banco /// para a versão mais recente. @@ -27,6 +27,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> { if versao_atual < 2 { migration_v2(conn)?; } + if versao_atual < 3 { + migration_v3(conn)?; + } if versao_atual == 0 { conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?; @@ -38,6 +41,16 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> { } /// Migration v2: adicionar índice único em layouts.nome. + +/// Migration v3: adicionar colunas de tipo de documento nos layouts. +fn migration_v3(conn: &Connection) -> Result<()> { + conn.execute_batch( + "ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; + ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", + )?; + Ok(()) +} + /// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar /// em bancos que já possuem nomes repetidos. fn migration_v2(conn: &Connection) -> Result<()> { diff --git a/src/ui/app.rs b/src/ui/app.rs index f7ec5d4..fa363b6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -4,6 +4,7 @@ use crate::application::usecases::{ }; use crate::domain::{ entities::{ + chave_serie::ChaveSerie, layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo}, nota::Nota, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, @@ -82,7 +83,7 @@ pub enum ResultadoPendente { /// Pré-análise concluída mas precisa de confirmação do usuário. AguardandoConfirmacao { pre: ResultadoPreAnalise, - series_excessivas: Vec<(String, u64)>, + series_excessivas: Vec<(ChaveSerie, u64)>, avisos: ResumoAvisos, notas: Vec, }, @@ -464,10 +465,10 @@ impl App { }; let msg = series_excessivas .iter() - .map(|(serie, count)| { + .map(|(chave, count)| { format!( "Série {}: intervalo de {} faltantes detectado", - serie, count + chave.label(), count ) }) .collect::>() @@ -506,10 +507,10 @@ impl App { if !excessivos.is_empty() { let msg = excessivos .iter() - .map(|(serie, count)| { + .map(|(chave, count)| { format!( "Série {}: intervalo de {} faltantes detectado", - serie, count + chave.label(), count ) }) .collect::>() diff --git a/src/ui/screens/configuracao_colunas.rs b/src/ui/screens/configuracao_colunas.rs index 2dfd229..a69fee5 100644 --- a/src/ui/screens/configuracao_colunas.rs +++ b/src/ui/screens/configuracao_colunas.rs @@ -233,6 +233,11 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) { "Data (opcional):", &mut app.layout_csv_atual.indice_data, ); + campo_indice_opcional( + ui, + "Tipo Documento (opcional):", + &mut app.layout_csv_atual.indice_documento_tipo, + ); }); } @@ -281,6 +286,11 @@ fn renderizar_xlsx(ui: &mut Ui, app: &mut App) { &mut app.layout_xlsx_atual.pos_valor, ); campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data); + campo_letra_linha_opcional( + ui, + "Tipo Documento (opcional):", + &mut app.layout_xlsx_atual.pos_documento_tipo, + ); }); } @@ -340,6 +350,9 @@ fn validar_config(app: &App) -> (bool, Vec) { if let Some(d) = c.indice_data { indices.push(("Data".to_string(), d)); } + if let Some(t) = c.indice_documento_tipo { + indices.push(("Tipo Documento".to_string(), t)); + } verificar_duplicados(&indices, &mut erros); } TipoArquivo::Xlsx => { diff --git a/src/ui/screens/resultado.rs b/src/ui/screens/resultado.rs index cf38135..dbcee38 100644 --- a/src/ui/screens/resultado.rs +++ b/src/ui/screens/resultado.rs @@ -1,7 +1,7 @@ use crate::application::usecases::exportar_pdf::exportar_pdf; use crate::domain::{ - entities::resultado_analise::ResultadoAnalise, - services::parser_monetario::formatar_valor_br, + entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise}, + services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br}, }; use crate::infrastructure::pdf_generator::GenpdfGenerator; use crate::ui::app::{AcaoModal, App, EstadoApp}; @@ -86,15 +86,15 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) { formatar_valor_br(&resultado.soma_total) )); - let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect(); - series.sort(); + let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect(); + chaves.sort(); - for serie in series { - let soma = &resultado.soma_por_serie[serie]; - let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0); + for chave in chaves { + let soma = &resultado.soma_por_serie[chave]; + let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); ui.label(format!( " Série {}: {} nota(s) — R$ {}", - serie, + chave.label(), total_notas, formatar_valor_br(soma) )); @@ -104,12 +104,9 @@ fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) { fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) { let total_faltantes = resultado.total_faltantes(); ui.label( - egui::RichText::new(format!( - "Notas Faltantes ({} total)", - total_faltantes - )) - .heading() - .strong(), + egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes)) + .heading() + .strong(), ); ui.add_space(4.0); @@ -118,22 +115,36 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise return; } - let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect(); - series.sort(); + let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect(); + chaves.sort(); - for serie in series { - let faltantes = &resultado.faltantes_por_serie[serie]; + for chave in chaves { + let faltantes = &resultado.faltantes_por_serie[chave]; if faltantes.is_empty() { continue; } - ui.label(format!( - "Série {} — {} faltante(s):", - serie, - faltantes.len() - )); + // Estatística de completude por série + let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); + let total_esperado = total_notas + faltantes.len(); + let percentual = total_notas as f64 / total_esperado as f64 * 100.0; - // Paginação + ui.horizontal(|ui| { + ui.label(format!( + "Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):", + chave.label(), + faltantes.len(), + total_notas, + total_esperado, + percentual, + )); + if ui.button("📋 Copiar").on_hover_text("Copiar todos os números faltantes").clicked() { + let texto = faltantes.iter().map(|n| n.to_string()).collect::>().join(", "); + ui.ctx().copy_text(texto); + } + }); + + // Paginação (por faltante individual) let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; if app.pagina_faltantes >= total_paginas { app.pagina_faltantes = 0; @@ -142,8 +153,13 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise let inicio = app.pagina_faltantes * app.itens_por_pagina; let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); - for numero in &faltantes[inicio..fim] { - ui.label(format!(" • {}", numero)); + // Exibir grupos contíguos da página atual + for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) { + if a == b { + ui.label(format!(" • {}", a)); + } else { + ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1)); + } } if total_paginas > 1 { @@ -181,20 +197,30 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis return; } - let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect(); - series.sort(); + let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect(); + chaves.sort(); - for serie in series { - let duplicatas = &resultado.duplicadas_por_serie[serie]; + for chave in chaves { + let duplicatas = &resultado.duplicadas_por_serie[chave]; if duplicatas.is_empty() { continue; } - ui.label(format!( - "Série {} — {} grupo(s) duplicado(s):", - serie, - duplicatas.len() - )); + ui.horizontal(|ui| { + ui.label(format!( + "Série {} — {} grupo(s) duplicado(s):", + chave.label(), + duplicatas.len() + )); + if ui.button("📋 Copiar").on_hover_text("Copiar números duplicados").clicked() { + let texto = duplicatas + .iter() + .map(|(n, c)| format!("{} ({}x)", n, c)) + .collect::>() + .join(", "); + ui.ctx().copy_text(texto); + } + }); let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; @@ -208,7 +234,7 @@ 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, serie, count + numero, chave.label(), count )); }