update.
This commit is contained in:
@@ -0,0 +1,353 @@
|
|||||||
|
# FIX — Salvar Layout no Banco de Dados
|
||||||
|
|
||||||
|
**Data:** 04/03/2026
|
||||||
|
**Versão do PRD:** 1.7
|
||||||
|
**Status:** Pendente de Implementação
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sumário dos Problemas
|
||||||
|
|
||||||
|
| # | Severidade | Arquivo(s) | Descrição |
|
||||||
|
|---|-----------|------------|-----------|
|
||||||
|
| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado |
|
||||||
|
| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations |
|
||||||
|
| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa |
|
||||||
|
| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco |
|
||||||
|
| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI |
|
||||||
|
| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas |
|
||||||
|
| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` |
|
||||||
|
| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite |
|
||||||
|
| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 1 (Crítico) — `migration_v3` sem transação atômica
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/migrations.rs` — função `migration_v3`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
```rust
|
||||||
|
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
||||||
|
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial:
|
||||||
|
|
||||||
|
- `indice_documento_tipo` existe, `pos_documento_tipo` não existe.
|
||||||
|
- A versão no banco não é atualizada (o erro propaga antes).
|
||||||
|
- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"` → **o app nunca mais inicializa sem intervenção manual**.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
O fluxo atual é:
|
||||||
|
1. Executa `migration_v1(conn)?`
|
||||||
|
2. Executa `migration_v2(conn)?`
|
||||||
|
3. Executa `migration_v3(conn)?`
|
||||||
|
4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...`
|
||||||
|
|
||||||
|
Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas.
|
||||||
|
|
||||||
|
Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn migration_v1(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch("
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS layouts ( ... );
|
||||||
|
INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1);
|
||||||
|
COMMIT;
|
||||||
|
")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch("
|
||||||
|
BEGIN;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
|
||||||
|
-- renomeia duplicatas existentes se houver
|
||||||
|
UPDATE schema_version SET versao = 2;
|
||||||
|
COMMIT;
|
||||||
|
")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado`
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
```rust
|
||||||
|
layout_repository::salvar(conn, layout)
|
||||||
|
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||||
|
|
||||||
|
layout_repository::atualizar(conn, layout)
|
||||||
|
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria:
|
||||||
|
|
||||||
|
- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado"
|
||||||
|
- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado"
|
||||||
|
- `"attempt to write a readonly database"` → exibido como "JSON malformado"
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
|
||||||
|
1. Adicionar variante própria em `domain/errors.rs`:
|
||||||
|
```rust
|
||||||
|
pub enum ErroLayout {
|
||||||
|
// ... variantes existentes ...
|
||||||
|
ErroBanco(String), // erros genéricos de I/O do banco
|
||||||
|
NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. No use case, inspecionar o erro antes de mapear:
|
||||||
|
```rust
|
||||||
|
layout_repository::salvar(conn, layout)
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("UNIQUE constraint failed") {
|
||||||
|
ErroLayout::NomeConflitante
|
||||||
|
} else {
|
||||||
|
ErroLayout::ErroBanco(msg)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV)
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Normalizar o delimitador para um token legível antes de salvar:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Ao salvar:
|
||||||
|
let delim_str = match config.delimitador {
|
||||||
|
'\t' => "tab".to_string(),
|
||||||
|
c => c.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ao ler:
|
||||||
|
let delimitador = match delim_str.as_str() {
|
||||||
|
"tab" => '\t',
|
||||||
|
s => s.chars().next().unwrap_or(';'),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/application/usecases/layouts.rs` — função `salvar_layout`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option<String>`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Expandir a validação no use case `salvar_layout`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match layout {
|
||||||
|
Layout::Csv { config, .. } => {
|
||||||
|
// indice_numero e indice_serie são usize — sempre válidos se presentes
|
||||||
|
// nenhuma validação adicional necessária aqui
|
||||||
|
}
|
||||||
|
Layout::Xlsx { config, .. } => {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)`
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/layout_repository.rs` — função `listar`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Em vez de:
|
||||||
|
let id: i64 = row.get(0)?;
|
||||||
|
let nome: String = row.get(1)?;
|
||||||
|
|
||||||
|
// Usar:
|
||||||
|
let id: i64 = row.get("id")?;
|
||||||
|
let nome: String = row.get("nome")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima).
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Verificar conflito excluindo o próprio registro
|
||||||
|
if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? {
|
||||||
|
return Err(ErroLayout::NomeConflitante);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adicionar função no repository:
|
||||||
|
```rust
|
||||||
|
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option<i64>) -> Result<bool> {
|
||||||
|
match id {
|
||||||
|
Some(id) => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
None => layout_repository::existe_nome(conn, nome),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/connection.rs`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
```rust
|
||||||
|
match conn.execute_batch("SELECT 1;") {
|
||||||
|
Ok(_) => return Ok((conn, false)),
|
||||||
|
Err(_) => { /* trata como banco corrompido */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
```rust
|
||||||
|
match conn.execute_batch("PRAGMA quick_check;") {
|
||||||
|
Ok(_) => return Ok((conn, false)),
|
||||||
|
Err(_) => { /* banco corrompido */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto
|
||||||
|
|
||||||
|
### Local
|
||||||
|
`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar`
|
||||||
|
|
||||||
|
### Situação Atual
|
||||||
|
O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco.
|
||||||
|
|
||||||
|
### Correção Necessária
|
||||||
|
Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- UPDATE CSV: zerar campos XLSX
|
||||||
|
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,
|
||||||
|
-- zerar campos XLSX:
|
||||||
|
aba = NULL, pos_numero = NULL, pos_serie = NULL,
|
||||||
|
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
|
||||||
|
WHERE id = ?10
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- UPDATE XLSX: zerar campos CSV
|
||||||
|
UPDATE layouts SET
|
||||||
|
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
||||||
|
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
|
||||||
|
-- zerar campos CSV:
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordem de Implementação Recomendada
|
||||||
|
|
||||||
|
1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro.
|
||||||
|
2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado.
|
||||||
|
3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos.
|
||||||
|
4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3.
|
||||||
|
5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash.
|
||||||
|
6. **Problema 6** (índices posicionais) — Refactor preventivo.
|
||||||
|
7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade.
|
||||||
|
8. **Problema 9** (zerar campos opostos) — Limpeza defensiva.
|
||||||
@@ -2,7 +2,7 @@ use crate::domain::{
|
|||||||
entities::{
|
entities::{
|
||||||
chave_serie::ChaveSerie,
|
chave_serie::ChaveSerie,
|
||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
detector_duplicidade::duplicidades_por_serie,
|
detector_duplicidade::duplicidades_por_serie,
|
||||||
@@ -56,12 +56,14 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verifica se algum grupo excede o limite de faltantes.
|
/// Verifica se algum grupo excede o limite de faltantes.
|
||||||
/// Retorna lista de chaves que precisam de confirmação.
|
/// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
|
||||||
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
|
pub fn series_com_intervalo_excessivo(
|
||||||
|
pre: &ResultadoPreAnalise,
|
||||||
|
) -> Vec<(ChaveSerie, IntervaloSerie)> {
|
||||||
pre.intervalos_por_serie
|
pre.intervalos_por_serie
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||||
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
|
.map(|(chave, iv)| (chave.clone(), iv.clone()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||||
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
|
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
@@ -9,6 +10,7 @@ use std::path::Path;
|
|||||||
pub fn exportar_pdf(
|
pub fn exportar_pdf(
|
||||||
gerador: &dyn PdfGenerator,
|
gerador: &dyn PdfGenerator,
|
||||||
resultado: &ResultadoAnalise,
|
resultado: &ResultadoAnalise,
|
||||||
|
notas: &[Nota],
|
||||||
nome_arquivo: &str,
|
nome_arquivo: &str,
|
||||||
nome_layout: Option<&str>,
|
nome_layout: Option<&str>,
|
||||||
caminho_saida: &Path,
|
caminho_saida: &Path,
|
||||||
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
|
|||||||
gerado_em: Local::now(),
|
gerado_em: Local::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
gerador.gerar(resultado, &meta, caminho_saida)
|
gerador.gerar(resultado, notas, &meta, caminho_saida)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub fn importar_csv(
|
|||||||
config.indice_valor,
|
config.indice_valor,
|
||||||
config.indice_data,
|
config.indice_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
);
|
)?;
|
||||||
|
|
||||||
Ok(ResultadoImportacao { notas, avisos })
|
Ok(ResultadoImportacao { notas, avisos })
|
||||||
}
|
}
|
||||||
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
|
|||||||
col_valor,
|
col_valor,
|
||||||
col_data,
|
col_data,
|
||||||
&mut avisos,
|
&mut avisos,
|
||||||
);
|
)?;
|
||||||
|
|
||||||
Ok(ResultadoImportacao { notas, avisos })
|
Ok(ResultadoImportacao { notas, avisos })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
|
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
|
||||||
/// aplicando validações e coletando avisos.
|
/// 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(
|
fn mapear_linhas_para_notas(
|
||||||
linhas: &[Vec<String>],
|
linhas: &[Vec<String>],
|
||||||
idx_numero: usize,
|
idx_numero: usize,
|
||||||
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
|
|||||||
idx_valor: Option<usize>,
|
idx_valor: Option<usize>,
|
||||||
idx_data: Option<usize>,
|
idx_data: Option<usize>,
|
||||||
avisos: &mut ResumoAvisos,
|
avisos: &mut ResumoAvisos,
|
||||||
) -> Vec<Nota> {
|
) -> Result<Vec<Nota>, ErroArquivo> {
|
||||||
let mut notas = Vec::new();
|
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() {
|
for (i, linha) in linhas.iter().enumerate() {
|
||||||
let linha_num = i + 1;
|
let linha_num = i + 1;
|
||||||
|
|
||||||
@@ -216,7 +248,7 @@ fn mapear_linhas_para_notas(
|
|||||||
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
|
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
notas
|
Ok(notas)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
|
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::domain::entities::chave_serie::ChaveSerie;
|
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||||
use crate::domain::services::detector_sequencia::agrupar_contiguos;
|
use crate::domain::services::detector_sequencia::agrupar_contiguos;
|
||||||
use crate::domain::services::parser_monetario::formatar_valor_br;
|
use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||||
@@ -7,6 +8,7 @@ use genpdf::{
|
|||||||
elements::{Break, Paragraph},
|
elements::{Break, Paragraph},
|
||||||
fonts, style, Document, SimplePageDecorator,
|
fonts, style, Document, SimplePageDecorator,
|
||||||
};
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
// Fontes embutidas no binário em tempo de compilação.
|
// Fontes embutidas no binário em tempo de compilação.
|
||||||
@@ -29,6 +31,7 @@ pub trait PdfGenerator {
|
|||||||
fn gerar(
|
fn gerar(
|
||||||
&self,
|
&self,
|
||||||
resultado: &ResultadoAnalise,
|
resultado: &ResultadoAnalise,
|
||||||
|
notas: &[Nota],
|
||||||
meta: &MetadadosRelatorio,
|
meta: &MetadadosRelatorio,
|
||||||
caminho_saida: &Path,
|
caminho_saida: &Path,
|
||||||
) -> Result<(), String>;
|
) -> Result<(), String>;
|
||||||
@@ -41,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
fn gerar(
|
fn gerar(
|
||||||
&self,
|
&self,
|
||||||
resultado: &ResultadoAnalise,
|
resultado: &ResultadoAnalise,
|
||||||
|
notas: &[Nota],
|
||||||
meta: &MetadadosRelatorio,
|
meta: &MetadadosRelatorio,
|
||||||
caminho_saida: &Path,
|
caminho_saida: &Path,
|
||||||
) -> Result<(), String> {
|
) -> 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 font_family = carregar_fonte_familia()?;
|
||||||
|
|
||||||
let mut doc = Document::new(font_family);
|
let mut doc = Document::new(font_family);
|
||||||
@@ -170,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
duplicatas.len()
|
duplicatas.len()
|
||||||
)));
|
)));
|
||||||
for (numero, count) in duplicatas {
|
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!(
|
doc.push(Paragraph::new(format!(
|
||||||
" NF {} / Série {} — {} ocorrências",
|
" NF {} / Série {} — {} ocorrências{}",
|
||||||
numero,
|
numero,
|
||||||
chave.label(),
|
chave.label(),
|
||||||
count
|
count,
|
||||||
|
data_str,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-27
@@ -9,7 +9,7 @@ use crate::domain::{
|
|||||||
nota::Nota,
|
nota::Nota,
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
},
|
},
|
||||||
errors::{ErroLayout, ResumoAvisos},
|
errors::{ErroArquivo, ErroLayout, ResumoAvisos},
|
||||||
};
|
};
|
||||||
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||||
use crate::ui::message::{Message, ResultadoPendente};
|
use crate::ui::message::{Message, ResultadoPendente};
|
||||||
@@ -36,7 +36,6 @@ pub enum EstadoApp {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum EstadoModal {
|
pub enum EstadoModal {
|
||||||
Informacao { titulo: String, mensagem: String },
|
|
||||||
Aviso { titulo: String, mensagem: String },
|
Aviso { titulo: String, mensagem: String },
|
||||||
Erro { titulo: String, mensagem: String },
|
Erro { titulo: String, mensagem: String },
|
||||||
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
||||||
@@ -82,9 +81,9 @@ pub struct App {
|
|||||||
// Avisos da última importação
|
// Avisos da última importação
|
||||||
pub avisos_importacao: Option<ResumoAvisos>,
|
pub avisos_importacao: Option<ResumoAvisos>,
|
||||||
|
|
||||||
// Paginação
|
// Paginação (independente por série)
|
||||||
pub pagina_faltantes: usize,
|
pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||||
pub pagina_duplicatas: usize,
|
pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||||
pub itens_por_pagina: usize,
|
pub itens_por_pagina: usize,
|
||||||
|
|
||||||
// Pré-visualização das primeiras linhas do arquivo
|
// Pré-visualização das primeiras linhas do arquivo
|
||||||
@@ -95,6 +94,9 @@ pub struct App {
|
|||||||
|
|
||||||
// Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela
|
// Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela
|
||||||
pub arquivo_em_hover: bool,
|
pub arquivo_em_hover: bool,
|
||||||
|
|
||||||
|
// Animação do spinner na tela de análise
|
||||||
|
pub spinner_frame: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
@@ -114,12 +116,13 @@ impl Default for App {
|
|||||||
layouts_salvos: Vec::new(),
|
layouts_salvos: Vec::new(),
|
||||||
modal: None,
|
modal: None,
|
||||||
avisos_importacao: None,
|
avisos_importacao: None,
|
||||||
pagina_faltantes: 0,
|
pagina_faltantes: std::collections::HashMap::new(),
|
||||||
pagina_duplicatas: 0,
|
pagina_duplicatas: std::collections::HashMap::new(),
|
||||||
itens_por_pagina: 100,
|
itens_por_pagina: 100,
|
||||||
preview_arquivo: None,
|
preview_arquivo: None,
|
||||||
resultado_anterior: None,
|
resultado_anterior: None,
|
||||||
arquivo_em_hover: false,
|
arquivo_em_hover: false,
|
||||||
|
spinner_frame: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,12 +455,6 @@ impl App {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::CancelarExpansao => {
|
|
||||||
self.estado = EstadoApp::ConfigurandoColunas;
|
|
||||||
self.modal = None;
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
Message::NovaAnalise => {
|
Message::NovaAnalise => {
|
||||||
self.exibir_confirmacao(
|
self.exibir_confirmacao(
|
||||||
"Nova Análise",
|
"Nova Análise",
|
||||||
@@ -473,18 +470,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Resultado ──
|
// ── Resultado ──
|
||||||
Message::PaginaFaltantesAlterada(p) => {
|
Message::PaginaFaltantesAlterada(chave, p) => {
|
||||||
self.pagina_faltantes = p;
|
self.pagina_faltantes.insert(chave, p);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::PaginaDuplicatasAlterada(p) => {
|
Message::PaginaDuplicatasAlterada(chave, p) => {
|
||||||
self.pagina_duplicatas = p;
|
self.pagina_duplicatas.insert(chave, p);
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::ItensPorPaginaAlterado(n) => {
|
Message::ItensPorPaginaAlterado(n) => {
|
||||||
self.itens_por_pagina = n;
|
self.itens_por_pagina = n;
|
||||||
self.pagina_faltantes = 0;
|
self.pagina_faltantes.clear();
|
||||||
self.pagina_duplicatas = 0;
|
self.pagina_duplicatas.clear();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::CopiarFaltantes(chave) => {
|
Message::CopiarFaltantes(chave) => {
|
||||||
@@ -659,6 +656,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Message::XlsxErroAoCarregar(e) => {
|
Message::XlsxErroAoCarregar(e) => {
|
||||||
|
self.caminho_arquivo = None;
|
||||||
|
self.nome_arquivo = String::new();
|
||||||
|
self.preview_arquivo = None;
|
||||||
|
self.notas_importadas.clear();
|
||||||
self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -679,6 +680,11 @@ impl App {
|
|||||||
|
|
||||||
// ── Sem operação ──
|
// ── Sem operação ──
|
||||||
Message::Noop => Task::none(),
|
Message::Noop => Task::none(),
|
||||||
|
|
||||||
|
Message::SpinnerTick => {
|
||||||
|
self.spinner_frame = self.spinner_frame.wrapping_add(1);
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,9 +704,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
|
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
|
||||||
EstadoApp::Analisando => {
|
EstadoApp::Analisando => {
|
||||||
|
const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
text("Analisando...").size(20).color(t::TEXT_SECONDARY),
|
text(format!("{} Analisando...", frame)).size(20).color(t::TEXT_SECONDARY),
|
||||||
text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED),
|
text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
@@ -742,7 +750,7 @@ impl App {
|
|||||||
// ─── Subscription (eventos de janela) ───────────────────────────────────
|
// ─── Subscription (eventos de janela) ───────────────────────────────────
|
||||||
|
|
||||||
pub fn subscription(&self) -> iced::Subscription<Message> {
|
pub fn subscription(&self) -> iced::Subscription<Message> {
|
||||||
iced::event::listen_with(|event, _status, _id| match event {
|
let drag_drop = iced::event::listen_with(|event, _status, _id| match event {
|
||||||
iced::Event::Window(iced::window::Event::FileDropped(path)) => {
|
iced::Event::Window(iced::window::Event::FileDropped(path)) => {
|
||||||
Some(Message::ArquivoSolto(path))
|
Some(Message::ArquivoSolto(path))
|
||||||
}
|
}
|
||||||
@@ -753,7 +761,15 @@ impl App {
|
|||||||
Some(Message::ArquivoHoverSaiu)
|
Some(Message::ArquivoHoverSaiu)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
});
|
||||||
|
|
||||||
|
if matches!(self.estado, EstadoApp::Analisando) {
|
||||||
|
let spinner = iced::time::every(std::time::Duration::from_millis(100))
|
||||||
|
.map(|_| Message::SpinnerTick);
|
||||||
|
iced::Subscription::batch([drag_drop, spinner])
|
||||||
|
} else {
|
||||||
|
drag_drop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Breadcrumb ──────────────────────────────────────────────────────────
|
// ─── Breadcrumb ──────────────────────────────────────────────────────────
|
||||||
@@ -862,6 +878,12 @@ impl App {
|
|||||||
let layout_csv = self.layout_csv_atual.clone();
|
let layout_csv = self.layout_csv_atual.clone();
|
||||||
let layout_xlsx = self.layout_xlsx_atual.clone();
|
let layout_xlsx = self.layout_xlsx_atual.clone();
|
||||||
|
|
||||||
|
// Validação de campos duplicados no mapeamento (RF02.3)
|
||||||
|
if let Some(erro) = validar_mapeamento_duplicado(&tipo, &layout_csv, &layout_xlsx) {
|
||||||
|
self.exibir_erro(erro);
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
self.estado = EstadoApp::Analisando;
|
self.estado = EstadoApp::Analisando;
|
||||||
|
|
||||||
Task::perform(
|
Task::perform(
|
||||||
@@ -929,11 +951,13 @@ impl App {
|
|||||||
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
|
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
|
||||||
let msg = series_excessivas
|
let msg = series_excessivas
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(chave, count)| {
|
.map(|(chave, iv)| {
|
||||||
format!(
|
format!(
|
||||||
"Série {}: intervalo de {} faltantes detectado",
|
"Série {}: intervalo de {} faltantes detectado (de {} a {})",
|
||||||
chave.label(),
|
chave.label(),
|
||||||
count
|
iv.contagem_faltantes,
|
||||||
|
iv.minimo,
|
||||||
|
iv.maximo,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -1072,6 +1096,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
Some(self.nome_layout_atual.clone())
|
Some(self.nome_layout_atual.clone())
|
||||||
};
|
};
|
||||||
|
let notas = self.notas_importadas.clone();
|
||||||
|
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async move {
|
async move {
|
||||||
@@ -1094,6 +1119,7 @@ impl App {
|
|||||||
exportar_pdf(
|
exportar_pdf(
|
||||||
&gerador,
|
&gerador,
|
||||||
&resultado,
|
&resultado,
|
||||||
|
¬as,
|
||||||
&nome_arquivo,
|
&nome_arquivo,
|
||||||
nome_layout.as_deref(),
|
nome_layout.as_deref(),
|
||||||
&caminho,
|
&caminho,
|
||||||
@@ -1215,6 +1241,78 @@ impl App {
|
|||||||
|
|
||||||
// ─── Função auxiliar síncrona para executar importação em spawn_blocking ─────
|
// ─── Função auxiliar síncrona para executar importação em spawn_blocking ─────
|
||||||
|
|
||||||
|
/// Verifica se algum par de campos mapeados aponta para o mesmo índice (CSV)
|
||||||
|
/// ou posição (XLSX). Retorna uma mensagem de erro descritiva se houver conflito.
|
||||||
|
fn validar_mapeamento_duplicado(
|
||||||
|
tipo: &TipoArquivo,
|
||||||
|
layout_csv: &LayoutCsv,
|
||||||
|
layout_xlsx: &LayoutXlsx,
|
||||||
|
) -> Option<String> {
|
||||||
|
match tipo {
|
||||||
|
TipoArquivo::Csv => {
|
||||||
|
// Campos obrigatórios + opcionais ativos
|
||||||
|
let mut campos: Vec<(&str, usize)> = vec![
|
||||||
|
("Numero", layout_csv.indice_numero),
|
||||||
|
("Serie", layout_csv.indice_serie),
|
||||||
|
];
|
||||||
|
if let Some(i) = layout_csv.indice_valor {
|
||||||
|
campos.push(("Valor", i));
|
||||||
|
}
|
||||||
|
if let Some(i) = layout_csv.indice_data {
|
||||||
|
campos.push(("Data", i));
|
||||||
|
}
|
||||||
|
if let Some(i) = layout_csv.indice_documento_tipo {
|
||||||
|
campos.push(("Tipo de Documento", i));
|
||||||
|
}
|
||||||
|
// Verificar duplicatas
|
||||||
|
for i in 0..campos.len() {
|
||||||
|
for j in (i + 1)..campos.len() {
|
||||||
|
if campos[i].1 == campos[j].1 {
|
||||||
|
return Some(format!(
|
||||||
|
"Mapeamento inválido: campos '{}' e '{}' apontam para o mesmo índice ({}).",
|
||||||
|
campos[i].0, campos[j].0, campos[i].1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
TipoArquivo::Xlsx => {
|
||||||
|
let mut campos: Vec<(&str, &str)> = vec![
|
||||||
|
("Numero", &layout_xlsx.pos_numero),
|
||||||
|
("Serie", &layout_xlsx.pos_serie),
|
||||||
|
];
|
||||||
|
if let Some(ref p) = layout_xlsx.pos_valor {
|
||||||
|
if !p.is_empty() {
|
||||||
|
campos.push(("Valor", p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref p) = layout_xlsx.pos_data {
|
||||||
|
if !p.is_empty() {
|
||||||
|
campos.push(("Data", p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref p) = layout_xlsx.pos_documento_tipo {
|
||||||
|
if !p.is_empty() {
|
||||||
|
campos.push(("Tipo de Documento", p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Verificar duplicatas (comparação case-insensitive)
|
||||||
|
for i in 0..campos.len() {
|
||||||
|
for j in (i + 1)..campos.len() {
|
||||||
|
if campos[i].1.to_uppercase() == campos[j].1.to_uppercase() {
|
||||||
|
return Some(format!(
|
||||||
|
"Mapeamento inválido: campos '{}' e '{}' apontam para a mesma posição ('{}').",
|
||||||
|
campos[i].0, campos[j].0, campos[i].1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn executar_importacao_sync(
|
fn executar_importacao_sync(
|
||||||
caminho: PathBuf,
|
caminho: PathBuf,
|
||||||
tipo: TipoArquivo,
|
tipo: TipoArquivo,
|
||||||
@@ -1222,8 +1320,20 @@ fn executar_importacao_sync(
|
|||||||
layout_xlsx: LayoutXlsx,
|
layout_xlsx: LayoutXlsx,
|
||||||
) -> ResultadoPendente {
|
) -> ResultadoPendente {
|
||||||
let res_importacao = match tipo {
|
let res_importacao = match tipo {
|
||||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| match e {
|
||||||
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
ErroArquivo::TamanhoExcedido(bytes) => format!(
|
||||||
|
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
|
||||||
|
bytes as f64 / (1024.0 * 1024.0)
|
||||||
|
),
|
||||||
|
other => other.to_string(),
|
||||||
|
}),
|
||||||
|
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| match e {
|
||||||
|
ErroArquivo::TamanhoExcedido(bytes) => format!(
|
||||||
|
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
|
||||||
|
bytes as f64 / (1024.0 * 1024.0)
|
||||||
|
),
|
||||||
|
other => other.to_string(),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
match res_importacao {
|
match res_importacao {
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
|
|||||||
|
|
||||||
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
||||||
match modal {
|
match modal {
|
||||||
EstadoModal::Informacao { titulo, mensagem } => {
|
|
||||||
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
|
|
||||||
}
|
|
||||||
EstadoModal::Aviso { titulo, mensagem } => {
|
EstadoModal::Aviso { titulo, mensagem } => {
|
||||||
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
|||||||
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
|
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cabeçalho estilo Excel — fundo SURFACE_2
|
// Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
|
||||||
let cabecalho = container(
|
let cabecalho = container(
|
||||||
row((0..num_colunas)
|
row((0..num_colunas)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
|||||||
.spacing(0),
|
.spacing(0),
|
||||||
)
|
)
|
||||||
.width(Length::Shrink)
|
.width(Length::Shrink)
|
||||||
.style(|_theme| iced::widget::container::Style {
|
.style(t::cabecalho_tabela);
|
||||||
background: Some(t::SURFACE_2.into()),
|
|
||||||
border: Border {
|
|
||||||
color: t::BORDER,
|
|
||||||
width: 0.0,
|
|
||||||
radius: 0.0.into(),
|
|
||||||
},
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
||||||
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
||||||
|
|||||||
+7
-5
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
|
|||||||
use crate::domain::entities::{
|
use crate::domain::entities::{
|
||||||
chave_serie::ChaveSerie,
|
chave_serie::ChaveSerie,
|
||||||
layout::{Layout, LayoutXlsx},
|
layout::{Layout, LayoutXlsx},
|
||||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||||
};
|
};
|
||||||
use crate::domain::errors::ResumoAvisos;
|
use crate::domain::errors::ResumoAvisos;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
@@ -73,12 +73,11 @@ pub enum Message {
|
|||||||
ExecutarImportacao,
|
ExecutarImportacao,
|
||||||
ReanalisarArquivo,
|
ReanalisarArquivo,
|
||||||
ConfirmarExpansaoFaltantes,
|
ConfirmarExpansaoFaltantes,
|
||||||
CancelarExpansao,
|
|
||||||
NovaAnalise,
|
NovaAnalise,
|
||||||
|
|
||||||
// --- Resultado ---
|
// --- Resultado ---
|
||||||
PaginaFaltantesAlterada(usize),
|
PaginaFaltantesAlterada(ChaveSerie, usize),
|
||||||
PaginaDuplicatasAlterada(usize),
|
PaginaDuplicatasAlterada(ChaveSerie, usize),
|
||||||
ItensPorPaginaAlterado(usize),
|
ItensPorPaginaAlterado(usize),
|
||||||
CopiarFaltantes(ChaveSerie),
|
CopiarFaltantes(ChaveSerie),
|
||||||
CopiarDuplicatas(ChaveSerie),
|
CopiarDuplicatas(ChaveSerie),
|
||||||
@@ -103,6 +102,9 @@ pub enum Message {
|
|||||||
|
|
||||||
// --- Sem operação (used as fallback) ---
|
// --- Sem operação (used as fallback) ---
|
||||||
Noop,
|
Noop,
|
||||||
|
|
||||||
|
// --- Animação ---
|
||||||
|
SpinnerTick,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resultado enviado pela task de análise em background para a UI.
|
/// Resultado enviado pela task de análise em background para a UI.
|
||||||
@@ -117,7 +119,7 @@ pub enum ResultadoPendente {
|
|||||||
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||||
AguardandoConfirmacao {
|
AguardandoConfirmacao {
|
||||||
pre: ResultadoPreAnalise,
|
pre: ResultadoPreAnalise,
|
||||||
series_excessivas: Vec<(ChaveSerie, u64)>,
|
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
|
||||||
avisos: ResumoAvisos,
|
avisos: ResumoAvisos,
|
||||||
notas: Vec<Nota>,
|
notas: Vec<Nota>,
|
||||||
},
|
},
|
||||||
|
|||||||
+69
-13
@@ -82,20 +82,43 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me
|
|||||||
let secao_duplicatas = view_duplicatas(app, resultado);
|
let secao_duplicatas = view_duplicatas(app, resultado);
|
||||||
let secao_totais = view_totais(resultado);
|
let secao_totais = view_totais(resultado);
|
||||||
|
|
||||||
let conteudo = column![
|
let mut col_principal = column![
|
||||||
row![text("Resultado da Análise").size(22).color(t::TEXT),],
|
row![text("Resultado da Análise").size(22).color(t::TEXT),],
|
||||||
botoes_topo,
|
botoes_topo,
|
||||||
stat_row,
|
stat_row,
|
||||||
controle_pagina,
|
controle_pagina,
|
||||||
secao_faltantes,
|
|
||||||
secao_duplicatas,
|
|
||||||
secao_totais,
|
|
||||||
]
|
]
|
||||||
.spacing(16)
|
.spacing(16)
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.width(Length::Fill);
|
.width(Length::Fill);
|
||||||
|
|
||||||
container(scrollable(conteudo))
|
// 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)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.style(t::fundo)
|
.style(t::fundo)
|
||||||
@@ -132,7 +155,16 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
|||||||
let mut col = column![titulo_row].spacing(12);
|
let mut col = column![titulo_row].spacing(12);
|
||||||
|
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS));
|
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)
|
return container(col)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
@@ -145,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
|||||||
|
|
||||||
for chave in chaves {
|
for chave in chaves {
|
||||||
let faltantes = &resultado.faltantes_por_serie[chave];
|
let faltantes = &resultado.faltantes_por_serie[chave];
|
||||||
|
|
||||||
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
|
|
||||||
if faltantes.is_empty() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
|
||||||
let total_esperado = total_notas + faltantes.len();
|
let total_esperado = total_notas + faltantes.len();
|
||||||
let percentual = total_notas as f64 / total_esperado as f64;
|
let percentual = total_notas as f64 / total_esperado as f64;
|
||||||
let percentual_f32 = percentual as f32;
|
let percentual_f32 = percentual as f32;
|
||||||
@@ -189,7 +235,12 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
|||||||
|
|
||||||
// Paginação e lista
|
// Paginação e lista
|
||||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1));
|
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 inicio = pagina * app.itens_por_pagina;
|
||||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||||
|
|
||||||
@@ -210,8 +261,8 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
|||||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||||
pagina,
|
pagina,
|
||||||
total_paginas,
|
total_paginas,
|
||||||
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
|
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||||
Message::PaginaFaltantesAlterada(pagina + 1),
|
Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +332,12 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
|
|||||||
col = col.push(cabecalho_serie);
|
col = col.push(cabecalho_serie);
|
||||||
|
|
||||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1));
|
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 inicio = pagina * app.itens_por_pagina;
|
||||||
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||||
|
|
||||||
@@ -304,8 +360,8 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
|
|||||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||||
pagina,
|
pagina,
|
||||||
total_paginas,
|
total_paginas,
|
||||||
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
|
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||||
Message::PaginaDuplicatasAlterada(pagina + 1),
|
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user