Compare commits

..
32 Commits
Author SHA1 Message Date
Felipe 4c4f573cd1 Fix button colors. 2026-03-04 22:46:27 -03:00
Felipe 857fcd6a82 Fix SalvarLayoutConfig 2026-03-04 21:45:52 -03:00
Felipe 841dfadde5 update 2026-03-04 18:33:32 -03:00
Felipe 3af14ab957 Create AGENTS.md 2026-03-04 18:18:11 -03:00
FelipeCN 06eca335db update. 2026-03-04 17:27:02 -03:00
FelipeCN 6a5d941a00 Add roteiro para corrigir warnings. 2026-03-04 13:48:51 -03:00
FelipeCN 5745a78a5a Update .gitignore 2026-03-04 13:39:15 -03:00
Felipe 1f4ebb4686 Drag and Drop 2026-03-04 12:35:36 -03:00
Felipe 007f6c818e Fix icons and fonts 2026-03-04 12:25:08 -03:00
Felipe d06110240f Corrected: Responsive design for low-resolution monitors. 2026-03-04 12:12:23 -03:00
FelipeCN ad09c53a5d feat: implement UI redesign for Comparador de Notas
- Introduced a new theme for the application based on dark navy aesthetics.
- Created design tokens for colors, spacings, and border radii.
- Developed a theme module in Rust to manage styles for various UI components.
- Updated multiple screens and components to align with the new design, including cards, buttons, badges, and progress bars.
- Enhanced user experience with improved layouts and visual elements across the application.
2026-03-04 10:58:54 -03:00
FelipeCN 8d00cfc4d7 Refactor UI components for layout management and results display
- Replaced the `renderizar` function in `layouts.rs` with a new `view` function using Iced for a more modern UI approach.
- Introduced a new `view_secao_layouts` function to handle the display of saved layouts.
- Updated the `resultado.rs` file to use Iced for rendering the results screen, including buttons for actions and pagination controls.
- Created a new `selecionar_aba.rs` file for the selection of XLSX sheet tabs, implementing a preview feature.
- Removed old rendering functions and replaced them with Iced components for better performance and maintainability.
- Added design tokens in `design_tokens.json` for consistent styling across the application.
- Created a mockup HTML file to visualize the UI design.
2026-03-04 10:33:40 -03:00
FelipeCN 2d1d29ce3d Create a MIGRATION_ICED.md 2026-03-04 09:43:43 -03:00
FelipeCN 12b8501ffb feat: adiciona novas dependências, implementa reimportação de arquivos e melhorias na interface 2026-03-04 09:19:47 -03:00
Felipe 2a21138bf8 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.
2026-03-03 23:28:55 -03:00
FelipeCN e7d72c7e01 F-03 está 40% implementado. O preview raw existe e funciona bem. O que
seria novo é sobrepor ao preview existente uma segunda linha de
"interpretação" — indicando qual coluna mapeada seria o número, qual
seria a série, e se o valor parsearia com sucesso.
2026-03-03 17:04:38 -03:00
FelipeCN b9052e073f Implementado (3 features)
**Agrupamento de faltantes contíguos** (`detector_sequencia.rs`)
- Nova função pública `agrupar_contiguos(&[u64]) -> Vec<(u64, u64)>` com
  4 testes
- Na tela de resultado, faltantes agora aparecem como `• 100–104 (5
  notas)` em vez de 5 linhas separadas

**Estatísticas de completude** (`resultado.rs`)
- Cada série exibe: `Série 001 — 10 faltante(s) — 990/1000 notas (99.0%
  completo):`

**Copiar para clipboard** (`resultado.rs`)
- Botão `📋 Copiar` ao lado de cada série nos faltantes — copia todos os
  números (não só a página atual)
- Botão `📋 Copiar` nas duplicatas — copia no formato `1234 (3x), 5678
  (2x)`

Total: **42 → 46 testes**, todos passando.
2026-03-03 16:41:32 -03:00
FelipeCN 2c96e99ccc update. 2026-03-03 13:51:26 -03:00
Felipe a5605de6a3 feat: adiciona análise e proposta de correção para duplicidade de layouts ao salvar 2026-03-03 12:37:16 -03:00
Felipe 0354ece935 refactor: organiza imports e simplifica funções em diversos arquivos 2026-03-03 11:59:50 -03:00
FelipeCN 2c08f237e9 feat: adiciona suporte para análise em background e processamento de resultados pendentes 2026-03-03 10:16:50 -03:00
FelipeCN dedb6a72c2 feat: atualiza a renderização da tabela de pré-visualização para exibir letras do Excel como cabeçalho 2026-03-03 09:27:22 -03:00
FelipeCN 41c75e5ed1 feat: adiciona pré-visualização de arquivos CSV e XLSX na interface 2026-03-03 09:25:12 -03:00
FelipeCN 0f728b9684 feat: adiciona modal para salvar layout com campo de texto e botão na tela de configuração de colunas 2026-03-03 09:19:23 -03:00
FelipeCN b2b91cda55 feat: adiciona nova ação de confirmação para iniciar nova análise e implementa breadcrumb na interface 2026-03-03 08:48:19 -03:00
FelipeCN b0e216a6ff feat: adiciona suporte para arquivos XLS e atualiza mensagens de erro na importação 2026-03-03 08:30:28 -03:00
Felipe bbfa66979b feat: adiciona sugestões de melhoria para o fluxo da interface 2026-03-02 22:24:48 -03:00
Felipe f3e6fa313d refactor: altera o texto do botão "Voltar" para "< Voltar" em várias telas 2026-03-02 22:19:43 -03:00
Felipe e64988a139 feat: implement main application structure with UI and layout management
- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs.
- Created module structure for UI components in `src/ui/mod.rs`.
- Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`.
- Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files.
- Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts.
- Created result display screen in `src/ui/screens/resultado.rs` to show analysis results.
- Introduced modular organization for screens in `src/ui/screens/mod.rs`.
2026-03-02 22:14:02 -03:00
Felipe 31cd94907a atualiza versão do PRD para 1.6 e detalha arquitetura e estrutura de pastas do projeto 2026-03-02 21:39:05 -03:00
Felipe 9d212747e7 atualiza versão do PRD para 1.5 e detalha campos lógicos e regras de validação 2026-03-02 21:21:18 -03:00
Felipe 913bbf3060 atualiza versão do PRD para 1.3 e detalha configurações de importação de arquivos CSV e XLSX 2026-03-02 21:09:45 -03:00
61 changed files with 15005 additions and 98 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(cargo build)",
"Bash(xargs grep -l \"tipo\\\\|invoice\\\\|tipo.*documento\")",
"Bash(xargs wc -l)",
"Bash(xargs grep -l \"grupo\\\\|grouping\\\\|agrupa\")",
"Bash(cargo metadata --no-deps --format-version 1)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''name''], p[''version'']\\) for p in d[''packages'']]\")",
"WebFetch(domain:github.com)",
"WebFetch(domain:api.github.com)",
"Bash(ls D:/PROJECTS/comparador-notas/*.md)"
]
}
}
+1
View File
@@ -1 +1,2 @@
/target
nul
+10
View File
@@ -0,0 +1,10 @@
{
"sqltools.connections": [
{
"previewLimit": 50,
"driver": "SQLite",
"database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db",
"name": "comparador-notas"
}
]
}
Generated
+6104
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -4,3 +4,22 @@ version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.13", features = ["tokio", "image"] }
tokio = { version = "1", features = ["full"] }
rusqlite = { version = "0.32", features = ["bundled"] }
csv = "1.3"
calamine = "0.26"
rust_decimal = { version = "1.36", features = ["serde"] }
rust_decimal_macros = "1.36"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
genpdf = "0.2"
chrono = { version = "0.4", features = ["serde"] }
encoding_rs = "0.8"
dirs = "5"
thiserror = "2"
regex = "1"
rfd = "0.15"
[build-dependencies]
winres = "0.1"
+664 -93
View File
@@ -1,8 +1,8 @@
# PRD — Comparador de Notas
**Versão:** 1.1
**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
@@ -71,16 +71,25 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
* Importação de arquivos CSV
* Importação de arquivos XLSX
* Interface gráfica
* Configuração de colunas
* Configuração de colunas (CSV por índice numérico; XLSX por letra+linha)
* Detecção de quebras de sequência
* Detecção de duplicidades
* Soma de valores
* Agrupamento por série
* Relatório visual
* Salvar layouts personalizados
* 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
* Exibição de faltantes agrupados em intervalos contíguos (ex: `1050 (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)
@@ -88,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
---
@@ -96,15 +106,16 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
O sistema trabalhará com os seguintes campos lógicos:
| Campo | Obrigatório | Descrição |
| ------ | ----------- | ------------------------------------------------------------------------- |
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único |
| Serie | Sim | Série da nota (13 dígitos, ex: 001999). Separa lotes de emissão |
| Valor | Não | Valor monetário da nota |
| Data | Não | Data de emissão da nota |
| --------------- | ----------- | ------------------------------------------------------------------------- |
| 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 via índice de coluna.
Os campos serão mapeados pelo usuário conforme o tipo de arquivo: via **índice numérico** (base 0) para CSV, e via **notação LetraLinha** (ex: `B3`) para XLSX.
> **Identificador único de uma nota:** combinação de `Numero + Serie`. Duplicidade e sequência são sempre avaliadas dentro da mesma série.
> **Identificador único de uma nota:** combinação de `Numero + Serie + 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).
---
@@ -123,34 +134,67 @@ 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 |
### RF01.2 — Limite de Tamanho de Arquivo
### RF01.2 — Configurações de Importação XLSX
| Parâmetro | Comportamento |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| Seleção de aba | Imediatamente após a seleção do arquivo, o sistema exibe a lista de abas disponíveis para o usuário selecionar, antes de qualquer configuração de campos |
| Coluna e linha de início | O usuário informa a posição inicial de cada campo no formato `LetraLinha` (ex: `B3`) |
| Linhas em branco | Devem ser ignoradas silenciosamente |
| Linhas malformadas | Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas |
| Arquivo corrompido | Se o arquivo não puder ser lido, exibir mensagem de erro em modal e limpar o arquivo carregado; o estado anterior é descartado |
### RF01.3 — Limite de Tamanho de Arquivo
O sistema deve recusar arquivos maiores que **50 MB** e exibir mensagem de erro ao usuário.
> Base de cálculo: 100.000 registros com 510 colunas geram aproximadamente 515 MB em CSV e até 30 MB em XLSX. O limite de 50 MB oferece margem adequada.
> O limite refere-se ao **tamanho do arquivo no disco** (tamanho comprimido para XLSX, que é um arquivo ZIP internamente). Base de cálculo: 100.000 registros com 510 colunas geram aproximadamente 515 MB em CSV e até 30 MB em XLSX. O limite de 50 MB oferece margem adequada.
---
## RF02 — Configuração de Colunas
O sistema deve permitir ao usuário definir, via **índice numérico** (posição da coluna), qual coluna representa cada campo:
O mapeamento de colunas varia conforme o tipo de arquivo.
* Qual coluna representa o número (obrigatório)
* Qual coluna representa a série (obrigatório)
* Qual coluna representa o valor (opcional)
* Qual coluna representa a data (opcional)
### RF02.1 — Mapeamento CSV (índice numérico)
O usuário define, via **índice numérico** (posição da coluna, base 0), qual coluna representa cada campo:
* Qual índice representa o número (obrigatório)
* Qual índice representa a série (obrigatório)
* Qual índice representa o valor (opcional)
* Qual índice representa a data (opcional)
* 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), 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.
### Tratamento de Erros de Configuração
| Situação | Comportamento |
| ----------------------------------------------------- | --------------------------------------------------------------- |
| Índice configurado não existe no arquivo importado | Exibir erro ao usuário identificando qual campo está inválido |
| ----------------------------------------------------------- | ---------------------------------------------------------------- |
| Índice/posição configurado não existe no arquivo importado | Exibir erro ao usuário identificando qual campo está inválido |
| Campo obrigatório (Numero ou Serie) não mapeado | Bloquear execução da análise e solicitar configuração |
| Campo opcional não mapeado | Ignorar o campo; funcionalidades dependentes ficam desabilitadas|
| Campo opcional não mapeado | Ignorar o campo; funcionalidades dependentes ficam desabilitadas |
| Dois campos mapeados para o mesmo índice/posição | Bloquear e exibir erro de validação imediatamente, antes de executar a análise |
---
@@ -167,20 +211,43 @@ O formato JSON é utilizado exclusivamente para importação e exportação de l
O armazenamento interno dos layouts é realizado via SQLite (ver Seção 10).
### Tipo de Layout
Cada layout é exclusivo de um tipo de arquivo: **CSV** ou **XLSX**. Um layout CSV armazena configurações específicas de CSV (delimitador, encoding, índices de coluna). Um layout XLSX armazena configurações específicas de XLSX (aba, posições no formato `LetraLinha`).
### Conflito ao Importar Layout JSON
Se o usuário importar um arquivo JSON com o nome de um layout já existente no banco, o sistema deve perguntar ao usuário o que fazer, oferecendo as opções:
* Sobrescrever o layout existente
* Cancelar a importação
### Erros na Importação de Layout JSON
Se o arquivo JSON estiver malformado ou com campos obrigatórios ausentes, o sistema deve exibir mensagem de erro descrevendo o problema e cancelar a importação. Nenhum dado parcial deve ser salvo.
### Nome do Arquivo ao Exportar Layout
Ao exportar um layout para JSON, o sistema deve sugerir o nome do arquivo com base no nome do layout (ex: layout `Padrão CSV``Padrão CSV.json`). O usuário pode alterar o nome antes de salvar.
### Limite de Layouts
Não há limite no número de layouts que podem ser armazenados.
---
## RF04 — Detecção de Sequência
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
@@ -191,9 +258,52 @@ Se o valor de um campo Numero não for numérico (ex: `NF-001`, `ABC`):
3. Se não restar valor numérico utilizável, descartar o registro
4. Em ambos os casos, reportar ao usuário quais registros foram afetados
### Número Zero
Registros com o campo Numero igual a `0` devem ser descartados e reportados ao usuário. O valor `0` não é considerado um número de nota válido.
### Escopo da Detecção
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 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 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: `1050 (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 grupo exceder **10.000 registros**, o sistema deve:
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.
Esse comportamento protege contra mapeamentos incorretos de colunas que gerariam listas ilegíveis.
### Tratamento de Série com Valor Inválido
A Série é válida se e somente se corresponder à regex `^[0-9]{1,3}$` após remoção de espaços.
Se o campo Série de um registro estiver vazio, não corresponder à regex ou contiver valor não utilizável:
1. O registro deve ser descartado
2. O sistema deve reportar ao usuário quais linhas foram afetadas, sem interromper a importação
Exemplos de valores inválidos: `ABC`, `1A`, `1234` (4 dígitos), string vazia.
---
@@ -201,7 +311,15 @@ Faltantes são detectados **por série**. Cada série possui sua própria sequê
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 + 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
Todas as ocorrências de registros duplicados são incluídas na soma de valores, pois refletem os lançamentos reais presentes no arquivo.
---
@@ -210,7 +328,50 @@ Um registro é considerado duplicado quando existe mais de uma ocorrência da me
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:**
- O último separador encontrado é o decimal; o outro é o separador de milhar.
- Exemplos: `1.000,00` → 1.000,00 | `1,000.00` → 1.000,00
**Regra 2 — Contém apenas um separador:**
- Vírgula ou ponto com **exatamente 2 dígitos** após → separador decimal.
- Exemplos: `1000,00` → 1.000,00 | `1000.00` → 1.000,00
- Vírgula ou ponto com **exatamente 3 dígitos** após → separador de milhar.
- Exemplos: `1,234` → 1.234,00 | `1.234` → 1.234,00
- Demais casos → separador tratado como decimal.
**Regra 3 — Sem separador:**
- Interpretar como número inteiro.
- Exemplo: `1000` → 1.000,00
| Exemplo de entrada | Regra aplicada | Resultado parseado |
| ------------------ | ---------------------------------- | ------------------ |
| `1000.00` | Regra 2 (ponto + 2 dígitos) | 1.000,00 |
| `1000,00` | Regra 2 (vírgula + 2 dígitos) | 1.000,00 |
| `1.000,00` | Regra 1 (ambos separadores) | 1.000,00 |
| `1,000.00` | Regra 1 (ambos separadores) | 1.000,00 |
| `1.000` | Regra 2 (ponto + 3 dígitos) | 1.000,00 |
| `1,234` | Regra 2 (vírgula + 3 dígitos) | 1.234,00 |
| `1000` | Regra 3 (sem separador) | 1.000,00 |
Valores negativos (precedidos de `-`) devem ser rejeitados e reportados ao usuário como inválidos.
Valores que não puderem ser interpretados como número devem ser descartados e reportados ao usuário.
> **Nota de implementação:** O valor deve ser parseado e armazenado internamente como `rust_decimal::Decimal`, nunca como `f64`. Aritmética de ponto flutuante introduz erros de representação em valores monetários (ex: `0.1 + 0.2 ≠ 0.3` em IEEE 754). A soma total e as somas por grupo devem ser calculadas inteiramente em `Decimal`.
### Formato de Exibição de Valores
Todos os valores monetários são exibidos com **2 casas decimais fixas** no formato brasileiro (ex: `1.234,56`).
---
@@ -218,9 +379,39 @@ O sistema deve calcular:
O sistema deve exibir:
* Lista de notas faltantes
* Lista de duplicadas
* Totais
* 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 `(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 |
### RF07.1 — Exportação de Relatório
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 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 (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.
---
@@ -260,6 +451,10 @@ 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.
---
## RNF04 — Usabilidade
@@ -268,42 +463,311 @@ O sistema deve possuir interface simples.
---
## RNF05 — Idioma
O idioma da interface é **Português do Brasil (PT-BR)**.
---
## RNF06 — Mensagens de Erro e Aviso
Todas as mensagens de erro e aviso devem ser exibidas em **modal/popup bloqueante**. O usuário deve fechar o modal explicitamente para continuar.
### Consolidação de Mensagens
Quando múltiplos avisos forem gerados durante uma mesma operação (ex: múltiplas linhas malformadas, múltiplos registros descartados), esses avisos devem ser **consolidados em um único modal**, exibindo um resumo ao final da operação. Exemplo de conteúdo consolidado:
* "32 linhas descartadas por 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.
---
# 8. Fluxo do Usuário
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
---
# 9. Arquitetura Inicial Sugerida
# 9. Arquitetura
Arquitetura desktop local.
Arquitetura desktop local com separação em quatro camadas: `domain`, `application`, `infrastructure` e `ui`.
Componentes:
* Interface gráfica
* Módulo de importação
* Módulo de processamento
* Módulo de configuração
Sem dependências externas obrigatórias.
Não é Clean Architecture radical. É apenas separação suficiente para manter fronteiras claras, domínio isolado e infraestrutura concreta sem vazar para a lógica de negócio.
---
## 9.1 Stack Tecnológica
## 9.1 Estrutura de Pastas
| Camada | Tecnologia | Status |
| ---------------- | ------------------ | -------------- |
| Linguagem | Rust | Definido |
| Framework de UI | egui | Definido |
| SQLite | rusqlite | Definido |
| Leitura de CSV | csv | Definido |
| Leitura de XLSX | calamine | Definido |
```
src/
├─ main.rs
├─ ui/
│ ├─ mod.rs
│ ├─ app.rs
│ ├─ screens/
│ │ ├─ mod.rs (renderizar_tabela_preview, indice_para_letra)
│ │ ├─ import.rs
│ │ ├─ configuracao_colunas.rs
│ │ ├─ layouts.rs
│ │ ├─ resultado.rs
├─ 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
├─ infrastructure/
│ ├─ mod.rs
│ ├─ csv_reader.rs
│ ├─ xlsx_reader.rs
│ ├─ pdf_generator.rs
│ ├─ sqlite/
│ │ ├─ mod.rs
│ │ ├─ connection.rs
│ │ ├─ migrations.rs
│ │ ├─ layout_repository.rs
```
---
## 9.2 Papel de Cada Camada
### Domain (núcleo puro)
Contém toda a lógica de negócio real.
**Não pode depender de:**
* egui / eframe
* rusqlite
* calamine
* csv
* genpdf
Apenas Rust puro + crates matemáticas (`rust_decimal`, `chrono`) e utilitários de erros (`thiserror`, `regex`).
#### Entidades
**`Nota`**
| Campo | Tipo |
| -------------- | ------------------- |
| numero | `u64` |
| serie | `String` |
| documento_tipo | `Option<String>` |
| valor | `Option<Decimal>` |
| data | `Option<NaiveDate>` |
**`ChaveSerie`**
Chave composta que identifica um grupo de notas. Combina `serie` e `documento_tipo`. Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento somente por série (retrocompatível). Implementa `Hash`, `Eq`, `Ord` para uso como chave de `HashMap` e chave de ordenação.
| Campo | Tipo |
| -------------- | ---------------- |
| serie | `String` |
| documento_tipo | `Option<String>` |
O método `label()` formata para exibição: `"001 / NFE"` quando tipo presente, `"001"` quando ausente.
**`ResultadoPreAnalise`**
Resultado intermediário, antes de materializar os faltantes.
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| intervalos_por_serie | `HashMap<ChaveSerie, IntervaloSerie>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
**`ResultadoAnalise`**
| Campo | Tipo |
| -------------------- | ---------------------------------------- |
| faltantes_por_serie | `HashMap<ChaveSerie, Vec<u64>>` |
| duplicadas_por_serie | `HashMap<ChaveSerie, Vec<(u64, usize)>>` |
| soma_total | `Decimal` |
| soma_por_serie | `HashMap<ChaveSerie, Decimal>` |
| total_por_serie | `HashMap<ChaveSerie, usize>` |
> **Importante:** a geração dos faltantes não deve ser eager. O use case `executar_analise` deve primeiro calcular os intervalos por série e retornar um resultado intermediário (`ResultadoPreAnalise`) contendo o intervalo calculado. Somente após confirmação do usuário — quando algum intervalo exceder 10.000 registros (RF04) — o sistema expande e materializa a lista completa de faltantes. Isso evita alocar memória para intervalos gerados por mapeamento incorreto de colunas.
#### Services
`detector_sequencia` — recebe `Vec<&Nota>` agrupadas por `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, documento_tipo)`.
`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`, `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.
---
### Application (orquestração)
Coordenam o fluxo entre domain e infrastructure.
Conhece o domain. O domain não conhece o application.
**`executar_analise.rs`**
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 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`.
---
### Infrastructure (implementações concretas)
Implementa leitores, persistência e geração de arquivos.
| Arquivo | Responsabilidade |
| -------------------------------- | ----------------------------------------- |
| `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.
---
### UI (interface)
Apenas coleta input, chama use cases e renderiza resultado.
Nenhuma regra de sequência ou parsing monetário deve estar na camada de UI.
#### 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 |
| ------------------------- | ----------------------------------------------------- |
| `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) |
---
## 9.3 Fluxo de Execução
```
UI → Application → Domain
Infrastructure entra apenas quando necessário.
```
Exemplo real:
1. UI chama `executar_importacao` (thread separada)
2. Infrastructure lê CSV/XLSX
3. Application transforma registros em entidades `Nota`
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 | 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 |
---
@@ -332,12 +796,42 @@ Motivos:
- Multiplataforma
- Alta confiabilidade
O banco de dados deve ser armazenado localmente no dispositivo do
usuário.
O banco de dados deve ser armazenado na **pasta de dados do usuário**, de acordo com o sistema operacional:
Exemplo:
| Sistema Operacional | Caminho |
| ------------------- | ---------------------------------------------------- |
| Linux | `~/.config/comparador-notas/config.db` |
| Windows | `%APPDATA%\comparador-notas\config.db` |
| macOS | `~/Library/Application Support/comparador-notas/config.db` |
config.db
O diretório e o arquivo devem ser **criados automaticamente** pelo sistema na primeira execução.
------------------------------------------------------------------------
## 10.2.1 — Banco de Dados Corrompido
Se o arquivo `config.db` estiver ilegível ou corrompido ao iniciar o programa, o sistema deve:
1. Exibir aviso ao usuário informando que o banco de dados está corrompido e será recriado
2. Renomear o arquivo corrompido para `config.db.bak` (sobrescrevendo qualquer `.bak` anterior)
3. Criar um novo banco de dados vazio
4. Continuar a execução normalmente
Os layouts salvos anteriormente serão perdidos neste cenário. O arquivo `config.db.bak` permanece no disco e permite recuperação manual por usuários avançados.
------------------------------------------------------------------------
## 10.2.2 — Migração de Schema
O banco de dados deve conter uma tabela de controle de versão (`schema_version`) com o número da versão atual do schema. A cada inicialização, o sistema deve verificar a versão e aplicar migrations automáticas quando necessário, garantindo compatibilidade com versões anteriores do banco.
**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) |
------------------------------------------------------------------------
@@ -351,16 +845,39 @@ Layouts personalizados
## 10.4 Entidade: Layout
Campos:
Cada layout é exclusivo de um tipo de arquivo (`csv` ou `xlsx`). As configurações variam conforme o tipo.
Campo Tipo Descrição
--------------- --------- --------------------------
id inteiro Identificador único
nome texto Nome do layout
coluna_numero texto Nome da coluna do número
coluna_serie texto Nome da coluna da série
coluna_valor texto Nome da coluna do valor
coluna_data texto Nome da coluna da data
**Campos comuns:**
| Campo | Tipo | Descrição |
| ---------- | ------- | -------------------------------------- |
| 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). 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 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) |
------------------------------------------------------------------------
@@ -380,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)
------------------------------------------------------------------------
@@ -424,51 +945,93 @@ Quando o usuário exportar ou importar um layout, o arquivo gerado será no form
Esse formato é usado apenas para portabilidade entre dispositivos.
O armazenamento interno é sempre via SQLite.
Exemplo de arquivo JSON exportado:
O campo `tipo` define qual conjunto de configurações está presente no arquivo.
**Exemplo — layout CSV:**
```json
{
"nome": "Layout Padrão",
"coluna_numero": "Nota",
"coluna_serie": "Serie",
"coluna_valor": "Valor",
"coluna_data": "Data"
"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_documento_tipo": null
}
```
**Exemplo — layout XLSX:**
```json
{
"tipo": "xlsx",
"nome": "Layout Padrão XLSX",
"aba": "Plan1",
"pos_numero": "D3",
"pos_serie": "B3",
"pos_valor": "F3",
"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
O sistema será considerado funcional quando:
* Importar planilha
* Detectar notas faltantes corretamente
* Calcular totais corretamente
* Permitir configuração
* 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 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 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
---
# 13. MVP — Versão Inicial
Escopo mínimo:
O MVP inclui o escopo completo descrito neste PRD:
* Importar CSV
* Mapear coluna Número
* Detectar faltantes
* Exibir resultado
* Importar CSV e XLSX
* 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/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 relatório
* Exportação PDF
* 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
* Banco de dados
* Automação
* Histórico de análises
* Automação de importação (monitorar pasta)
* Multiusuário
---
@@ -482,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)`.
---
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
let mut res = winres::WindowsResource::new();
res.set_icon("icon.ico");
res.compile().unwrap();
}
}
+86
View File
@@ -0,0 +1,86 @@
# Backlog de Features — Comparador de Notas
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Média Prioridade
### F-03 — Preview de Colunas Antes da Análise (Isso já existe?)
**Problema:** Erros de mapeamento de colunas (índice errado, encoding incorreto) só são descobertos depois da importação, que pode falhar silenciosamente com "0 notas válidas".
**Solução:** Na tela de configuração de colunas, exibir uma tabela das primeiras 5 linhas mostrando como os campos serão interpretados, com highlight em células que falhariam o parse.
**Escopo técnico:**
- Função `pre_visualizar(caminho, layout, n_linhas: usize) -> Vec<LinhaPreview>`
- `LinhaPreview`: `{ numero: Result<u64>, serie: Result<String>, valor: Result<Decimal>, ... }`
- Widget de tabela em `configuracao_colunas.rs`
- Atualizar preview ao mudar qualquer campo de configuração (via `changed()`)
**Esforço estimado:** Alto (46h)
**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-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 `100104 (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 | Status |
|---|---|---|---|---|---|
| F-03 | Preview de colunas antes da análise | Alto | Alto | 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 |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+2
View File
@@ -0,0 +1,2 @@
[tools]
rust = "latest"
+205
View File
@@ -0,0 +1,205 @@
# Application — AGENTS.md
Camada de aplicação do projeto `comparador-notas`. Orquestra os serviços de domínio
e a infraestrutura para expor casos de uso coesos à camada de UI (Tauri/frontend).
Não contém regras de negócio próprias; delega ao `domain` e ao `infrastructure`.
---
## Estrutura dos arquivos
```
src/application/
├── mod.rs
└── usecases/
├── mod.rs
├── executar_analise.rs # Análise de sequência e duplicatas
├── exportar_pdf.rs # Exportação de relatório PDF
├── importar_arquivo.rs # Importação de CSV e XLSX → Vec<Nota>
└── layouts.rs # CRUD e import/export JSON de layouts
```
---
## usecases/mod.rs
Re-exporta os quatro módulos de casos de uso:
```rust
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;
```
---
## executar_analise.rs
Orquestra a análise em **duas etapas** para lidar com grandes intervalos de
faltantes sem travar a UI (regra RF04).
### Etapa 1 — `pre_analisar(notas) -> ResultadoPreAnalise`
1. Agrupa as notas por `ChaveSerie` (`serie` + `documento_tipo`).
2. Para cada grupo:
- Acumula soma total e por série.
- Chama `calcular_intervalo` (sem materializar faltantes).
3. Detecta duplicidades via `duplicidades_por_serie`.
4. Retorna `ResultadoPreAnalise` com intervalos, duplicadas e somas.
### Verificação intermediária — `series_com_intervalo_excessivo(pre) -> Vec<(ChaveSerie, IntervaloSerie)>`
Filtra as séries cujo `contagem_faltantes > LIMITE_FALTANTES` (10.000).
O caller (UI) deve exibir confirmação ao usuário se a lista não for vazia.
### Etapa 2 — `expandir_analise(pre, notas) -> ResultadoAnalise`
Materializa a lista completa de faltantes via `detectar_faltantes` e combina
com os dados já calculados na pré-análise (duplicadas, somas, totais).
### Fluxo de uso
```
pre_analisar(notas)
└─ ResultadoPreAnalise
series_com_intervalo_excessivo(&pre)
├─ [] → chamar expandir_analise diretamente
└─ [...] → exibir diálogo de confirmação na UI
└─ confirmado → expandir_analise(pre, notas)
expandir_analise(pre, notas)
└─ ResultadoAnalise (com faltantes materializados)
```
---
## exportar_pdf.rs
### `exportar_pdf(gerador, resultado, notas, nome_arquivo, nome_layout, caminho_saida) -> Result<(), String>`
Caso de uso simples que:
1. Constrói `MetadadosRelatorio` com `nome_arquivo`, `nome_layout` e timestamp
`Local::now()`.
2. Delega a geração para `gerador.gerar(...)` via a trait abstrata `PdfGenerator`.
A dependência em `&dyn PdfGenerator` (e não em `GenpdfGenerator` diretamente)
mantém o use case desacoplado da implementação concreta e facilita testes.
---
## importar_arquivo.rs
Converte arquivos brutos (CSV ou XLSX) em `Vec<Nota>` prontas para análise,
acumulando avisos não-fatais em `ResumoAvisos`.
### Tipos de saída
```rust
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
pub struct InfoXlsx {
pub abas: Vec<String>,
}
```
### `listar_abas_xlsx(caminho) -> Result<InfoXlsx, ErroArquivo>`
Delega para `xlsx_reader::listar_abas`. Retorna `InfoXlsx` com os nomes das
abas para que a UI permita ao usuário selecionar a aba correta.
### `importar_csv(caminho, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo>`
1. Chama `csv_reader::ler_csv` com os parâmetros do layout.
2. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `importar_xlsx(caminho, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo>`
1. Converte `pos_numero` e `pos_serie` (LetraLinha) para coordenadas.
2. Determina `linha_inicio` como o mínimo entre as linhas das duas posições.
3. Chama `xlsx_reader::ler_xlsx`.
4. Converte posições de todos os campos mapeados para índices de coluna (base 0).
5. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `mapear_linhas_para_notas(...)` (privada)
Função central de mapeamento. Para cada linha:
1. **Validação de índices** (na primeira linha disponível):
- Campos obrigatórios (`Numero`, `Serie`): retorna `Err` se o índice não existe.
- Campos opcionais (`Valor`, `Data`, `Tipo de Documento`): retorna `Err` se
configurado com índice fora dos limites.
2. **Numero**: tenta `parse::<u64>` direto; se falhar, extrai apenas dígitos.
Rejeita zero. Incrementa `avisos.numeros_invalidos` e pula a linha se inválido.
3. **Serie**: valida via `domain::entities::serie::validar_serie`. Pula linha se inválida.
4. **Valor** (opcional): faz `parse_valor`; se inválido, registra em
`avisos.valores_invalidos` e usa `None` (não descarta a linha).
5. **Data** (opcional): tenta `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. `None`
se nenhum formato casar (não gera aviso).
6. **Tipo de Documento** (opcional): qualquer string não vazia.
### `parse_numero(s) -> Result<u64, String>` (privada)
- Tenta `s.parse::<u64>()` diretamente.
- Se falhar, extrai apenas dígitos ASCII e tenta novamente.
- Rejeita zero em ambos os casos.
### `parse_data(s) -> Option<NaiveDate>` (privada)
Tenta os formatos `%d/%m/%Y`, `%Y-%m-%d` e `%d-%m-%Y` nessa ordem.
---
## layouts.rs
CRUD de layouts sobre o banco SQLite e import/export em JSON.
### `salvar_layout(conn, layout) -> Result<i64, ErroLayout>`
- Valida que `nome` não está vazio.
- Se `layout.id()` é `Some` → chama `layout_repository::atualizar` e retorna o id.
- Se `None` → verifica conflito de nome via `existe_nome`; se existir, retorna
`ErroLayout::NomeConflitante`; caso contrário, insere e retorna o novo id.
### `listar_layouts(conn) -> Result<Vec<Layout>, String>`
Delega para `layout_repository::listar`. Retorna todos os layouts ordenados
por nome.
### `excluir_layout(conn, id) -> Result<(), String>`
Delega para `layout_repository::excluir`.
### `exportar_layout_json(layout) -> Result<(String, String), String>`
1. Converte `Layout` para `LayoutJson` via `From<&Layout>`.
2. Serializa com `serde_json::to_string_pretty`.
3. Retorna `(conteúdo_json, nome_arquivo_sugerido)` onde o nome é `"{nome}.json"`.
### `importar_layout_json(conn, json, sobrescrever_se_existir, novo_nome) -> Result<i64, ErroLayout>`
1. Desserializa `json` para `LayoutJson`.
2. Converte para `Layout` via `TryFrom` (valida campos obrigatórios).
3. Aplica `novo_nome` se fornecido (mutação direta no enum).
4. Verifica conflito de nome:
- Se existe e `sobrescrever_se_existir == true`: busca o id existente,
injeta no layout e chama `atualizar`.
- Se existe e `false`: retorna `ErroLayout::NomeConflitante`.
5. Se não existe: chama `layout_repository::salvar`.
---
## Dependencias de outros módulos
```
application::usecases
├─ domain::entities::{chave_serie, nota, resultado_analise, layout, serie}
├─ domain::services::{detector_duplicidade, detector_sequencia, parser_monetario}
├─ domain::errors::{ErroArquivo, ErroLayout, ResumoAvisos}
└─ infrastructure::{csv_reader, xlsx_reader, pdf_generator, sqlite::layout_repository}
```
+1
View File
@@ -0,0 +1 @@
pub mod usecases;
@@ -0,0 +1,174 @@
use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
},
services::{
detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
},
};
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Executa a pré-análise: calcula intervalos e duplicatas, sem expandir faltantes.
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por (serie, documento_tipo)
let mut por_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
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<ChaveSerie, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<ChaveSerie, usize> = HashMap::new();
for (chave, notas_grupo) in &por_chave {
// Somar valores
for nota in notas_grupo.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(chave.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(chave.clone()).or_insert(0) += notas_grupo.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_grupo) {
intervalos_por_serie.insert(chave.clone(), intervalo);
}
}
let duplicadas_por_serie = duplicidades_por_serie(notas);
ResultadoPreAnalise {
intervalos_por_serie,
duplicadas_por_serie,
soma_total,
soma_por_serie,
total_por_serie,
}
}
/// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(ChaveSerie, IntervaloSerie)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(chave, iv)| (chave.clone(), iv.clone()))
.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_chave: HashMap<ChaveSerie, Vec<&Nota>> = HashMap::new();
for nota in notas {
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 (chave, notas_grupo) in &por_chave {
let faltantes = detectar_faltantes(notas_grupo);
faltantes_por_serie.insert(chave.clone(), faltantes);
}
ResultadoAnalise {
faltantes_por_serie,
duplicadas_por_serie: pre.duplicadas_por_serie,
soma_total: pre.soma_total,
soma_por_serie: pre.soma_por_serie,
total_por_serie: pre.total_por_serie,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec;
/// Executa análise completa sem verificar limites (atalho para testes).
fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
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]
fn analise_simples() {
let notas = vec![
nota(1, "001", Some(dec!(100.00))),
nota(2, "001", Some(dec!(200.00))),
nota(4, "001", Some(dec!(50.00))),
];
let resultado = executar_analise(&notas);
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));
}
#[test]
fn analise_multiplas_series() {
let notas = vec![
nota(1, "001", None),
nota(3, "001", None),
nota(1, "002", None),
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
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]
fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, 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(&notas);
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]);
}
}
+25
View File
@@ -0,0 +1,25 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local;
use std::path::Path;
/// Exporta o resultado da análise para um arquivo PDF.
///
/// Depende da trait abstrata `PdfGenerator`, não de `genpdf` diretamente.
pub fn exportar_pdf(
gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise,
notas: &[Nota],
nome_arquivo: &str,
nome_layout: Option<&str>,
caminho_saida: &Path,
) -> Result<(), String> {
let meta = MetadadosRelatorio {
nome_arquivo: nome_arquivo.to_string(),
nome_layout: nome_layout.map(|s| s.to_string()),
gerado_em: Local::now(),
};
gerador.gerar(resultado, notas, &meta, caminho_saida)
}
@@ -0,0 +1,293 @@
use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
use crate::domain::entities::nota::Nota;
use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use crate::infrastructure::{csv_reader, xlsx_reader};
use chrono::NaiveDate;
use std::path::Path;
/// Resultado da importação de arquivo.
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
/// Resultado parcial após seleção de arquivo XLSX (antes de configurar campos).
pub struct InfoXlsx {
pub abas: Vec<String>,
}
/// Lista as abas de um arquivo XLSX para exibição ao usuário.
pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
let abas = xlsx_reader::listar_abas(caminho)?;
Ok(InfoXlsx { abas })
}
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(
caminho: &Path,
config: &LayoutCsv,
) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv(
caminho,
config.delimitador as u8,
&config.encoding,
config.linha_cabecalho,
)?;
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_documento_tipo,
config.indice_valor,
config.indice_data,
&mut avisos,
)?;
Ok(ResultadoImportacao { notas, avisos })
}
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(
caminho: &Path,
config: &LayoutXlsx,
) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'",
config.pos_numero
))
})?;
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie).ok_or_else(|| {
ErroArquivo::ErroLeitura(format!("Posição de Serie inválida: '{}'", config.pos_serie))
})?;
coord_num.linha.min(coord_ser.linha)
};
let resultado = xlsx_reader::ler_xlsx(caminho, &config.aba, linha_inicio)?;
// Calcular índices de coluna para cada campo
let col_numero = xlsx_reader::parsear_letra_linha(&config.pos_numero)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Numero inválida".to_string()))?;
let col_serie = xlsx_reader::parsear_letra_linha(&config.pos_serie)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Serie inválida".to_string()))?;
let col_valor = config
.pos_valor
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_data = config
.pos_data
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let 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,
)?;
Ok(ResultadoImportacao { notas, avisos })
}
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos.
///
/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de
/// colunas da primeira linha, identificando qual campo está inválido.
fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_documento_tipo: Option<usize>,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
) -> Result<Vec<Nota>, ErroArquivo> {
let mut notas = Vec::new();
// Verificar se os índices existem na primeira linha disponível
if let Some(primeira_linha) = linhas.first() {
let total_colunas = primeira_linha.len();
let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)];
for (nome, idx) in obrigatorios {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
let opcionais: &[(&str, Option<usize>)] = &[
("Valor", idx_valor),
("Data", idx_data),
("Tipo de Documento", idx_documento_tipo),
];
for (nome, maybe_idx) in opcionais {
if let Some(idx) = maybe_idx {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
}
}
for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1;
// Extrair número
let str_numero = match linha.get(idx_numero) {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
avisos.numeros_invalidos += 1;
avisos
.detalhes
.push(format!("Linha {}: campo Numero ausente", linha_num));
continue;
}
};
// Tentar extrair apenas dígitos se não for numérico
let numero = match parse_numero(&str_numero) {
Ok(n) => n,
Err(msg) => {
avisos.numeros_invalidos += 1;
avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue;
}
};
// Extrair série
let str_serie = match linha.get(idx_serie) {
Some(s) => s.trim().to_string(),
None => {
avisos.series_invalidas += 1;
avisos
.detalhes
.push(format!("Linha {}: campo Serie ausente", linha_num));
continue;
}
};
let serie = match validar_serie(&str_serie) {
Ok(s) => s,
Err(_) => {
avisos.series_invalidas += 1;
avisos.detalhes.push(format!(
"Linha {}: Série inválida '{}'",
linha_num, str_serie
));
continue;
}
};
// Extrair valor (opcional)
let valor = if let Some(idx) = idx_valor {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => {
match crate::domain::services::parser_monetario::parse_valor(s.trim()) {
Ok(v) => Some(v),
Err(_) => {
avisos.valores_invalidos += 1;
avisos.detalhes.push(format!(
"Linha {}: valor monetário inválido '{}'",
linha_num, s
));
None
}
}
}
_ => None,
}
} else {
None
};
// Extrair data (opcional) — suporte a formatos dd/mm/aaaa e aaaa-mm-dd
let data = if let Some(idx) = idx_data {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => parse_data(s.trim()),
_ => None,
}
} else {
None
};
// 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));
}
Ok(notas)
}
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
/// Rejeita zero.
fn parse_numero(s: &str) -> Result<u64, String> {
// Tentativa direta
if let Ok(n) = s.parse::<u64>() {
if n == 0 {
return Err(format!("Numero zero ('{}')", s));
}
return Ok(n);
}
// Tentar extrair apenas dígitos
let apenas_digitos: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if apenas_digitos.is_empty() {
return Err(format!("Numero não numérico: '{}'", s));
}
match apenas_digitos.parse::<u64>() {
Ok(0) => Err(format!("Numero zero após extração de dígitos: '{}'", s)),
Ok(n) => Ok(n),
Err(_) => Err(format!("Numero inválido: '{}'", s)),
}
}
/// Tenta parsear uma string de data em diversos formatos.
fn parse_data(s: &str) -> Option<NaiveDate> {
// dd/mm/aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
return Some(d);
}
// aaaa-mm-dd
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d);
}
// dd-mm-aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d-%m-%Y") {
return Some(d);
}
None
}
+144
View File
@@ -0,0 +1,144 @@
use crate::domain::{
entities::layout::{Layout, LayoutJson},
errors::ErroLayout,
};
use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection;
/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome
/// de erros genéricos de banco.
fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante(nome.to_string())
} else {
ErroLayout::ErroBanco(msg)
}
}
/// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayout> {
// Validar campos obrigatórios
if layout.nome().trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
}
// Validar campos XLSX obrigatórios
if let Layout::Xlsx { config, .. } = layout {
if config.pos_numero.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if config.pos_serie.trim().is_empty() {
return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string()));
}
}
if let Some(id) = layout.id() {
// Verificar conflito de nome com outro layout (excluindo o próprio)
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
.and_then(|conflito| {
if conflito {
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
} else {
Ok(())
}
})?;
layout_repository::atualizar(conn, layout)
.map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id)
} else {
let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe {
return Err(ErroLayout::NomeConflitante(nome));
}
layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
}
}
/// Lista todos os layouts salvos.
pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
layout_repository::listar(conn).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
}
/// Exporta um layout para JSON.
/// Retorna o conteúdo JSON como string e o nome de arquivo sugerido.
pub fn exportar_layout_json(layout: &Layout) -> Result<(String, String), String> {
let json_repr = LayoutJson::from(layout);
let conteudo = serde_json::to_string_pretty(&json_repr)
.map_err(|e| format!("Erro ao serializar layout: {}", e))?;
let nome_arquivo = format!("{}.json", layout.nome());
Ok((conteudo, nome_arquivo))
}
/// Importa um layout a partir de uma string JSON.
///
/// # Comportamento de conflito de nome
/// Se `sobrescrever_se_existir` for `true` e o nome já existir no banco, sobrescreve.
/// Se `false` e o nome já existir, retorna `ErroLayout::NomeConflitante`.
pub fn importar_layout_json(
conn: &Connection,
json: &str,
sobrescrever_se_existir: bool,
novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> {
// Deserializar
let json_repr: LayoutJson =
serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?;
// Aplicar novo nome se fornecido
if let Some(nome) = novo_nome {
match &mut layout {
Layout::Csv { nome: n, .. } => *n = nome.to_string(),
Layout::Xlsx { nome: n, .. } => *n = nome.to_string(),
}
}
// Verificar conflito de nome
let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe {
if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
let id_existente = layouts_existentes
.iter()
.find(|l| l.nome() == nome_atual)
.and_then(|l| l.id());
if let Some(id) = id_existente {
match &mut layout {
Layout::Csv { id: i, .. } => *i = Some(id),
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
layout_repository::atualizar(conn, &layout)
.map_err(|e| mapear_erro_banco(e, &nome_atual))?;
return Ok(id);
}
} else {
return Err(ErroLayout::NomeConflitante(nome_atual));
}
}
// Inserir novo
layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;
+304
View File
@@ -0,0 +1,304 @@
# Domain — AGENTS.md
Camada de domínio do projeto `comparador-notas`. Contém as entidades, serviços
de negócio e erros tipados. Não possui dependências de infraestrutura — todo
acesso a banco, arquivos ou UI é responsabilidade das camadas superiores.
---
## Estrutura dos arquivos
```
src/domain/
├── mod.rs
├── errors.rs # Enums de erro tipados
├── entities/
│ ├── mod.rs
│ ├── chave_serie.rs # Chave composta (serie + documento_tipo)
│ ├── layout.rs # Entidades de configuração de layout CSV/XLSX
│ ├── nota.rs # Entidade Nota Fiscal
│ ├── resultado_analise.rs # Resultados de análise (pré e completo)
│ └── serie.rs # Validação de série
└── services/
├── mod.rs
├── detector_duplicidade.rs # Detecção de notas duplicadas
├── detector_sequencia.rs # Detecção de notas faltantes na sequência
└── parser_monetario.rs # Parsing e formatação de valores monetários
```
---
## errors.rs
Erros tipados com a crate `thiserror`. Cada domínio tem seu próprio enum.
### `ErroSerie`
| Variante | Mensagem |
|---|---|
| `Invalida(String)` | Série inválida: deve conter de 1 a 3 dígitos numéricos |
| `Vazia` | Série vazia |
### `ErroValor`
| Variante | Mensagem |
|---|---|
| `Negativo(String)` | Valor negativo não é permitido |
| `NaoNumerico(String)` | Valor não numérico |
### `ErroLayout`
| Variante | Mensagem |
|---|---|
| `CampoObrigatorioAusente(String)` | Campo obrigatório ausente |
| `JsonMalformado(String)` | JSON malformado |
| `NomeConflitante(String)` | Layout com esse nome já existe |
### `ErroArquivo`
| Variante | Mensagem |
|---|---|
| `TamanhoExcedido(u64)` | Arquivo > 50 MB |
| `Corrompido(String)` | Arquivo corrompido ou ilegível |
| `ErroLeitura(String)` | Erro genérico de leitura |
### `ResumoAvisos`
Estrutura de avisos não-fatais acumulados durante a importação de um arquivo.
```rust
pub struct ResumoAvisos {
pub linhas_malformadas: usize,
pub numeros_invalidos: usize,
pub series_invalidas: usize,
pub valores_invalidos: usize,
pub detalhes: Vec<String>, // mensagens individuais por linha
}
```
- `tem_avisos()``true` se qualquer contador > 0
- `linhas_para_exibir()``Vec<String>` com resumo para exibição em modal
---
## entities/
### `chave_serie.rs` — `ChaveSerie`
Chave composta que identifica um grupo de notas fiscais. Combina série com
tipo de documento (`NFE`, `NFCE`, etc.).
```rust
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
```
- Deriva `Hash`, `Eq`, `Ord` — usada como chave em `HashMap` e para ordenação.
- `new(serie, documento_tipo)` — construtor.
- `label()` — formata para exibição:
- `Some(tipo)``"001 / NFE"`
- `None``"001"`
### `nota.rs` — `Nota`
Entidade central. `(numero, serie, documento_tipo)` é o identificador único.
```rust
pub struct Nota {
pub numero: u64,
pub serie: String,
pub documento_tipo: Option<String>, // None quando não mapeado
pub valor: Option<Decimal>,
pub data: Option<NaiveDate>, // usado no PDF, não em regras
}
```
### `serie.rs` — `validar_serie`
```rust
pub fn validar_serie(s: &str) -> Result<String, ErroSerie>
```
Valida via regex `^[0-9]{1,3}$` (1 a 3 dígitos numéricos). Faz trim antes
de validar. Usa `OnceLock` para compilar o regex uma única vez.
### `resultado_analise.rs`
Dois tipos de resultado que modelam o fluxo de análise em duas etapas:
#### `ResultadoPreAnalise`
Resultado intermediário — calculado sem expandir a lista completa de faltantes.
Usado para verificar se algum intervalo excede 10.000 registros (RF04) antes
de pedir confirmação ao usuário.
```rust
pub struct ResultadoPreAnalise {
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
#### `IntervaloSerie`
```rust
pub struct IntervaloSerie {
pub minimo: u64,
pub maximo: u64,
pub contagem_faltantes: u64,
}
```
- `excede_limite(limite)``contagem_faltantes > limite`
#### `ResultadoAnalise`
Resultado completo com a lista materializada de faltantes.
```rust
pub struct ResultadoAnalise {
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>, // ordenados crescentemente
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
Métodos auxiliares:
- `sem_inconsistencias()``true` se não há faltantes nem duplicatas
- `total_faltantes()` → soma do tamanho de todas as listas de faltantes
- `total_duplicatas()` → soma do número de grupos de duplicatas
### `layout.rs`
Entidades de configuração de layout de arquivo.
#### `TipoArquivo`
```rust
pub enum TipoArquivo { Csv, Xlsx }
```
#### `LayoutCsv`
| Campo | Tipo | Descrição |
|---|---|---|
| `delimitador` | `char` | `','`, `';'` ou `'\t'` |
| `encoding` | `String` | `"utf-8"` ou `"windows-1252"` |
| `linha_cabecalho` | `usize` | Linha do cabeçalho (base 1). `0` = sem cabeçalho |
| `indice_numero` | `usize` | Índice da coluna Numero (base 0) |
| `indice_serie` | `usize` | Índice da coluna Serie (base 0) |
| `indice_valor` | `Option<usize>` | Índice da coluna Valor (base 0), opcional |
| `indice_data` | `Option<usize>` | Índice da coluna Data (base 0), opcional |
| `indice_documento_tipo` | `Option<usize>` | Índice da coluna Tipo Documento (base 0), opcional |
#### `LayoutXlsx`
| Campo | Tipo | Descrição |
|---|---|---|
| `aba` | `String` | Nome da aba a processar |
| `pos_numero` | `String` | Posição LetraLinha (ex: `"D3"`) |
| `pos_serie` | `String` | Posição LetraLinha (ex: `"B3"`) |
| `pos_valor` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_data` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_documento_tipo` | `Option<String>` | Posição LetraLinha, opcional |
#### `Layout` (enum)
```rust
pub enum Layout {
Csv { id: Option<i64>, nome: String, config: LayoutCsv },
Xlsx { id: Option<i64>, nome: String, config: LayoutXlsx },
}
```
Métodos: `id()`, `nome()`, `tipo()`.
#### `LayoutJson` (serialização)
Representação `serde` com tag `"tipo"` para importação/exportação em JSON.
Implementa `TryFrom<LayoutJson> for Layout` (valida campos obrigatórios) e
`From<&Layout> for LayoutJson`.
---
## services/
### `detector_duplicidade.rs`
#### `detectar_duplicidades(notas) -> HashMap<(u64, String, Option<String>), usize>`
Conta ocorrências de cada `(numero, serie, documento_tipo)`. Retém apenas
grupos com mais de uma ocorrência.
#### `duplicidades_por_serie(notas) -> HashMap<ChaveSerie, Vec<(u64, usize)>>`
Agrupa o resultado de `detectar_duplicidades` por `ChaveSerie`. Cada vetor
é ordenado por `numero` crescente.
Regras:
- O mesmo número em séries diferentes **não** é duplicata.
- O mesmo número com `documento_tipo` diferente **não** é duplicata.
- O mesmo número com mesma série e mesmo `documento_tipo` **é** duplicata.
---
### `detector_sequencia.rs`
#### Constante
```rust
pub const LIMITE_FALTANTES: u64 = 10_000;
```
#### `calcular_intervalo(notas) -> Option<IntervaloSerie>`
Calcula `minimo`, `maximo` e `contagem_faltantes` de forma incremental
(usando `windows(2)`) **sem materializar** a lista de faltantes. Deduplica
números antes do cálculo para não contar duplicatas como faltantes.
#### `detectar_faltantes(notas) -> Vec<u64>`
Materializa a lista completa de faltantes em ordem crescente. Deve ser
chamado apenas após confirmação do usuário quando `calcular_intervalo`
indica que o intervalo excede `LIMITE_FALTANTES`.
#### `agrupar_contiguos(faltantes) -> Vec<(u64, u64)>`
Recebe uma lista **já ordenada** de números faltantes e retorna intervalos
contíguos como pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
Exemplo: `[1, 2, 3, 5, 8, 9]``[(1, 3), (5, 5), (8, 9)]`
---
### `parser_monetario.rs`
#### `parse_valor(input) -> Result<Decimal, ErroValor>`
Faz o parsing de uma string monetária suportando formatos brasileiro e americano.
Rejeita valores negativos.
Algoritmo (RF06):
| Condição | Regra |
|---|---|
| Contém ponto **e** vírgula | Último separador é o decimal |
| Apenas ponto, 3 dígitos após | Separador de milhar (`1.234``1234`) |
| Apenas ponto, outros casos | Decimal (`1000.00`) |
| Apenas vírgula, 3 dígitos após | Separador de milhar (`1,234``1234`) |
| Apenas vírgula, outros casos | Decimal (`1000,00``1000.00`) |
| Sem separador | Número inteiro |
Aceita prefixo `R$` (case-insensitive).
#### `formatar_valor_br(valor) -> String`
Formata `Decimal` para exibição brasileira com 2 casas decimais e pontos de
milhar. Ex: `1234567.89``"1.234.567,89"`.
+26
View File
@@ -0,0 +1,26 @@
/// Chave composta que identifica um grupo de notas fiscais.
/// Combina a série com o tipo de documento (ex: NFE, NFCE), ambos opcionais.
/// Quando `documento_tipo` é `None`, o comportamento é idêntico ao agrupamento
/// somente por série (retrocompatível).
#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
impl ChaveSerie {
pub fn new(serie: String, documento_tipo: Option<String>) -> Self {
Self {
serie,
documento_tipo,
}
}
/// Formata para exibição: "001 / NFE" quando tipo presente, "001" quando ausente.
pub fn label(&self) -> String {
match &self.documento_tipo {
Some(tipo) => format!("{} / {}", self.serie, tipo),
None => self.serie.clone(),
}
}
}
+260
View File
@@ -0,0 +1,260 @@
use serde::{Deserialize, Serialize};
/// Tipo de arquivo suportado pelo sistema.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TipoArquivo {
Csv,
Xlsx,
}
impl std::fmt::Display for TipoArquivo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TipoArquivo::Csv => write!(f, "csv"),
TipoArquivo::Xlsx => write!(f, "xlsx"),
}
}
}
/// Configurações de um layout para arquivo CSV.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutCsv {
/// Caractere delimitador: ',', ';' ou '\t'
pub delimitador: char,
/// Encoding do arquivo: "utf-8" ou "windows-1252"
pub encoding: String,
/// Número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
pub linha_cabecalho: usize,
/// Índice da coluna Numero (base 0)
pub indice_numero: usize,
/// Índice da coluna Serie (base 0)
pub indice_serie: usize,
/// Índice da coluna Valor (base 0, None se não mapeado)
pub indice_valor: Option<usize>,
/// Índice da coluna Data (base 0, None se não mapeado)
pub indice_data: Option<usize>,
/// Índice da coluna Tipo Documento (base 0, None se não mapeado)
pub indice_documento_tipo: Option<usize>,
}
impl Default for LayoutCsv {
fn default() -> Self {
Self {
delimitador: ';',
encoding: "utf-8".to_string(),
linha_cabecalho: 1,
indice_numero: 0,
indice_serie: 1,
indice_valor: None,
indice_data: None,
indice_documento_tipo: None,
}
}
}
/// Configurações de um layout para arquivo XLSX.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutXlsx {
/// Nome ou índice (como string) da aba a ser processada
pub aba: String,
/// Posição inicial da coluna Numero no formato LetraLinha (ex: "D3")
pub pos_numero: String,
/// Posição inicial da coluna Serie no formato LetraLinha (ex: "B3")
pub pos_serie: String,
/// Posição inicial da coluna Valor (None se não mapeado)
pub pos_valor: Option<String>,
/// Posição inicial da coluna Data (None se não mapeado)
pub pos_data: Option<String>,
/// Posição inicial da coluna Tipo Documento (None se não mapeado)
pub pos_documento_tipo: Option<String>,
}
impl Default for LayoutXlsx {
fn default() -> Self {
Self {
aba: String::new(),
pos_numero: String::new(),
pos_serie: String::new(),
pos_valor: None,
pos_data: None,
pos_documento_tipo: None,
}
}
}
/// Layout de configuração do usuário.
/// Cada layout é exclusivo de um tipo de arquivo (CSV ou XLSX).
#[derive(Debug, Clone)]
pub enum Layout {
Csv {
id: Option<i64>,
nome: String,
config: LayoutCsv,
},
Xlsx {
id: Option<i64>,
nome: String,
config: LayoutXlsx,
},
}
impl Layout {
pub fn id(&self) -> Option<i64> {
match self {
Layout::Csv { id, .. } => *id,
Layout::Xlsx { id, .. } => *id,
}
}
pub fn nome(&self) -> &str {
match self {
Layout::Csv { nome, .. } => nome,
Layout::Xlsx { nome, .. } => nome,
}
}
pub fn tipo(&self) -> TipoArquivo {
match self {
Layout::Csv { .. } => TipoArquivo::Csv,
Layout::Xlsx { .. } => TipoArquivo::Xlsx,
}
}
}
/// Representação JSON de um layout (para importação/exportação).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "tipo", rename_all = "lowercase")]
pub enum LayoutJson {
Csv {
nome: String,
delimitador: String,
encoding: String,
linha_cabecalho: usize,
indice_numero: usize,
indice_serie: usize,
indice_valor: Option<usize>,
indice_data: Option<usize>,
#[serde(default)]
indice_documento_tipo: Option<usize>,
},
Xlsx {
nome: String,
aba: String,
pos_numero: String,
pos_serie: String,
pos_valor: Option<String>,
pos_data: Option<String>,
#[serde(default)]
pos_documento_tipo: Option<String>,
},
}
impl TryFrom<LayoutJson> for Layout {
type Error = crate::domain::errors::ErroLayout;
fn try_from(json: LayoutJson) -> Result<Self, Self::Error> {
match json {
LayoutJson::Csv {
nome,
delimitador,
encoding,
linha_cabecalho,
indice_numero,
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"nome".to_string(),
));
}
let delim_char = delimitador.chars().next().ok_or_else(|| {
crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"delimitador".to_string(),
)
})?;
Ok(Layout::Csv {
id: None,
nome,
config: LayoutCsv {
delimitador: delim_char,
encoding,
linha_cabecalho,
indice_numero,
indice_serie,
indice_valor,
indice_data,
indice_documento_tipo,
},
})
}
LayoutJson::Xlsx {
nome,
aba,
pos_numero,
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
} => {
if nome.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"nome".to_string(),
));
}
if pos_numero.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"pos_numero".to_string(),
));
}
if pos_serie.trim().is_empty() {
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
"pos_serie".to_string(),
));
}
Ok(Layout::Xlsx {
id: None,
nome,
config: LayoutXlsx {
aba,
pos_numero,
pos_serie,
pos_valor,
pos_data,
pos_documento_tipo,
},
})
}
}
}
}
impl From<&Layout> for LayoutJson {
fn from(layout: &Layout) -> Self {
match layout {
Layout::Csv { nome, config, .. } => LayoutJson::Csv {
nome: nome.clone(),
delimitador: config.delimitador.to_string(),
encoding: config.encoding.clone(),
linha_cabecalho: config.linha_cabecalho,
indice_numero: config.indice_numero,
indice_serie: config.indice_serie,
indice_valor: config.indice_valor,
indice_data: config.indice_data,
indice_documento_tipo: config.indice_documento_tipo,
},
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
nome: nome.clone(),
aba: config.aba.clone(),
pos_numero: config.pos_numero.clone(),
pos_serie: config.pos_serie.clone(),
pos_valor: config.pos_valor.clone(),
pos_data: config.pos_data.clone(),
pos_documento_tipo: config.pos_documento_tipo.clone(),
},
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod chave_serie;
pub mod layout;
pub mod nota;
pub mod resultado_analise;
pub mod serie;
+36
View File
@@ -0,0 +1,36 @@
use chrono::NaiveDate;
use rust_decimal::Decimal;
/// Representa uma nota fiscal com seus campos lógicos.
/// `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 (13 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<String>,
/// Valor monetário da nota (opcional).
pub valor: Option<Decimal>,
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
pub data: Option<NaiveDate>,
}
impl Nota {
pub fn new(
numero: u64,
serie: String,
documento_tipo: Option<String>,
valor: Option<Decimal>,
data: Option<NaiveDate>,
) -> Self {
Self {
numero,
serie,
documento_tipo,
valor,
data,
}
}
}
+65
View File
@@ -0,0 +1,65 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Resultado intermediário da análise, antes de materializar os faltantes.
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
#[derive(Debug, Clone)]
pub struct ResultadoPreAnalise {
/// Mapa (serie, documento_tipo) → (minimo, maximo, contagem_faltantes)
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
/// Duplicatas já processadas (não dependem dos faltantes)
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Somas já calculadas
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
/// Intervalo de sequência de uma série.
#[derive(Debug, Clone)]
pub struct IntervaloSerie {
pub minimo: u64,
pub maximo: u64,
pub contagem_faltantes: u64,
}
impl IntervaloSerie {
pub fn excede_limite(&self, limite: u64) -> bool {
self.contagem_faltantes > limite
}
}
/// Resultado completo da análise, com a lista materializada de faltantes.
#[derive(Debug, Clone)]
pub struct ResultadoAnalise {
/// Números faltantes por (serie, documento_tipo) (ordenados crescentemente)
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>,
/// Duplicatas: mapa (serie, documento_tipo) → [(numero, contagem_ocorrencias)]
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
/// Soma total de todos os valores
pub soma_total: Decimal,
/// Soma por (serie, documento_tipo)
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
/// Total de notas processadas por (serie, documento_tipo)
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
impl ResultadoAnalise {
/// Retorna true se não houver faltantes nem duplicatas.
pub fn sem_inconsistencias(&self) -> bool {
self.faltantes_por_serie.values().all(|v| v.is_empty())
&& self.duplicadas_por_serie.values().all(|v| v.is_empty())
}
/// Retorna o total de notas faltantes somando todas as séries.
pub fn total_faltantes(&self) -> usize {
self.faltantes_por_serie.values().map(|v| v.len()).sum()
}
/// Retorna o total de grupos de duplicatas somando todas as séries.
pub fn total_duplicatas(&self) -> usize {
self.duplicadas_por_serie.values().map(|v| v.len()).sum()
}
}
+58
View File
@@ -0,0 +1,58 @@
use crate::domain::errors::ErroSerie;
use regex::Regex;
use std::sync::OnceLock;
static REGEX_SERIE: OnceLock<Regex> = OnceLock::new();
fn regex_serie() -> &'static Regex {
REGEX_SERIE.get_or_init(|| Regex::new(r"^[0-9]{1,3}$").expect("Regex de série inválida"))
}
/// Valida e normaliza uma série.
/// Retorna `Ok(serie_normalizada)` ou `Err(ErroSerie)`.
pub fn validar_serie(s: &str) -> Result<String, ErroSerie> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(ErroSerie::Vazia);
}
if regex_serie().is_match(trimmed) {
Ok(trimmed.to_string())
} else {
Err(ErroSerie::Invalida(trimmed.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn serie_valida_simples() {
assert!(validar_serie("1").is_ok());
assert!(validar_serie("01").is_ok());
assert!(validar_serie("001").is_ok());
assert!(validar_serie("999").is_ok());
}
#[test]
fn serie_com_espacos_e_valida() {
assert!(validar_serie(" 1 ").is_ok());
}
#[test]
fn serie_invalida_letras() {
assert!(validar_serie("ABC").is_err());
assert!(validar_serie("1A").is_err());
}
#[test]
fn serie_invalida_quatro_digitos() {
assert!(validar_serie("1234").is_err());
}
#[test]
fn serie_vazia() {
assert!(matches!(validar_serie(""), Err(ErroSerie::Vazia)));
assert!(matches!(validar_serie(" "), Err(ErroSerie::Vazia)));
}
}
+88
View File
@@ -0,0 +1,88 @@
use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
Invalida(String),
#[error("Série vazia")]
Vazia,
}
#[derive(Debug, Error, Clone)]
pub enum ErroValor {
#[error("Valor negativo não é permitido: '{0}'")]
Negativo(String),
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)]
pub enum ErroLayout {
#[error("Campo obrigatório ausente: '{0}'")]
CampoObrigatorioAusente(String),
#[error("JSON malformado: {0}")]
JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
}
#[derive(Debug, Error, Clone)]
pub enum ErroArquivo {
#[error("Arquivo muito grande ({0} bytes). Limite: 50 MB")]
TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String),
#[error("Erro de leitura: {0}")]
ErroLeitura(String),
}
/// Resumo consolidado de avisos para exibição em um único modal.
#[derive(Debug, Default, Clone)]
pub struct ResumoAvisos {
pub linhas_malformadas: usize,
pub numeros_invalidos: usize,
pub series_invalidas: usize,
pub valores_invalidos: usize,
pub detalhes: Vec<String>,
}
impl ResumoAvisos {
pub fn tem_avisos(&self) -> bool {
self.linhas_malformadas > 0
|| self.numeros_invalidos > 0
|| self.series_invalidas > 0
|| self.valores_invalidos > 0
}
pub fn linhas_para_exibir(&self) -> Vec<String> {
let mut linhas = Vec::new();
if self.linhas_malformadas > 0 {
linhas.push(format!(
"{} linhas descartadas por malformação",
self.linhas_malformadas
));
}
if self.numeros_invalidos > 0 {
linhas.push(format!(
"{} valores de Numero inválidos convertidos ou descartados",
self.numeros_invalidos
));
}
if self.series_invalidas > 0 {
linhas.push(format!(
"{} registros com Série inválida descartados",
self.series_invalidas
));
}
if self.valores_invalidos > 0 {
linhas.push(format!(
"{} valores monetários inválidos descartados",
self.valores_invalidos
));
}
linhas.extend(self.detalhes.clone());
linhas
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod entities;
pub mod errors;
pub mod services;
+135
View File
@@ -0,0 +1,135 @@
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, documento_tipo) → contagem de ocorrências,
/// contendo apenas grupos com mais de uma ocorrência.
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String, Option<String>), usize> {
let mut contagem: HashMap<(u64, String, Option<String>), usize> = HashMap::new();
for nota in notas {
*contagem
.entry((nota.numero, nota.serie.clone(), nota.documento_tipo.clone()))
.or_insert(0) += 1;
}
// Manter apenas os que têm mais de uma ocorrência
contagem.retain(|_, count| *count > 1);
contagem
}
/// Agrupa as duplicidades por (serie, documento_tipo).
///
/// Retorna HashMap<ChaveSerie, Vec<(numero, contagem)>>, ordenado por numero crescente.
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<ChaveSerie, Vec<(u64, usize)>> {
let raw = detectar_duplicidades(notas);
let mut result: HashMap<ChaveSerie, Vec<(u64, usize)>> = HashMap::new();
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 grupo
for lista in result.values_mut() {
lista.sort_by_key(|(num, _)| *num);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
fn nota(numero: u64, serie: &str) -> Nota {
Nota::new(numero, serie.to_string(), None, None, 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]
fn sem_duplicatas() {
let notas = vec![nota(1, "001"), nota(2, "001"), nota(3, "001")];
let dup = detectar_duplicidades(&notas);
assert!(dup.is_empty());
}
#[test]
fn com_duplicata_simples() {
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(1, "001".to_string(), None)), Some(&2));
assert_eq!(dup.len(), 1);
}
#[test]
fn duplicata_multiplas_ocorrencias() {
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
let dup = detectar_duplicidades(&notas);
assert_eq!(dup.get(&(4, "001".to_string(), None)), Some(&3));
}
#[test]
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
let notas = vec![nota(1, "001"), nota(1, "002")];
let dup = detectar_duplicidades(&notas);
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(&notas);
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(&notas);
assert_eq!(
dup.get(&(1, "001".to_string(), Some("NFE".to_string()))),
Some(&2)
);
}
#[test]
fn agrupamento_por_serie() {
let notas = vec![
nota(1, "001"),
nota(1, "001"),
nota(1, "002"),
nota(1, "002"),
nota(2, "001"),
nota(2, "001"),
];
let por_serie = duplicidades_por_serie(&notas);
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));
let serie002 = por_serie.get(&chave002).unwrap();
assert_eq!(serie002.len(), 1);
assert_eq!(serie002[0], (1, 2));
}
}
+188
View File
@@ -0,0 +1,188 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::IntervaloSerie;
/// Limite de faltantes por série antes de solicitar confirmação do usuário (RF04).
pub const LIMITE_FALTANTES: u64 = 10_000;
/// Calcula o intervalo de faltantes de uma lista de notas de uma mesma série,
/// sem materializar a lista completa.
///
/// Retorna `None` se não há notas.
pub fn calcular_intervalo(notas: &[&Nota]) -> Option<IntervaloSerie> {
if notas.is_empty() {
return None;
}
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
numeros.sort_unstable();
numeros.dedup(); // Ignorar duplicatas no cálculo de sequência
let minimo = *numeros.first().unwrap();
let maximo = *numeros.last().unwrap();
// Contar faltantes de forma incremental
let mut faltantes: u64 = 0;
for w in numeros.windows(2) {
let a = w[0];
let b = w[1];
if b > a + 1 {
faltantes += b - a - 1;
}
}
Some(IntervaloSerie {
minimo,
maximo,
contagem_faltantes: faltantes,
})
}
/// Materializa a lista completa de números faltantes para uma série.
/// Deve ser chamado apenas após confirmação do usuário quando o intervalo
/// excede `LIMITE_FALTANTES`.
///
/// A lista é retornada em ordem crescente.
pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
if notas.is_empty() {
return Vec::new();
}
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
numeros.sort_unstable();
numeros.dedup();
if numeros.len() <= 1 {
return Vec::new();
}
let mut faltantes = Vec::new();
for w in numeros.windows(2) {
let a = w[0];
let b = w[1];
for faltante in (a + 1)..b {
faltantes.push(faltante);
}
}
faltantes
}
/// 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::*;
use crate::domain::entities::nota::Nota;
use rust_decimal::Decimal;
fn nota(numero: u64) -> Nota {
Nota::new(numero, "001".to_string(), None, None, None)
}
#[test]
fn sem_faltantes() {
let notas = vec![nota(1), nota(2), nota(3)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn com_faltante_simples() {
let notas = vec![nota(1), nota(2), nota(3), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), vec![4u64]);
}
#[test]
fn com_multiplos_faltantes() {
let notas = vec![nota(1), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), vec![2u64, 3, 4]);
}
#[test]
fn serie_com_um_registro() {
let notas = vec![nota(7)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn vazio() {
let refs: Vec<&Nota> = vec![];
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[test]
fn intervalo_correto() {
let notas = vec![nota(1), nota(2), nota(5)];
let refs: Vec<&Nota> = notas.iter().collect();
let intervalo = calcular_intervalo(&refs).unwrap();
assert_eq!(intervalo.minimo, 1);
assert_eq!(intervalo.maximo, 5);
assert_eq!(intervalo.contagem_faltantes, 2);
}
#[test]
fn intervalo_excede_limite() {
let notas = vec![nota(1), nota(20_000)];
let refs: Vec<&Nota> = notas.iter().collect();
let intervalo = calcular_intervalo(&refs).unwrap();
assert!(intervalo.excede_limite(LIMITE_FALTANTES));
}
#[test]
fn duplicatas_ignoradas_no_calculo() {
// Duplicados não devem gerar faltantes falsos
let notas = vec![nota(1), nota(1), nota(2), nota(3)];
let refs: Vec<&Nota> = notas.iter().collect();
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
}
#[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)]
);
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod detector_duplicidade;
pub mod detector_sequencia;
pub mod parser_monetario;
+175
View File
@@ -0,0 +1,175 @@
use crate::domain::errors::ErroValor;
use rust_decimal::Decimal;
use std::str::FromStr;
/// Faz o parsing de uma string de valor monetário para `Decimal`,
/// suportando formatos brasileiro (1.000,00) e americano (1,000.00).
///
/// Algoritmo conforme RF06:
/// - Regra 1: contém ponto E vírgula → último separador é o decimal
/// - Regra 2: apenas um separador + 2 dígitos após → decimal; 3 dígitos → milhar
/// - Regra 3: sem separador → número inteiro
///
/// Rejeita valores negativos.
pub fn parse_valor(input: &str) -> Result<Decimal, ErroValor> {
let s = input.trim();
// Rejeitar negativos
if s.starts_with('-') {
return Err(ErroValor::Negativo(s.to_string()));
}
// Remover prefixos/sufixos comuns (ex: "R$", espaços internos, "BRL")
let s = s.trim_start_matches("R$").trim_start_matches("r$").trim();
let tem_ponto = s.contains('.');
let tem_virgula = s.contains(',');
let normalizado = match (tem_ponto, tem_virgula) {
// Regra 1: tem ambos — último separador é o decimal
(true, true) => {
let pos_ponto = s.rfind('.').unwrap();
let pos_virgula = s.rfind(',').unwrap();
if pos_ponto > pos_virgula {
// Formato americano: 1,000.00 → ponto é decimal
s.replace(',', "")
} else {
// Formato brasileiro: 1.000,00 → vírgula é decimal
s.replace('.', "").replace(',', ".")
}
}
// Regra 2: apenas ponto
(true, false) => {
let depois_do_ponto = &s[s.rfind('.').unwrap() + 1..];
match depois_do_ponto.len() {
3 => {
// 3 dígitos após ponto → separador de milhar (ex: 1.234)
s.replace('.', "")
}
_ => {
// 2 dígitos ou outro → decimal (ex: 1000.00)
s.to_string()
}
}
}
// Regra 2: apenas vírgula
(false, true) => {
let depois_da_virgula = &s[s.rfind(',').unwrap() + 1..];
match depois_da_virgula.len() {
3 => {
// 3 dígitos após vírgula → separador de milhar (ex: 1,234)
s.replace(',', "")
}
_ => {
// 2 dígitos ou outro → decimal (ex: 1000,00)
s.replace(',', ".")
}
}
}
// Regra 3: sem separador → inteiro
(false, false) => s.to_string(),
};
// Verificar se ainda há caracteres não numéricos (exceto ponto decimal)
if normalizado.chars().any(|c| !c.is_ascii_digit() && c != '.') {
return Err(ErroValor::NaoNumerico(input.to_string()));
}
Decimal::from_str(&normalizado).map_err(|_| ErroValor::NaoNumerico(input.to_string()))
}
/// Formata um `Decimal` para exibição no formato monetário brasileiro.
/// Ex: 1234.56 → "1.234,56"
pub fn formatar_valor_br(valor: &Decimal) -> String {
let s = format!("{:.2}", valor);
// Separar parte inteira e decimal
let partes: Vec<&str> = s.split('.').collect();
let inteira = partes[0];
let decimal = partes.get(1).copied().unwrap_or("00");
// Inserir pontos de milhar
let inteira_com_milhar = inserir_pontos_milhar(inteira);
format!("{},{}", inteira_com_milhar, decimal)
}
fn inserir_pontos_milhar(s: &str) -> String {
let digits: Vec<char> = s.chars().collect();
let len = digits.len();
let mut result = String::new();
for (i, &c) in digits.iter().enumerate() {
if i > 0 && (len - i) % 3 == 0 {
result.push('.');
}
result.push(c);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
#[test]
fn regra1_formato_br() {
assert_eq!(parse_valor("1.000,00").unwrap(), dec!(1000.00));
}
#[test]
fn regra1_formato_en() {
assert_eq!(parse_valor("1,000.00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_ponto_2_digitos() {
assert_eq!(parse_valor("1000.00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_virgula_2_digitos() {
assert_eq!(parse_valor("1000,00").unwrap(), dec!(1000.00));
}
#[test]
fn regra2_ponto_3_digitos_milhar() {
assert_eq!(parse_valor("1.234").unwrap(), dec!(1234));
}
#[test]
fn regra2_virgula_3_digitos_milhar() {
assert_eq!(parse_valor("1,234").unwrap(), dec!(1234));
}
#[test]
fn regra3_inteiro() {
assert_eq!(parse_valor("1000").unwrap(), dec!(1000));
}
#[test]
fn rejeitar_negativo() {
assert!(matches!(
parse_valor("-100,00"),
Err(ErroValor::Negativo(_))
));
}
#[test]
fn rejeitar_nao_numerico() {
assert!(matches!(parse_valor("abc"), Err(ErroValor::NaoNumerico(_))));
}
#[test]
fn formatar_milhar() {
assert_eq!(formatar_valor_br(&dec!(1234.56)), "1.234,56");
}
#[test]
fn formatar_sem_milhar() {
assert_eq!(formatar_valor_br(&dec!(100.00)), "100,00");
}
#[test]
fn formatar_grande() {
assert_eq!(formatar_valor_br(&dec!(1234567.89)), "1.234.567,89");
}
}
+207
View File
@@ -0,0 +1,207 @@
# Infrastructure — AGENTS.md
Camada de infraestrutura do projeto `comparador-notas`. Responsável por toda I/O
concreta: leitura de arquivos (CSV e XLSX), geração de PDF e persistência SQLite.
Não contém regras de negócio; depende do `domain` para tipos e erros.
---
## Estrutura dos arquivos
```
src/infrastructure/
├── mod.rs # Re-exporta os submódulos públicos
├── csv_reader.rs # Leitura e preview de arquivos CSV
├── xlsx_reader.rs # Leitura e preview de arquivos XLSX/XLS
├── pdf_generator.rs # Trait abstrata + implementação concreta de geração de PDF
└── sqlite/ # Submódulo de persistência (ver sqlite/AGENTS.md)
```
---
## mod.rs
Re-exporta os quatro submódulos:
```rust
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
```
---
## csv_reader.rs
Leitura de arquivos CSV com suporte a múltiplos encodings e delimitadores.
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### `ResultadoCsv`
```rust
pub struct ResultadoCsv {
pub linhas: Vec<Vec<String>>, // dados sem o cabeçalho
pub avisos: ResumoAvisos,
}
```
### `ler_csv(caminho, delimitador, encoding, linha_cabecalho) -> Result<ResultadoCsv, ErroArquivo>`
Fluxo:
1. Verifica tamanho do arquivo — retorna `ErroArquivo::TamanhoExcedido` se > 50 MB.
2. Lê os bytes brutos com `std::fs::read`.
3. Decodifica o conteúdo:
- `"windows-1252"`, `"latin-1"`, `"iso-8859-1"``encoding_rs::WINDOWS_1252`
- qualquer outro → `String::from_utf8` (UTF-8)
4. Constrói um `csv::ReaderBuilder` com `flexible(true)` e `has_headers(false)`.
5. Itera sobre todos os registros:
- Pula linhas até e incluindo `linha_cabecalho` (quando > 0).
- Ignora linhas completamente em branco.
- Registra erros de parse em `avisos.linhas_malformadas`.
6. Retorna `ResultadoCsv` com as linhas de dados e os avisos.
### `preview_csv(caminho, delimitador, encoding, n) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras `n` linhas brutas (sem pular cabeçalho). Usado exclusivamente
para pré-visualização na UI. Não verifica tamanho do arquivo.
---
## xlsx_reader.rs
Leitura de arquivos XLSX (e XLS por magic bytes) com suporte a coordenadas
no formato `LetraLinha` (ex: `"B3"`).
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### Tipos auxiliares
```rust
pub struct Coordenada {
pub coluna: u32, // base 0
pub linha: u32, // base 1
}
```
### `listar_abas(caminho) -> Result<Vec<String>, ErroArquivo>`
Abre o workbook via `calamine::open_workbook_auto` (detecção por magic bytes)
e retorna os nomes das abas.
### `ler_xlsx(caminho, nome_aba, linha_inicio) -> Result<ResultadoXlsx, ErroArquivo>`
1. Verifica tamanho (50 MB).
2. Abre workbook e seleciona a aba pelo nome.
3. Itera sobre as linhas a partir de `linha_inicio - 1` (base 0 internamente).
4. Converte cada célula para `String` via `celula_para_string` (ver abaixo).
5. Ignora linhas completamente em branco.
6. Retorna `ResultadoXlsx { linhas, avisos }`.
### `preview_xlsx(caminho, nome_aba) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras 5 linhas brutas da aba, a partir da linha 1. Usado para
pré-visualização na UI.
### `parsear_letra_linha(s) -> Option<Coordenada>`
Converte notação Excel (`"B3"`) para `Coordenada { coluna: 1, linha: 3 }`:
- Normaliza para maiúsculas e faz trim.
- Divide entre letras e dígitos.
- Converte letras para índice de coluna base 0:
`A=0, B=1, ..., Z=25, AA=26, ...`
- Retorna `None` para notações inválidas (vazio, só números, zero, etc.).
### `celula_para_string(cell) -> String` (privada)
| Tipo calamine | Conversão |
|---|---|
| `Empty` | `""` |
| `String(s)` | `s.clone()` |
| `Float(f)` | sem `.0` quando `f.fract() == 0.0` |
| `Int(i)` | `i.to_string()` |
| `Bool(b)` | `b.to_string()` |
| `DateTime` / `DateTimeIso` / `DurationIso` | representação string |
| `Error(_)` | `""` |
---
## pdf_generator.rs
Geração de relatórios PDF com fontes embutidas no binário.
### Fontes embutidas
```rust
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
```
Liberation Sans (~402 KB/variante) é usada em vez de Arial do sistema (~993 KB),
eliminando dependência externa e reduzindo o tamanho dos PDFs.
### `PdfGenerator` (trait pública)
```rust
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
```
Permite que o use case `exportar_pdf` dependa da abstração, não da crate `genpdf`.
### `GenpdfGenerator` (implementação concreta)
Implementa `PdfGenerator` usando a crate `genpdf`. Estrutura do PDF gerado:
1. **Título** — "Relatório de Análise de Notas Fiscais" (bold, 16pt)
2. **Metadados** — nome do arquivo, layout (se presente) e data/hora de geração
3. **Totais** — soma total e por série (`ChaveSerie.label()`)
4. **Notas Faltantes por Série** — lista agrupada em intervalos contíguos
(ex: `100104 (5 notas)` em vez de `100, 101, 102, 103, 104`)
5. **Duplicatas por Série** — grupo por número com contagem e data da última ocorrência
### `MetadadosRelatorio`
```rust
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
```
### `carregar_fonte_familia()` (privada)
Constrói `fonts::FontFamily` com os 4 slots exigidos por `genpdf`. Como o
relatório não usa itálico, `italic` e `bold_italic` reutilizam os dados de
`regular` e `bold` respectivamente.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `csv` | Parser de arquivos CSV com suporte a delimitadores e modo flexível |
| `encoding_rs` | Decodificação Windows-1252 / Latin-1 |
| `calamine` | Leitura de XLSX/XLS com detecção automática por magic bytes |
| `genpdf` | Geração de PDF com layout de parágrafos e decorador de página |
| `chrono` | `DateTime<Local>` para timestamp do relatório |
+122
View File
@@ -0,0 +1,122 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use encoding_rs::WINDOWS_1252;
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Resultado da leitura de um arquivo CSV.
pub struct ResultadoCsv {
/// Linhas de dados (já sem o cabeçalho), cada linha é um vetor de strings.
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lê um arquivo CSV e retorna as linhas de dados (sem o cabeçalho).
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `delimitador`: caractere delimitador (`,`, `;` ou `\t`)
/// - `encoding`: "utf-8" ou "windows-1252"
/// - `linha_cabecalho`: número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
pub fn ler_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
linha_cabecalho: usize,
) -> Result<ResultadoCsv, ErroArquivo> {
// Verificar tamanho
let metadata =
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
// Ler conteúdo bruto
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
// Decodificar encoding
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut avisos = ResumoAvisos::default();
let mut linhas_dados: Vec<Vec<String>> = Vec::new();
let mut numero_linha_arquivo: usize = 0;
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
for resultado in reader.records() {
numero_linha_arquivo += 1;
// Pular linhas antes ou na linha do cabeçalho
if linha_cabecalho > 0 && numero_linha_arquivo <= linha_cabecalho {
continue;
}
match resultado {
Ok(record) => {
let campos: Vec<String> = record.iter().map(|s| s.to_string()).collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s| s.trim().is_empty()) {
continue;
}
linhas_dados.push(campos);
}
Err(_) => {
avisos.linhas_malformadas += 1;
}
}
}
Ok(ResultadoCsv {
linhas: linhas_dados,
avisos,
})
}
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_csv(
caminho: &Path,
delimitador: u8,
encoding: &str,
n: usize,
) -> Result<Vec<Vec<String>>, ErroArquivo> {
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
let conteudo = match encoding.to_lowercase().as_str() {
"windows-1252" | "latin-1" | "iso-8859-1" => {
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
decoded.into_owned()
}
_ => String::from_utf8(bytes)
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
};
let mut reader = csv::ReaderBuilder::new()
.delimiter(delimitador)
.has_headers(false)
.flexible(true)
.from_reader(conteudo.as_bytes());
let linhas = reader
.records()
.take(n)
.filter_map(|r| r.ok())
.map(|r| r.iter().map(|s| s.to_string()).collect())
.collect();
Ok(linhas)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
+226
View File
@@ -0,0 +1,226 @@
use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::domain::services::detector_sequencia::agrupar_contiguos;
use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local};
use genpdf::{
elements::{Break, Paragraph},
fonts, style, Document, SimplePageDecorator,
};
use std::collections::HashMap;
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,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
/// Trait abstrata para geração de PDF.
/// Definida aqui para que o use case `exportar_pdf` dependa da abstração,
/// não da crate `genpdf` diretamente.
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
/// Implementação concreta usando `genpdf`.
pub struct GenpdfGenerator;
impl PdfGenerator for GenpdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String> {
// Construir mapa (chave_serie, numero) → data para uso na seção de duplicatas
let datas: HashMap<(ChaveSerie, u64), String> = notas
.iter()
.filter_map(|n| {
n.data.map(|d| {
let chave = ChaveSerie::new(n.serie.clone(), n.documento_tipo.clone());
((chave, n.numero), d.format("%d/%m/%Y").to_string())
})
})
.collect();
let font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family);
doc.set_title("Relatório — Comparador de Notas");
doc.set_minimal_conformance();
let mut decorator = SimplePageDecorator::new();
decorator.set_margins(10);
doc.set_page_decorator(decorator);
// Título
doc.push(Paragraph::new("").styled_string(
"Relatório de Análise de Notas Fiscais",
style::Style::new().bold().with_font_size(16),
));
doc.push(Break::new(1));
// Metadados
doc.push(Paragraph::new(format!("Arquivo: {}", meta.nome_arquivo)));
if let Some(layout) = &meta.nome_layout {
doc.push(Paragraph::new(format!("Layout: {}", layout)));
}
doc.push(Paragraph::new(format!(
"Gerado em: {}",
meta.gerado_em.format("%d/%m/%Y %H:%M:%S")
)));
doc.push(Break::new(1));
// 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)),
);
doc.push(Paragraph::new(format!(
"Total Geral: R$ {}",
formatar_valor_br(&resultado.soma_total)
)));
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$ {}",
chave.label(),
total,
formatar_valor_br(soma)
)));
}
doc.push(Break::new(1));
// Notas Faltantes
doc.push(Paragraph::new("").styled_string(
"Notas Faltantes por Série",
style::Style::new().bold().with_font_size(14),
));
// 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",
chave.label()
)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} faltante(s)",
chave.label(),
faltantes.len()
)));
// Agrupar faltantes contíguos em intervalos (ex: 100104 em vez de 100, 101, 102, 103, 104)
let grupos = agrupar_contiguos(faltantes);
let descricao: Vec<String> = grupos
.iter()
.map(|(ini, fim)| {
if ini == fim {
ini.to_string()
} else {
format!("{ini}{fim} ({} notas)", fim - ini + 1)
}
})
.collect();
doc.push(Paragraph::new(format!(" {}", descricao.join(", "))));
}
doc.push(Break::new(1));
// Duplicatas
doc.push(Paragraph::new("").styled_string(
"Duplicatas por Série",
style::Style::new().bold().with_font_size(14),
));
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",
chave.label()
)));
continue;
}
};
doc.push(Paragraph::new(format!(
" Série {}: {} grupo(s) duplicado(s)",
chave.label(),
duplicatas.len()
)));
for (numero, count) in duplicatas {
let data_str = datas
.get(&((*chave).clone(), *numero))
.map(|d| format!(" (última: {})", d))
.unwrap_or_default();
doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências{}",
numero,
chave.label(),
count,
data_str,
)));
}
}
// Renderizar PDF
doc.render_to_file(caminho_saida)
.map_err(|e| format!("Erro ao gerar PDF: {}", e))?;
Ok(())
}
}
/// 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<fonts::FontFamily<fonts::FontData>, 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 {
italic: regular.clone(),
bold_italic: bold.clone(),
regular,
bold,
})
}
+212
View File
@@ -0,0 +1,212 @@
# SQLite Infrastructure — AGENTS.md
Visão geral da camada de persistência SQLite do projeto `comparador-notas`.
---
## Estrutura dos arquivos
```
src/infrastructure/sqlite/
├── mod.rs # Re-exporta os módulos públicos
├── connection.rs # Abertura e validação da conexão
├── migrations.rs # Controle de versão do schema
└── layout_repository.rs # CRUD da entidade Layout
```
---
## mod.rs
Ponto de entrada do módulo. Apenas re-exporta os três submódulos:
```rust
pub mod connection;
pub mod layout_repository;
pub mod migrations;
```
---
## connection.rs
Responsável por localizar, abrir e validar o arquivo SQLite.
### Caminho do banco
`caminho_banco()` resolve o diretório de configuração do sistema operacional via
`dirs::config_dir()` e retorna:
```
<config_dir>/comparador-notas/config.db
```
Exemplos por SO:
- **Linux**: `~/.config/comparador-notas/config.db`
- **macOS**: `~/Library/Application Support/comparador-notas/config.db`
- **Windows**: `%APPDATA%\comparador-notas\config.db`
### Abertura da conexão — `abrir_banco()`
Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
`Result<(Connection, bool), String>`, onde o `bool` indica se o banco foi
**recriado** (era corrompido).
### Lógica de recuperação de corrupção — `abrir_banco_no_caminho(path)`
1. Cria o diretório pai caso não exista (`create_dir_all`).
2. Se o arquivo já existe, tenta abri-lo com `Connection::open`.
3. Executa `SELECT 1;` como teste de sanidade.
- Sucesso → retorna a conexão com flag `false` (não recriado).
- Falha (corrupção ou erro de abertura) → renomeia o arquivo para
`config.db.bak` e segue para a criação de um banco novo.
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8).
---
## migrations.rs
Controla a evolução incremental do schema via uma tabela interna de versão.
### Tabela de controle
```sql
CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);
```
Armazena apenas uma linha com a versão atual do schema.
### `aplicar_migrations(conn)`
Fluxo:
1. Garante que `schema_version` existe.
2. Lê a versão atual (padrão `0` caso a tabela esteja vazia).
3. Executa sequencialmente as migrations pendentes:
- `versao_atual < 1``migration_v1`
- `versao_atual < 2``migration_v2`
- `versao_atual < 3``migration_v3`
4. Persiste a nova versão na tabela `schema_version`:
- `versao_atual == 0``INSERT INTO schema_version (versao) VALUES (3)`
- `versao_atual > 0 && versao_atual < 3``UPDATE schema_version SET versao = 3`
> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2).
### Histórico de migrations
| Versão | Descrição |
|--------|-----------|
| **v1** | Cria a tabela `layouts` com campos para CSV e XLSX. |
| **v2** | Renomeia layouts com nomes duplicados (sufixo `(id)`) e cria índice único `idx_layouts_nome` em `layouts.nome`. |
| **v3** | Adiciona colunas `indice_documento_tipo` (INTEGER) e `pos_documento_tipo` (TEXT) na tabela `layouts`. |
### Schema final da tabela `layouts`
```sql
CREATE TABLE layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
indice_documento_tipo INTEGER, -- adicionado em v3
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT,
pos_documento_tipo TEXT -- adicionado em v3
);
CREATE UNIQUE INDEX idx_layouts_nome ON layouts (nome); -- adicionado em v2
```
---
## layout_repository.rs
Implementa as operações CRUD sobre a entidade `Layout` (enum com variantes
`Layout::Csv` e `Layout::Xlsx`).
### Funções públicas
#### `salvar(conn, layout) -> Result<i64>`
Insere um novo layout e retorna o `rowid` gerado.
- `Layout::Csv` → preenche colunas CSV; colunas XLSX ficam `NULL`.
- `Layout::Xlsx` → preenche colunas XLSX; colunas CSV ficam `NULL`.
#### `atualizar(conn, layout) -> Result<()>`
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
`id` for `None`.
- `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
- `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco.
> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9).
#### `listar(conn) -> Result<Vec<Layout>>`
Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
- `tipo == "csv"` → constrói `Layout::Csv` mapeando as colunas de índice.
- `tipo == "xlsx"` → constrói `Layout::Xlsx` mapeando as colunas de posição.
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6).
#### `excluir(conn, id) -> Result<()>`
Remove o registro com o `id` informado via `DELETE`.
#### `existe_nome(conn, nome) -> Result<bool>`
Conta registros com o nome fornecido; retorna `true` se `COUNT(*) > 0`.
Usado para validar unicidade antes de salvar.
---
## Fluxo de inicialização
```
abrir_banco()
└─> abrir_banco_no_caminho(caminho)
├─ cria diretório se necessário
├─ testa banco existente (SELECT 1)
│ ├─ OK → retorna (conn, false)
│ └─ ERR → renomeia para .bak, cria banco novo → (conn, true)
└─ banco novo → retorna (conn, true)
aplicar_migrations(conn)
├─ cria schema_version se necessário
├─ lê versão atual
├─ executa migrations pendentes (v1 → v2 → v3)
└─ grava versão final (INSERT se versao=0, UPDATE se versao<3)
```
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `rusqlite` | Driver SQLite embutido (sem servidor externo) |
| `dirs` | Resolve `config_dir()` conforme o SO |
+54
View File
@@ -0,0 +1,54 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
/// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
config_dir.join("comparador-notas").join("config.db")
}
/// Abre (ou cria) o banco de dados SQLite no caminho padrão.
///
/// Retorna a conexão aberta e pronta para uso, ou um erro descritivo.
/// Em caso de banco corrompido, renomeia para `.bak` e recria.
pub fn abrir_banco() -> Result<(Connection, bool), String> {
let caminho = caminho_banco();
abrir_banco_no_caminho(&caminho)
}
/// Versão testável que aceita um caminho explícito.
pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), String> {
// Criar diretório se não existir
if let Some(dir) = caminho.parent() {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Não foi possível criar diretório do banco: {}", e))?;
}
// Verificar se o arquivo existe e está corrompido
if caminho.exists() {
match Connection::open(caminho) {
Ok(conn) => {
// Testar integridade estrutural do banco
match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)),
Err(_e) => {
// Banco corrompido
drop(conn);
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
Err(_) => {
let bak = caminho.with_extension("db.bak");
let _ = std::fs::rename(caminho, &bak);
}
}
}
// Criar banco novo
let conn = Connection::open(caminho)
.map_err(|e| format!("Não foi possível criar banco de dados: {}", e))?;
Ok((conn, true)) // true = banco foi recriado (era corrompido)
}
@@ -0,0 +1,206 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result};
/// Converte o char delimitador para string legível no banco.
fn delim_para_str(c: char) -> String {
match c {
'\t' => "tab".to_string(),
c => c.to_string(),
}
}
/// Converte a string armazenada no banco de volta para char delimitador.
fn str_para_delim(s: &str) -> char {
match s {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
}
}
/// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
indice_documento_tipo)
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
nome,
delim_para_str(config.delimitador),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
],
)?;
Ok(conn.last_insert_rowid())
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"INSERT INTO layouts
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data,
pos_documento_tipo)
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6, ?7)",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
],
)?;
Ok(conn.last_insert_rowid())
}
}
}
/// Atualiza um layout existente no banco.
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout
.id()
.ok_or_else(|| rusqlite::Error::InvalidParameterName("id ausente".to_string()))?;
match layout {
Layout::Csv { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10",
params![
nome,
delim_para_str(config.delimitador),
config.encoding,
config.linha_cabecalho as i64,
config.indice_numero as i64,
config.indice_serie as i64,
config.indice_valor.map(|v| v as i64),
config.indice_data.map(|v| v as i64),
config.indice_documento_tipo.map(|v| v as i64),
id,
],
)?;
}
Layout::Xlsx { nome, config, .. } => {
conn.execute(
"UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
delimitador = NULL, encoding = NULL, linha_cabecalho = NULL,
indice_numero = NULL, indice_serie = NULL, indice_valor = NULL,
indice_data = NULL, indice_documento_tipo = NULL
WHERE id = ?8",
params![
nome,
config.aba,
config.pos_numero,
config.pos_serie,
config.pos_valor,
config.pos_data,
config.pos_documento_tipo,
id,
],
)?;
}
}
Ok(())
}
/// Lista todos os layouts salvos.
pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let mut stmt = conn.prepare(
"SELECT id, nome, tipo,
delimitador, encoding, linha_cabecalho,
indice_numero, indice_serie, indice_valor, indice_data,
aba, pos_numero, pos_serie, pos_valor, pos_data,
indice_documento_tipo, pos_documento_tipo
FROM layouts ORDER BY nome ASC",
)?;
let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| {
let id: i64 = row.get("id")?;
let nome: String = row.get("nome")?;
let tipo: String = row.get("tipo")?;
if tipo == "csv" {
let delim_str: String = row.get("delimitador")?;
let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row
.get::<_, Option<i64>>("indice_valor")?
.map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>("indice_data")?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>("indice_documento_tipo")?
.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get("aba")?,
pos_numero: row.get("pos_numero")?,
pos_serie: row.get("pos_serie")?,
pos_valor: row.get("pos_valor")?,
pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get("pos_documento_tipo")?,
},
})
}
})?
.collect();
layouts
}
/// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
Ok(())
}
/// Verifica se já existe um layout com o nome fornecido.
pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1",
[nome],
|row| row.get(0),
)?;
Ok(count > 0)
}
/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado.
/// Usado para validar conflito de nome ao renomear um layout existente.
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result<bool> {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2",
params![nome, id],
|row| row.get(0),
)?;
Ok(count > 0)
}
+92
View File
@@ -0,0 +1,92 @@
use rusqlite::{Connection, Result};
/// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
// Criar tabela de controle de versão se não existir
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);",
)?;
let versao_atual: i64 = conn
.query_row("SELECT versao FROM schema_version LIMIT 1;", [], |row| {
row.get(0)
})
.unwrap_or(0);
if versao_atual < 1 {
migration_v1(conn)?;
}
if versao_atual < 2 {
migration_v2(conn)?;
}
if versao_atual < 3 {
migration_v3(conn)?;
}
Ok(())
}
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch(
"BEGIN;
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
UPDATE schema_version SET versao = 3;
COMMIT;",
)?;
Ok(())
}
/// Migration v2: adicionar índice único em layouts.nome.
///
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
/// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch(
"BEGIN;
UPDATE layouts
SET nome = nome || ' (' || id || ')'
WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?;
Ok(())
}
/// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT
);
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?;
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod connection;
pub mod layout_repository;
pub mod migrations;
+213
View File
@@ -0,0 +1,213 @@
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use calamine::{open_workbook_auto, Reader};
use std::path::Path;
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
/// Representa uma coordenada de célula (coluna base-0, linha base-0).
#[derive(Debug, Clone, Copy)]
pub struct Coordenada {
pub coluna: u32,
pub linha: u32,
}
/// Resultado da leitura de um arquivo XLSX.
pub struct ResultadoXlsx {
/// Linhas de dados, a partir da posição de início.
/// Cada linha é um vetor de strings (indexado pela posição da coluna de início).
pub linhas: Vec<Vec<String>>,
/// Avisos coletados durante a leitura.
pub avisos: ResumoAvisos,
}
/// Lista as abas disponíveis em um arquivo XLSX (ou XLS com extensão .xls mas conteúdo XLSX).
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
verificar_tamanho(caminho)?;
let workbook =
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
Ok(workbook.sheet_names().to_vec())
}
/// Lê os dados de uma aba XLSX (ou XLS com extensão .xls mas conteúdo XLSX) a partir de uma linha de início.
///
/// O formato real é detectado pelo conteúdo do arquivo (magic bytes), não pela extensão.
///
/// # Parâmetros
/// - `caminho`: caminho do arquivo
/// - `nome_aba`: nome da aba a ser lida
/// - `linha_inicio`: número da linha de início dos dados (base 1, ex: 3 para "B3")
pub fn ler_xlsx(
caminho: &Path,
nome_aba: &str,
linha_inicio: u32,
) -> Result<ResultadoXlsx, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook =
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
for (row_idx, row) in range.rows().enumerate() {
if row_idx < linha_inicio_base0 {
continue;
}
let campos: Vec<String> = row.iter().map(celula_para_string).collect();
// Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) {
continue;
}
linhas.push(campos);
}
Ok(ResultadoXlsx { linhas, avisos })
}
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
/// Usado exclusivamente para pré-visualização na UI.
pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>, ErroArquivo> {
verificar_tamanho(caminho)?;
let mut workbook =
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let range: calamine::Range<calamine::Data> = workbook
.worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let linhas = range
.rows()
.take(5)
.map(|row| {
row.iter().map(celula_para_string).collect()
})
.collect();
Ok(linhas)
}
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
///
/// Retorna `None` se a notação for inválida.
pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
let s = s.trim().to_uppercase();
if s.is_empty() {
return None;
}
let pos_numero = s.find(|c: char| c.is_ascii_digit())?;
let (letras, numeros) = s.split_at(pos_numero);
if letras.is_empty() || numeros.is_empty() {
return None;
}
// Converter letras para índice de coluna (base 0)
// A=0, B=1, ..., Z=25, AA=26, ...
let coluna = letras
.chars()
.try_fold(0u32, |acc, c| {
if c.is_ascii_uppercase() {
Some(acc * 26 + (c as u32 - 'A' as u32 + 1))
} else {
None
}
})?
.checked_sub(1)?;
let linha: u32 = numeros.parse().ok()?;
if linha == 0 {
return None;
}
Some(Coordenada { coluna, linha })
}
/// Converte uma célula calamine em String, usando representação legível.
/// Inteiros sem parte decimal são formatados sem ".0"; erros e células vazias
/// retornam string vazia.
fn celula_para_string(cell: &calamine::Data) -> String {
use calamine::Data;
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => {
if f.fract() == 0.0 {
format!("{}", *f as i64)
} else {
format!("{}", f)
}
}
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(dt) => dt.to_string(),
Data::DateTimeIso(s) => s.clone(),
Data::DurationIso(s) => s.clone(),
Data::Error(_) => String::new(),
}
}
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata =
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
if metadata.len() > LIMITE_BYTES {
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parsear_b3() {
let coord = parsear_letra_linha("B3").unwrap();
assert_eq!(coord.coluna, 1); // B = coluna 1 (base 0)
assert_eq!(coord.linha, 3);
}
#[test]
fn parsear_a1() {
let coord = parsear_letra_linha("A1").unwrap();
assert_eq!(coord.coluna, 0);
assert_eq!(coord.linha, 1);
}
#[test]
fn parsear_z1() {
let coord = parsear_letra_linha("Z1").unwrap();
assert_eq!(coord.coluna, 25);
}
#[test]
fn parsear_aa1() {
let coord = parsear_letra_linha("AA1").unwrap();
assert_eq!(coord.coluna, 26);
}
#[test]
fn parsear_invalido() {
assert!(parsear_letra_linha("").is_none());
assert!(parsear_letra_linha("3B").is_none());
assert!(parsear_letra_linha("123").is_none());
}
#[test]
fn parsear_minusculo() {
let coord = parsear_letra_linha("b3").unwrap();
assert_eq!(coord.coluna, 1);
}
}
+19 -2
View File
@@ -1,3 +1,20 @@
fn main() {
println!("Hello, world!");
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod application;
mod domain;
mod infrastructure;
mod ui;
use ui::app::App;
fn main() -> iced::Result {
iced::application("Comparador de Notas", App::update, App::view)
.subscription(App::subscription)
.theme(|_app| ui::theme::tema_dark())
.window(iced::window::Settings {
size: iced::Size::new(1024.0, 768.0),
min_size: Some(iced::Size::new(800.0, 600.0)),
..Default::default()
})
.run_with(App::new)
}
+338
View File
@@ -0,0 +1,338 @@
# AGENTS.md — Guia de desenvolvimento da UI
Este documento descreve como a interface do **Comparador de Notas** é estruturada,
quais padrões devem ser seguidos e o que não fazer. Leia antes de criar ou modificar
qualquer arquivo em `src/ui/`.
---
## 1. Arquitetura da UI
O projeto usa **iced 0.13** com arquitetura Elm (Model / Update / View).
```
src/ui/
├── app.rs Estado global (App), update, view raiz, breadcrumb
├── message.rs Enum Message — todos os eventos da UI
├── mod.rs Re-exporta submódulos
├── theme.rs Paleta de cores, estilos de widgets (sem lógica)
├── components/
│ ├── modal.rs Overlay de modal bloqueante
│ ├── paginacao.rs Controles de paginação (◀ / ▶)
│ └── tabela_preview.rs Tabela de pré-visualização do arquivo importado
└── screens/
├── import.rs Tela 1 — seleção de arquivo e layout
├── selecionar_aba.rs Tela 1.5 — seleção de aba XLSX
├── configuracao_colunas.rs Tela 2 — mapeamento de colunas
├── resultado.rs Tela 3 — resultado da análise
└── layouts.rs Tela lateral — gerenciamento de layouts
```
### Responsabilidades por camada
| Camada | Responsabilidade |
|--------|-----------------|
| `app.rs` | Estado global, `update()`, `view()` raiz, roteamento entre telas, tarefas assíncronas |
| `screens/` | Renderização de cada tela: coleta inputs, monta widgets, emite `Message` |
| `components/` | Widgets reutilizáveis sem estado próprio (recebem dados por parâmetro) |
| `theme.rs` | Apenas estilos visuais. Sem lógica de negócio. Sem `Message`. |
| `message.rs` | Todos os eventos possíveis da UI. Nenhuma lógica aqui. |
**Regra:** nenhuma lógica de domínio (parsing, validação de sequência, cálculos) pode
estar em `src/ui/`. A UI apenas chama use cases de `src/application/`.
---
## 2. Tema e paleta de cores
Todas as cores estão em `theme.rs` como constantes `Color`. **Nunca use valores RGB
literais fora de `theme.rs`.**
### Paleta
| Constante | Hex | Uso |
|-----------|-----|-----|
| `BG` | `#0F172A` | Fundo geral da janela |
| `SURFACE` | `#1E293B` | Cards primários |
| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela |
| `BORDER` | `#334155` | Bordas de inputs e cards |
| `TEXT` | `#F1F5F9` | Texto principal |
| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders |
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
| `PRIMARY` | `#3B82F6` | Botões primários, step ativo |
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
| `SUCCESS` | `#22C55E` | Badge OK, barra ≥ 90% |
| `WARNING` | `#F59E0B` | Badge faltante, barra 6089% |
| `DANGER` | `#EF4444` | Badge duplicada, barra < 60% |
| `TRACK_BG` | `#111827` | Trilha da progress bar |
### Helpers de estilo disponíveis em `theme.rs`
**Containers:**
- `t::fundo` — fundo geral da janela
- `t::card` — card principal (SURFACE + borda + radius 8)
- `t::card_secondary` — card secundário (SURFACE_2 + borda + radius 6)
- `t::cabecalho_tabela` — cabeçalho de tabela sem borda
- `t::badge_sucesso` / `t::badge_aviso` / `t::badge_perigo` — badges coloridos
- `t::area_erro` — área de validação com fundo vermelho sutil
- `t::stat_card` — card de estatística (igual a `t::card`)
- `t::separador` — linha divisória fina
- `t::breadcrumb_bg` — fundo da barra de breadcrumb
**Botões:**
- `t::btn_primary` — azul sólido, ação principal
- `t::btn_secondary` — SURFACE_2 com borda `TEXT_SECONDARY` 55%, ação secundária
- `t::btn_ghost` — transparente com borda `TEXT_SECONDARY` 55%, ação terciária
- `t::btn_danger` — vermelho semitransparente, exclusão
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
- `t::btn_breadcrumb_visitado` — botão sem fundo para passos anteriores do breadcrumb
### Contraste de estados de botão
`btn_secondary` e `btn_ghost` usam borda visível (`TEXT_SECONDARY` a 55% de opacidade)
no estado normal para se destacar de containers com fundo `SURFACE_2`. O estado
`Disabled` reduz a borda para 20% de opacidade, criando contraste claro entre ativo e
inativo. **Nunca remova a borda desses estilos** — sem ela os botões se perdem no fundo.
**Inputs:**
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
**Progress bar:**
- `t::progress_bar_por_percentual(f32)` — retorna closure com cor por threshold:
- ≥ 0.90 → SUCCESS, 0.600.89 → WARNING, < 0.60 → DANGER
---
## 3. Regras de layout e responsividade
### Princípio geral
A janela tem tamanho mínimo de **800×600**. Todo layout deve funcionar bem nessa
dimensão e escalar corretamente ao aumentar.
### O que usar
| Situação | Valor correto |
|----------|--------------|
| Widget que deve preencher o espaço disponível | `Length::Fill` |
| Label ao lado de input em linha | `Length::FillPortion(3)` (label) + input com tamanho fixo pequeno ou `Fill` |
| Input numérico curto (índice, posição) | `Length::Fixed(90.0)` ou `Length::Fixed(110.0)` |
| Input de texto longo (nome, aba) | `Length::Fill` |
| Pick list de opções | `Length::Fill` |
| Modal/card centralizado com largura máxima | `.max_width(N)` + `Length::Fill` |
| Botões em linha que podem quebrar | `.wrap()` no `row![]` |
| Elemento que deve ter tamanho mínimo sem crescer | `Length::Shrink` |
| Células de tabela com scroll horizontal | `Length::Fixed(100.0)` mínimo |
### O que **não** fazer
- **Não use `Length::Fixed` em labels de formulário.** Labels devem usar
`FillPortion` para se adaptar ao espaço disponível.
- **Não use `Length::Fixed` em pick lists ou text inputs de texto livre.**
Use `Fill` para que se adaptem à largura do container pai.
- **Não coloque valores maiores que `max_width` em modais.** Use `.max_width(N)`
em vez de `Fixed(N)` para que o modal encolha em janelas menores.
- **Não deixe telas sem `scrollable`.** Toda tela com conteúdo vertical deve ser
envolvida em `scrollable()` para evitar clipping em janelas pequenas.
- **Não use `row![]` com muitos itens sem `.wrap()`.** Botões de ação e grupos
de controles devem usar `.wrap()` para quebrar linha quando não couberem.
### Padrão de campo de formulário
```rust
// Linha de campo: label proporcional + input
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
row![
text(label)
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::FillPortion(3)), // proporcional, não fixo
input, // input define seu próprio tamanho
]
.spacing(10)
.align_y(Alignment::Center)
.into()
}
```
### Padrão de campo opcional com checkbox
```rust
// Quando ativo: checkbox (FillPortion) + input
// Quando inativo: só o checkbox
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("...", &val)
.width(Length::Fixed(90.0)), // input numérico curto
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
}
```
---
## 4. Estrutura de uma tela (screen)
Toda tela segue o mesmo padrão de função pública `view`:
```rust
pub fn view(app: &App) -> Element<'_, Message> {
// 1. Montar seções/componentes individuais
let secao_x = ...;
let secao_y = ...;
// 2. Combinar em coluna principal
let conteudo = column![secao_x, secao_y]
.spacing(14)
.padding([20, 24])
.width(Length::Fill);
// 3. Envolver em scrollable + container de fundo
container(scrollable(conteudo))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
```
**Toda tela deve ter `scrollable` e `container` com `t::fundo` na raiz.**
---
## 5. Componentes reutilizáveis
### `modal::view_com_modal(conteudo, modal)`
Envolve qualquer `Element` com um overlay de modal bloqueante.
Chamado em `app.rs` quando `self.modal.is_some()`.
- Modal tem `max_width(420)` + `Length::Fill` para ser responsivo.
- Tipos disponíveis: `Informacao`, `Aviso`, `Erro`, `Confirmacao`, `InputTexto`.
- Disparar modal: usar os helpers em `app.rs` (`exibir_erro`, `exibir_aviso`, `exibir_confirmacao`).
### `tabela_preview::tabela_preview(linhas)`
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
- A altura da área de scroll está fixada em `Fixed(200.0)` — acomoda 5 linhas + cabeçalho + scrollbar horizontal.
- Scroll horizontal via `scrollable::Direction::Horizontal`.
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
Row de botões ◀ / "Página X / Y" / ▶.
Emite as mensagens passadas como parâmetro.
---
## 6. Roteamento entre telas
O roteamento é feito pelo enum `EstadoApp` em `app.rs`:
| Estado | Tela renderizada |
|--------|-----------------|
| `Importando` | `screens/import.rs` |
| `SelecionandoAba` | `screens/selecionar_aba.rs` |
| `ConfigurandoColunas` | `screens/configuracao_colunas.rs` |
| `ConfirmandoIntervalo` | `screens/configuracao_colunas.rs` (mesmo view) |
| `ExibindoResultado(r)` | `screens/resultado.rs` |
| `GerenciandoLayouts` | `screens/layouts.rs` |
| `Analisando` | Spinner inline em `app.rs` |
Transições são sempre via `Message``update()`. **Nunca altere `self.estado`
diretamente de dentro de uma tela.**
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
exceto `GerenciandoLayouts` e `Analisando`.
### Comportamento de navegação do breadcrumb
- **Passo ativo** — destaque azul com fundo semitransparente (`PRIMARY` a 15%).
- **Passos anteriores** — `button` com `t::btn_breadcrumb_visitado` e `on_press` com a mensagem de navegação correspondente (`Message::IrParaImportacao` para passo 1, `Message::IrParaConfiguracaoColunas` para passo 2). O hover revela fundo sutil + borda, sinalizando interatividade.
- **Passos futuros** — `container` com texto `TEXT_MUTED`, sem interação (não é possível pular à frente).
---
## 7. Adicionando uma nova tela
1. Crie `src/ui/screens/minha_tela.rs` com função `pub fn view(app: &App) -> Element<'_, Message>`.
2. Adicione `pub mod minha_tela;` em `src/ui/screens/mod.rs`.
3. Adicione a variante correspondente em `EstadoApp` (`app.rs`).
4. Adicione o arm no `match &self.estado` em `app.view()` (`app.rs`).
5. Adicione as mensagens necessárias em `message.rs`.
6. Trate as mensagens no `update()` de `app.rs`.
---
## 8. Adicionando um novo componente
1. Crie `src/ui/components/meu_componente.rs`.
2. Adicione `pub mod meu_componente;` em `src/ui/components/mod.rs`.
3. O componente deve ser uma função pura: recebe dados por parâmetro, retorna `Element<'_, Message>`.
4. Sem estado interno, sem `self`, sem acesso ao banco.
---
## 9. Adicionando novos estilos ao tema
- Sempre adicione em `theme.rs`.
- Siga o padrão dos helpers existentes: função que recebe `&Theme` e retorna o `Style` do widget.
- Para cores com alpha: use `Color { a: 0.N, ..CONSTANTE }` em vez de valores RGB manuais.
- Nomeie helpers de container como `nome_do_contexto`, botões como `btn_nome`, inputs como `input_nome`.
---
## 10. Mensagens e estado assíncrono
- Operações bloqueantes (I/O, análise) são sempre executadas em `Task::perform` com
`tokio::task::spawn_blocking`.
- O resultado retorna para `update()` via `Message`.
- O estado `EstadoApp::Analisando` é usado enquanto a operação está em background.
- `ResultadoPendente` é o tipo intermediário entre a thread de análise e a UI.
**Não bloquear a thread principal da UI.** Qualquer operação lenta deve usar `Task`.
---
## 11. Drag-and-drop de arquivos
O iced 0.13 expõe eventos de janela para drag-and-drop via `iced::window::Event`.
O projeto os captura através de `App::subscription()` registrado em `main.rs`.
### Eventos capturados
| Evento iced | Mensagem emitida | Efeito |
|---|---|---|
| `window::Event::FileDropped(path)` | `Message::ArquivoSolto(path)` | Processa o arquivo como se fosse selecionado via botão |
| `window::Event::FileHovered(_)` | `Message::ArquivoEmHover` | Liga `App.arquivo_em_hover = true` |
| `window::Event::FilesHoveredLeft` | `Message::ArquivoHoverSaiu` | Liga `App.arquivo_em_hover = false` |
### Subscription
`App::subscription()` em `app.rs` usa `iced::event::listen_with` para filtrar apenas
os três eventos acima. Está registrado em `main.rs` via `.subscription(App::subscription)`.
### Estado de hover
O campo `App.arquivo_em_hover: bool` é `true` enquanto um arquivo está sendo arrastado
sobre a janela. A tela `screens/import.rs` usa esse campo para alterar visualmente a
`drop_zone`: borda mais brilhante (`a: 0.9`) e mais espessa (`width: 2.0`) durante o hover.
### Fluxo de processamento
`Message::ArquivoSolto` chama `self.processar_arquivo_selecionado(caminho)` — o mesmo
método chamado pelo botão de seleção de arquivo. O comportamento é idêntico: validação
de extensão, leitura de abas (XLSX) ou preview (CSV), e transição de estado.
### Como estender
Para adicionar suporte a drag-and-drop em outras telas (ex: importar layout JSON por
drag), basta verificar `App.estado` dentro do arm `Message::ArquivoSolto` antes de
chamar `processar_arquivo_selecionado`, e desviar conforme necessário.
+1380
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub mod modal;
pub mod paginacao;
pub mod tabela_preview;
+143
View File
@@ -0,0 +1,143 @@
use crate::ui::app::EstadoModal;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, mouse_area, row, stack, text, text_input};
use iced::{Alignment, Color, Element, Length};
/// Envolve o conteúdo principal com uma camada de modal por cima.
/// O overlay escuro bloqueia cliques no conteúdo de baixo.
pub fn view_com_modal<'a>(
conteudo: Element<'a, Message>,
modal: &'a EstadoModal,
) -> Element<'a, Message> {
let overlay = mouse_area(
container(view_modal(modal))
.width(Length::Fill)
.height(Length::Fill)
.style(|_theme| container::Style {
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()),
..Default::default()
})
.center_x(Length::Fill)
.center_y(Length::Fill),
)
.on_press(Message::ModalCancelado);
stack![conteudo, overlay].into()
}
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal {
EstadoModal::Aviso { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
}
EstadoModal::Erro { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Erro, None, false)
}
EstadoModal::Confirmacao {
titulo, mensagem, ..
} => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true),
EstadoModal::InputTexto {
titulo,
mensagem,
texto,
..
} => caixa_modal(
titulo,
mensagem,
TipoModal::Info,
Some(texto.as_str()),
true,
),
}
}
enum TipoModal {
Info,
Aviso,
Erro,
Confirmacao,
}
fn caixa_modal<'a>(
titulo: &'a str,
mensagem: &'a str,
tipo: TipoModal,
input: Option<&'a str>,
com_confirmar: bool,
) -> Element<'a, Message> {
// Cor e label de ícone por tipo
let (cor_titulo, icone) = match tipo {
TipoModal::Info => (t::PRIMARY, "i"),
TipoModal::Aviso => (t::WARNING, "!"),
TipoModal::Erro => (t::DANGER, "x"),
TipoModal::Confirmacao => (t::WARNING, "?"),
};
let badge_icone = container(text(icone).size(13).color(cor_titulo))
.padding([2, 8])
.style(move |_theme| iced::widget::container::Style {
background: Some(
Color {
a: 0.15,
..cor_titulo
}
.into(),
),
border: iced::Border {
color: Color {
a: 0.3,
..cor_titulo
},
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
});
let titulo_row = row![badge_icone, text(titulo).size(17).color(t::TEXT),]
.spacing(8)
.align_y(Alignment::Center);
let mut col =
column![titulo_row, text(mensagem).size(14).color(t::TEXT_SECONDARY),].spacing(12);
if let Some(valor) = input {
col = col.push(
text_input("Nome...", valor)
.on_input(Message::ModalTextoAlterado)
.padding(8)
.style(t::input_dark),
);
}
// Separador
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
// Botões
let btn_fechar = button("Fechar")
.on_press(Message::ModalCancelado)
.style(t::btn_ghost);
let mut botoes = row![btn_fechar].spacing(8);
if com_confirmar {
let btn_confirmar = button("Confirmar")
.on_press(Message::ModalConfirmado)
.style(t::btn_primary);
botoes = botoes.push(btn_confirmar);
}
col = col.push(botoes);
container(col.align_x(Alignment::Start))
.max_width(420)
.width(Length::Fill)
.padding(24)
.style(t::card)
.into()
}
+31
View File
@@ -0,0 +1,31 @@
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, row, text};
use iced::{Alignment, Element};
/// Renderiza controles de paginação reutilizáveis.
pub fn controles_paginacao(
pagina_atual: usize,
total_paginas: usize,
msg_anterior: Message,
msg_proximo: Message,
) -> Element<'static, Message> {
let btn_anterior = button("<")
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
.style(t::btn_ghost);
let btn_proximo = button(">")
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
.style(t::btn_ghost);
row![
btn_anterior,
text(format!("Página {} / {}", pagina_atual + 1, total_paginas))
.size(13)
.color(t::TEXT_SECONDARY),
btn_proximo,
]
.spacing(8)
.align_y(Alignment::Center)
.into()
}
+97
View File
@@ -0,0 +1,97 @@
use crate::ui::message::Message;
use crate::ui::screens::indice_para_letra;
use crate::ui::theme as t;
use iced::widget::{column, container, row, scrollable, text};
use iced::{Border, Color, Element, Font, Length};
/// Renderiza uma tabela de pré-visualização das primeiras linhas do arquivo.
/// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados.
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
if num_colunas == 0 {
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
}
// Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
let cabecalho = container(
row((0..num_colunas)
.map(|i| {
container(
text(format!("{} ({})", indice_para_letra(i), i))
.font(Font::MONOSPACE)
.size(12)
.color(t::TEXT_SECONDARY),
)
.width(Length::Fixed(100.0))
.padding([4, 6])
.into()
})
.collect::<Vec<_>>())
.spacing(0),
)
.width(Length::Shrink)
.style(t::cabecalho_tabela);
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
// Linhas alternadas: SURFACE e SURFACE_2 levemente
let bg = if idx % 2 == 0 {
t::SURFACE
} else {
Color {
r: t::SURFACE.r + 0.01,
g: t::SURFACE.g + 0.01,
b: t::SURFACE.b + 0.015,
a: 1.0,
}
};
container(
row((0..num_colunas)
.map(|col| {
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
let truncado: String = if celula.chars().count() > 30 {
format!("{}...", celula.chars().take(30).collect::<String>())
} else {
celula.to_string()
};
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
.width(Length::Fixed(100.0))
.padding([3, 6])
.into()
})
.collect::<Vec<_>>())
.spacing(0),
)
.width(Length::Shrink)
.style(move |_theme| iced::widget::container::Style {
background: Some(bg.into()),
..Default::default()
})
.into()
});
let todas_linhas = column(
std::iter::once(cabecalho.into())
.chain(linhas_view)
.collect::<Vec<_>>(),
)
.spacing(0);
container(
scrollable(todas_linhas)
.direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(),
))
.height(Length::Fixed(200.0)),
)
.style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE.into()),
border: Border {
color: t::BORDER,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
.into()
}
+130
View File
@@ -0,0 +1,130 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutXlsx},
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
};
use crate::domain::errors::ResumoAvisos;
use rusqlite::Connection;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
/// Todos os eventos/interações da UI.
#[derive(Debug, Clone)]
pub enum Message {
// --- Inicialização ---
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
LayoutsRecarregados(Vec<Layout>),
// --- Navegação ---
IrParaImportacao,
IrParaConfiguracaoColunas,
IrParaLayouts,
Voltar,
// --- Arquivo ---
SelecionarArquivo,
ArquivoSelecionado(PathBuf),
/// Arquivo arrastado e solto sobre a janela (drag-and-drop).
ArquivoSolto(PathBuf),
/// Arquivo está sendo arrastado sobre a janela (hover).
ArquivoEmHover,
/// Arquivo arrastado saiu da janela sem ser solto.
ArquivoHoverSaiu,
AbaSelecionada(String),
// --- XLSX: abas carregadas em background ---
AbaxlsxCarregadas {
caminho: PathBuf,
abas: Vec<String>,
layout_xlsx: LayoutXlsx,
nome_layout: String,
},
XlsxErroAoCarregar(String),
// --- Background tasks ---
AnaliseCompleta(ResultadoPendente),
// --- Configuração CSV ---
DelimitadorAlterado(char),
EncodingAlterado(String),
LinhaCabecalhoAlterada(usize),
IndiceNumeroAlterado(usize),
IndiceSerieAlterado(usize),
IndiceValorToggle(bool),
IndiceValorAlterado(usize),
IndiceDataToggle(bool),
IndiceDataAlterado(usize),
IndiceDocTipoToggle(bool),
IndiceDocTipoAlterado(usize),
// --- Configuração XLSX ---
AbaXlsxAlterada(String),
PosNumeroAlterada(String),
PosSerieAlterada(String),
PosValorToggle(bool),
PosValorAlterada(String),
PosDataToggle(bool),
PosDataAlterada(String),
PosDocTipoToggle(bool),
PosDocTipoAlterada(String),
// --- Análise ---
ExecutarImportacao,
ReanalisarArquivo,
ConfirmarExpansaoFaltantes,
NovaAnalise,
// --- Resultado ---
PaginaFaltantesAlterada(ChaveSerie, usize),
PaginaDuplicatasAlterada(ChaveSerie, usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie),
ExportarPdf,
PdfExportado(Result<PathBuf, String>),
// --- Layouts ---
LayoutSelecionado(i64),
SalvarLayout,
NomeLayoutAlterado(String),
ExcluirLayout(i64),
ExclusaoConfirmada(i64),
ExportarLayoutJson(i64),
ImportarLayoutJson,
LayoutJsonImportado(String),
SobrescreverLayout(Layout),
// --- Modal ---
ModalTextoAlterado(String),
ModalConfirmado,
ModalCancelado,
// --- Sem operação (used as fallback) ---
Noop,
// --- Animação ---
SpinnerTick,
}
/// Resultado enviado pela task de análise em background para a UI.
#[derive(Debug, Clone)]
pub enum ResultadoPendente {
/// Análise concluída com sucesso.
Concluido {
resultado: ResultadoAnalise,
avisos: Option<ResumoAvisos>,
notas: Option<Vec<Nota>>,
},
/// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
/// Arquivo importado não continha notas válidas.
Vazio,
/// Erro durante importação ou análise.
Erro(String),
}
+5
View File
@@ -0,0 +1,5 @@
pub mod app;
pub mod components;
pub mod message;
pub mod screens;
pub mod theme;
+556
View File
@@ -0,0 +1,556 @@
use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column,
};
use iced::{Alignment, Element, Length};
const OPCOES_DELIMITADOR: &[(&str, char)] = &[
("Vírgula (,)", ','),
("Ponto e vírgula (;)", ';'),
("Tabulação (Tab)", '\t'),
];
const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"];
/// Tela de configuração de colunas.
pub fn view(app: &App) -> Element<'_, Message> {
// ── Cabeçalho ─────────────────────────────────────────────────────────────
let header = column![
text("Configuração de Colunas").size(20).color(t::TEXT),
if let Some(caminho) = &app.caminho_arquivo {
row![
text("Arquivo:").size(12).color(t::TEXT_MUTED),
text(
caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default()
)
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_y(Alignment::Center)
} else {
row![text("").size(12)]
},
]
.spacing(4);
// ── Seletor de layout ──────────────────────────────────────────────────────
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<String> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.map(|l| l.nome().to_string())
.collect();
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
None
} else {
Some(app.nome_layout_atual.clone())
};
let secao_layout = container(
row![
text("Layout:")
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| {
if let Some(id) = layouts
.iter()
.find(|l| l.nome() == nome_selecionado)
.and_then(|l| l.id())
{
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome_selecionado)
}
}
})
.placeholder("— Selecionar layout —")
.style(input_style_pick)
.width(Length::Fill),
]
.spacing(8)
.align_y(Alignment::Center),
)
.style(t::card_secondary)
.padding([10, 14])
.width(Length::Fill);
// ── Configuração específica ───────────────────────────────────────────────
let config_section = match app.tipo_arquivo_atual {
TipoArquivo::Csv => view_csv(app),
TipoArquivo::Xlsx => view_xlsx(app),
};
// ── Pré-visualização ──────────────────────────────────────────────────────
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
container(
column![
text("Pré-visualização (5 primeiras linhas)")
.size(13)
.color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas),
]
.spacing(8),
)
.style(t::card)
.padding(14)
.width(Length::Fill)
.into()
} else {
text("").size(1).into()
};
// ── Validação ─────────────────────────────────────────────────────────────
let (valido, erros) = validar_config(app);
let erros_section: Element<Message> = if erros.is_empty() {
text("").size(1).into()
} else {
container(
Column::with_children(
erros
.iter()
.map(|e| text(format!("(!) {}", e)).size(13).color(t::DANGER).into())
.collect::<Vec<_>>(),
)
.spacing(4),
)
.style(t::area_erro)
.padding([10, 14])
.width(Length::Fill)
.into()
};
// ── Botões ────────────────────────────────────────────────────────────────
let tem_arquivo = app.caminho_arquivo.is_some();
let tem_notas = !app.notas_importadas.is_empty();
let botoes = container(
row![
button(text("Voltar").size(13))
.on_press(Message::Voltar)
.style(t::btn_ghost)
.padding([9, 14]),
button(text("Importar e Analisar").size(13))
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao))
.style(t::btn_primary)
.padding([9, 14]),
button(text("Reanalisar").size(13))
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
.style(t::btn_secondary)
.padding([9, 14]),
button(text("Salvar layout").size(13))
.on_press(Message::SalvarLayout)
.style(t::btn_ghost)
.padding([9, 14]),
]
.spacing(8)
.align_y(Alignment::Center),
)
.style(t::card_secondary)
.padding([12, 16])
.width(Length::Fill);
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![
header,
secao_layout,
config_section,
preview_section,
erros_section,
botoes,
]
.spacing(14)
.padding([20, 24])
.width(Length::Fill);
container(scrollable(content))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
// ─── Estilo do pick_list ──────────────────────────────────────────────────────
fn input_style_pick(
theme: &iced::Theme,
status: iced::widget::pick_list::Status,
) -> iced::widget::pick_list::Style {
let base = iced::widget::pick_list::Style {
text_color: t::TEXT,
placeholder_color: t::TEXT_MUTED,
handle_color: t::TEXT_SECONDARY,
background: t::BG.into(),
border: iced::Border {
color: t::BORDER,
width: 1.0,
radius: 6.0.into(),
},
};
match status {
iced::widget::pick_list::Status::Opened | iced::widget::pick_list::Status::Hovered => {
iced::widget::pick_list::Style {
border: iced::Border {
color: t::PRIMARY,
..base.border
},
..base
}
}
_ => {
let _ = theme;
base
}
}
}
// ─── Configuração CSV ────────────────────────────────────────────────────────
fn view_csv(app: &App) -> Element<'_, Message> {
let c = &app.layout_csv_atual;
let delim_str = match c.delimitador {
',' => "Vírgula (,)",
';' => "Ponto e vírgula (;)",
'\t' => "Tabulação (Tab)",
_ => "Outro",
}
.to_string();
let opcoes_delim: Vec<String> = OPCOES_DELIMITADOR
.iter()
.map(|(s, _)| s.to_string())
.collect();
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
let linha_cabecalho_str = c.linha_cabecalho.to_string();
let indice_numero_str = c.indice_numero.to_string();
let indice_serie_str = c.indice_serie.to_string();
let inner = column![
secao_titulo("Importação CSV"),
campo_row(
"Delimitador",
pick_list(opcoes_delim, Some(delim_str), |selecionado| {
let c = OPCOES_DELIMITADOR
.iter()
.find(|(s, _)| *s == selecionado)
.map(|(_, c)| *c)
.unwrap_or(',');
Message::DelimitadorAlterado(c)
})
.style(input_style_pick)
.width(Length::Fill)
.into(),
),
campo_row(
"Encoding",
pick_list(
opcoes_enc,
Some(c.encoding.clone()),
Message::EncodingAlterado
)
.style(input_style_pick)
.width(Length::Fill)
.into(),
),
campo_row(
"Linha cabeçalho",
text_input("0", &linha_cabecalho_str)
.on_input(|s| {
s.parse::<usize>()
.map(Message::LinhaCabecalhoAlterada)
.unwrap_or(Message::Noop)
})
.style(t::input_dark)
.width(Length::Fixed(90.0))
.into(),
),
secao_subtitulo("Mapeamento de colunas (índice base 0)"),
campo_row(
"Número (obrigatório)",
text_input("0", &indice_numero_str)
.on_input(|s| {
s.parse::<usize>()
.map(Message::IndiceNumeroAlterado)
.unwrap_or(Message::Noop)
})
.style(t::input_dark)
.width(Length::Fixed(90.0))
.into(),
),
campo_row(
"Série (obrigatório)",
text_input("0", &indice_serie_str)
.on_input(|s| {
s.parse::<usize>()
.map(Message::IndiceSerieAlterado)
.unwrap_or(Message::Noop)
})
.style(t::input_dark)
.width(Length::Fixed(90.0))
.into(),
),
campo_indice_opcional_csv(
"Valor (opcional)",
c.indice_valor,
Message::IndiceValorToggle,
Message::IndiceValorAlterado,
),
campo_indice_opcional_csv(
"Data (opcional)",
c.indice_data,
Message::IndiceDataToggle,
Message::IndiceDataAlterado,
),
campo_indice_opcional_csv(
"Tipo Documento (opcional)",
c.indice_documento_tipo,
Message::IndiceDocTipoToggle,
Message::IndiceDocTipoAlterado,
),
]
.spacing(10);
container(inner)
.style(t::card)
.padding([14, 18])
.width(Length::Fill)
.into()
}
// ─── Configuração XLSX ────────────────────────────────────────────────────────
fn view_xlsx(app: &App) -> Element<'_, Message> {
let c = &app.layout_xlsx_atual;
let secao_aba: Element<Message> = if app.abas_xlsx.is_empty() {
campo_row(
"Aba",
text_input("Nome da aba", &c.aba)
.on_input(Message::AbaXlsxAlterada)
.style(t::input_dark)
.width(Length::Fill)
.into(),
)
} else {
let aba_sel = if c.aba.is_empty() {
None
} else {
Some(c.aba.clone())
};
campo_row(
"Aba",
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
.style(input_style_pick)
.width(Length::Fill)
.into(),
)
};
let inner = column![
secao_titulo("Importação XLSX"),
secao_aba,
secao_subtitulo("Mapeamento de colunas (formato LetraLinha, ex: B3)"),
campo_letra_linha(
"Número (obrigatório)",
&c.pos_numero,
Message::PosNumeroAlterada
),
campo_letra_linha(
"Série (obrigatório)",
&c.pos_serie,
Message::PosSerieAlterada
),
campo_letra_linha_opcional(
"Valor (opcional)",
c.pos_valor.as_deref(),
Message::PosValorToggle,
Message::PosValorAlterada,
),
campo_letra_linha_opcional(
"Data (opcional)",
c.pos_data.as_deref(),
Message::PosDataToggle,
Message::PosDataAlterada,
),
campo_letra_linha_opcional(
"Tipo Documento (opcional)",
c.pos_documento_tipo.as_deref(),
Message::PosDocTipoToggle,
Message::PosDocTipoAlterada,
),
]
.spacing(10);
container(inner)
.style(t::card)
.padding([14, 18])
.width(Length::Fill)
.into()
}
// ─── Helpers visuais ─────────────────────────────────────────────────────────
fn secao_titulo(label: &str) -> Element<'_, Message> {
text(label).size(14).color(t::TEXT).into()
}
fn secao_subtitulo(label: &str) -> Element<'_, Message> {
text(label).size(12).color(t::TEXT_SECONDARY).into()
}
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
row![
text(label)
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::FillPortion(3)),
input,
]
.spacing(10)
.align_y(Alignment::Center)
.into()
}
fn campo_indice_opcional_csv(
label: &str,
valor: Option<usize>,
msg_toggle: impl Fn(bool) -> Message + 'static,
msg_valor: impl Fn(usize) -> Message + 'static,
) -> Element<'static, Message> {
let ativo = valor.is_some();
let val_str = valor.map(|v| v.to_string()).unwrap_or_default();
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("0", &val_str)
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
.style(t::input_dark)
.width(Length::Fixed(90.0)),
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
}
}
fn campo_letra_linha<'a>(
label: &'a str,
valor: &'a str,
msg: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
campo_row(
label,
text_input("ex: B3", valor)
.on_input(msg)
.style(t::input_dark)
.width(Length::Fixed(110.0))
.into(),
)
}
fn campo_letra_linha_opcional<'a>(
label: &'a str,
valor: Option<&'a str>,
msg_toggle: impl Fn(bool) -> Message + 'a,
msg_valor: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
let ativo = valor.is_some();
let val_str = valor.unwrap_or("").to_string();
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
if ativo {
row![
cb.width(Length::FillPortion(3)),
text_input("ex: B3", &val_str)
.on_input(msg_valor)
.style(t::input_dark)
.width(Length::Fixed(110.0)),
]
.spacing(10)
.align_y(Alignment::Center)
.into()
} else {
row![cb].into()
}
}
// ─── Validação ────────────────────────────────────────────────────────────────
fn validar_config(app: &App) -> (bool, Vec<String>) {
let mut erros = Vec::new();
match &app.tipo_arquivo_atual {
TipoArquivo::Csv => {
let c = &app.layout_csv_atual;
let mut indices: Vec<(String, usize)> = vec![
("Numero".to_string(), c.indice_numero),
("Serie".to_string(), c.indice_serie),
];
if let Some(v) = c.indice_valor {
indices.push(("Valor".to_string(), v));
}
if let Some(d) = c.indice_data {
indices.push(("Data".to_string(), d));
}
if let Some(t) = c.indice_documento_tipo {
indices.push(("Tipo Documento".to_string(), t));
}
verificar_duplicados(&indices, &mut erros);
}
TipoArquivo::Xlsx => {
let c = &app.layout_xlsx_atual;
if c.aba.trim().is_empty() {
erros.push("Selecione uma aba".to_string());
}
if c.pos_numero.trim().is_empty() {
erros.push("Posição do campo Numero é obrigatória".to_string());
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_numero)
.is_none()
{
erros.push(format!("Posição Numero inválida: '{}'", c.pos_numero));
}
if c.pos_serie.trim().is_empty() {
erros.push("Posição do campo Serie é obrigatória".to_string());
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_serie)
.is_none()
{
erros.push(format!("Posição Serie inválida: '{}'", c.pos_serie));
}
}
}
let valido = erros.is_empty();
(valido, erros)
}
fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
for i in 0..indices.len() {
for j in (i + 1)..indices.len() {
if indices[i].1 == indices[j].1 {
erros.push(format!(
"Campos '{}' e '{}' mapeados para o mesmo índice {}",
indices[i].0, indices[j].0, indices[i].1
));
}
}
}
}
+230
View File
@@ -0,0 +1,230 @@
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, pick_list, row, scrollable, text};
use iced::{Alignment, Element, Length};
/// Tela de importação de arquivos.
pub fn view(app: &App) -> Element<'_, Message> {
// ── Área de seleção de arquivo ────────────────────────────────────────────
let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() {
("Nenhum arquivo selecionado".to_string(), false)
} else {
(app.nome_arquivo.clone(), true)
};
let em_hover = app.arquivo_em_hover;
let icone_arquivo: Element<Message> = container(
text(if tem_arquivo { "CSV / XLSX" } else { "Arquivo" })
.size(12)
.color(if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
}),
)
.padding([6, 12])
.style(move |_theme| iced::widget::container::Style {
background: Some(
iced::Color {
a: 0.1,
..if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
}
}
.into(),
),
border: iced::Border {
color: iced::Color {
a: 0.3,
..if tem_arquivo {
t::PRIMARY
} else {
t::TEXT_MUTED
}
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
.into();
let texto_arquivo: Element<Message> = if tem_arquivo {
let nome_str = nome_arquivo.clone();
column![
text(nome_str).size(14).color(t::TEXT),
text("Arquivo pronto para configuração")
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_x(Alignment::Center)
.into()
} else {
column![
text("Selecione ou arraste um arquivo CSV ou XLSX")
.size(14)
.color(t::TEXT_SECONDARY),
text("Suportado: .csv, .xlsx, .xls")
.size(12)
.color(t::TEXT_MUTED),
]
.spacing(4)
.align_x(Alignment::Center)
.into()
};
let drop_zone = container(
column![icone_arquivo, texto_arquivo]
.spacing(12)
.align_x(Alignment::Center)
.width(Length::Fill),
)
.style(move |_theme| iced::widget::container::Style {
background: Some(
iced::Color {
a: if em_hover { 0.12 } else { 0.05 },
..t::PRIMARY
}
.into(),
),
border: iced::Border {
color: if em_hover {
iced::Color {
a: 0.9,
..t::PRIMARY
}
} else if tem_arquivo {
iced::Color {
a: 0.5,
..t::PRIMARY
}
} else {
iced::Color {
a: 0.3,
..t::BORDER
}
},
width: if em_hover { 2.0 } else { 1.5 },
radius: 8.0.into(),
},
..Default::default()
})
.padding([28, 20])
.width(Length::Fill);
let btn_selecionar = button(
row![text("Selecionar arquivo...").size(14)]
.align_y(Alignment::Center)
.spacing(6),
)
.on_press(Message::SelecionarArquivo)
.style(t::btn_primary)
.width(Length::Fill)
.padding([10, 16]);
// ── Seção de layout ───────────────────────────────────────────────────────
let tipo_atual = app.tipo_arquivo_atual.clone();
let opcoes_layout: Vec<String> = app
.layouts_salvos
.iter()
.filter(|l| l.tipo() == tipo_atual)
.map(|l| l.nome().to_string())
.collect();
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
None
} else {
Some(app.nome_layout_atual.clone())
};
let secao_layout = row![
text("Layout salvo:")
.size(13)
.color(t::TEXT_SECONDARY)
.width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| {
if let Some(id) = layouts
.iter()
.find(|l| l.nome() == nome_selecionado)
.and_then(|l| l.id())
{
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome_selecionado)
}
}
})
.placeholder("— Selecionar layout —")
.width(Length::Fill),
button(text("Gerenciar").size(13))
.on_press(Message::IrParaLayouts)
.style(t::btn_ghost)
.padding([8, 12]),
]
.spacing(8)
.align_y(Alignment::Center);
// ── Botão avançar ─────────────────────────────────────────────────────────
let botao_avancar: Element<Message> = if tem_arquivo {
button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center))
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_primary)
.width(Length::Fill)
.padding([11, 16])
.into()
} else {
container(text("").size(1))
.height(Length::Fixed(42.0))
.into()
};
// ── Separador visual ──────────────────────────────────────────────────────
let separador = container(text(""))
.height(Length::Fixed(1.0))
.width(Length::Fill)
.style(t::separador);
// ── Card central ──────────────────────────────────────────────────────────
let card_inner = column![
drop_zone,
btn_selecionar,
separador,
secao_layout,
botao_avancar,
]
.spacing(14)
.padding(24)
.width(Length::Fill);
let card = container(card_inner)
.style(t::card)
.max_width(560)
.width(Length::Fill);
// ── Layout geral ──────────────────────────────────────────────────────────
container(scrollable(
column![
text("Importar Arquivo").size(20).color(t::TEXT),
text("Selecione e configure sua planilha para análise")
.size(13)
.color(t::TEXT_SECONDARY),
card,
]
.spacing(16)
.align_x(Alignment::Center)
.padding([32, 20])
.width(Length::Fill),
))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.into()
}
+97
View File
@@ -0,0 +1,97 @@
use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
use iced::{Alignment, Element, Length};
/// Tela de gerenciamento de layouts.
pub fn view(app: &App) -> Element<'_, Message> {
let cabecalho = row![
button("Voltar")
.on_press(Message::IrParaImportacao)
.style(t::btn_secondary),
horizontal_space(),
button("Importar JSON")
.on_press(Message::ImportarLayoutJson)
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center)
.width(Length::Fill);
let titulo = text("Gerenciar Layouts").size(22).color(t::TEXT);
let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
let content = column![titulo, cabecalho, secao_csv, secao_xlsx,]
.spacing(16)
.padding(20)
.width(Length::Fill);
container(scrollable(content))
.width(Length::Fill)
.height(Length::Fill)
.style(t::fundo)
.into()
}
fn view_secao_layouts<'a>(
titulo: &'a str,
layouts: &'a [Layout],
tipo: TipoArquivo,
) -> Element<'a, Message> {
let titulo_widget = text(titulo).size(16).color(t::TEXT_SECONDARY);
let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
let mut col = column![titulo_widget].spacing(4);
if filtrados.is_empty() {
col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED));
return container(col)
.padding([12, 16])
.width(Length::Fill)
.style(t::card)
.into();
}
for layout in filtrados {
if let Some(id) = layout.id() {
let linha = container(
row![
text(layout.nome())
.size(14)
.color(t::TEXT)
.width(Length::Fill),
horizontal_space(),
button("Carregar")
.on_press(Message::LayoutSelecionado(id))
.style(t::btn_primary),
button("Exportar JSON")
.on_press(Message::ExportarLayoutJson(id))
.style(t::btn_ghost),
button("Excluir")
.on_press(Message::ExcluirLayout(id))
.style(t::btn_danger),
]
.spacing(8)
.align_y(Alignment::Center)
.padding([10, 0]),
)
.width(Length::Fill);
col = col.push(linha);
// Separador entre linhas
col = col.push(container(iced::widget::horizontal_rule(1)).width(Length::Fill));
}
}
container(col)
.padding([12, 16])
.width(Length::Fill)
.style(t::card)
.into()
}
+18
View File
@@ -0,0 +1,18 @@
pub mod configuracao_colunas;
pub mod import;
pub mod layouts;
pub mod resultado;
pub mod selecionar_aba;
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
pub fn indice_para_letra(mut idx: usize) -> String {
let mut resultado = String::new();
loop {
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
if idx < 26 {
break;
}
idx = idx / 26 - 1;
}
resultado
}
+437
View File
@@ -0,0 +1,437 @@
use crate::domain::{
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
};
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, progress_bar, row, scrollable, text};
use iced::{Alignment, Element, Length};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
/// Tela de resultados da análise.
pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
// ── Stat cards no topo ────────────────────────────────────────────────────
let total_faltantes = resultado.total_faltantes();
let total_duplicatas = resultado.total_duplicatas();
let total_notas: usize = resultado.total_por_serie.values().sum();
let valor_total_str = format!("R$ {}", formatar_valor_br(&resultado.soma_total));
let stat_faltantes = stat_card_widget("Faltantes", total_faltantes.to_string(), t::WARNING);
let stat_duplicatas = stat_card_widget("Duplicatas", total_duplicatas.to_string(), t::DANGER);
let stat_total = stat_card_widget("Total de Notas", total_notas.to_string(), t::PRIMARY);
let stat_valor = stat_card_widget("Valor Total", valor_total_str, t::SUCCESS);
let stat_row = row![stat_faltantes, stat_duplicatas, stat_total, stat_valor]
.spacing(12)
.width(Length::Fill);
// ── Botões de ação ────────────────────────────────────────────────────────
let botoes_topo = row![
button("Nova Análise")
.on_press(Message::NovaAnalise)
.style(t::btn_secondary)
.width(Length::Shrink),
button("Reconfigurar")
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_ghost)
.width(Length::Shrink),
button("Reanalisar")
.on_press_maybe(
app.caminho_arquivo
.as_ref()
.map(|_| Message::ReanalisarArquivo)
)
.style(t::btn_ghost)
.width(Length::Shrink),
button("Exportar PDF")
.on_press(Message::ExportarPdf)
.style(t::btn_primary)
.width(Length::Shrink),
]
.spacing(8)
.wrap();
// ── Controle de itens por página ──────────────────────────────────────────
let opcoes_por_pagina = row(OPCOES_PAGINA
.iter()
.map(|&n| {
let ativo = n == app.itens_por_pagina;
button(text(n.to_string()).size(13))
.on_press(Message::ItensPorPaginaAlterado(n))
.style(if ativo {
t::btn_pagina_ativo
} else {
t::btn_pagina_inativo
})
.into()
})
.collect::<Vec<_>>())
.spacing(4);
let controle_pagina = row![
text("Itens por página:").size(13).color(t::TEXT_SECONDARY),
opcoes_por_pagina,
]
.spacing(8)
.align_y(Alignment::Center);
// ── Seções principais ─────────────────────────────────────────────────────
let secao_faltantes = view_faltantes(app, resultado);
let secao_duplicatas = view_duplicatas(app, resultado);
let secao_totais = view_totais(resultado);
let mut col_principal = column![
row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo,
stat_row,
controle_pagina,
]
.spacing(16)
.padding(20)
.width(Length::Fill);
// Badge "Sem inconsistências" quando não há faltantes nem duplicatas
if resultado.sem_inconsistencias() {
col_principal = col_principal.push(
container(
row![
container(text("").size(13).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Sem inconsistências — todas as séries estão completas.")
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([10, 16])
.width(Length::Fill)
.style(t::card),
);
}
col_principal = col_principal
.push(secao_faltantes)
.push(secao_duplicatas)
.push(secao_totais);
container(scrollable(col_principal))
.width(Length::Fill)
.height(Length::Fill)
.style(t::fundo)
.into()
}
/// Cria um stat card com label, valor e cor de destaque.
fn stat_card_widget<'a>(label: &'a str, valor: String, cor: iced::Color) -> Element<'a, Message> {
let conteudo = column![
text(label).size(12).color(t::TEXT_SECONDARY),
text(valor).size(24).color(cor),
]
.spacing(4);
container(conteudo)
.padding([14, 18])
.width(Length::Fill)
.style(t::stat_card)
.into()
}
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_faltantes();
let titulo_row = row![
text("Notas Faltantes").size(18).color(t::TEXT),
container(text(format!(" {} ", total)).size(12).color(t::WARNING))
.padding([2, 8])
.style(t::badge_aviso),
]
.spacing(8)
.align_y(Alignment::Center);
let mut col = column![titulo_row].spacing(12);
if total == 0 {
col = col.push(
row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Nenhuma nota faltante.").size(14).color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
);
return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
}
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
if faltantes.is_empty() {
// Série completa — exibir badge verde
let linha_serie = row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text(format!("Série {} — completa", chave.label()))
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center);
col = col.push(linha_serie);
continue;
}
let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64;
let percentual_f32 = percentual as f32;
// Cabeçalho da série
let cabecalho_serie = row![
column![
text(format!("Série {}", chave.label()))
.size(14)
.color(t::TEXT),
text(format!(
"{} faltante(s) — {}/{} notas ({:.1}% completo)",
faltantes.len(),
total_notas,
total_esperado,
percentual * 100.0,
))
.size(12)
.color(t::TEXT_SECONDARY),
]
.spacing(2)
.width(Length::Fill),
button("Copiar")
.on_press(Message::CopiarFaltantes(chave.clone()))
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center);
// Progress bar de completude
let barra = progress_bar(0.0..=1.0, percentual_f32)
.height(6)
.style(t::progress_bar_por_percentual(percentual_f32));
col = col.push(cabecalho_serie);
col = col.push(barra);
// Paginação e lista
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app
.pagina_faltantes
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
let mut lista = column![].spacing(2);
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
let txt = if a == b {
text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY)
} else {
text(format!(" {}{} ({} notas)", a, b, b - a + 1))
.size(13)
.color(t::TEXT_SECONDARY)
};
lista = lista.push(txt);
}
col = col.push(lista);
if total_paginas > 1 {
col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina,
total_paginas,
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
));
}
// Separador
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
}
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
}
fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_duplicatas();
let titulo_row = row![
text("Notas Duplicadas").size(18).color(t::TEXT),
container(text(format!(" {} ", total)).size(12).color(t::DANGER))
.padding([2, 8])
.style(t::badge_perigo),
]
.spacing(8)
.align_y(Alignment::Center);
let mut col = column![titulo_row].spacing(12);
if total == 0 {
col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS));
return container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into();
}
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let duplicatas = &resultado.duplicadas_por_serie[chave];
if duplicatas.is_empty() {
continue;
}
let cabecalho_serie = row![
text(format!(
"Série {}{} grupo(s) duplicado(s)",
chave.label(),
duplicatas.len()
))
.size(14)
.color(t::TEXT)
.width(Length::Fill),
button("Copiar")
.on_press(Message::CopiarDuplicatas(chave.clone()))
.style(t::btn_ghost),
]
.spacing(8)
.align_y(Alignment::Center);
col = col.push(cabecalho_serie);
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app
.pagina_duplicatas
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
let mut lista = column![].spacing(2);
for (numero, count) in &duplicatas[inicio..fim] {
lista = lista.push(
text(format!(
" NF {} / Série {}{} ocorrências",
numero,
chave.label(),
count
))
.size(13)
.color(t::TEXT_SECONDARY),
);
}
col = col.push(lista);
if total_paginas > 1 {
col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina,
total_paginas,
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
));
}
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
}
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
}
fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
let titulo = text("Totais por Série").size(18).color(t::TEXT);
let mut col = column![titulo].spacing(8);
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
chaves.sort();
for chave in chaves {
let soma = &resultado.soma_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let linha = row![
text(format!("Série {}", chave.label()))
.size(14)
.color(t::TEXT)
.width(Length::Fill),
text(format!("{} nota(s)", total_notas))
.size(13)
.color(t::TEXT_SECONDARY),
text(format!("R$ {}", formatar_valor_br(soma)))
.size(13)
.color(t::SUCCESS),
]
.spacing(12)
.align_y(Alignment::Center);
col = col.push(linha);
}
// Total geral
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
col = col.push(
row![
text("Total Geral")
.size(15)
.color(t::TEXT)
.width(Length::Fill),
text(format!("R$ {}", formatar_valor_br(&resultado.soma_total)))
.size(15)
.color(t::SUCCESS),
]
.spacing(12)
.align_y(Alignment::Center),
);
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
}
+156
View File
@@ -0,0 +1,156 @@
use crate::ui::app::App;
use crate::ui::message::Message;
use crate::ui::theme as t;
use iced::widget::{button, column, container, row, scrollable, text};
use iced::{Alignment, Element, Length};
use std::path::PathBuf;
/// Tela de seleção de aba de arquivo XLSX.
pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Element<'a, Message> {
let nome_arquivo = caminho
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| caminho.display().to_string());
let nome_arquivo_owned = nome_arquivo.clone();
// ── Cabeçalho ─────────────────────────────────────────────────────────────
let header = column![
text("Selecionar Aba").size(20).color(t::TEXT),
row![
text("Arquivo:").size(12).color(t::TEXT_MUTED),
text(nome_arquivo_owned).size(12).color(t::TEXT_SECONDARY),
]
.spacing(4)
.align_y(Alignment::Center),
]
.spacing(4);
// ── Lista de abas ─────────────────────────────────────────────────────────
let aba_atual = &app.layout_xlsx_atual.aba;
let lista_abas = column(
abas.iter()
.map(|aba| {
let selecionada = aba == aba_atual;
let label = row![
text(if selecionada { ">>" } else { " " })
.size(12)
.color(if selecionada {
t::PRIMARY
} else {
t::TEXT_MUTED
}),
text(aba)
.size(14)
.color(if selecionada { t::PRIMARY } else { t::TEXT }),
]
.spacing(8)
.align_y(Alignment::Center);
button(label)
.on_press(Message::AbaSelecionada(aba.clone()))
.style(if selecionada {
t::btn_aba_ativa
} else {
t::btn_aba_inativa
})
.width(Length::Fill)
.padding([8, 12])
.into()
})
.collect::<Vec<_>>(),
)
.spacing(4)
.width(Length::Fill);
let card_abas = container(
column![
text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY),
scrollable(lista_abas).height(Length::Fixed(220.0)),
]
.spacing(10),
)
.style(t::card)
.padding(16)
.width(Length::Fill);
// ── Preview ───────────────────────────────────────────────────────────────
let preview_section: Element<Message> = if !aba_atual.is_empty() {
if let Some(linhas) = &app.preview_arquivo {
container(
column![
text(format!("Pré-visualização: {}", aba_atual))
.size(13)
.color(t::TEXT_SECONDARY),
crate::ui::components::tabela_preview::tabela_preview(linhas),
]
.spacing(8),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
} else {
container(
text("(sem pré-visualização disponível)")
.size(12)
.color(t::TEXT_MUTED),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
}
} else {
container(
text("Selecione uma aba para pré-visualizar o conteúdo.")
.size(13)
.color(t::TEXT_MUTED),
)
.style(t::card)
.padding(16)
.width(Length::Fill)
.into()
};
// ── Botões de ação ────────────────────────────────────────────────────────
let tem_preset = !app.nome_layout_atual.is_empty();
let aba_selecionada = !aba_atual.is_empty();
let mut botoes = row![button(text("Voltar").size(13))
.on_press(Message::Voltar)
.style(t::btn_ghost)
.padding([9, 14]),]
.spacing(8)
.align_y(Alignment::Center);
if aba_selecionada && tem_preset {
botoes = botoes.push(
button(text("Processar").size(13))
.on_press(Message::ExecutarImportacao)
.style(t::btn_primary)
.padding([9, 14]),
);
}
if aba_selecionada {
botoes = botoes.push(
button(text("Configurar Colunas").size(13))
.on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_secondary)
.padding([9, 14]),
);
}
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![header, card_abas, preview_section, botoes]
.spacing(16)
.padding([20, 24])
.width(Length::Fill);
container(scrollable(content))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
+527
View File
@@ -0,0 +1,527 @@
use iced::widget::{button, container, progress_bar, text_input};
use iced::{Border, Color, Theme};
// ─── Paleta de cores ──────────────────────────────────────────────────────────
pub const BG: Color = Color {
r: 0.059,
g: 0.090,
b: 0.165,
a: 1.0,
}; // #0F172A
pub const SURFACE: Color = Color {
r: 0.118,
g: 0.161,
b: 0.231,
a: 1.0,
}; // #1E293B
pub const SURFACE_2: Color = Color {
r: 0.200,
g: 0.255,
b: 0.333,
a: 1.0,
}; // #334155
pub const BORDER: Color = Color {
r: 0.200,
g: 0.255,
b: 0.333,
a: 1.0,
}; // #334155
pub const TEXT: Color = Color {
r: 0.945,
g: 0.961,
b: 0.980,
a: 1.0,
}; // #F1F5F9
pub const TEXT_SECONDARY: Color = Color {
r: 0.580,
g: 0.639,
b: 0.722,
a: 1.0,
}; // #94A3B8
pub const TEXT_MUTED: Color = Color {
r: 0.392,
g: 0.455,
b: 0.545,
a: 1.0,
}; // #64748B
pub const PRIMARY: Color = Color {
r: 0.231,
g: 0.510,
b: 0.965,
a: 1.0,
}; // #3B82F6
pub const PRIMARY_HOVER: Color = Color {
r: 0.145,
g: 0.388,
b: 0.922,
a: 1.0,
}; // #2563EB
pub const SUCCESS: Color = Color {
r: 0.133,
g: 0.773,
b: 0.369,
a: 1.0,
}; // #22C55E
pub const WARNING: Color = Color {
r: 0.961,
g: 0.620,
b: 0.043,
a: 1.0,
}; // #F59E0B
pub const DANGER: Color = Color {
r: 0.937,
g: 0.267,
b: 0.267,
a: 1.0,
}; // #EF4444
pub const TRACK_BG: Color = Color {
r: 0.067,
g: 0.094,
b: 0.153,
a: 1.0,
}; // #111827
// ─── Tema global ─────────────────────────────────────────────────────────────
pub fn tema_dark() -> Theme {
Theme::custom(
"dark".to_string(),
iced::theme::Palette {
background: BG,
text: TEXT,
primary: PRIMARY,
success: SUCCESS,
danger: DANGER,
},
)
}
// ─── Container styles ────────────────────────────────────────────────────────
/// Card principal: surface + borda + radius 8
pub fn card(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Card secundário: surface_2 + borda + radius 6
pub fn card_secondary(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE_2.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Fundo geral da tela
pub fn fundo(_theme: &Theme) -> container::Style {
container::Style {
background: Some(BG.into()),
text_color: Some(TEXT),
..Default::default()
}
}
/// Cabeçalho de tabela (surface_2 sem borda)
pub fn cabecalho_tabela(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE_2.into()),
text_color: Some(TEXT_SECONDARY),
..Default::default()
}
}
/// Badge de sucesso (OK)
pub fn badge_sucesso(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..SUCCESS }.into()),
border: Border {
color: Color { a: 0.3, ..SUCCESS },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(SUCCESS),
..Default::default()
}
}
/// Badge de aviso (faltante)
pub fn badge_aviso(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..WARNING }.into()),
border: Border {
color: Color { a: 0.3, ..WARNING },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(WARNING),
..Default::default()
}
}
/// Badge de perigo (duplicada)
pub fn badge_perigo(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.15, ..DANGER }.into()),
border: Border {
color: Color { a: 0.3, ..DANGER },
width: 1.0,
radius: 4.0.into(),
},
text_color: Some(DANGER),
..Default::default()
}
}
/// Área de erro/validação
pub fn area_erro(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Color { a: 0.10, ..DANGER }.into()),
border: Border {
color: Color { a: 0.4, ..DANGER },
width: 1.0,
radius: 6.0.into(),
},
text_color: Some(DANGER),
..Default::default()
}
}
/// Stat card com borda colorida no topo
pub fn stat_card(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
text_color: Some(TEXT),
..Default::default()
}
}
/// Separador horizontal (linha fina)
pub fn separador(_theme: &Theme) -> container::Style {
container::Style {
background: Some(BORDER.into()),
..Default::default()
}
}
/// Fundo do breadcrumb
pub fn breadcrumb_bg(_theme: &Theme) -> container::Style {
container::Style {
background: Some(SURFACE.into()),
border: Border {
color: BORDER,
width: 0.0,
radius: 0.0.into(),
},
text_color: Some(TEXT_SECONDARY),
..Default::default()
}
}
// ─── Button styles ───────────────────────────────────────────────────────────
/// Botão primário (azul sólido)
pub fn btn_primary(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(PRIMARY.into()),
text_color: Color::WHITE,
border: Border {
radius: 6.0.into(),
..Default::default()
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(PRIMARY_HOVER.into()),
..base
},
button::Status::Pressed => button::Style {
background: Some(
Color {
r: 0.114,
g: 0.306,
b: 0.847,
a: 1.0,
}
.into(),
), // #1D4ED8
..base
},
button::Status::Disabled => button::Style {
background: Some(Color { a: 0.4, ..PRIMARY }.into()),
text_color: Color {
a: 0.5,
..Color::WHITE
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão secundário (surface_2)
pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(SURFACE_2.into()),
text_color: TEXT,
border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(
Color {
r: 0.25,
g: 0.31,
b: 0.40,
a: 1.0,
}
.into(),
),
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base
},
button::Status::Disabled => button::Style {
background: Some(
Color {
a: 0.5,
..SURFACE_2
}
.into(),
),
text_color: Color { a: 0.4, ..TEXT },
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão ghost (sem fundo, borda visível)
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(Color::TRANSPARENT.into()),
text_color: TEXT,
border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
text_color: TEXT,
border: Border {
color: PRIMARY,
..base.border
},
..base
},
button::Status::Disabled => button::Style {
text_color: TEXT_MUTED,
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão de perigo (exclusão)
pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(Color { a: 0.15, ..DANGER }.into()),
text_color: DANGER,
border: Border {
color: Color { a: 0.3, ..DANGER },
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.25, ..DANGER }.into()),
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão de passo anterior no breadcrumb (clicável, mas sem destaque)
pub fn btn_breadcrumb_visitado(_theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: None,
text_color: TEXT_SECONDARY,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..TEXT }.into()),
text_color: TEXT,
border: Border {
color: Color { a: 0.15, ..TEXT },
width: 1.0,
radius: 4.0.into(),
},
..base
},
_ => base,
}
}
/// Botão de aba selecionada (tela selecionar_aba)
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: Some(Color { a: 0.20, ..PRIMARY }.into()),
text_color: PRIMARY,
border: Border {
color: PRIMARY,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.30, ..PRIMARY }.into()),
..base
},
_ => {
let _ = theme;
base
}
}
}
/// Botão de aba inativa (tela selecionar_aba)
pub fn btn_aba_inativa(theme: &Theme, status: button::Status) -> button::Style {
btn_secondary(theme, status)
}
/// Botão de itens por página (ativo)
pub fn btn_pagina_ativo(theme: &Theme, status: button::Status) -> button::Style {
btn_primary(theme, status)
}
/// Botão de itens por página (inativo)
pub fn btn_pagina_inativo(theme: &Theme, status: button::Status) -> button::Style {
btn_ghost(theme, status)
}
// ─── Text input styles ────────────────────────────────────────────────────────
pub fn input_dark(theme: &Theme, status: text_input::Status) -> text_input::Style {
let base = text_input::Style {
background: BG.into(),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
icon: TEXT_MUTED,
placeholder: TEXT_MUTED,
value: TEXT,
selection: Color { a: 0.3, ..PRIMARY },
};
match status {
text_input::Status::Focused => text_input::Style {
border: Border {
color: PRIMARY,
..base.border
},
..base
},
text_input::Status::Hovered => text_input::Style {
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base
},
_ => {
let _ = theme;
base
}
}
}
// ─── Progress bar styles ─────────────────────────────────────────────────────
/// Progress bar colorida por percentual (0.0 1.0)
pub fn progress_bar_por_percentual(percentual: f32) -> impl Fn(&Theme) -> progress_bar::Style {
move |_theme| {
let cor = if percentual >= 0.90 {
SUCCESS
} else if percentual >= 0.60 {
WARNING
} else {
DANGER
};
progress_bar::Style {
background: TRACK_BG.into(),
bar: cor.into(),
border: Border {
radius: 10.0.into(),
..Default::default()
},
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+109
View File
@@ -0,0 +1,109 @@
{
"color": {
"primary": {
"base": "#3B82F6",
"hover": "#2563EB",
"active": "#1D4ED8"
},
"semantic": {
"success": "#22C55E",
"warning": "#F59E0B",
"error": "#EF4444",
"info": "#0EA5E9"
},
"dark": {
"background": "#0F172A",
"surface": "#1E293B",
"surfaceSecondary": "#334155",
"text": {
"primary": "#F1F5F9",
"secondary": "#94A3B8",
"muted": "#64748B"
},
"border": "#334155",
"interaction": {
"hover": "#3B82F622",
"selection": "#3B82F633",
"focus": "#3B82F6"
}
},
"light": {
"background": "#F8FAFC",
"surface": "#FFFFFF",
"surfaceSecondary": "#F1F5F9",
"text": {
"primary": "#0F172A",
"secondary": "#64748B",
"muted": "#94A3B8"
},
"border": "#E2E8F0",
"interaction": {
"hover": "#3B82F611",
"selection": "#3B82F622",
"focus": "#3B82F6"
}
}
},
"table": {
"row": {
"hover": "#3B82F611",
"selected": "#3B82F622"
},
"status": {
"ok": "#22C55E",
"missing": "#F59E0B",
"duplicate": "#EF4444",
"invalid": "#EF4444"
}
},
"spacing": {
"xs": 4,
"sm": 8,
"md": 12,
"lg": 16,
"xl": 24,
"xxl": 32
},
"radius": {
"sm": 4,
"md": 6,
"lg": 8
},
"font": {
"family": "Inter, system-ui, sans-serif",
"size": {
"xs": 11,
"sm": 12,
"md": 14,
"lg": 16,
"xl": 20
},
"weight": {
"normal": 400,
"medium": 500,
"bold": 600
}
},
"shadow": {
"sm": "0 1px 2px rgba(0,0,0,0.05)",
"md": "0 4px 8px rgba(0,0,0,0.08)",
"lg": "0 10px 20px rgba(0,0,0,0.12)"
}
}
+287
View File
@@ -0,0 +1,287 @@
```html
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<title>Comparador de Notas - Mockup</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--text: #f1f5f9;
--text-secondary: #94a3b8;
--border: #334155;
--primary: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
}
.container {
width: 1100px;
margin: 40px auto;
background: var(--surface);
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 20px;
font-weight: 600;
}
.actions button {
background: var(--primary);
border: none;
padding: 10px 16px;
border-radius: 6px;
color: white;
margin-left: 8px;
cursor: pointer;
}
.actions button.secondary {
background: var(--surface-2);
}
.stats {
display: flex;
gap: 40px;
margin: 20px 0;
}
.stat {
font-size: 14px;
}
.stat strong {
font-size: 20px;
}
.series-box {
background: var(--surface-2);
padding: 16px;
border-radius: 6px;
margin-bottom: 20px;
}
.series {
margin-bottom: 14px;
}
.progress {
height: 8px;
background: #111827;
border-radius: 10px;
overflow: hidden;
margin-top: 6px;
}
.bar {
height: 100%;
}
.green {
background: var(--success);
}
.orange {
background: var(--warning);
}
.red {
background: var(--error);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 16px;
}
thead {
background: var(--surface-2);
}
th,
td {
padding: 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
.status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.ok {
background: rgba(34, 197, 94, 0.2);
color: var(--success);
}
.dup {
background: rgba(239, 68, 68, 0.2);
color: var(--error);
}
.missing {
background: rgba(245, 158, 11, 0.2);
color: var(--warning);
}
.footer {
margin-top: 20px;
display: flex;
gap: 10px;
}
.footer button {
background: var(--surface-2);
border: none;
padding: 10px 14px;
border-radius: 6px;
color: var(--text);
cursor: pointer;
}
.footer button.primary {
background: var(--primary);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="title">Comparador de Notas</div>
<div class="actions">
<button>Importar Planilha</button>
<button class="secondary">Configurar Campos</button>
<button class="secondary">Exportar Relatório</button>
</div>
</div>
<div class="stats">
<div class="stat">
<strong style="color: #3b82f6">48</strong> Notas Faltantes
</div>
<div class="stat">
<strong style="color: #ef4444">6</strong> Notas Duplicadas
</div>
<div class="stat"><strong>R$ 125.600,00</strong> Total</div>
</div>
<div class="series-box">
<div class="series">
Série 1 NFE — 48 / 50 notas — 96% completo
<div class="progress">
<div class="bar green" style="width: 96%"></div>
</div>
</div>
<div class="series">
Série 2 NFCE — 20 / 25 notas — 80% completo
<div class="progress">
<div class="bar orange" style="width: 80%"></div>
</div>
</div>
<div class="series">
Série 3 NFE — 12 / 20 notas — 60% completo
<div class="progress">
<div class="bar red" style="width: 60%"></div>
</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Número</th>
<th>Série</th>
<th>Tipo</th>
<th>Valor</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>1005</td>
<td>1</td>
<td>NFE</td>
<td>R$ 2.500,00</td>
<td><span class="status dup">Duplicada</span></td>
</tr>
<tr>
<td>1006</td>
<td>1</td>
<td>NFE</td>
<td>R$ 3.200,00</td>
<td><span class="status ok">OK</span></td>
</tr>
<tr>
<td>1010</td>
<td>1</td>
<td>NFE</td>
<td>R$ 4.000,00</td>
<td>
<span class="status missing">Falta: 10101050</span>
</td>
</tr>
<tr>
<td>1051</td>
<td>1</td>
<td>NFE</td>
<td>R$ 2.800,00</td>
<td><span class="status ok">OK</span></td>
</tr>
<tr>
<td>1075</td>
<td>2</td>
<td>NFCE</td>
<td>R$ 1.200,00</td>
<td><span class="status dup">Duplicada</span></td>
</tr>
</tbody>
</table>
<div class="footer">
<button>Copiar Faltantes</button>
<button>Copiar Duplicadas</button>
<button class="primary">Exportar PDF</button>
</div>
</div>
</body>
</html>
```