Compare commits
32
Commits
82e3f40c4f
...
change-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4f573cd1 | ||
|
|
857fcd6a82 | ||
|
|
841dfadde5 | ||
|
|
3af14ab957 | ||
|
|
06eca335db | ||
|
|
6a5d941a00 | ||
|
|
5745a78a5a | ||
|
|
1f4ebb4686 | ||
|
|
007f6c818e | ||
|
|
d06110240f | ||
|
|
ad09c53a5d | ||
|
|
8d00cfc4d7 | ||
|
|
2d1d29ce3d | ||
|
|
12b8501ffb | ||
|
|
2a21138bf8 | ||
|
|
e7d72c7e01 | ||
|
|
b9052e073f | ||
|
|
2c96e99ccc | ||
|
|
a5605de6a3 | ||
|
|
0354ece935 | ||
|
|
2c08f237e9 | ||
|
|
dedb6a72c2 | ||
|
|
41c75e5ed1 | ||
|
|
0f728b9684 | ||
|
|
b2b91cda55 | ||
|
|
b0e216a6ff | ||
|
|
bbfa66979b | ||
|
|
f3e6fa313d | ||
|
|
e64988a139 | ||
|
|
31cd94907a | ||
|
|
9d212747e7 | ||
|
|
913bbf3060 |
@@ -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 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
nul
|
||||||
|
|||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"sqltools.connections": [
|
||||||
|
{
|
||||||
|
"previewLimit": 50,
|
||||||
|
"driver": "SQLite",
|
||||||
|
"database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db",
|
||||||
|
"name": "comparador-notas"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+6104
File diff suppressed because it is too large
Load Diff
+19
@@ -4,3 +4,22 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[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"
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# PRD — Comparador de Notas
|
# PRD — Comparador de Notas
|
||||||
|
|
||||||
**Versão:** 1.1
|
**Versão:** 1.7
|
||||||
**Data:** 02/03/2026
|
**Data:** 03/03/2026
|
||||||
**Status:** Planejamento
|
**Status:** Implementado (MVP)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
* Notas faltantes
|
* Notas faltantes
|
||||||
* Notas duplicadas
|
* Notas duplicadas
|
||||||
* Soma total dos valores
|
* Soma total dos valores
|
||||||
* Agrupamento por série
|
* Agrupamento por série e tipo de documento
|
||||||
|
|
||||||
## 3.2 Objetivos Secundários
|
## 3.2 Objetivos Secundários
|
||||||
|
|
||||||
@@ -71,16 +71,25 @@ Permitir que o usuário importe uma planilha e obtenha automaticamente:
|
|||||||
* Importação de arquivos CSV
|
* Importação de arquivos CSV
|
||||||
* Importação de arquivos XLSX
|
* Importação de arquivos XLSX
|
||||||
* Interface gráfica
|
* 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 quebras de sequência
|
||||||
* Detecção de duplicidades
|
* Detecção de duplicidades
|
||||||
* Soma de valores
|
* Soma de valores
|
||||||
* Agrupamento por série
|
* Agrupamento por série e tipo de documento
|
||||||
* Relatório visual
|
* Pré-visualização das primeiras linhas do arquivo na tela de configuração
|
||||||
* Salvar layouts personalizados
|
* Relatório visual com paginação
|
||||||
|
* Exibição de faltantes agrupados em intervalos contíguos (ex: `10–50 (41 notas)`)
|
||||||
|
* Indicador de completude por série (ex: `48/50 notas — 96,0% completo`)
|
||||||
|
* Botão de cópia rápida de listas de faltantes/duplicatas para área de transferência
|
||||||
|
* Exportação de relatório para PDF (fontes Liberation Sans embutidas no binário)
|
||||||
|
* Salvar layouts personalizados (exclusivos por tipo de arquivo)
|
||||||
* Carregar layouts salvos
|
* Carregar layouts salvos
|
||||||
* Selecionar layout por menu dropdown
|
* Selecionar layout por menu dropdown
|
||||||
* Excluir layouts
|
* 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)
|
## 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
|
* Integração com banco de dados externo
|
||||||
* Multiusuário
|
* Multiusuário
|
||||||
* Acesso remoto
|
* 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:
|
O sistema trabalhará com os seguintes campos lógicos:
|
||||||
|
|
||||||
| Campo | Obrigatório | Descrição |
|
| Campo | Obrigatório | Descrição |
|
||||||
| ------ | ----------- | ------------------------------------------------------------------------- |
|
| --------------- | ----------- | ------------------------------------------------------------------------- |
|
||||||
| Numero | Sim | Número incremental da nota. Junto com a Série forma o identificador único |
|
| Numero | Sim | Número incremental da nota. Armazenado internamente como `u64`. |
|
||||||
| Serie | Sim | Série da nota (1–3 dígitos, ex: 001–999). Separa lotes de emissão |
|
| Serie | Sim | Série da nota. Deve conter de 1 a 3 dígitos numéricos (regex: `^[0-9]{1,3}$`, ex: `1`, `01`, `001`). Separa lotes de emissão. |
|
||||||
| Valor | Não | Valor monetário da nota |
|
| Valor | Não | Valor monetário da nota. Armazenado internamente como decimal fixo (`rust_decimal`). |
|
||||||
| Data | Não | Data de emissão da nota |
|
| 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 |
|
| Delimitador | Configurável pelo usuário: vírgula, ponto e vírgula ou tabulação |
|
||||||
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
|
| Encoding | Suportados: UTF-8 e Windows-1252 (Latin-1). Configurável pelo usuário |
|
||||||
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.) |
|
| Linha do cabeçalho| Configurável pelo usuário (pode estar na linha 1, 4, etc.). 0 = sem cabeçalho |
|
||||||
| Linhas em branco | Devem ser ignoradas silenciosamente |
|
| Linhas em branco | Devem ser ignoradas silenciosamente |
|
||||||
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
|
| Linhas malformadas| Devem ser ignoradas; o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação |
|
||||||
|
|
||||||
### 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.
|
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 5–10 colunas geram aproximadamente 5–15 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 5–10 colunas geram aproximadamente 5–15 MB em CSV e até 30 MB em XLSX. O limite de 50 MB oferece margem adequada.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RF02 — Configuração de Colunas
|
## 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)
|
### RF02.1 — Mapeamento CSV (índice numérico)
|
||||||
* Qual coluna representa a série (obrigatório)
|
|
||||||
* Qual coluna representa o valor (opcional)
|
O usuário define, via **índice numérico** (posição da coluna, base 0), qual coluna representa cada campo:
|
||||||
* Qual coluna representa a data (opcional)
|
|
||||||
|
* 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
|
### Tratamento de Erros de Configuração
|
||||||
|
|
||||||
| Situação | Comportamento |
|
| 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 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).
|
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
|
## RF04 — Detecção de Sequência
|
||||||
|
|
||||||
O sistema deve:
|
O sistema deve:
|
||||||
|
|
||||||
* Ordenar os registros por número dentro de cada série
|
* Ordenar os registros por número dentro de cada grupo `(Serie, TipoDocumento)`
|
||||||
* Detectar números faltantes na sequência
|
* Detectar números faltantes na sequência
|
||||||
|
|
||||||
### Regra de Detecção
|
### Regra de Detecção
|
||||||
|
|
||||||
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada série. Qualquer número ausente nesse intervalo é considerado faltante.
|
A sequência é avaliada entre o **menor** e o **maior** número encontrado dentro de cada grupo. Qualquer número ausente nesse intervalo é considerado faltante.
|
||||||
|
|
||||||
Exemplo: série 001 contém os números `0001, 0002, 0003, 0005` → `0004` está faltando.
|
Exemplo: série 001 / NFE contém os números `0001, 0002, 0003, 0005` → `0004` está faltando.
|
||||||
|
|
||||||
### Tratamento de Valores Não Numéricos
|
### Tratamento de Valores Não Numéricos
|
||||||
|
|
||||||
@@ -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
|
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
|
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
|
### 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: `10–50 (41 notas)`) para facilitar a leitura. Faltantes isolados são exibidos individualmente (ex: `• 75`).
|
||||||
|
|
||||||
|
### Proteção contra Intervalos Anormalmente Grandes
|
||||||
|
|
||||||
|
Se o intervalo de faltantes de qualquer 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.
|
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:
|
O sistema deve calcular:
|
||||||
|
|
||||||
* Soma total
|
* 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:
|
O sistema deve exibir:
|
||||||
|
|
||||||
* Lista de notas faltantes
|
* Lista de notas faltantes agrupadas por `(Serie, TipoDocumento)`, com faltantes contíguos agrupados em intervalos
|
||||||
* Lista de duplicadas
|
* Indicador de completude por grupo (ex: `48/50 notas — 96,0% completo`)
|
||||||
* Totais
|
* 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
|
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
|
## 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
|
# 8. Fluxo do Usuário
|
||||||
|
|
||||||
Fluxo principal:
|
Fluxo principal:
|
||||||
|
|
||||||
1. Usuário abre o sistema
|
1. Usuário abre o sistema
|
||||||
2. Usuário importa planilha
|
2. Usuário importa planilha
|
||||||
3. Usuário configura colunas
|
3. (Para XLSX) Usuário seleciona aba
|
||||||
4. Usuário executa análise
|
4. Usuário configura colunas (com pré-visualização das primeiras 5 linhas)
|
||||||
5. Sistema exibe resultado
|
5. Usuário executa análise (processamento em background)
|
||||||
|
6. Sistema exibe resultado
|
||||||
|
|
||||||
|
Fluxo alternativo — layout salvo:
|
||||||
|
|
||||||
|
1. Usuário abre o sistema
|
||||||
|
2. Usuário seleciona layout no dropdown
|
||||||
|
3. Usuário importa planilha → análise é disparada automaticamente
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 9. Arquitetura Inicial Sugerida
|
# 9. Arquitetura
|
||||||
|
|
||||||
Arquitetura desktop local.
|
Arquitetura desktop local com separação em quatro camadas: `domain`, `application`, `infrastructure` e `ui`.
|
||||||
|
|
||||||
Componentes:
|
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.
|
||||||
|
|
||||||
* Interface gráfica
|
|
||||||
* Módulo de importação
|
|
||||||
* Módulo de processamento
|
|
||||||
* Módulo de configuração
|
|
||||||
|
|
||||||
Sem dependências externas obrigatórias.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9.1 Stack Tecnológica
|
## 9.1 Estrutura de Pastas
|
||||||
|
|
||||||
| Camada | Tecnologia | Status |
|
```
|
||||||
| ---------------- | ------------------ | -------------- |
|
src/
|
||||||
| Linguagem | Rust | Definido |
|
├─ main.rs
|
||||||
| Framework de UI | egui | Definido |
|
├─ ui/
|
||||||
| SQLite | rusqlite | Definido |
|
│ ├─ mod.rs
|
||||||
| Leitura de CSV | csv | Definido |
|
│ ├─ app.rs
|
||||||
| Leitura de XLSX | calamine | Definido |
|
│ ├─ 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
|
- Multiplataforma
|
||||||
- Alta confiabilidade
|
- Alta confiabilidade
|
||||||
|
|
||||||
O banco de dados deve ser armazenado localmente no dispositivo do
|
O banco de dados deve ser armazenado na **pasta de dados do usuário**, de acordo com o sistema operacional:
|
||||||
usuário.
|
|
||||||
|
|
||||||
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
|
## 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
|
**Campos comuns:**
|
||||||
--------------- --------- --------------------------
|
|
||||||
id inteiro Identificador único
|
| Campo | Tipo | Descrição |
|
||||||
nome texto Nome do layout
|
| ---------- | ------- | -------------------------------------- |
|
||||||
coluna_numero texto Nome da coluna do número
|
| id | inteiro | Identificador único (auto-incremento) |
|
||||||
coluna_serie texto Nome da coluna da série
|
| nome | texto | Nome do layout (único no banco) |
|
||||||
coluna_valor texto Nome da coluna do valor
|
| tipo | texto | Tipo do arquivo: `csv` ou `xlsx` |
|
||||||
coluna_data texto Nome da coluna da data
|
|
||||||
|
**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
|
## 10.6 Interface do Usuário
|
||||||
|
|
||||||
Os layouts salvos devem ser exibidos em um menu dropdown.
|
Os layouts salvos devem ser exibidos em um menu dropdown, filtrado pelo tipo de arquivo atual (CSV ou XLSX).
|
||||||
|
|
||||||
O usuário deve poder:
|
O usuário deve poder:
|
||||||
|
|
||||||
Selecionar layout existente
|
Selecionar layout existente
|
||||||
|
|
||||||
Criar novo layout
|
Criar novo layout (via modal com campo de texto ou via tela de gerenciamento)
|
||||||
|
|
||||||
Excluir layout
|
Excluir layout (com confirmação)
|
||||||
|
|
||||||
|
Exportar layout para JSON
|
||||||
|
|
||||||
|
Importar layout de JSON (com tratamento de conflito de nome)
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -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.
|
Esse formato é usado apenas para portabilidade entre dispositivos.
|
||||||
O armazenamento interno é sempre via SQLite.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"nome": "Layout Padrão",
|
"tipo": "csv",
|
||||||
"coluna_numero": "Nota",
|
"nome": "Layout Padrão CSV",
|
||||||
"coluna_serie": "Serie",
|
"delimitador": ";",
|
||||||
"coluna_valor": "Valor",
|
"encoding": "utf-8",
|
||||||
"coluna_data": "Data"
|
"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
|
# 12. Critérios de Aceite
|
||||||
|
|
||||||
O sistema será considerado funcional quando:
|
O sistema será considerado funcional quando:
|
||||||
|
|
||||||
* Importar planilha
|
* Importar planilha CSV com configurações de delimitador, encoding e linha de cabeçalho
|
||||||
* Detectar notas faltantes corretamente
|
* Importar planilha XLSX com seleção de aba e posicionamento por `LetraLinha`
|
||||||
* Calcular totais corretamente
|
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)` corretamente
|
||||||
* Permitir configuração
|
* 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
|
# 13. MVP — Versão Inicial
|
||||||
|
|
||||||
Escopo mínimo:
|
O MVP inclui o escopo completo descrito neste PRD:
|
||||||
|
|
||||||
* Importar CSV
|
* Importar CSV e XLSX
|
||||||
* Mapear coluna Número
|
* Mapear colunas Numero, Serie, Valor, Data e TipoDocumento
|
||||||
* Detectar faltantes
|
* Detectar notas faltantes por grupo `(Serie, TipoDocumento)`
|
||||||
* Exibir resultado
|
* 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
|
# 14. Evoluções Futuras
|
||||||
|
|
||||||
Possíveis melhorias:
|
Possíveis melhorias (ver `FEATURES_BACKLOG.md` para detalhes):
|
||||||
|
|
||||||
* Exportação de relatório
|
* Exportação de resultado em CSV (`faltantes.csv`, `duplicatas.csv`)
|
||||||
* Exportação PDF
|
* Busca por número na tela de resultado
|
||||||
|
* Auto-detecção de delimitador CSV
|
||||||
|
* Auto-detecção de encoding CSV
|
||||||
|
* Agrupamento de faltantes como intervalos no PDF
|
||||||
* Integração com ERP
|
* Integração com ERP
|
||||||
* Banco de dados
|
* Histórico de análises
|
||||||
* Automação
|
* Automação de importação (monitorar pasta)
|
||||||
|
* Multiusuário
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -482,8 +1045,16 @@ Série
|
|||||||
|
|
||||||
Agrupador independente de sequência.
|
Agrupador independente de sequência.
|
||||||
|
|
||||||
|
TipoDocumento
|
||||||
|
|
||||||
|
Subtipo de documento dentro de uma série (ex: NFE, NFCE). Quando mapeado, compõe a chave de agrupamento junto com a Série.
|
||||||
|
|
||||||
|
ChaveSerie
|
||||||
|
|
||||||
|
Chave composta `(Serie, TipoDocumento)` que identifica um grupo de notas para fins de detecção de sequência, duplicidade e cálculo de somas.
|
||||||
|
|
||||||
Sequência
|
Sequência
|
||||||
|
|
||||||
Ordem numérica crescente sem lacunas.
|
Ordem numérica crescente sem lacunas dentro de um mesmo grupo `(Serie, TipoDocumento)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (4–6h)
|
||||||
|
**Impacto:** Alto — reduz drasticamente "por que não importou nada?"
|
||||||
|
|
||||||
|
**Observações:**
|
||||||
|
Verificar se já existe no codigo, pois na tela de configuração do Layout ele exibe as 5 primeiras linhas do arquivo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-06 — Auto-detecção de Delimitador CSV (Util)
|
||||||
|
|
||||||
|
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
|
||||||
|
|
||||||
|
**Solução:** Ao selecionar o arquivo CSV, amostrar as primeiras 5 linhas e contar ocorrências de cada delimitador candidato. Sugerir o mais frequente como padrão, mantendo a ComboBox editável.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Função `detectar_delimitador(primeiras_linhas: &[&str]) -> char`
|
||||||
|
- Chamar na transição para `ConfigurandoColunas` quando `TipoArquivo::Csv`
|
||||||
|
- Pré-selecionar na ComboBox sem impedir mudança manual
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2h)
|
||||||
|
**Impacto:** Baixo-Médio — reduz um passo de configuração na maioria dos casos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baixa Prioridade
|
||||||
|
|
||||||
|
### F-07 — Auto-detecção de Encoding CSV (Util)
|
||||||
|
|
||||||
|
**Problema:** Arquivos gerados por sistemas legados brasileiros frequentemente usam `windows-1252`, mas o padrão selecionado é `utf-8`. O usuário descobre o problema somente ao ver caracteres estranhos nos dados.
|
||||||
|
|
||||||
|
**Solução:** Tentar ler as primeiras linhas em `utf-8`; se houver erro de decodificação, sugerir automaticamente `windows-1252` com aviso visual na tela de configuração.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Função `detectar_encoding(caminho: &Path) -> String`
|
||||||
|
- Usar `encoding_rs` (já dependência) para tentar decode e verificar `had_errors`
|
||||||
|
- Aviso na tela de configuração: "Encoding detectado: windows-1252"
|
||||||
|
|
||||||
|
**Esforço estimado:** Médio (2h)
|
||||||
|
**Impacto:** Baixo — problema raro, mas confuso quando ocorre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F-09 — Paginação Automática de Faltantes no PDF (Util)
|
||||||
|
|
||||||
|
**Problema:** PDFs com muitos faltantes (ex: 5.000 números) geram um único bloco de texto muito longo, difícil de ler e imprimir.
|
||||||
|
|
||||||
|
**Solução:** No `pdf_generator.rs`, ao adicionar a lista de faltantes, agrupar em intervalos contíguos (usando a função `agrupar_contiguos` já implementada) e formatar como `100–104 (5 notas)`. Reduz drasticamente o tamanho do PDF e melhora legibilidade.
|
||||||
|
|
||||||
|
**Escopo técnico:**
|
||||||
|
- Importar `agrupar_contiguos` em `pdf_generator.rs`
|
||||||
|
- Substituir o `join(", ")` atual pelo formato de intervalos
|
||||||
|
- Sem novas dependências
|
||||||
|
|
||||||
|
**Esforço estimado:** Baixo (30min)
|
||||||
|
**Impacto:** Baixo-Médio — melhora legibilidade de PDFs com muitos faltantes
|
||||||
|
|
||||||
|
> **Nota:** Esta feature é especialmente fácil porque a lógica de agrupamento já existe — é só aplicar no PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resumo
|
||||||
|
|
||||||
|
| ID | Feature | Esforço | Impacto | Prioridade | 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 |
|
||||||
@@ -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}
|
||||||
|
```
|
||||||
@@ -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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
let chave_nfe = ChaveSerie::new("001".to_string(), Some("NFE".to_string()));
|
||||||
|
let chave_nfce = ChaveSerie::new("001".to_string(), Some("NFCE".to_string()));
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave_nfe], vec![2u64]);
|
||||||
|
assert_eq!(resultado.faltantes_por_serie[&chave_nfce], vec![3u64, 4u64]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod executar_analise;
|
||||||
|
pub mod exportar_pdf;
|
||||||
|
pub mod importar_arquivo;
|
||||||
|
pub mod layouts;
|
||||||
@@ -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"`.
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod chave_serie;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod nota;
|
||||||
|
pub mod resultado_analise;
|
||||||
|
pub mod serie;
|
||||||
@@ -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 (1–3 dígitos numéricos). Ex: "001", "1".
|
||||||
|
pub serie: String,
|
||||||
|
/// Tipo de documento (ex: "NFE", "NFCE"). None quando não mapeado.
|
||||||
|
pub documento_tipo: Option<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod entities;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod services;
|
||||||
@@ -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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
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(¬as);
|
||||||
|
assert!(dup.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
||||||
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(1, "001", "NFCE"),
|
||||||
|
];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert!(dup.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
|
||||||
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert_eq!(
|
||||||
|
dup.get(&(1, "001".to_string(), Some("NFE".to_string()))),
|
||||||
|
Some(&2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupamento_por_serie() {
|
||||||
|
let notas = vec![
|
||||||
|
nota(1, "001"),
|
||||||
|
nota(1, "001"),
|
||||||
|
nota(1, "002"),
|
||||||
|
nota(1, "002"),
|
||||||
|
nota(2, "001"),
|
||||||
|
nota(2, "001"),
|
||||||
|
];
|
||||||
|
let por_serie = duplicidades_por_serie(¬as);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod detector_duplicidade;
|
||||||
|
pub mod detector_sequencia;
|
||||||
|
pub mod parser_monetario;
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: `100–104 (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 |
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod csv_reader;
|
||||||
|
pub mod pdf_generator;
|
||||||
|
pub mod sqlite;
|
||||||
|
pub mod xlsx_reader;
|
||||||
@@ -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: 100–104 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 |
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod connection;
|
||||||
|
pub mod layout_repository;
|
||||||
|
pub mod migrations;
|
||||||
@@ -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
@@ -1,3 +1,20 @@
|
|||||||
fn main() {
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
println!("Hello, world!");
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 60–89% |
|
||||||
|
| `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.60–0.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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod modal;
|
||||||
|
pub mod paginacao;
|
||||||
|
pub mod tabela_preview;
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod components;
|
||||||
|
pub mod message;
|
||||||
|
pub mod screens;
|
||||||
|
pub mod theme;
|
||||||
@@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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 |
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: 1010–1050</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>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user