Compare commits

..
2 Commits
15 changed files with 294 additions and 202 deletions
+184
View File
@@ -0,0 +1,184 @@
# Análise — Duplicidade de Layouts ao Salvar
**Data:** 03/03/2026
**Status:** Análise / Proposta de Correção
---
## 1. Problema Identificado
Ao clicar em **"💾 Salvar"** na tela de gerenciamento de layouts, o sistema permite salvar um layout com um nome já existente, criando entradas duplicadas no banco sem qualquer aviso ou confirmação ao usuário.
O PRD (RF03) exige que, ao existir conflito de nome, o sistema pergunte ao usuário se deseja **sobrescrever** o existente ou **salvar com novo nome**. Esse comportamento está **implementado apenas no fluxo de importação JSON**, mas está **ausente no fluxo de salvar layout atual**.
---
## 2. Raiz do Problema — Análise por Camada
### 2.1 Camada Infrastructure — `sqlite/migrations.rs`
A tabela `layouts` foi criada na `migration_v1` **sem nenhuma constraint `UNIQUE` no campo `nome`**:
```sql
-- migration_v1 (src/infrastructure/sqlite/migrations.rs)
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, -- ← sem UNIQUE
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
...
);
```
O banco de dados não impede, por si só, a inserção de registros com o mesmo nome.
---
### 2.2 Camada Infrastructure — `sqlite/layout_repository.rs`
A função `salvar` executa um `INSERT` direto, sem qualquer verificação prévia:
```rust
// src/infrastructure/sqlite/layout_repository.rs — fn salvar()
conn.execute(
"INSERT INTO layouts (nome, tipo, ...) VALUES (?1, 'csv', ...)",
params![nome, ...],
)?;
```
Existe a função `existe_nome` no mesmo arquivo, que verifica duplicidade por consulta, mas ela **não é chamada** pelo fluxo de salvar layout atual — apenas pelo fluxo de importação JSON.
---
### 2.3 Camada Application — `usecases/layouts.rs`
A função `salvar_layout` só verifica se o `id` está presente:
```rust
// src/application/usecases/layouts.rs — fn salvar_layout()
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout) // atualiza se tem id
} else {
layout_repository::salvar(conn, layout) // insere direto sem checar nome
}
```
A verificação de nome duplicado (`existe_nome`) **está implementada em `importar_layout_json`**, mas **não foi replicada** em `salvar_layout`.
---
### 2.4 Camada UI — `ui/screens/layouts.rs`
A função `salvar_layout_atual` monta o layout sempre com `id: None` e chama `salvar_layout` sem tratar o caso de conflito de nome:
```rust
// src/ui/screens/layouts.rs — fn salvar_layout_atual()
let layout = match app.tipo_arquivo_atual.clone() {
TipoArquivo::Csv => Layout::Csv {
id: None, // ← sempre None, nunca verifica se nome já existe
nome: app.nome_layout_atual.trim().to_string(),
config: app.layout_csv_atual.clone(),
},
...
};
match salvar_layout(conn, &layout) {
Ok(_) => { ... } // ← não há tratamento para conflito de nome
Err(e) => { ... }
}
```
O fluxo de resolução de conflito (sobrescrever / salvar com novo nome) **existe apenas em `importar_json`**, que trata `ErroLayout::NomeConflitante`, mas mesmo nesse caso o tratamento está incompleto (há um `TODO` no código).
---
## 3. Resumo dos Pontos Falhos
| # | Localização | Problema |
|---|-------------|----------|
| 1 | `sqlite/migrations.rs``migration_v1` | Nenhum `UNIQUE` constraint em `nome` na tabela `layouts` |
| 2 | `usecases/layouts.rs``salvar_layout` | Não consulta `existe_nome` antes de inserir |
| 3 | `domain/errors.rs` | Falta variante de erro para duplicidade no fluxo de salvar (apenas existe em importar JSON) |
| 4 | `ui/screens/layouts.rs``salvar_layout_atual` | Não trata conflito de nome; não exibe opção de sobrescrever ou renomear |
| 5 | `ui/screens/layouts.rs``importar_json` | Tratamento de `NomeConflitante` incompleto (TODO pendente) |
---
## 4. Proposta de Correção
### 4.1 Migration v2 — Adicionar `UNIQUE` em `nome`
**Arquivo:** `src/infrastructure/sqlite/migrations.rs`
Adicionar uma segunda migration que cria uma `UNIQUE` constraint na coluna `nome`.
Atualizar `VERSAO_SCHEMA_ATUAL` de `1` para `2` e chamar `migration_v2` no bloco condicional de aplicação de migrations.
```
-- migration_v2
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
```
> Usar um índice único em vez de recriar a tabela é mais seguro, pois preserva dados já existentes. Bancos que já possuem duplicatas precisariam de tratamento antes da migração (ex.: renomear automaticamente os conflitantes com sufixo numérico).
---
### 4.2 Retornar Erro Tipado de Conflito em `salvar_layout`
**Arquivo:** `src/application/usecases/layouts.rs` — função `salvar_layout`
Antes da inserção, verificar se já existe layout com o mesmo nome usando `layout_repository::existe_nome`. Retornar um erro tipado (ex.: `ErroLayout::NomeConflitante`) em vez de uma `String` quando o nome já existir e nenhum `id` foi fornecido.
Fluxo esperado:
```
1. layout.id() é None (novo layout)
2. Consultar existe_nome(conn, layout.nome())
3. Se existir → retornar Err(ErroLayout::NomeConflitante(nome))
4. Se não existir → prosseguir com INSERT
```
A assinatura da função deverá mudar de `Result<i64, String>` para `Result<i64, ErroLayout>` para que o chamador consiga distinguir o tipo de erro.
---
### 4.3 Tratar Conflito na UI — Fluxo de Sobrescrever ou Renomear
**Arquivo:** `src/ui/screens/layouts.rs` — função `salvar_layout_atual`
Ao receber `Err(ErroLayout::NomeConflitante(nome))`, a UI deve exibir um modal de confirmação com duas opções:
- **Sobrescrever** — buscar o `id` do layout existente pelo nome, montar o layout com esse `id` e chamar `salvar_layout` novamente (o use case irá para o branch `atualizar`)
- **Cancelar** — descartar a operação sem salvar
> A opção "Salvar com novo nome" pode ser considerada, porém aumenta a complexidade da tela. Para a correção mínima, sobrescrever ou cancelar já atende ao PRD.
O estado da `App` pode precisar de um campo intermediário para guardar o layout pendente de confirmação entre frames do egui, ou se o sistema já possui um mecanismo de `AcaoModal`, criar uma variante `AcaoModal::ConfirmarSobrescritaLayout(Layout)` e processar no handler de ações do modal.
---
### 4.4 Completar o `TODO` em `importar_json`
**Arquivo:** `src/ui/screens/layouts.rs` — função `importar_json`
O bloco `Err(ErroLayout::NomeConflitante(nome))` contém um `TODO` que não implementa o fluxo completo. Após a correção do item 4.3, a mesma lógica de modal de confirmação deve ser reutilizada aqui, chamando `importar_layout_json` com `sobrescrever_se_existir: true` quando o usuário confirmar.
---
## 5. Ordem de Implementação Sugerida
```
1. migration_v2 (constraint UNIQUE) ............ infrastructure/sqlite/migrations.rs
2. Erro tipado em salvar_layout ................. application/usecases/layouts.rs
3. Modal de conflito na UI (salvar atual) ....... ui/screens/layouts.rs
4. Modal de conflito na UI (importar JSON) ...... ui/screens/layouts.rs (completar TODO)
```
---
## 6. Impacto da Correção
| Aspecto | Antes | Depois |
|---------|-------|--------|
| Layouts duplicados no banco | Possível | Bloqueado pelo banco (UNIQUE) |
| Feedback ao usuário | Nenhum — salva silenciosamente | Modal com escolha: sobrescrever ou cancelar |
| Conformidade com PRD (RF03) | Parcial (apenas importação JSON) | Completa em todos os fluxos |
| Importação JSON com conflito | TODO incompleto | Resolvido com mesma lógica |
+10 -16
View File
@@ -1,11 +1,11 @@
use crate::domain::{ use crate::domain::{
entities::{ entities::{
nota::Nota, nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
}, },
services::{ services::{
detector_duplicidade::duplicidades_por_serie, detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES}, detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
}, },
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -55,9 +55,7 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
/// Verifica se alguma série excede o limite de faltantes. /// Verifica se alguma série excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação. /// Retorna lista de séries que precisam de confirmação.
pub fn series_com_intervalo_excessivo( pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(String, u64)> {
pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> {
pre.intervalos_por_serie pre.intervalos_por_serie
.iter() .iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
@@ -89,19 +87,18 @@ pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAn
} }
} }
/// Executa análise completa sem verificar limites (use case simplificado).
/// Útil quando o caller já confirmou ou sabe que não há intervalos excessivos.
pub fn executar_analise(notas: &[Nota]) -> ResultadoAnalise {
let pre = pre_analisar(notas);
expandir_analise(pre, notas)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec; 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 { fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, None) Nota::new(numero, serie.to_string(), valor, None)
} }
@@ -133,10 +130,7 @@ mod tests {
#[test] #[test]
fn pre_analise_detecta_intervalo_excessivo() { fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![ let notas = vec![nota(1, "001", None), nota(20_000, "001", None)];
nota(1, "001", None),
nota(20_000, "001", None),
];
let pre = pre_analisar(&notas); let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre); let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1); assert_eq!(excessivos.len(), 1);
+27 -14
View File
@@ -1,4 +1,4 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{LayoutCsv, LayoutXlsx};
use crate::domain::entities::nota::Nota; use crate::domain::entities::nota::Nota;
use crate::domain::entities::serie::validar_serie; use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos}; use crate::domain::errors::{ErroArquivo, ResumoAvisos};
@@ -24,7 +24,10 @@ pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
} }
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados. /// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_csv(
caminho: &Path,
config: &LayoutCsv,
) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv( let resultado = csv_reader::ler_csv(
caminho, caminho,
config.delimitador as u8, config.delimitador as u8,
@@ -46,17 +49,21 @@ pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImpor
} }
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados. /// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> { pub fn importar_xlsx(
caminho: &Path,
config: &LayoutXlsx,
) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado // Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = { let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero) let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero).ok_or_else(|| {
.ok_or_else(|| ErroArquivo::ErroLeitura(format!( ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'", config.pos_numero "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 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) coord_num.linha.min(coord_ser.linha)
}; };
@@ -113,7 +120,9 @@ fn mapear_linhas_para_notas(
Some(s) if !s.trim().is_empty() => s.trim().to_string(), Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => { _ => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Numero ausente", linha_num));
continue; continue;
} }
}; };
@@ -123,7 +132,9 @@ fn mapear_linhas_para_notas(
Ok(n) => n, Ok(n) => n,
Err(msg) => { Err(msg) => {
avisos.numeros_invalidos += 1; avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg)); avisos
.detalhes
.push(format!("Linha {}: {}", linha_num, msg));
continue; continue;
} }
}; };
@@ -133,7 +144,9 @@ fn mapear_linhas_para_notas(
Some(s) => s.trim().to_string(), Some(s) => s.trim().to_string(),
None => { None => {
avisos.series_invalidas += 1; avisos.series_invalidas += 1;
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num)); avisos
.detalhes
.push(format!("Linha {}: campo Serie ausente", linha_num));
continue; continue;
} }
}; };
+6 -14
View File
@@ -2,7 +2,7 @@ use crate::domain::{
entities::layout::{Layout, LayoutJson}, entities::layout::{Layout, LayoutJson},
errors::ErroLayout, errors::ErroLayout,
}; };
use crate::infrastructure::sqlite::{layout_repository, migrations}; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; use rusqlite::Connection;
/// Salva um layout no banco de dados. /// Salva um layout no banco de dados.
@@ -14,12 +14,10 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String>
} }
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout) layout_repository::atualizar(conn, layout).map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
Ok(id) Ok(id)
} else { } else {
layout_repository::salvar(conn, layout) layout_repository::salvar(conn, layout).map_err(|e| e.to_string())
.map_err(|e| e.to_string())
} }
} }
@@ -28,11 +26,6 @@ pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
layout_repository::listar(conn).map_err(|e| e.to_string()) layout_repository::listar(conn).map_err(|e| e.to_string())
} }
/// Carrega um layout pelo id.
pub fn carregar_layout(conn: &Connection, id: i64) -> Result<Option<Layout>, String> {
layout_repository::buscar_por_id(conn, id).map_err(|e| e.to_string())
}
/// Exclui um layout pelo id. /// Exclui um layout pelo id.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> { pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string()) layout_repository::excluir(conn, id).map_err(|e| e.to_string())
@@ -61,8 +54,8 @@ pub fn importar_layout_json(
novo_nome: Option<&str>, novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> { ) -> Result<i64, ErroLayout> {
// Deserializar // Deserializar
let json_repr: LayoutJson = serde_json::from_str(json) let json_repr: LayoutJson =
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; serde_json::from_str(json).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?; let mut layout = Layout::try_from(json_repr)?;
@@ -105,6 +98,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // Inserir novo
layout_repository::salvar(conn, &layout) layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
} }
-50
View File
@@ -1,13 +1,5 @@
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error, Clone)]
pub enum ErroNumero {
#[error("Número zero não é válido para uma nota fiscal")]
Zero,
#[error("Valor não numérico: '{0}'")]
NaoNumerico(String),
}
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
pub enum ErroSerie { pub enum ErroSerie {
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")] #[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
@@ -40,52 +32,10 @@ pub enum ErroArquivo {
TamanhoExcedido(u64), TamanhoExcedido(u64),
#[error("Arquivo corrompido ou ilegível: {0}")] #[error("Arquivo corrompido ou ilegível: {0}")]
Corrompido(String), Corrompido(String),
#[error("Formato não suportado: {0}")]
FormatoNaoSuportado(String),
#[error("Erro de leitura: {0}")] #[error("Erro de leitura: {0}")]
ErroLeitura(String), ErroLeitura(String),
} }
#[derive(Debug, Error)]
pub enum ErroAnalise {
#[error("Intervalo de faltantes muito grande para a série '{serie}': {intervalo} registros")]
IntervaloMuitoGrande { serie: String, intervalo: u64 },
#[error("Campo obrigatório não mapeado: {0}")]
CampoObrigatorioNaoMapeado(String),
#[error("Índice de coluna inválido: campo '{campo}', índice {indice}")]
IndiceInvalido { campo: String, indice: usize },
#[error("Dois campos mapeados para o mesmo índice: {0}")]
IndicesDuplicados(String),
}
/// Aviso coletado durante a importação, para ser exibido de forma consolidada ao usuário.
#[derive(Debug, Clone)]
pub struct AvisoImportacao {
pub categoria: CategoriaAviso,
pub mensagem: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CategoriaAviso {
LinhaMalformada,
NumeroInvalido,
SerieInvalida,
ValorInvalido,
RegistroDescartado,
}
impl std::fmt::Display for CategoriaAviso {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CategoriaAviso::LinhaMalformada => write!(f, "Linhas malformadas"),
CategoriaAviso::NumeroInvalido => write!(f, "Valores de Numero inválidos"),
CategoriaAviso::SerieInvalida => write!(f, "Registros com Série inválida"),
CategoriaAviso::ValorInvalido => write!(f, "Valores monetários inválidos"),
CategoriaAviso::RegistroDescartado => write!(f, "Registros descartados"),
}
}
}
/// Resumo consolidado de avisos para exibição em um único modal. /// Resumo consolidado de avisos para exibição em um único modal.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct ResumoAvisos { pub struct ResumoAvisos {
+1 -1
View File
@@ -3,7 +3,7 @@ use crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use genpdf::{ use genpdf::{
elements::{Break, Paragraph}, elements::{Break, Paragraph},
fonts, style, Document, Element, SimplePageDecorator, fonts, style, Document, SimplePageDecorator,
}; };
use std::path::Path; use std::path::Path;
+1 -3
View File
@@ -1,8 +1,6 @@
use rusqlite::Connection; use rusqlite::Connection;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
/// Determina o caminho do banco de dados conforme o sistema operacional. /// Determina o caminho do banco de dados conforme o sistema operacional.
pub fn caminho_banco() -> PathBuf { pub fn caminho_banco() -> PathBuf {
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from(".")); let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
@@ -33,7 +31,7 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
// Testar se o banco funciona com uma query simples // Testar se o banco funciona com uma query simples
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("SELECT 1;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
drop(conn); drop(conn);
let bak = caminho.with_extension("db.bak"); let bak = caminho.with_extension("db.bak");
@@ -145,55 +145,6 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
layouts layouts
} }
/// Busca um layout pelo id.
pub fn buscar_por_id(conn: &Connection, id: i64) -> Result<Option<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
FROM layouts WHERE id = ?1",
)?;
let mut results = stmt.query_map([id], |row| {
let id: i64 = row.get(0)?;
let nome: String = row.get(1)?;
let tipo: String = row.get(2)?;
if tipo == "csv" {
let delim_str: String = row.get(3)?;
let delimitador = delim_str.chars().next().unwrap_or(';');
Ok(Layout::Csv {
id: Some(id),
nome,
config: LayoutCsv {
delimitador,
encoding: row.get(4)?,
linha_cabecalho: row.get::<_, i64>(5)? as usize,
indice_numero: row.get::<_, i64>(6)? as usize,
indice_serie: row.get::<_, i64>(7)? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
},
})
} else {
Ok(Layout::Xlsx {
id: Some(id),
nome,
config: LayoutXlsx {
aba: row.get(10)?,
pos_numero: row.get(11)?,
pos_serie: row.get(12)?,
pos_valor: row.get(13)?,
pos_data: row.get(14)?,
},
})
}
})?;
results.next().transpose()
}
/// Remove um layout pelo id. /// Remove um layout pelo id.
pub fn excluir(conn: &Connection, id: i64) -> Result<()> { pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?; conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
+3 -3
View File
@@ -21,12 +21,12 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
) )
.unwrap_or(0); .unwrap_or(0);
if versao_atual < 1 { if versao_atual < VERSAO_SCHEMA_ATUAL {
migration_v1(conn)?; migration_v1(conn)?;
if versao_atual == 0 { if versao_atual == 0 {
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?; conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
} else { } else {
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?; conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
} }
} }
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn ler_xlsx(
.worksheet_range(nome_aba) .worksheet_range(nome_aba)
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?; .map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
let mut avisos = ResumoAvisos::default(); let avisos = ResumoAvisos::default();
let mut linhas: Vec<Vec<String>> = Vec::new(); let mut linhas: Vec<Vec<String>> = Vec::new();
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize; let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
+32 -30
View File
@@ -1,10 +1,6 @@
use crate::application::usecases::{ use crate::application::usecases::{
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo}, executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao}, layouts::{excluir_layout, listar_layouts, salvar_layout},
layouts::{
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
listar_layouts, salvar_layout,
},
}; };
use crate::domain::{ use crate::domain::{
entities::{ entities::{
@@ -14,10 +10,7 @@ use crate::domain::{
}, },
errors::ResumoAvisos, errors::ResumoAvisos,
}; };
use crate::infrastructure::{ use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
pdf_generator::GenpdfGenerator,
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
};
use egui::Context; use egui::Context;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
@@ -34,10 +27,7 @@ pub enum EstadoApp {
/// Resultado pronto para exibição. /// Resultado pronto para exibição.
ExibindoResultado(ResultadoAnalise), ExibindoResultado(ResultadoAnalise),
/// Aguardando confirmação do usuário para expandir faltantes. /// Aguardando confirmação do usuário para expandir faltantes.
ConfirmandoIntervalo { ConfirmandoIntervalo { pre: ResultadoPreAnalise },
pre: ResultadoPreAnalise,
series_excessivas: Vec<(String, u64)>,
},
/// Gerenciamento de layouts. /// Gerenciamento de layouts.
GerenciandoLayouts, GerenciandoLayouts,
/// Análise em execução em background (thread separada). /// Análise em execução em background (thread separada).
@@ -72,6 +62,7 @@ pub enum TipoModal {
pub enum AcaoModal { pub enum AcaoModal {
ConfirmarExpansaoFaltantes, ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64), ConfirmarExclusaoLayout(i64),
#[allow(dead_code)]
SobrescreverLayout, SobrescreverLayout,
ConfirmarNovaAnalise, ConfirmarNovaAnalise,
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome. /// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
@@ -396,12 +387,20 @@ impl App {
/// Processa o resultado recebido do canal de análise em background. /// Processa o resultado recebido do canal de análise em background.
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) { pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
match resultado { match resultado {
ResultadoPendente::Concluido { resultado, avisos, notas } => { ResultadoPendente::Concluido {
resultado,
avisos,
notas,
} => {
if let Some(n) = notas { if let Some(n) = notas {
self.notas_importadas = n; self.notas_importadas = n;
} }
if let Some(av) = &avisos { if let Some(av) = &avisos {
self.avisos_importacao = if av.tem_avisos() { Some(av.clone()) } else { None }; self.avisos_importacao = if av.tem_avisos() {
Some(av.clone())
} else {
None
};
} }
self.estado = EstadoApp::ExibindoResultado(resultado); self.estado = EstadoApp::ExibindoResultado(resultado);
if let Some(av) = &self.avisos_importacao.clone() { if let Some(av) = &self.avisos_importacao.clone() {
@@ -418,18 +417,22 @@ impl App {
notas, notas,
} => { } => {
self.notas_importadas = notas; self.notas_importadas = notas;
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(|(serie, count)| { .map(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count) format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
self.estado = EstadoApp::ConfirmandoIntervalo { self.estado = EstadoApp::ConfirmandoIntervalo { pre };
pre,
series_excessivas,
};
self.exibir_confirmacao( self.exibir_confirmacao(
"Intervalo muito grande", "Intervalo muito grande",
format!( format!(
@@ -464,16 +467,16 @@ impl App {
let msg = excessivos let msg = excessivos
.iter() .iter()
.map(|(serie, count)| { .map(|(serie, count)| {
format!("Série {}: intervalo de {} faltantes detectado", serie, count) format!(
"Série {}: intervalo de {} faltantes detectado",
serie, count
)
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let acao = AcaoModal::ConfirmarExpansaoFaltantes; let acao = AcaoModal::ConfirmarExpansaoFaltantes;
self.estado = EstadoApp::ConfirmandoIntervalo { self.estado = EstadoApp::ConfirmandoIntervalo { pre };
pre,
series_excessivas: excessivos,
};
self.exibir_confirmacao( self.exibir_confirmacao(
"Intervalo muito grande", "Intervalo muito grande",
format!( format!(
@@ -502,7 +505,8 @@ impl App {
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| { egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
ui.add_space(4.0); ui.add_space(4.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate() { for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate()
{
let n = i + 1; let n = i + 1;
let texto = egui::RichText::new(*label); let texto = egui::RichText::new(*label);
if n == passo_ativo { if n == passo_ativo {
@@ -552,8 +556,7 @@ impl eframe::App for App {
let tela_id = self.tela_atual(); let tela_id = self.tela_atual();
// Renderizar a tela atual // Renderizar a tela atual
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| match tela_id {
match tela_id {
0 => crate::ui::screens::import::renderizar(ui, ctx, self), 0 => crate::ui::screens::import::renderizar(ui, ctx, self),
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self), 1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self), 2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
@@ -570,7 +573,6 @@ impl eframe::App for App {
}); });
} }
_ => {} _ => {}
}
}); });
} }
} }
+10
View File
@@ -100,6 +100,16 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
} }
}); });
if valido && !app.notas_importadas.is_empty() {
if ui
.button("🔄 Reanalisar")
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
.clicked()
{
app.executar_analise();
}
}
if ui.button("💾 Salvar como layout...").clicked() { if ui.button("💾 Salvar como layout...").clicked() {
app.exibir_modal_salvar_layout(); app.exibir_modal_salvar_layout();
} }
+1 -3
View File
@@ -1,6 +1,4 @@
use crate::application::usecases::importar_arquivo::{ use crate::application::usecases::importar_arquivo::listar_abas_xlsx;
importar_csv, importar_xlsx, listar_abas_xlsx,
};
use crate::domain::entities::layout::TipoArquivo; use crate::domain::entities::layout::TipoArquivo;
use crate::ui::app::{App, EstadoApp}; use crate::ui::app::{App, EstadoApp};
use egui::{Context, Ui}; use egui::{Context, Ui};
+2 -2
View File
@@ -1,7 +1,7 @@
use crate::application::usecases::layouts::{ use crate::application::usecases::layouts::{
exportar_layout_json, importar_layout_json, salvar_layout, exportar_layout_json, importar_layout_json, salvar_layout,
}; };
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo}; use crate::domain::entities::layout::{Layout, TipoArquivo};
use crate::domain::errors::ErroLayout; use crate::domain::errors::ErroLayout;
use crate::ui::app::{AcaoModal, App, EstadoApp}; use crate::ui::app::{AcaoModal, App, EstadoApp};
use egui::{Context, Ui}; use egui::{Context, Ui};
@@ -83,7 +83,7 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
fn renderizar_secao_layouts( fn renderizar_secao_layouts(
ui: &mut Ui, ui: &mut Ui,
ctx: &Context, _ctx: &Context,
app: &mut App, app: &mut App,
titulo: &str, titulo: &str,
layouts: &[Layout], layouts: &[Layout],
+1 -1
View File
@@ -10,7 +10,7 @@ use egui::{Context, Ui};
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000]; const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
/// Renderiza a tela de resultados. /// Renderiza a tela de resultados.
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) { pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
// Extrair resultado do estado (sem mover) // Extrair resultado do estado (sem mover)
let resultado = match &app.estado { let resultado = match &app.estado {
EstadoApp::ExibindoResultado(r) => r.clone(), EstadoApp::ExibindoResultado(r) => r.clone(),