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:
@@ -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(¬as);
|
||||
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(¬as);
|
||||
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(¬as);
|
||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||
assert_eq!(excessivos.len(), 1);
|
||||
assert_eq!(excessivos[0].0, "001");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod executar_analise;
|
||||
pub mod exportar_pdf;
|
||||
pub mod importar_arquivo;
|
||||
pub mod layouts;
|
||||
Reference in New Issue
Block a user