This commit is contained in:
FelipeCN
2026-03-04 17:27:02 -03:00
parent 6a5d941a00
commit 06eca335db
10 changed files with 635 additions and 70 deletions
+353
View File
@@ -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.
+6 -4
View File
@@ -2,7 +2,7 @@ use crate::domain::{
entities::{
chave_serie::ChaveSerie,
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
},
services::{
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.
/// Retorna lista de chaves que precisam de confirmação.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
/// 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.contagem_faltantes))
.map(|(chave, iv)| (chave.clone(), iv.clone()))
.collect()
}
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local;
@@ -9,6 +10,7 @@ use std::path::Path;
pub fn exportar_pdf(
gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise,
notas: &[Nota],
nome_arquivo: &str,
nome_layout: Option<&str>,
caminho_saida: &Path,
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
gerado_em: Local::now(),
};
gerador.gerar(resultado, &meta, caminho_saida)
gerador.gerar(resultado, notas, &meta, caminho_saida)
}
+36 -4
View File
@@ -44,7 +44,7 @@ pub fn importar_csv(
config.indice_valor,
config.indice_data,
&mut avisos,
);
)?;
Ok(ResultadoImportacao { notas, avisos })
}
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
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,
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
) -> Vec<Nota> {
) -> 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;
@@ -216,7 +248,7 @@ fn mapear_linhas_para_notas(
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.
+21 -2
View File
@@ -1,4 +1,5 @@
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;
@@ -7,6 +8,7 @@ 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.
@@ -29,6 +31,7 @@ pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
@@ -41,9 +44,20 @@ 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);
@@ -170,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
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",
" NF {} / Série {}{} ocorrências{}",
numero,
chave.label(),
count
count,
data_str,
)));
}
}
+137 -27
View File
@@ -9,7 +9,7 @@ use crate::domain::{
nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
},
errors::{ErroLayout, ResumoAvisos},
errors::{ErroArquivo, ErroLayout, ResumoAvisos},
};
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use crate::ui::message::{Message, ResultadoPendente};
@@ -36,7 +36,6 @@ pub enum EstadoApp {
#[derive(Debug, Clone)]
pub enum EstadoModal {
Informacao { titulo: String, mensagem: String },
Aviso { titulo: String, mensagem: String },
Erro { titulo: String, mensagem: String },
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
@@ -82,9 +81,9 @@ pub struct App {
// Avisos da última importação
pub avisos_importacao: Option<ResumoAvisos>,
// Paginação
pub pagina_faltantes: usize,
pub pagina_duplicatas: usize,
// Paginação (independente por série)
pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub itens_por_pagina: usize,
// 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
pub arquivo_em_hover: bool,
// Animação do spinner na tela de análise
pub spinner_frame: usize,
}
impl Default for App {
@@ -114,12 +116,13 @@ impl Default for App {
layouts_salvos: Vec::new(),
modal: None,
avisos_importacao: None,
pagina_faltantes: 0,
pagina_duplicatas: 0,
pagina_faltantes: std::collections::HashMap::new(),
pagina_duplicatas: std::collections::HashMap::new(),
itens_por_pagina: 100,
preview_arquivo: None,
resultado_anterior: None,
arquivo_em_hover: false,
spinner_frame: 0,
}
}
}
@@ -452,12 +455,6 @@ impl App {
Task::none()
}
Message::CancelarExpansao => {
self.estado = EstadoApp::ConfigurandoColunas;
self.modal = None;
Task::none()
}
Message::NovaAnalise => {
self.exibir_confirmacao(
"Nova Análise",
@@ -473,18 +470,18 @@ impl App {
}
// ── Resultado ──
Message::PaginaFaltantesAlterada(p) => {
self.pagina_faltantes = p;
Message::PaginaFaltantesAlterada(chave, p) => {
self.pagina_faltantes.insert(chave, p);
Task::none()
}
Message::PaginaDuplicatasAlterada(p) => {
self.pagina_duplicatas = p;
Message::PaginaDuplicatasAlterada(chave, p) => {
self.pagina_duplicatas.insert(chave, p);
Task::none()
}
Message::ItensPorPaginaAlterado(n) => {
self.itens_por_pagina = n;
self.pagina_faltantes = 0;
self.pagina_duplicatas = 0;
self.pagina_faltantes.clear();
self.pagina_duplicatas.clear();
Task::none()
}
Message::CopiarFaltantes(chave) => {
@@ -659,6 +656,10 @@ impl App {
}
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));
Task::none()
}
@@ -679,6 +680,11 @@ impl App {
// ── Sem operação ──
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::Analisando => {
const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
container(
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),
]
.spacing(8)
@@ -742,7 +750,7 @@ impl App {
// ─── Subscription (eventos de janela) ───────────────────────────────────
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)) => {
Some(Message::ArquivoSolto(path))
}
@@ -753,7 +761,15 @@ impl App {
Some(Message::ArquivoHoverSaiu)
}
_ => 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 ──────────────────────────────────────────────────────────
@@ -862,6 +878,12 @@ impl App {
let layout_csv = self.layout_csv_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;
Task::perform(
@@ -929,11 +951,13 @@ impl App {
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
let msg = series_excessivas
.iter()
.map(|(chave, count)| {
.map(|(chave, iv)| {
format!(
"Série {}: intervalo de {} faltantes detectado",
"Série {}: intervalo de {} faltantes detectado (de {} a {})",
chave.label(),
count
iv.contagem_faltantes,
iv.minimo,
iv.maximo,
)
})
.collect::<Vec<_>>()
@@ -1072,6 +1096,7 @@ impl App {
} else {
Some(self.nome_layout_atual.clone())
};
let notas = self.notas_importadas.clone();
Task::perform(
async move {
@@ -1094,6 +1119,7 @@ impl App {
exportar_pdf(
&gerador,
&resultado,
&notas,
&nome_arquivo,
nome_layout.as_deref(),
&caminho,
@@ -1215,6 +1241,78 @@ impl App {
// ─── 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(
caminho: PathBuf,
tipo: TipoArquivo,
@@ -1222,8 +1320,20 @@ fn executar_importacao_sync(
layout_xlsx: LayoutXlsx,
) -> ResultadoPendente {
let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).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(),
}),
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 {
-3
View File
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal {
EstadoModal::Informacao { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
}
EstadoModal::Aviso { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
}
+2 -10
View File
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
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(
row((0..num_colunas)
.map(|i| {
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.spacing(0),
)
.width(Length::Shrink)
.style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE_2.into()),
border: Border {
color: t::BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
.style(t::cabecalho_tabela);
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
// Linhas alternadas: SURFACE e SURFACE_2 levemente
+7 -5
View File
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
use crate::domain::entities::{
chave_serie::ChaveSerie,
layout::{Layout, LayoutXlsx},
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
};
use crate::domain::errors::ResumoAvisos;
use rusqlite::Connection;
@@ -73,12 +73,11 @@ pub enum Message {
ExecutarImportacao,
ReanalisarArquivo,
ConfirmarExpansaoFaltantes,
CancelarExpansao,
NovaAnalise,
// --- Resultado ---
PaginaFaltantesAlterada(usize),
PaginaDuplicatasAlterada(usize),
PaginaFaltantesAlterada(ChaveSerie, usize),
PaginaDuplicatasAlterada(ChaveSerie, usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie),
@@ -103,6 +102,9 @@ pub enum Message {
// --- Sem operação (used as fallback) ---
Noop,
// --- Animação ---
SpinnerTick,
}
/// 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.
AguardandoConfirmacao {
pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, u64)>,
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
avisos: ResumoAvisos,
notas: Vec<Nota>,
},
+69 -13
View File
@@ -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_totais = view_totais(resultado);
let conteudo = column![
let mut col_principal = column![
row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo,
stat_row,
controle_pagina,
secao_faltantes,
secao_duplicatas,
secao_totais,
]
.spacing(16)
.padding(20)
.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)
.height(Length::Fill)
.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);
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)
.padding(16)
.width(Length::Fill)
@@ -145,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
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_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64;
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
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 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(
pagina,
total_paginas,
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
Message::PaginaFaltantesAlterada(pagina + 1),
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(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);
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 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(
pagina,
total_paginas,
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
Message::PaginaDuplicatasAlterada(pagina + 1),
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
));
}