feat: implement main application structure with UI and layout management

- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs.
- Created module structure for UI components in `src/ui/mod.rs`.
- Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`.
- Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files.
- Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts.
- Created result display screen in `src/ui/screens/resultado.rs` to show analysis results.
- Introduced modular organization for screens in `src/ui/screens/mod.rs`.
This commit is contained in:
2026-03-02 22:14:02 -03:00
parent 31cd94907a
commit e64988a139
36 changed files with 9166 additions and 2 deletions
@@ -0,0 +1,145 @@
use crate::domain::{
entities::{
nota::Nota,
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
},
services::{
detector_duplicidade::duplicidades_por_serie,
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
},
};
use rust_decimal::Decimal;
use std::collections::HashMap;
/// Executa a pré-análise: calcula intervalos e duplicatas, sem expandir faltantes.
/// O caller deve verificar se algum intervalo excede `LIMITE_FALTANTES` e,
/// se sim, exibir confirmação ao usuário antes de chamar `expandir_analise`.
pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
// Agrupar por série
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
}
let mut intervalos_por_serie = HashMap::new();
let mut soma_total = Decimal::ZERO;
let mut soma_por_serie: HashMap<String, Decimal> = HashMap::new();
let mut total_por_serie: HashMap<String, usize> = HashMap::new();
for (serie, notas_serie) in &por_serie {
// Somar valores
for nota in notas_serie.iter() {
if let Some(v) = nota.valor {
soma_total += v;
*soma_por_serie.entry(serie.clone()).or_insert(Decimal::ZERO) += v;
}
}
*total_por_serie.entry(serie.clone()).or_insert(0) += notas_serie.len();
// Calcular intervalo de faltantes
if let Some(intervalo) = calcular_intervalo(notas_serie) {
intervalos_por_serie.insert(serie.clone(), intervalo);
}
}
let duplicadas_por_serie = duplicidades_por_serie(notas);
ResultadoPreAnalise {
intervalos_por_serie,
duplicadas_por_serie,
soma_total,
soma_por_serie,
total_por_serie,
}
}
/// Verifica se alguma série excede o limite de faltantes.
/// Retorna lista de séries que precisam de confirmação.
pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(String, u64)> {
pre.intervalos_por_serie
.iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(serie, iv)| (serie.clone(), iv.contagem_faltantes))
.collect()
}
/// Expande a pré-análise para o resultado completo, materializando a lista de faltantes.
/// Deve ser chamado após confirmação do usuário (ou quando nenhum intervalo excede o limite).
pub fn expandir_analise(pre: ResultadoPreAnalise, notas: &[Nota]) -> ResultadoAnalise {
let mut por_serie: HashMap<String, Vec<&Nota>> = HashMap::new();
for nota in notas {
por_serie.entry(nota.serie.clone()).or_default().push(nota);
}
let mut faltantes_por_serie = HashMap::new();
for (serie, notas_serie) in &por_serie {
let faltantes = detectar_faltantes(notas_serie);
faltantes_por_serie.insert(serie.clone(), faltantes);
}
ResultadoAnalise {
faltantes_por_serie,
duplicadas_por_serie: pre.duplicadas_por_serie,
soma_total: pre.soma_total,
soma_por_serie: pre.soma_por_serie,
total_por_serie: pre.total_por_serie,
}
}
/// 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)]
mod tests {
use super::*;
use crate::domain::entities::nota::Nota;
use rust_decimal_macros::dec;
fn nota(numero: u64, serie: &str, valor: Option<rust_decimal::Decimal>) -> Nota {
Nota::new(numero, serie.to_string(), valor, None)
}
#[test]
fn analise_simples() {
let notas = vec![
nota(1, "001", Some(dec!(100.00))),
nota(2, "001", Some(dec!(200.00))),
nota(4, "001", Some(dec!(50.00))),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![3u64]);
assert_eq!(resultado.soma_total, dec!(350.00));
}
#[test]
fn analise_multiplas_series() {
let notas = vec![
nota(1, "001", None),
nota(3, "001", None),
nota(1, "002", None),
nota(2, "002", None),
];
let resultado = executar_analise(&notas);
assert_eq!(resultado.faltantes_por_serie["001"], vec![2u64]);
assert!(resultado.faltantes_por_serie["002"].is_empty());
}
#[test]
fn pre_analise_detecta_intervalo_excessivo() {
let notas = vec![
nota(1, "001", None),
nota(20_000, "001", None),
];
let pre = pre_analisar(&notas);
let excessivos = series_com_intervalo_excessivo(&pre);
assert_eq!(excessivos.len(), 1);
assert_eq!(excessivos[0].0, "001");
}
}
+23
View File
@@ -0,0 +1,23 @@
use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local;
use std::path::Path;
/// Exporta o resultado da análise para um arquivo PDF.
///
/// Depende da trait abstrata `PdfGenerator`, não de `genpdf` diretamente.
pub fn exportar_pdf(
gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise,
nome_arquivo: &str,
nome_layout: Option<&str>,
caminho_saida: &Path,
) -> Result<(), String> {
let meta = MetadadosRelatorio {
nome_arquivo: nome_arquivo.to_string(),
nome_layout: nome_layout.map(|s| s.to_string()),
gerado_em: Local::now(),
};
gerador.gerar(resultado, &meta, caminho_saida)
}
@@ -0,0 +1,230 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use crate::domain::entities::nota::Nota;
use crate::domain::entities::serie::validar_serie;
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
use crate::infrastructure::{csv_reader, xlsx_reader};
use chrono::NaiveDate;
use std::path::Path;
/// Resultado da importação de arquivo.
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
/// Resultado parcial após seleção de arquivo XLSX (antes de configurar campos).
pub struct InfoXlsx {
pub abas: Vec<String>,
}
/// Lista as abas de um arquivo XLSX para exibição ao usuário.
pub fn listar_abas_xlsx(caminho: &Path) -> Result<InfoXlsx, ErroArquivo> {
let abas = xlsx_reader::listar_abas(caminho)?;
Ok(InfoXlsx { abas })
}
/// Importa um arquivo CSV e retorna as notas válidas + avisos consolidados.
pub fn importar_csv(caminho: &Path, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo> {
let resultado = csv_reader::ler_csv(
caminho,
config.delimitador as u8,
&config.encoding,
config.linha_cabecalho,
)?;
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
config.indice_numero,
config.indice_serie,
config.indice_valor,
config.indice_data,
&mut avisos,
);
Ok(ResultadoImportacao { notas, avisos })
}
/// Importa um arquivo XLSX e retorna as notas válidas + avisos consolidados.
pub fn importar_xlsx(caminho: &Path, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo> {
// Determinar linha de início a partir de qualquer campo mapeado
let linha_inicio = {
let coord_num = xlsx_reader::parsear_letra_linha(&config.pos_numero)
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
"Posição de Numero inválida: '{}'", config.pos_numero
)))?;
let coord_ser = xlsx_reader::parsear_letra_linha(&config.pos_serie)
.ok_or_else(|| ErroArquivo::ErroLeitura(format!(
"Posição de Serie inválida: '{}'", config.pos_serie
)))?;
coord_num.linha.min(coord_ser.linha)
};
let resultado = xlsx_reader::ler_xlsx(caminho, &config.aba, linha_inicio)?;
// Calcular índices de coluna para cada campo
let col_numero = xlsx_reader::parsear_letra_linha(&config.pos_numero)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Numero inválida".to_string()))?;
let col_serie = xlsx_reader::parsear_letra_linha(&config.pos_serie)
.map(|c| c.coluna as usize)
.ok_or_else(|| ErroArquivo::ErroLeitura("Posição de Serie inválida".to_string()))?;
let col_valor = config
.pos_valor
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let col_data = config
.pos_data
.as_ref()
.and_then(|p| xlsx_reader::parsear_letra_linha(p))
.map(|c| c.coluna as usize);
let mut avisos = resultado.avisos;
let notas = mapear_linhas_para_notas(
&resultado.linhas,
col_numero,
col_serie,
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.
fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
idx_serie: usize,
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
) -> Vec<Nota> {
let mut notas = Vec::new();
for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1;
// Extrair número
let str_numero = match linha.get(idx_numero) {
Some(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => {
avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: campo Numero ausente", linha_num));
continue;
}
};
// Tentar extrair apenas dígitos se não for numérico
let numero = match parse_numero(&str_numero) {
Ok(n) => n,
Err(msg) => {
avisos.numeros_invalidos += 1;
avisos.detalhes.push(format!("Linha {}: {}", linha_num, msg));
continue;
}
};
// Extrair série
let str_serie = match linha.get(idx_serie) {
Some(s) => s.trim().to_string(),
None => {
avisos.series_invalidas += 1;
avisos.detalhes.push(format!("Linha {}: campo Serie ausente", linha_num));
continue;
}
};
let serie = match validar_serie(&str_serie) {
Ok(s) => s,
Err(_) => {
avisos.series_invalidas += 1;
avisos.detalhes.push(format!(
"Linha {}: Série inválida '{}'",
linha_num, str_serie
));
continue;
}
};
// Extrair valor (opcional)
let valor = if let Some(idx) = idx_valor {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => {
match crate::domain::services::parser_monetario::parse_valor(s.trim()) {
Ok(v) => Some(v),
Err(_) => {
avisos.valores_invalidos += 1;
avisos.detalhes.push(format!(
"Linha {}: valor monetário inválido '{}'",
linha_num, s
));
None
}
}
}
_ => None,
}
} else {
None
};
// Extrair data (opcional) — suporte a formatos dd/mm/aaaa e aaaa-mm-dd
let data = if let Some(idx) = idx_data {
match linha.get(idx) {
Some(s) if !s.trim().is_empty() => parse_data(s.trim()),
_ => None,
}
} else {
None
};
notas.push(Nota::new(numero, serie, valor, data));
}
notas
}
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
/// Rejeita zero.
fn parse_numero(s: &str) -> Result<u64, String> {
// Tentativa direta
if let Ok(n) = s.parse::<u64>() {
if n == 0 {
return Err(format!("Numero zero ('{}')", s));
}
return Ok(n);
}
// Tentar extrair apenas dígitos
let apenas_digitos: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if apenas_digitos.is_empty() {
return Err(format!("Numero não numérico: '{}'", s));
}
match apenas_digitos.parse::<u64>() {
Ok(0) => Err(format!("Numero zero após extração de dígitos: '{}'", s)),
Ok(n) => Ok(n),
Err(_) => Err(format!("Numero inválido: '{}'", s)),
}
}
/// Tenta parsear uma string de data em diversos formatos.
fn parse_data(s: &str) -> Option<NaiveDate> {
// dd/mm/aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d/%m/%Y") {
return Some(d);
}
// aaaa-mm-dd
if let Ok(d) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return Some(d);
}
// dd-mm-aaaa
if let Ok(d) = NaiveDate::parse_from_str(s, "%d-%m-%Y") {
return Some(d);
}
None
}
+110
View File
@@ -0,0 +1,110 @@
use crate::domain::{
entities::layout::{Layout, LayoutJson},
errors::ErroLayout,
};
use crate::infrastructure::sqlite::{layout_repository, migrations};
use rusqlite::Connection;
/// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, String> {
// Validar campos obrigatórios
if layout.nome().trim().is_empty() {
return Err("Nome do layout não pode ser vazio".to_string());
}
if let Some(id) = layout.id() {
layout_repository::atualizar(conn, layout)
.map_err(|e| e.to_string())?;
Ok(id)
} else {
layout_repository::salvar(conn, layout)
.map_err(|e| e.to_string())
}
}
/// Lista todos os layouts salvos.
pub fn listar_layouts(conn: &Connection) -> Result<Vec<Layout>, String> {
layout_repository::listar(conn).map_err(|e| e.to_string())
}
/// 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.
pub fn excluir_layout(conn: &Connection, id: i64) -> Result<(), String> {
layout_repository::excluir(conn, id).map_err(|e| e.to_string())
}
/// Exporta um layout para JSON.
/// Retorna o conteúdo JSON como string e o nome de arquivo sugerido.
pub fn exportar_layout_json(layout: &Layout) -> Result<(String, String), String> {
let json_repr = LayoutJson::from(layout);
let conteudo = serde_json::to_string_pretty(&json_repr)
.map_err(|e| format!("Erro ao serializar layout: {}", e))?;
let nome_arquivo = format!("{}.json", layout.nome());
Ok((conteudo, nome_arquivo))
}
/// Importa um layout a partir de uma string JSON.
///
/// # Comportamento de conflito de nome
/// Se `sobrescrever_se_existir` for `true` e o nome já existir no banco, sobrescreve.
/// Se `false` e o nome já existir, retorna `ErroLayout::NomeConflitante`.
pub fn importar_layout_json(
conn: &Connection,
json: &str,
sobrescrever_se_existir: bool,
novo_nome: Option<&str>,
) -> Result<i64, ErroLayout> {
// Deserializar
let json_repr: LayoutJson = serde_json::from_str(json)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let mut layout = Layout::try_from(json_repr)?;
// Aplicar novo nome se fornecido
if let Some(nome) = novo_nome {
match &mut layout {
Layout::Csv { nome: n, .. } => *n = nome.to_string(),
Layout::Xlsx { nome: n, .. } => *n = nome.to_string(),
}
}
// Verificar conflito de nome
let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
if existe {
if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
let id_existente = layouts_existentes
.iter()
.find(|l| l.nome() == nome_atual)
.and_then(|l| l.id());
if let Some(id) = id_existente {
match &mut layout {
Layout::Csv { id: i, .. } => *i = Some(id),
Layout::Xlsx { id: i, .. } => *i = Some(id),
}
layout_repository::atualizar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
return Ok(id);
}
} else {
return Err(ErroLayout::NomeConflitante(nome_atual));
}
}
// Inserir novo
layout_repository::salvar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
}
+4
View File
@@ -0,0 +1,4 @@
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;