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:
Generated
+5562
File diff suppressed because it is too large
Load Diff
+16
@@ -4,3 +4,19 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
eframe = "0.31"
|
||||||
|
egui = "0.31"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
csv = "1.3"
|
||||||
|
calamine = "0.26"
|
||||||
|
rust_decimal = { version = "1.36", features = ["serde"] }
|
||||||
|
rust_decimal_macros = "1.36"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
genpdf = "0.2"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
dirs = "5"
|
||||||
|
thiserror = "2"
|
||||||
|
regex = "1"
|
||||||
|
rfd = "0.15"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod usecases;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Tipo de arquivo suportado pelo sistema.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TipoArquivo {
|
||||||
|
Csv,
|
||||||
|
Xlsx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TipoArquivo {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
TipoArquivo::Csv => write!(f, "csv"),
|
||||||
|
TipoArquivo::Xlsx => write!(f, "xlsx"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configurações de um layout para arquivo CSV.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LayoutCsv {
|
||||||
|
/// Caractere delimitador: ',', ';' ou '\t'
|
||||||
|
pub delimitador: char,
|
||||||
|
/// Encoding do arquivo: "utf-8" ou "windows-1252"
|
||||||
|
pub encoding: String,
|
||||||
|
/// Número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
|
||||||
|
pub linha_cabecalho: usize,
|
||||||
|
/// Índice da coluna Numero (base 0)
|
||||||
|
pub indice_numero: usize,
|
||||||
|
/// Índice da coluna Serie (base 0)
|
||||||
|
pub indice_serie: usize,
|
||||||
|
/// Índice da coluna Valor (base 0, None se não mapeado)
|
||||||
|
pub indice_valor: Option<usize>,
|
||||||
|
/// Índice da coluna Data (base 0, None se não mapeado)
|
||||||
|
pub indice_data: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LayoutCsv {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
delimitador: ';',
|
||||||
|
encoding: "utf-8".to_string(),
|
||||||
|
linha_cabecalho: 1,
|
||||||
|
indice_numero: 0,
|
||||||
|
indice_serie: 1,
|
||||||
|
indice_valor: None,
|
||||||
|
indice_data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configurações de um layout para arquivo XLSX.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LayoutXlsx {
|
||||||
|
/// Nome ou índice (como string) da aba a ser processada
|
||||||
|
pub aba: String,
|
||||||
|
/// Posição inicial da coluna Numero no formato LetraLinha (ex: "D3")
|
||||||
|
pub pos_numero: String,
|
||||||
|
/// Posição inicial da coluna Serie no formato LetraLinha (ex: "B3")
|
||||||
|
pub pos_serie: String,
|
||||||
|
/// Posição inicial da coluna Valor (None se não mapeado)
|
||||||
|
pub pos_valor: Option<String>,
|
||||||
|
/// Posição inicial da coluna Data (None se não mapeado)
|
||||||
|
pub pos_data: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LayoutXlsx {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
aba: String::new(),
|
||||||
|
pos_numero: String::new(),
|
||||||
|
pos_serie: String::new(),
|
||||||
|
pos_valor: None,
|
||||||
|
pos_data: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout de configuração do usuário.
|
||||||
|
/// Cada layout é exclusivo de um tipo de arquivo (CSV ou XLSX).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Layout {
|
||||||
|
Csv {
|
||||||
|
id: Option<i64>,
|
||||||
|
nome: String,
|
||||||
|
config: LayoutCsv,
|
||||||
|
},
|
||||||
|
Xlsx {
|
||||||
|
id: Option<i64>,
|
||||||
|
nome: String,
|
||||||
|
config: LayoutXlsx,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn id(&self) -> Option<i64> {
|
||||||
|
match self {
|
||||||
|
Layout::Csv { id, .. } => *id,
|
||||||
|
Layout::Xlsx { id, .. } => *id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nome(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Layout::Csv { nome, .. } => nome,
|
||||||
|
Layout::Xlsx { nome, .. } => nome,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tipo(&self) -> TipoArquivo {
|
||||||
|
match self {
|
||||||
|
Layout::Csv { .. } => TipoArquivo::Csv,
|
||||||
|
Layout::Xlsx { .. } => TipoArquivo::Xlsx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Representação JSON de um layout (para importação/exportação).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "tipo", rename_all = "lowercase")]
|
||||||
|
pub enum LayoutJson {
|
||||||
|
Csv {
|
||||||
|
nome: String,
|
||||||
|
delimitador: String,
|
||||||
|
encoding: String,
|
||||||
|
linha_cabecalho: usize,
|
||||||
|
indice_numero: usize,
|
||||||
|
indice_serie: usize,
|
||||||
|
indice_valor: Option<usize>,
|
||||||
|
indice_data: Option<usize>,
|
||||||
|
},
|
||||||
|
Xlsx {
|
||||||
|
nome: String,
|
||||||
|
aba: String,
|
||||||
|
pos_numero: String,
|
||||||
|
pos_serie: String,
|
||||||
|
pos_valor: Option<String>,
|
||||||
|
pos_data: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<LayoutJson> for Layout {
|
||||||
|
type Error = crate::domain::errors::ErroLayout;
|
||||||
|
|
||||||
|
fn try_from(json: LayoutJson) -> Result<Self, Self::Error> {
|
||||||
|
match json {
|
||||||
|
LayoutJson::Csv {
|
||||||
|
nome,
|
||||||
|
delimitador,
|
||||||
|
encoding,
|
||||||
|
linha_cabecalho,
|
||||||
|
indice_numero,
|
||||||
|
indice_serie,
|
||||||
|
indice_valor,
|
||||||
|
indice_data,
|
||||||
|
} => {
|
||||||
|
if nome.trim().is_empty() {
|
||||||
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"nome".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let delim_char = delimitador.chars().next().ok_or_else(|| {
|
||||||
|
crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"delimitador".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Layout::Csv {
|
||||||
|
id: None,
|
||||||
|
nome,
|
||||||
|
config: LayoutCsv {
|
||||||
|
delimitador: delim_char,
|
||||||
|
encoding,
|
||||||
|
linha_cabecalho,
|
||||||
|
indice_numero,
|
||||||
|
indice_serie,
|
||||||
|
indice_valor,
|
||||||
|
indice_data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
LayoutJson::Xlsx {
|
||||||
|
nome,
|
||||||
|
aba,
|
||||||
|
pos_numero,
|
||||||
|
pos_serie,
|
||||||
|
pos_valor,
|
||||||
|
pos_data,
|
||||||
|
} => {
|
||||||
|
if nome.trim().is_empty() {
|
||||||
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"nome".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pos_numero.trim().is_empty() {
|
||||||
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"pos_numero".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if pos_serie.trim().is_empty() {
|
||||||
|
return Err(crate::domain::errors::ErroLayout::CampoObrigatorioAusente(
|
||||||
|
"pos_serie".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Layout::Xlsx {
|
||||||
|
id: None,
|
||||||
|
nome,
|
||||||
|
config: LayoutXlsx {
|
||||||
|
aba,
|
||||||
|
pos_numero,
|
||||||
|
pos_serie,
|
||||||
|
pos_valor,
|
||||||
|
pos_data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Layout> for LayoutJson {
|
||||||
|
fn from(layout: &Layout) -> Self {
|
||||||
|
match layout {
|
||||||
|
Layout::Csv { nome, config, .. } => LayoutJson::Csv {
|
||||||
|
nome: nome.clone(),
|
||||||
|
delimitador: config.delimitador.to_string(),
|
||||||
|
encoding: config.encoding.clone(),
|
||||||
|
linha_cabecalho: config.linha_cabecalho,
|
||||||
|
indice_numero: config.indice_numero,
|
||||||
|
indice_serie: config.indice_serie,
|
||||||
|
indice_valor: config.indice_valor,
|
||||||
|
indice_data: config.indice_data,
|
||||||
|
},
|
||||||
|
Layout::Xlsx { nome, config, .. } => LayoutJson::Xlsx {
|
||||||
|
nome: nome.clone(),
|
||||||
|
aba: config.aba.clone(),
|
||||||
|
pos_numero: config.pos_numero.clone(),
|
||||||
|
pos_serie: config.pos_serie.clone(),
|
||||||
|
pos_valor: config.pos_valor.clone(),
|
||||||
|
pos_data: config.pos_data.clone(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod layout;
|
||||||
|
pub mod nota;
|
||||||
|
pub mod resultado_analise;
|
||||||
|
pub mod serie;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
/// Representa uma nota fiscal com seus campos lógicos.
|
||||||
|
/// `numero + serie` é o identificador único de cada nota.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Nota {
|
||||||
|
/// Número incremental da nota. Armazenado como u64.
|
||||||
|
pub numero: u64,
|
||||||
|
/// Série da nota (1–3 dígitos numéricos). Ex: "001", "1".
|
||||||
|
pub serie: String,
|
||||||
|
/// Valor monetário da nota (opcional).
|
||||||
|
pub valor: Option<Decimal>,
|
||||||
|
/// Data de emissão da nota (opcional, exibida no PDF mas não usada em regras).
|
||||||
|
pub data: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nota {
|
||||||
|
pub fn new(
|
||||||
|
numero: u64,
|
||||||
|
serie: String,
|
||||||
|
valor: Option<Decimal>,
|
||||||
|
data: Option<NaiveDate>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
numero,
|
||||||
|
serie,
|
||||||
|
valor,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Resultado intermediário da análise, antes de materializar os faltantes.
|
||||||
|
/// Usado para verificar se algum intervalo excede 10.000 registros (RF04).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResultadoPreAnalise {
|
||||||
|
/// Mapa série → (minimo, maximo, contagem_faltantes)
|
||||||
|
pub intervalos_por_serie: HashMap<String, IntervaloSerie>,
|
||||||
|
/// Duplicatas já processadas (não dependem dos faltantes)
|
||||||
|
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
||||||
|
/// Somas já calculadas
|
||||||
|
pub soma_total: Decimal,
|
||||||
|
pub soma_por_serie: HashMap<String, Decimal>,
|
||||||
|
/// Total de notas processadas por série
|
||||||
|
pub total_por_serie: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intervalo de sequência de uma série.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IntervaloSerie {
|
||||||
|
pub minimo: u64,
|
||||||
|
pub maximo: u64,
|
||||||
|
pub contagem_faltantes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntervaloSerie {
|
||||||
|
pub fn excede_limite(&self, limite: u64) -> bool {
|
||||||
|
self.contagem_faltantes > limite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado completo da análise, com a lista materializada de faltantes.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResultadoAnalise {
|
||||||
|
/// Números faltantes por série (ordenados crescentemente)
|
||||||
|
pub faltantes_por_serie: HashMap<String, Vec<u64>>,
|
||||||
|
/// Duplicatas: mapa série → [(numero, contagem_ocorrencias)]
|
||||||
|
pub duplicadas_por_serie: HashMap<String, Vec<(u64, usize)>>,
|
||||||
|
/// Soma total de todos os valores
|
||||||
|
pub soma_total: Decimal,
|
||||||
|
/// Soma por série
|
||||||
|
pub soma_por_serie: HashMap<String, Decimal>,
|
||||||
|
/// Total de notas processadas por série
|
||||||
|
pub total_por_serie: HashMap<String, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResultadoAnalise {
|
||||||
|
/// Retorna true se não houver faltantes nem duplicatas.
|
||||||
|
pub fn sem_inconsistencias(&self) -> bool {
|
||||||
|
self.faltantes_por_serie.values().all(|v| v.is_empty())
|
||||||
|
&& self.duplicadas_por_serie.values().all(|v| v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retorna o total de notas faltantes somando todas as séries.
|
||||||
|
pub fn total_faltantes(&self) -> usize {
|
||||||
|
self.faltantes_por_serie.values().map(|v| v.len()).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retorna o total de grupos de duplicatas somando todas as séries.
|
||||||
|
pub fn total_duplicatas(&self) -> usize {
|
||||||
|
self.duplicadas_por_serie.values().map(|v| v.len()).sum()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::domain::errors::ErroSerie;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
static REGEX_SERIE: OnceLock<Regex> = OnceLock::new();
|
||||||
|
|
||||||
|
fn regex_serie() -> &'static Regex {
|
||||||
|
REGEX_SERIE.get_or_init(|| Regex::new(r"^[0-9]{1,3}$").expect("Regex de série inválida"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valida e normaliza uma série.
|
||||||
|
/// Retorna `Ok(serie_normalizada)` ou `Err(ErroSerie)`.
|
||||||
|
pub fn validar_serie(s: &str) -> Result<String, ErroSerie> {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(ErroSerie::Vazia);
|
||||||
|
}
|
||||||
|
if regex_serie().is_match(trimmed) {
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
} else {
|
||||||
|
Err(ErroSerie::Invalida(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_valida_simples() {
|
||||||
|
assert!(validar_serie("1").is_ok());
|
||||||
|
assert!(validar_serie("01").is_ok());
|
||||||
|
assert!(validar_serie("001").is_ok());
|
||||||
|
assert!(validar_serie("999").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_com_espacos_e_valida() {
|
||||||
|
assert!(validar_serie(" 1 ").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_invalida_letras() {
|
||||||
|
assert!(validar_serie("ABC").is_err());
|
||||||
|
assert!(validar_serie("1A").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_invalida_quatro_digitos() {
|
||||||
|
assert!(validar_serie("1234").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_vazia() {
|
||||||
|
assert!(matches!(validar_serie(""), Err(ErroSerie::Vazia)));
|
||||||
|
assert!(matches!(validar_serie(" "), Err(ErroSerie::Vazia)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
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)]
|
||||||
|
pub enum ErroSerie {
|
||||||
|
#[error("Série inválida: '{0}' (deve conter de 1 a 3 dígitos numéricos)")]
|
||||||
|
Invalida(String),
|
||||||
|
#[error("Série vazia")]
|
||||||
|
Vazia,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum ErroValor {
|
||||||
|
#[error("Valor negativo não é permitido: '{0}'")]
|
||||||
|
Negativo(String),
|
||||||
|
#[error("Valor não numérico: '{0}'")]
|
||||||
|
NaoNumerico(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum ErroLayout {
|
||||||
|
#[error("Campo obrigatório ausente: '{0}'")]
|
||||||
|
CampoObrigatorioAusente(String),
|
||||||
|
#[error("JSON malformado: {0}")]
|
||||||
|
JsonMalformado(String),
|
||||||
|
#[error("Conflito de nome: layout '{0}' já existe")]
|
||||||
|
NomeConflitante(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error, Clone)]
|
||||||
|
pub enum ErroArquivo {
|
||||||
|
#[error("Arquivo muito grande ({0} bytes). Limite: 50 MB")]
|
||||||
|
TamanhoExcedido(u64),
|
||||||
|
#[error("Arquivo corrompido ou ilegível: {0}")]
|
||||||
|
Corrompido(String),
|
||||||
|
#[error("Formato não suportado: {0}")]
|
||||||
|
FormatoNaoSuportado(String),
|
||||||
|
#[error("Erro de leitura: {0}")]
|
||||||
|
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.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct ResumoAvisos {
|
||||||
|
pub linhas_malformadas: usize,
|
||||||
|
pub numeros_invalidos: usize,
|
||||||
|
pub series_invalidas: usize,
|
||||||
|
pub valores_invalidos: usize,
|
||||||
|
pub detalhes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResumoAvisos {
|
||||||
|
pub fn tem_avisos(&self) -> bool {
|
||||||
|
self.linhas_malformadas > 0
|
||||||
|
|| self.numeros_invalidos > 0
|
||||||
|
|| self.series_invalidas > 0
|
||||||
|
|| self.valores_invalidos > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn linhas_para_exibir(&self) -> Vec<String> {
|
||||||
|
let mut linhas = Vec::new();
|
||||||
|
if self.linhas_malformadas > 0 {
|
||||||
|
linhas.push(format!(
|
||||||
|
"{} linhas descartadas por malformação",
|
||||||
|
self.linhas_malformadas
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.numeros_invalidos > 0 {
|
||||||
|
linhas.push(format!(
|
||||||
|
"{} valores de Numero inválidos convertidos ou descartados",
|
||||||
|
self.numeros_invalidos
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.series_invalidas > 0 {
|
||||||
|
linhas.push(format!(
|
||||||
|
"{} registros com Série inválida descartados",
|
||||||
|
self.series_invalidas
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.valores_invalidos > 0 {
|
||||||
|
linhas.push(format!(
|
||||||
|
"{} valores monetários inválidos descartados",
|
||||||
|
self.valores_invalidos
|
||||||
|
));
|
||||||
|
}
|
||||||
|
linhas.extend(self.detalhes.clone());
|
||||||
|
linhas
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod entities;
|
||||||
|
pub mod errors;
|
||||||
|
pub mod services;
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Detecta registros duplicados em uma lista de notas.
|
||||||
|
///
|
||||||
|
/// Retorna um mapa (numero, serie) → contagem de ocorrências,
|
||||||
|
/// contendo apenas grupos com mais de uma ocorrência.
|
||||||
|
pub fn detectar_duplicidades(notas: &[Nota]) -> HashMap<(u64, String), usize> {
|
||||||
|
let mut contagem: HashMap<(u64, String), usize> = HashMap::new();
|
||||||
|
|
||||||
|
for nota in notas {
|
||||||
|
*contagem
|
||||||
|
.entry((nota.numero, nota.serie.clone()))
|
||||||
|
.or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manter apenas os que têm mais de uma ocorrência
|
||||||
|
contagem.retain(|_, count| *count > 1);
|
||||||
|
contagem
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agrupa as duplicidades por série.
|
||||||
|
///
|
||||||
|
/// Retorna HashMap<serie, Vec<(numero, contagem)>>, ordenado por numero crescente.
|
||||||
|
pub fn duplicidades_por_serie(notas: &[Nota]) -> HashMap<String, Vec<(u64, usize)>> {
|
||||||
|
let raw = detectar_duplicidades(notas);
|
||||||
|
let mut result: HashMap<String, Vec<(u64, usize)>> = HashMap::new();
|
||||||
|
|
||||||
|
for ((numero, serie), contagem) in raw {
|
||||||
|
result.entry(serie).or_default().push((numero, contagem));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por numero dentro de cada série
|
||||||
|
for lista in result.values_mut() {
|
||||||
|
lista.sort_by_key(|(num, _)| *num);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
|
|
||||||
|
fn nota(numero: u64, serie: &str) -> Nota {
|
||||||
|
Nota::new(numero, serie.to_string(), None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sem_duplicatas() {
|
||||||
|
let notas = vec![nota(1, "001"), nota(2, "001"), nota(3, "001")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert!(dup.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn com_duplicata_simples() {
|
||||||
|
let notas = vec![nota(1, "001"), nota(1, "001"), nota(2, "001")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert_eq!(dup.get(&(1, "001".to_string())), Some(&2));
|
||||||
|
assert_eq!(dup.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicata_multiplas_ocorrencias() {
|
||||||
|
let notas = vec![nota(4, "001"), nota(4, "001"), nota(4, "001")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert_eq!(dup.get(&(4, "001".to_string())), Some(&3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesmo_numero_series_diferentes_nao_e_duplicata() {
|
||||||
|
// Número 1 em séries diferentes não é duplicata
|
||||||
|
let notas = vec![nota(1, "001"), nota(1, "002")];
|
||||||
|
let dup = detectar_duplicidades(¬as);
|
||||||
|
assert!(dup.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agrupamento_por_serie() {
|
||||||
|
let notas = vec![
|
||||||
|
nota(1, "001"),
|
||||||
|
nota(1, "001"),
|
||||||
|
nota(1, "002"),
|
||||||
|
nota(1, "002"),
|
||||||
|
nota(2, "001"),
|
||||||
|
nota(2, "001"),
|
||||||
|
];
|
||||||
|
let por_serie = duplicidades_por_serie(¬as);
|
||||||
|
// Série 001 deve ter notas 1 e 2 duplicadas
|
||||||
|
let serie001 = por_serie.get("001").unwrap();
|
||||||
|
assert_eq!(serie001.len(), 2);
|
||||||
|
assert_eq!(serie001[0], (1, 2));
|
||||||
|
assert_eq!(serie001[1], (2, 2));
|
||||||
|
// Série 002 deve ter nota 1 duplicada
|
||||||
|
let serie002 = por_serie.get("002").unwrap();
|
||||||
|
assert_eq!(serie002.len(), 1);
|
||||||
|
assert_eq!(serie002[0], (1, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
|
use crate::domain::entities::resultado_analise::IntervaloSerie;
|
||||||
|
|
||||||
|
/// Limite de faltantes por série antes de solicitar confirmação do usuário (RF04).
|
||||||
|
pub const LIMITE_FALTANTES: u64 = 10_000;
|
||||||
|
|
||||||
|
/// Calcula o intervalo de faltantes de uma lista de notas de uma mesma série,
|
||||||
|
/// sem materializar a lista completa.
|
||||||
|
///
|
||||||
|
/// Retorna `None` se não há notas.
|
||||||
|
pub fn calcular_intervalo(notas: &[&Nota]) -> Option<IntervaloSerie> {
|
||||||
|
if notas.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
|
||||||
|
numeros.sort_unstable();
|
||||||
|
numeros.dedup(); // Ignorar duplicatas no cálculo de sequência
|
||||||
|
|
||||||
|
let minimo = *numeros.first().unwrap();
|
||||||
|
let maximo = *numeros.last().unwrap();
|
||||||
|
|
||||||
|
// Contar faltantes de forma incremental
|
||||||
|
let mut faltantes: u64 = 0;
|
||||||
|
for w in numeros.windows(2) {
|
||||||
|
let a = w[0];
|
||||||
|
let b = w[1];
|
||||||
|
if b > a + 1 {
|
||||||
|
faltantes += b - a - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(IntervaloSerie {
|
||||||
|
minimo,
|
||||||
|
maximo,
|
||||||
|
contagem_faltantes: faltantes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Materializa a lista completa de números faltantes para uma série.
|
||||||
|
/// Deve ser chamado apenas após confirmação do usuário quando o intervalo
|
||||||
|
/// excede `LIMITE_FALTANTES`.
|
||||||
|
///
|
||||||
|
/// A lista é retornada em ordem crescente.
|
||||||
|
pub fn detectar_faltantes(notas: &[&Nota]) -> Vec<u64> {
|
||||||
|
if notas.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut numeros: Vec<u64> = notas.iter().map(|n| n.numero).collect();
|
||||||
|
numeros.sort_unstable();
|
||||||
|
numeros.dedup();
|
||||||
|
|
||||||
|
if numeros.len() <= 1 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut faltantes = Vec::new();
|
||||||
|
for w in numeros.windows(2) {
|
||||||
|
let a = w[0];
|
||||||
|
let b = w[1];
|
||||||
|
for faltante in (a + 1)..b {
|
||||||
|
faltantes.push(faltante);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
faltantes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn nota(numero: u64) -> Nota {
|
||||||
|
Nota::new(numero, "001".to_string(), None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sem_faltantes() {
|
||||||
|
let notas = vec![nota(1), nota(2), nota(3)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn com_faltante_simples() {
|
||||||
|
let notas = vec![nota(1), nota(2), nota(3), nota(5)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
assert_eq!(detectar_faltantes(&refs), vec![4u64]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn com_multiplos_faltantes() {
|
||||||
|
let notas = vec![nota(1), nota(5)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
assert_eq!(detectar_faltantes(&refs), vec![2u64, 3, 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serie_com_um_registro() {
|
||||||
|
let notas = vec![nota(7)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vazio() {
|
||||||
|
let refs: Vec<&Nota> = vec![];
|
||||||
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intervalo_correto() {
|
||||||
|
let notas = vec![nota(1), nota(2), nota(5)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
let intervalo = calcular_intervalo(&refs).unwrap();
|
||||||
|
assert_eq!(intervalo.minimo, 1);
|
||||||
|
assert_eq!(intervalo.maximo, 5);
|
||||||
|
assert_eq!(intervalo.contagem_faltantes, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intervalo_excede_limite() {
|
||||||
|
let notas = vec![nota(1), nota(20_000)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
let intervalo = calcular_intervalo(&refs).unwrap();
|
||||||
|
assert!(intervalo.excede_limite(LIMITE_FALTANTES));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicatas_ignoradas_no_calculo() {
|
||||||
|
// Duplicados não devem gerar faltantes falsos
|
||||||
|
let notas = vec![nota(1), nota(1), nota(2), nota(3)];
|
||||||
|
let refs: Vec<&Nota> = notas.iter().collect();
|
||||||
|
assert_eq!(detectar_faltantes(&refs), Vec::<u64>::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod detector_duplicidade;
|
||||||
|
pub mod detector_sequencia;
|
||||||
|
pub mod parser_monetario;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::domain::errors::ErroValor;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Faz o parsing de uma string de valor monetário para `Decimal`,
|
||||||
|
/// suportando formatos brasileiro (1.000,00) e americano (1,000.00).
|
||||||
|
///
|
||||||
|
/// Algoritmo conforme RF06:
|
||||||
|
/// - Regra 1: contém ponto E vírgula → último separador é o decimal
|
||||||
|
/// - Regra 2: apenas um separador + 2 dígitos após → decimal; 3 dígitos → milhar
|
||||||
|
/// - Regra 3: sem separador → número inteiro
|
||||||
|
///
|
||||||
|
/// Rejeita valores negativos.
|
||||||
|
pub fn parse_valor(input: &str) -> Result<Decimal, ErroValor> {
|
||||||
|
let s = input.trim();
|
||||||
|
|
||||||
|
// Rejeitar negativos
|
||||||
|
if s.starts_with('-') {
|
||||||
|
return Err(ErroValor::Negativo(s.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover prefixos/sufixos comuns (ex: "R$", espaços internos, "BRL")
|
||||||
|
let s = s.trim_start_matches("R$").trim_start_matches("r$").trim();
|
||||||
|
|
||||||
|
let tem_ponto = s.contains('.');
|
||||||
|
let tem_virgula = s.contains(',');
|
||||||
|
|
||||||
|
let normalizado = match (tem_ponto, tem_virgula) {
|
||||||
|
// Regra 1: tem ambos — último separador é o decimal
|
||||||
|
(true, true) => {
|
||||||
|
let pos_ponto = s.rfind('.').unwrap();
|
||||||
|
let pos_virgula = s.rfind(',').unwrap();
|
||||||
|
if pos_ponto > pos_virgula {
|
||||||
|
// Formato americano: 1,000.00 → ponto é decimal
|
||||||
|
s.replace(',', "")
|
||||||
|
} else {
|
||||||
|
// Formato brasileiro: 1.000,00 → vírgula é decimal
|
||||||
|
s.replace('.', "").replace(',', ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regra 2: apenas ponto
|
||||||
|
(true, false) => {
|
||||||
|
let depois_do_ponto = &s[s.rfind('.').unwrap() + 1..];
|
||||||
|
match depois_do_ponto.len() {
|
||||||
|
3 => {
|
||||||
|
// 3 dígitos após ponto → separador de milhar (ex: 1.234)
|
||||||
|
s.replace('.', "")
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 2 dígitos ou outro → decimal (ex: 1000.00)
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regra 2: apenas vírgula
|
||||||
|
(false, true) => {
|
||||||
|
let depois_da_virgula = &s[s.rfind(',').unwrap() + 1..];
|
||||||
|
match depois_da_virgula.len() {
|
||||||
|
3 => {
|
||||||
|
// 3 dígitos após vírgula → separador de milhar (ex: 1,234)
|
||||||
|
s.replace(',', "")
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// 2 dígitos ou outro → decimal (ex: 1000,00)
|
||||||
|
s.replace(',', ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regra 3: sem separador → inteiro
|
||||||
|
(false, false) => s.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verificar se ainda há caracteres não numéricos (exceto ponto decimal)
|
||||||
|
if normalizado.chars().any(|c| !c.is_ascii_digit() && c != '.') {
|
||||||
|
return Err(ErroValor::NaoNumerico(input.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Decimal::from_str(&normalizado).map_err(|_| ErroValor::NaoNumerico(input.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formata um `Decimal` para exibição no formato monetário brasileiro.
|
||||||
|
/// Ex: 1234.56 → "1.234,56"
|
||||||
|
pub fn formatar_valor_br(valor: &Decimal) -> String {
|
||||||
|
let s = format!("{:.2}", valor);
|
||||||
|
// Separar parte inteira e decimal
|
||||||
|
let partes: Vec<&str> = s.split('.').collect();
|
||||||
|
let inteira = partes[0];
|
||||||
|
let decimal = partes.get(1).copied().unwrap_or("00");
|
||||||
|
|
||||||
|
// Inserir pontos de milhar
|
||||||
|
let inteira_com_milhar = inserir_pontos_milhar(inteira);
|
||||||
|
format!("{},{}", inteira_com_milhar, decimal)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inserir_pontos_milhar(s: &str) -> String {
|
||||||
|
let digits: Vec<char> = s.chars().collect();
|
||||||
|
let len = digits.len();
|
||||||
|
let mut result = String::new();
|
||||||
|
for (i, &c) in digits.iter().enumerate() {
|
||||||
|
if i > 0 && (len - i) % 3 == 0 {
|
||||||
|
result.push('.');
|
||||||
|
}
|
||||||
|
result.push(c);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rust_decimal_macros::dec;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra1_formato_br() {
|
||||||
|
assert_eq!(parse_valor("1.000,00").unwrap(), dec!(1000.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra1_formato_en() {
|
||||||
|
assert_eq!(parse_valor("1,000.00").unwrap(), dec!(1000.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra2_ponto_2_digitos() {
|
||||||
|
assert_eq!(parse_valor("1000.00").unwrap(), dec!(1000.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra2_virgula_2_digitos() {
|
||||||
|
assert_eq!(parse_valor("1000,00").unwrap(), dec!(1000.00));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra2_ponto_3_digitos_milhar() {
|
||||||
|
assert_eq!(parse_valor("1.234").unwrap(), dec!(1234));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra2_virgula_3_digitos_milhar() {
|
||||||
|
assert_eq!(parse_valor("1,234").unwrap(), dec!(1234));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regra3_inteiro() {
|
||||||
|
assert_eq!(parse_valor("1000").unwrap(), dec!(1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejeitar_negativo() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse_valor("-100,00"),
|
||||||
|
Err(ErroValor::Negativo(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejeitar_nao_numerico() {
|
||||||
|
assert!(matches!(parse_valor("abc"), Err(ErroValor::NaoNumerico(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formatar_milhar() {
|
||||||
|
assert_eq!(formatar_valor_br(&dec!(1234.56)), "1.234,56");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formatar_sem_milhar() {
|
||||||
|
assert_eq!(formatar_valor_br(&dec!(100.00)), "100,00");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formatar_grande() {
|
||||||
|
assert_eq!(formatar_valor_br(&dec!(1234567.89)), "1.234.567,89");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
||||||
|
use encoding_rs::WINDOWS_1252;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Resultado da leitura de um arquivo CSV.
|
||||||
|
pub struct ResultadoCsv {
|
||||||
|
/// Linhas de dados (já sem o cabeçalho), cada linha é um vetor de strings.
|
||||||
|
pub linhas: Vec<Vec<String>>,
|
||||||
|
/// Avisos coletados durante a leitura.
|
||||||
|
pub avisos: ResumoAvisos,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê um arquivo CSV e retorna as linhas de dados (sem o cabeçalho).
|
||||||
|
///
|
||||||
|
/// # Parâmetros
|
||||||
|
/// - `caminho`: caminho do arquivo
|
||||||
|
/// - `delimitador`: caractere delimitador (`,`, `;` ou `\t`)
|
||||||
|
/// - `encoding`: "utf-8" ou "windows-1252"
|
||||||
|
/// - `linha_cabecalho`: número da linha do cabeçalho (base 1). 0 = sem cabeçalho.
|
||||||
|
pub fn ler_csv(
|
||||||
|
caminho: &Path,
|
||||||
|
delimitador: u8,
|
||||||
|
encoding: &str,
|
||||||
|
linha_cabecalho: usize,
|
||||||
|
) -> Result<ResultadoCsv, ErroArquivo> {
|
||||||
|
// Verificar tamanho
|
||||||
|
let metadata = std::fs::metadata(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
|
if metadata.len() > LIMITE_BYTES {
|
||||||
|
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ler conteúdo bruto
|
||||||
|
let bytes = std::fs::read(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
|
|
||||||
|
// Decodificar encoding
|
||||||
|
let conteudo = match encoding.to_lowercase().as_str() {
|
||||||
|
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
||||||
|
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
String::from_utf8(bytes)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut avisos = ResumoAvisos::default();
|
||||||
|
let mut linhas_dados: Vec<Vec<String>> = Vec::new();
|
||||||
|
let mut numero_linha_arquivo: usize = 0;
|
||||||
|
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.delimiter(delimitador)
|
||||||
|
.has_headers(false)
|
||||||
|
.flexible(true)
|
||||||
|
.from_reader(conteudo.as_bytes());
|
||||||
|
|
||||||
|
for resultado in reader.records() {
|
||||||
|
numero_linha_arquivo += 1;
|
||||||
|
|
||||||
|
// Pular linhas antes ou na linha do cabeçalho
|
||||||
|
if linha_cabecalho > 0 && numero_linha_arquivo <= linha_cabecalho {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match resultado {
|
||||||
|
Ok(record) => {
|
||||||
|
let campos: Vec<String> = record.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
// Ignorar linhas completamente em branco
|
||||||
|
if campos.iter().all(|s| s.trim().is_empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
linhas_dados.push(campos);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
avisos.linhas_malformadas += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResultadoCsv {
|
||||||
|
linhas: linhas_dados,
|
||||||
|
avisos,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod csv_reader;
|
||||||
|
pub mod pdf_generator;
|
||||||
|
pub mod sqlite;
|
||||||
|
pub mod xlsx_reader;
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||||
|
use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use genpdf::{
|
||||||
|
elements::{Break, Paragraph},
|
||||||
|
fonts, style, Document, Element, SimplePageDecorator,
|
||||||
|
};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Metadados do relatório.
|
||||||
|
pub struct MetadadosRelatorio {
|
||||||
|
pub nome_arquivo: String,
|
||||||
|
pub nome_layout: Option<String>,
|
||||||
|
pub gerado_em: DateTime<Local>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trait abstrata para geração de PDF.
|
||||||
|
/// Definida aqui para que o use case `exportar_pdf` dependa da abstração,
|
||||||
|
/// não da crate `genpdf` diretamente.
|
||||||
|
pub trait PdfGenerator {
|
||||||
|
fn gerar(
|
||||||
|
&self,
|
||||||
|
resultado: &ResultadoAnalise,
|
||||||
|
meta: &MetadadosRelatorio,
|
||||||
|
caminho_saida: &Path,
|
||||||
|
) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implementação concreta usando `genpdf`.
|
||||||
|
pub struct GenpdfGenerator;
|
||||||
|
|
||||||
|
impl PdfGenerator for GenpdfGenerator {
|
||||||
|
fn gerar(
|
||||||
|
&self,
|
||||||
|
resultado: &ResultadoAnalise,
|
||||||
|
meta: &MetadadosRelatorio,
|
||||||
|
caminho_saida: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Carregar fonte do sistema (DejaVu Sans)
|
||||||
|
let font_family = carregar_fonte_sistema()
|
||||||
|
.map_err(|e| format!("Erro ao carregar fontes: {}", e))?;
|
||||||
|
|
||||||
|
let mut doc = Document::new(font_family);
|
||||||
|
doc.set_title("Relatório — Comparador de Notas");
|
||||||
|
doc.set_minimal_conformance();
|
||||||
|
|
||||||
|
let mut decorator = SimplePageDecorator::new();
|
||||||
|
decorator.set_margins(10);
|
||||||
|
doc.set_page_decorator(decorator);
|
||||||
|
|
||||||
|
// Título
|
||||||
|
doc.push(
|
||||||
|
Paragraph::new("").styled_string(
|
||||||
|
"Relatório de Análise de Notas Fiscais",
|
||||||
|
style::Style::new().bold().with_font_size(16),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Metadados
|
||||||
|
doc.push(Paragraph::new(format!("Arquivo: {}", meta.nome_arquivo)));
|
||||||
|
if let Some(layout) = &meta.nome_layout {
|
||||||
|
doc.push(Paragraph::new(format!("Layout: {}", layout)));
|
||||||
|
}
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
"Gerado em: {}",
|
||||||
|
meta.gerado_em.format("%d/%m/%Y %H:%M:%S")
|
||||||
|
)));
|
||||||
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Totais
|
||||||
|
doc.push(
|
||||||
|
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||||
|
);
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
"Total Geral: R$ {}",
|
||||||
|
formatar_valor_br(&resultado.soma_total)
|
||||||
|
)));
|
||||||
|
|
||||||
|
let mut series_ordenadas: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
||||||
|
series_ordenadas.sort();
|
||||||
|
|
||||||
|
for serie in &series_ordenadas {
|
||||||
|
let soma = &resultado.soma_por_serie[*serie];
|
||||||
|
let total = resultado.total_por_serie.get(*serie).copied().unwrap_or(0);
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
|
serie,
|
||||||
|
total,
|
||||||
|
formatar_valor_br(soma)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Notas Faltantes
|
||||||
|
doc.push(
|
||||||
|
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for serie in &series_ordenadas {
|
||||||
|
let faltantes = match resultado.faltantes_por_serie.get(*serie) {
|
||||||
|
Some(f) if !f.is_empty() => f,
|
||||||
|
_ => {
|
||||||
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", serie)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
" Série {}: {} faltante(s)",
|
||||||
|
serie,
|
||||||
|
faltantes.len()
|
||||||
|
)));
|
||||||
|
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||||
|
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
|
// Duplicatas
|
||||||
|
doc.push(
|
||||||
|
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for serie in &series_ordenadas {
|
||||||
|
let duplicatas = match resultado.duplicadas_por_serie.get(*serie) {
|
||||||
|
Some(d) if !d.is_empty() => d,
|
||||||
|
_ => {
|
||||||
|
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", serie)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
" Série {}: {} grupo(s) duplicado(s)",
|
||||||
|
serie,
|
||||||
|
duplicatas.len()
|
||||||
|
)));
|
||||||
|
for (numero, count) in duplicatas {
|
||||||
|
doc.push(Paragraph::new(format!(
|
||||||
|
" NF {} / Série {} — {} ocorrências",
|
||||||
|
numero, serie, count
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderizar PDF
|
||||||
|
doc.render_to_file(caminho_saida)
|
||||||
|
.map_err(|e| format!("Erro ao gerar PDF: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tenta carregar fontes DejaVu Sans do sistema.
|
||||||
|
fn carregar_fonte_sistema() -> Result<fonts::FontFamily<fonts::FontData>, String> {
|
||||||
|
// Caminhos comuns no Linux, Windows e macOS
|
||||||
|
let candidatos_regular = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||||
|
"C:\\Windows\\Fonts\\arial.ttf",
|
||||||
|
"/Library/Fonts/Arial.ttf",
|
||||||
|
];
|
||||||
|
let candidatos_bold = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||||
|
"C:\\Windows\\Fonts\\arialbd.ttf",
|
||||||
|
"/Library/Fonts/Arial Bold.ttf",
|
||||||
|
];
|
||||||
|
|
||||||
|
let regular_path = candidatos_regular
|
||||||
|
.iter()
|
||||||
|
.find(|p| std::path::Path::new(p).exists())
|
||||||
|
.ok_or_else(|| "Fonte regular não encontrada no sistema".to_string())?;
|
||||||
|
|
||||||
|
let bold_path = candidatos_bold
|
||||||
|
.iter()
|
||||||
|
.find(|p| std::path::Path::new(p).exists())
|
||||||
|
.unwrap_or(regular_path);
|
||||||
|
|
||||||
|
let regular = fonts::FontData::load(regular_path, None)
|
||||||
|
.map_err(|e| format!("Erro ao carregar fonte regular: {}", e))?;
|
||||||
|
let bold = fonts::FontData::load(bold_path, None)
|
||||||
|
.map_err(|e| format!("Erro ao carregar fonte bold: {}", e))?;
|
||||||
|
|
||||||
|
Ok(fonts::FontFamily {
|
||||||
|
regular: regular.clone(),
|
||||||
|
bold,
|
||||||
|
italic: regular.clone(),
|
||||||
|
bold_italic: regular,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const LIMITE_50MB: u64 = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Determina o caminho do banco de dados conforme o sistema operacional.
|
||||||
|
pub fn caminho_banco() -> PathBuf {
|
||||||
|
let config_dir = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
config_dir.join("comparador-notas").join("config.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre (ou cria) o banco de dados SQLite no caminho padrão.
|
||||||
|
///
|
||||||
|
/// Retorna a conexão aberta e pronta para uso, ou um erro descritivo.
|
||||||
|
/// Em caso de banco corrompido, renomeia para `.bak` e recria.
|
||||||
|
pub fn abrir_banco() -> Result<(Connection, bool), String> {
|
||||||
|
let caminho = caminho_banco();
|
||||||
|
abrir_banco_no_caminho(&caminho)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Versão testável que aceita um caminho explícito.
|
||||||
|
pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), String> {
|
||||||
|
// Criar diretório se não existir
|
||||||
|
if let Some(dir) = caminho.parent() {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| format!("Não foi possível criar diretório do banco: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o arquivo existe e está corrompido
|
||||||
|
if caminho.exists() {
|
||||||
|
match Connection::open(caminho) {
|
||||||
|
Ok(conn) => {
|
||||||
|
// Testar se o banco funciona com uma query simples
|
||||||
|
match conn.execute_batch("SELECT 1;") {
|
||||||
|
Ok(_) => return Ok((conn, false)),
|
||||||
|
Err(e) => {
|
||||||
|
// Banco corrompido
|
||||||
|
drop(conn);
|
||||||
|
let bak = caminho.with_extension("db.bak");
|
||||||
|
let _ = std::fs::rename(caminho, &bak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let bak = caminho.with_extension("db.bak");
|
||||||
|
let _ = std::fs::rename(caminho, &bak);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar banco novo
|
||||||
|
let conn = Connection::open(caminho)
|
||||||
|
.map_err(|e| format!("Não foi possível criar banco de dados: {}", e))?;
|
||||||
|
|
||||||
|
Ok((conn, true)) // true = banco foi recriado (era corrompido)
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
||||||
|
use rusqlite::{params, Connection, Result};
|
||||||
|
|
||||||
|
/// Salva um layout no banco. Retorna o id gerado.
|
||||||
|
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
||||||
|
match layout {
|
||||||
|
Layout::Csv { nome, config, .. } => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO layouts
|
||||||
|
(nome, tipo, delimitador, encoding, linha_cabecalho,
|
||||||
|
indice_numero, indice_serie, indice_valor, indice_data)
|
||||||
|
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![
|
||||||
|
nome,
|
||||||
|
config.delimitador.to_string(),
|
||||||
|
config.encoding,
|
||||||
|
config.linha_cabecalho as i64,
|
||||||
|
config.indice_numero as i64,
|
||||||
|
config.indice_serie as i64,
|
||||||
|
config.indice_valor.map(|v| v as i64),
|
||||||
|
config.indice_data.map(|v| v as i64),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
Layout::Xlsx { nome, config, .. } => {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO layouts
|
||||||
|
(nome, tipo, aba, pos_numero, pos_serie, pos_valor, pos_data)
|
||||||
|
VALUES (?1, 'xlsx', ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![
|
||||||
|
nome,
|
||||||
|
config.aba,
|
||||||
|
config.pos_numero,
|
||||||
|
config.pos_serie,
|
||||||
|
config.pos_valor,
|
||||||
|
config.pos_data,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atualiza um layout existente no banco.
|
||||||
|
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
||||||
|
let id = layout
|
||||||
|
.id()
|
||||||
|
.ok_or_else(|| rusqlite::Error::InvalidParameterName("id ausente".to_string()))?;
|
||||||
|
|
||||||
|
match layout {
|
||||||
|
Layout::Csv { nome, config, .. } => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE layouts SET
|
||||||
|
nome = ?1, delimitador = ?2, encoding = ?3,
|
||||||
|
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
||||||
|
indice_valor = ?7, indice_data = ?8
|
||||||
|
WHERE id = ?9",
|
||||||
|
params![
|
||||||
|
nome,
|
||||||
|
config.delimitador.to_string(),
|
||||||
|
config.encoding,
|
||||||
|
config.linha_cabecalho as i64,
|
||||||
|
config.indice_numero as i64,
|
||||||
|
config.indice_serie as i64,
|
||||||
|
config.indice_valor.map(|v| v as i64),
|
||||||
|
config.indice_data.map(|v| v as i64),
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Layout::Xlsx { nome, config, .. } => {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE layouts SET
|
||||||
|
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
|
||||||
|
pos_valor = ?5, pos_data = ?6
|
||||||
|
WHERE id = ?7",
|
||||||
|
params![
|
||||||
|
nome,
|
||||||
|
config.aba,
|
||||||
|
config.pos_numero,
|
||||||
|
config.pos_serie,
|
||||||
|
config.pos_valor,
|
||||||
|
config.pos_data,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista todos os layouts salvos.
|
||||||
|
pub fn listar(conn: &Connection) -> Result<Vec<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 ORDER BY nome ASC",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let layouts: Result<Vec<Layout>> = stmt
|
||||||
|
.query_map([], |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)?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
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.
|
||||||
|
pub fn excluir(conn: &Connection, id: i64) -> Result<()> {
|
||||||
|
conn.execute("DELETE FROM layouts WHERE id = ?1", [id])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica se já existe um layout com o nome fornecido.
|
||||||
|
pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
|
||||||
|
let count: i64 = conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM layouts WHERE nome = ?1",
|
||||||
|
[nome],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use rusqlite::{Connection, Result};
|
||||||
|
|
||||||
|
/// Versão atual do schema do banco de dados.
|
||||||
|
const VERSAO_SCHEMA_ATUAL: i64 = 1;
|
||||||
|
|
||||||
|
/// Aplica todas as migrations necessárias para atualizar o banco
|
||||||
|
/// para a versão mais recente.
|
||||||
|
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||||
|
// Criar tabela de controle de versão se não existir
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
versao INTEGER NOT NULL
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let versao_atual: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT versao FROM schema_version LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if versao_atual < 1 {
|
||||||
|
migration_v1(conn)?;
|
||||||
|
if versao_atual == 0 {
|
||||||
|
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [1])?;
|
||||||
|
} else {
|
||||||
|
conn.execute("UPDATE schema_version SET versao = ?1;", [1])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migration v1: criar tabela de layouts.
|
||||||
|
fn migration_v1(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS layouts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
nome TEXT NOT NULL,
|
||||||
|
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
||||||
|
|
||||||
|
-- Campos CSV
|
||||||
|
delimitador TEXT,
|
||||||
|
encoding TEXT,
|
||||||
|
linha_cabecalho INTEGER,
|
||||||
|
indice_numero INTEGER,
|
||||||
|
indice_serie INTEGER,
|
||||||
|
indice_valor INTEGER,
|
||||||
|
indice_data INTEGER,
|
||||||
|
|
||||||
|
-- Campos XLSX
|
||||||
|
aba TEXT,
|
||||||
|
pos_numero TEXT,
|
||||||
|
pos_serie TEXT,
|
||||||
|
pos_valor TEXT,
|
||||||
|
pos_data TEXT
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod connection;
|
||||||
|
pub mod layout_repository;
|
||||||
|
pub mod migrations;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
use crate::domain::errors::{ErroArquivo, ResumoAvisos};
|
||||||
|
use calamine::{open_workbook, Reader, Xlsx};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::BufReader;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const LIMITE_BYTES: u64 = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Representa uma coordenada de célula (coluna base-0, linha base-0).
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Coordenada {
|
||||||
|
pub coluna: u32,
|
||||||
|
pub linha: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado da leitura de um arquivo XLSX.
|
||||||
|
pub struct ResultadoXlsx {
|
||||||
|
/// Linhas de dados, a partir da posição de início.
|
||||||
|
/// Cada linha é um vetor de strings (indexado pela posição da coluna de início).
|
||||||
|
pub linhas: Vec<Vec<String>>,
|
||||||
|
/// Avisos coletados durante a leitura.
|
||||||
|
pub avisos: ResumoAvisos,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lista as abas disponíveis em um arquivo XLSX.
|
||||||
|
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
||||||
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
|
let workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(workbook.sheet_names().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lê os dados de uma aba XLSX a partir de uma linha de início.
|
||||||
|
///
|
||||||
|
/// # Parâmetros
|
||||||
|
/// - `caminho`: caminho do arquivo
|
||||||
|
/// - `nome_aba`: nome da aba a ser lida
|
||||||
|
/// - `linha_inicio`: número da linha de início dos dados (base 1, ex: 3 para "B3")
|
||||||
|
pub fn ler_xlsx(
|
||||||
|
caminho: &Path,
|
||||||
|
nome_aba: &str,
|
||||||
|
linha_inicio: u32,
|
||||||
|
) -> Result<ResultadoXlsx, ErroArquivo> {
|
||||||
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
|
let mut workbook: Xlsx<BufReader<File>> = open_workbook::<Xlsx<_>, _>(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
|
.worksheet_range(nome_aba)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut avisos = ResumoAvisos::default();
|
||||||
|
let mut linhas: Vec<Vec<String>> = Vec::new();
|
||||||
|
|
||||||
|
let linha_inicio_base0 = (linha_inicio.saturating_sub(1)) as usize;
|
||||||
|
|
||||||
|
for (row_idx, row) in range.rows().enumerate() {
|
||||||
|
if row_idx < linha_inicio_base0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let campos: Vec<String> = row
|
||||||
|
.iter()
|
||||||
|
.map(|cell| {
|
||||||
|
use calamine::Data;
|
||||||
|
match cell {
|
||||||
|
Data::Empty => String::new(),
|
||||||
|
Data::String(s) => s.clone(),
|
||||||
|
Data::Float(f) => {
|
||||||
|
if f.fract() == 0.0 {
|
||||||
|
format!("{}", *f as i64)
|
||||||
|
} else {
|
||||||
|
format!("{}", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::Int(i) => i.to_string(),
|
||||||
|
Data::Bool(b) => b.to_string(),
|
||||||
|
Data::DateTime(dt) => dt.to_string(),
|
||||||
|
Data::DateTimeIso(s) => s.clone(),
|
||||||
|
Data::DurationIso(s) => s.clone(),
|
||||||
|
Data::Error(_) => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ignorar linhas completamente em branco
|
||||||
|
if campos.iter().all(|s: &String| s.trim().is_empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
linhas.push(campos);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ResultadoXlsx { linhas, avisos })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
|
||||||
|
///
|
||||||
|
/// Retorna `None` se a notação for inválida.
|
||||||
|
pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
|
||||||
|
let s = s.trim().to_uppercase();
|
||||||
|
if s.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pos_numero = s.find(|c: char| c.is_ascii_digit())?;
|
||||||
|
let (letras, numeros) = s.split_at(pos_numero);
|
||||||
|
|
||||||
|
if letras.is_empty() || numeros.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converter letras para índice de coluna (base 0)
|
||||||
|
// A=0, B=1, ..., Z=25, AA=26, ...
|
||||||
|
let coluna = letras
|
||||||
|
.chars()
|
||||||
|
.try_fold(0u32, |acc, c| {
|
||||||
|
if c.is_ascii_uppercase() {
|
||||||
|
Some(acc * 26 + (c as u32 - 'A' as u32 + 1))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.checked_sub(1)?;
|
||||||
|
|
||||||
|
let linha: u32 = numeros.parse().ok()?;
|
||||||
|
if linha == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Coordenada { coluna, linha })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
|
||||||
|
let metadata = std::fs::metadata(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
|
if metadata.len() > LIMITE_BYTES {
|
||||||
|
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_b3() {
|
||||||
|
let coord = parsear_letra_linha("B3").unwrap();
|
||||||
|
assert_eq!(coord.coluna, 1); // B = coluna 1 (base 0)
|
||||||
|
assert_eq!(coord.linha, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_a1() {
|
||||||
|
let coord = parsear_letra_linha("A1").unwrap();
|
||||||
|
assert_eq!(coord.coluna, 0);
|
||||||
|
assert_eq!(coord.linha, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_z1() {
|
||||||
|
let coord = parsear_letra_linha("Z1").unwrap();
|
||||||
|
assert_eq!(coord.coluna, 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_aa1() {
|
||||||
|
let coord = parsear_letra_linha("AA1").unwrap();
|
||||||
|
assert_eq!(coord.coluna, 26);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_invalido() {
|
||||||
|
assert!(parsear_letra_linha("").is_none());
|
||||||
|
assert!(parsear_letra_linha("3B").is_none());
|
||||||
|
assert!(parsear_letra_linha("123").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsear_minusculo() {
|
||||||
|
let coord = parsear_letra_linha("b3").unwrap();
|
||||||
|
assert_eq!(coord.coluna, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-2
@@ -1,3 +1,26 @@
|
|||||||
fn main() {
|
mod application;
|
||||||
println!("Hello, world!");
|
mod domain;
|
||||||
|
mod infrastructure;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use ui::app::App;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result {
|
||||||
|
let native_options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_title("Comparador de Notas")
|
||||||
|
.with_inner_size([1024.0, 768.0])
|
||||||
|
.with_min_inner_size([800.0, 600.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Comparador de Notas",
|
||||||
|
native_options,
|
||||||
|
Box::new(|_cc| {
|
||||||
|
let mut app = App::default();
|
||||||
|
app.inicializar();
|
||||||
|
Ok(Box::new(app))
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+340
@@ -0,0 +1,340 @@
|
|||||||
|
use crate::application::usecases::{
|
||||||
|
executar_analise::{expandir_analise, pre_analisar, series_com_intervalo_excessivo},
|
||||||
|
importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx, ResultadoImportacao},
|
||||||
|
layouts::{
|
||||||
|
carregar_layout, excluir_layout, exportar_layout_json, importar_layout_json,
|
||||||
|
listar_layouts, salvar_layout,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use crate::domain::{
|
||||||
|
entities::{
|
||||||
|
layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo},
|
||||||
|
nota::Nota,
|
||||||
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
|
},
|
||||||
|
errors::ResumoAvisos,
|
||||||
|
};
|
||||||
|
use crate::infrastructure::{
|
||||||
|
pdf_generator::GenpdfGenerator,
|
||||||
|
sqlite::{connection::abrir_banco, migrations::aplicar_migrations},
|
||||||
|
};
|
||||||
|
use egui::Context;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Estado global da aplicação.
|
||||||
|
pub enum EstadoApp {
|
||||||
|
/// Tela inicial: importar arquivo.
|
||||||
|
Importando,
|
||||||
|
/// Aguardando seleção de aba de arquivo XLSX.
|
||||||
|
SelecionandoAba { abas: Vec<String>, caminho: PathBuf },
|
||||||
|
/// Configuração de colunas após importar arquivo.
|
||||||
|
ConfigurandoColunas,
|
||||||
|
/// Resultado pronto para exibição.
|
||||||
|
ExibindoResultado(ResultadoAnalise),
|
||||||
|
/// Aguardando confirmação do usuário para expandir faltantes.
|
||||||
|
ConfirmandoIntervalo {
|
||||||
|
pre: ResultadoPreAnalise,
|
||||||
|
series_excessivas: Vec<(String, u64)>,
|
||||||
|
},
|
||||||
|
/// Gerenciamento de layouts.
|
||||||
|
GerenciandoLayouts,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Modal {
|
||||||
|
pub visivel: bool,
|
||||||
|
pub titulo: String,
|
||||||
|
pub mensagem: String,
|
||||||
|
pub tipo: TipoModal,
|
||||||
|
/// Para modal de confirmação, a ação ao confirmar.
|
||||||
|
pub acao_confirmacao: Option<AcaoModal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, PartialEq)]
|
||||||
|
pub enum TipoModal {
|
||||||
|
#[default]
|
||||||
|
Informacao,
|
||||||
|
Aviso,
|
||||||
|
Erro,
|
||||||
|
Confirmacao,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum AcaoModal {
|
||||||
|
ConfirmarExpansaoFaltantes,
|
||||||
|
ConfirmarExclusaoLayout(i64),
|
||||||
|
SobrescreverLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Struct principal da aplicação egui.
|
||||||
|
pub struct App {
|
||||||
|
pub estado: EstadoApp,
|
||||||
|
pub conn: Option<Connection>,
|
||||||
|
pub banco_foi_recriado: bool,
|
||||||
|
|
||||||
|
// Dados em memória do arquivo atual
|
||||||
|
pub notas_importadas: Vec<Nota>,
|
||||||
|
pub caminho_arquivo: Option<PathBuf>,
|
||||||
|
pub nome_arquivo: String,
|
||||||
|
|
||||||
|
// Configuração de layout atual
|
||||||
|
pub tipo_arquivo_atual: TipoArquivo,
|
||||||
|
pub layout_csv_atual: LayoutCsv,
|
||||||
|
pub layout_xlsx_atual: LayoutXlsx,
|
||||||
|
pub nome_layout_atual: String,
|
||||||
|
|
||||||
|
// Abas XLSX disponíveis (após seleção do arquivo)
|
||||||
|
pub abas_xlsx: Vec<String>,
|
||||||
|
|
||||||
|
// Layouts salvos no banco
|
||||||
|
pub layouts_salvos: Vec<Layout>,
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
pub modal: Modal,
|
||||||
|
|
||||||
|
// Avisos da última importação
|
||||||
|
pub avisos_importacao: Option<ResumoAvisos>,
|
||||||
|
|
||||||
|
// Paginação (para tela de resultado)
|
||||||
|
pub pagina_faltantes: usize,
|
||||||
|
pub pagina_duplicatas: usize,
|
||||||
|
pub itens_por_pagina: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for App {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
estado: EstadoApp::Importando,
|
||||||
|
conn: None,
|
||||||
|
banco_foi_recriado: false,
|
||||||
|
notas_importadas: Vec::new(),
|
||||||
|
caminho_arquivo: None,
|
||||||
|
nome_arquivo: String::new(),
|
||||||
|
tipo_arquivo_atual: TipoArquivo::Csv,
|
||||||
|
layout_csv_atual: LayoutCsv::default(),
|
||||||
|
layout_xlsx_atual: LayoutXlsx::default(),
|
||||||
|
nome_layout_atual: String::new(),
|
||||||
|
abas_xlsx: Vec::new(),
|
||||||
|
layouts_salvos: Vec::new(),
|
||||||
|
modal: Modal::default(),
|
||||||
|
avisos_importacao: None,
|
||||||
|
pagina_faltantes: 0,
|
||||||
|
pagina_duplicatas: 0,
|
||||||
|
itens_por_pagina: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Retorna um id numérico da tela atual para evitar borrow conflicts.
|
||||||
|
pub fn tela_atual(&self) -> u8 {
|
||||||
|
match &self.estado {
|
||||||
|
EstadoApp::Importando => 0,
|
||||||
|
EstadoApp::SelecionandoAba { .. } => 1,
|
||||||
|
EstadoApp::ConfigurandoColunas => 2,
|
||||||
|
EstadoApp::ExibindoResultado(_) => 3,
|
||||||
|
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
||||||
|
EstadoApp::GerenciandoLayouts => 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inicializa o banco de dados e carrega os layouts salvos.
|
||||||
|
pub fn inicializar(&mut self) {
|
||||||
|
match abrir_banco() {
|
||||||
|
Ok((conn, recriado)) => {
|
||||||
|
self.banco_foi_recriado = recriado;
|
||||||
|
if let Err(e) = aplicar_migrations(&conn) {
|
||||||
|
self.exibir_erro(format!("Erro ao inicializar banco: {}", e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match listar_layouts(&conn) {
|
||||||
|
Ok(layouts) => self.layouts_salvos = layouts,
|
||||||
|
Err(e) => self.exibir_erro(format!("Erro ao carregar layouts: {}", e)),
|
||||||
|
}
|
||||||
|
self.conn = Some(conn);
|
||||||
|
|
||||||
|
if recriado {
|
||||||
|
self.exibir_aviso(
|
||||||
|
"Banco de dados corrompido",
|
||||||
|
"O banco de dados estava corrompido e foi recriado. Os layouts anteriores foram arquivados em config.db.bak.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.exibir_erro(format!("Erro crítico ao abrir banco de dados: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exibir_erro(&mut self, msg: impl Into<String>) {
|
||||||
|
self.modal = Modal {
|
||||||
|
visivel: true,
|
||||||
|
titulo: "Erro".to_string(),
|
||||||
|
mensagem: msg.into(),
|
||||||
|
tipo: TipoModal::Erro,
|
||||||
|
acao_confirmacao: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exibir_aviso(&mut self, titulo: impl Into<String>, msg: impl Into<String>) {
|
||||||
|
self.modal = Modal {
|
||||||
|
visivel: true,
|
||||||
|
titulo: titulo.into(),
|
||||||
|
mensagem: msg.into(),
|
||||||
|
tipo: TipoModal::Aviso,
|
||||||
|
acao_confirmacao: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exibir_confirmacao(
|
||||||
|
&mut self,
|
||||||
|
titulo: impl Into<String>,
|
||||||
|
msg: impl Into<String>,
|
||||||
|
acao: AcaoModal,
|
||||||
|
) {
|
||||||
|
self.modal = Modal {
|
||||||
|
visivel: true,
|
||||||
|
titulo: titulo.into(),
|
||||||
|
mensagem: msg.into(),
|
||||||
|
tipo: TipoModal::Confirmacao,
|
||||||
|
acao_confirmacao: Some(acao),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recarrega a lista de layouts do banco.
|
||||||
|
pub fn recarregar_layouts(&mut self) {
|
||||||
|
if let Some(conn) = &self.conn {
|
||||||
|
match listar_layouts(conn) {
|
||||||
|
Ok(layouts) => self.layouts_salvos = layouts,
|
||||||
|
Err(e) => self.exibir_erro(format!("Erro ao carregar layouts: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza o modal bloqueante, se visível.
|
||||||
|
pub fn renderizar_modal(&mut self, ctx: &Context) {
|
||||||
|
if !self.modal.visivel {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let titulo = self.modal.titulo.clone();
|
||||||
|
let mensagem = self.modal.mensagem.clone();
|
||||||
|
let tipo = self.modal.tipo.clone();
|
||||||
|
let acao = self.modal.acao_confirmacao.clone();
|
||||||
|
|
||||||
|
egui::Window::new(&titulo)
|
||||||
|
.collapsible(false)
|
||||||
|
.resizable(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(&mensagem);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if tipo == TipoModal::Confirmacao {
|
||||||
|
if ui.button("✔ Confirmar").clicked() {
|
||||||
|
self.modal.visivel = false;
|
||||||
|
if let Some(acao) = acao.clone() {
|
||||||
|
self.executar_acao_modal(acao);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ui.button("✖ Cancelar").clicked() {
|
||||||
|
self.modal.visivel = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ui.button("OK").clicked() {
|
||||||
|
self.modal.visivel = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
||||||
|
match acao {
|
||||||
|
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||||
|
// Retirar pre-análise do estado e expandir
|
||||||
|
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
|
||||||
|
std::mem::replace(&mut self.estado, EstadoApp::Importando)
|
||||||
|
{
|
||||||
|
let resultado = expandir_analise(pre, &self.notas_importadas);
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
||||||
|
if let Some(conn) = &self.conn {
|
||||||
|
if let Err(e) = excluir_layout(conn, id) {
|
||||||
|
self.exibir_erro(e);
|
||||||
|
} else {
|
||||||
|
self.recarregar_layouts();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executa a análise com as notas importadas.
|
||||||
|
pub fn executar_analise(&mut self) {
|
||||||
|
if self.notas_importadas.is_empty() {
|
||||||
|
self.exibir_aviso("Aviso", "Nenhuma nota importada para analisar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pre = pre_analisar(&self.notas_importadas);
|
||||||
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
|
|
||||||
|
if !excessivos.is_empty() {
|
||||||
|
let msg = excessivos
|
||||||
|
.iter()
|
||||||
|
.map(|(serie, count)| {
|
||||||
|
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
let acao = AcaoModal::ConfirmarExpansaoFaltantes;
|
||||||
|
self.estado = EstadoApp::ConfirmandoIntervalo {
|
||||||
|
pre,
|
||||||
|
series_excessivas: excessivos,
|
||||||
|
};
|
||||||
|
self.exibir_confirmacao(
|
||||||
|
"Intervalo muito grande",
|
||||||
|
format!(
|
||||||
|
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
|
||||||
|
msg
|
||||||
|
),
|
||||||
|
acao,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let resultado = expandir_analise(pre, &self.notas_importadas);
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for App {
|
||||||
|
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
||||||
|
// Sempre renderizar modal por cima de tudo
|
||||||
|
self.renderizar_modal(ctx);
|
||||||
|
|
||||||
|
// Determinar qual tela exibir sem borrar self.estado
|
||||||
|
let tela_id = self.tela_atual();
|
||||||
|
|
||||||
|
// Renderizar a tela atual
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
match tela_id {
|
||||||
|
0 => crate::ui::screens::import::renderizar(ui, ctx, self),
|
||||||
|
1 => crate::ui::screens::import::renderizar_selecao_aba(ui, ctx, self),
|
||||||
|
2 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||||
|
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
||||||
|
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||||
|
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod screens;
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||||
|
use crate::domain::entities::layout::TipoArquivo;
|
||||||
|
use crate::ui::app::{App, EstadoApp};
|
||||||
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
|
/// Renderiza a tela de configuração de colunas.
|
||||||
|
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||||
|
ui.heading("Configuração de Colunas");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||||
|
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
match app.tipo_arquivo_atual.clone() {
|
||||||
|
TipoArquivo::Csv => renderizar_csv(ui, app),
|
||||||
|
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Validação e botões de ação
|
||||||
|
let (valido, erros) = validar_config(app);
|
||||||
|
|
||||||
|
if !erros.is_empty() {
|
||||||
|
for erro in &erros {
|
||||||
|
ui.colored_label(egui::Color32::RED, format!("⚠ {}", erro));
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Voltar").clicked() {
|
||||||
|
app.estado = EstadoApp::Importando;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||||
|
if ui.button("▶ Importar e Analisar").clicked() {
|
||||||
|
executar_importacao(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Configurações CSV");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
// Delimitador
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Delimitador:");
|
||||||
|
let delim_str = match app.layout_csv_atual.delimitador {
|
||||||
|
',' => "Vírgula (,)",
|
||||||
|
';' => "Ponto e vírgula (;)",
|
||||||
|
'\t' => "Tabulação (Tab)",
|
||||||
|
_ => "Outro",
|
||||||
|
};
|
||||||
|
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||||
|
.selected_text(delim_str)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
if ui.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)").clicked() {
|
||||||
|
app.layout_csv_atual.delimitador = ',';
|
||||||
|
}
|
||||||
|
if ui.selectable_label(app.layout_csv_atual.delimitador == ';', "Ponto e vírgula (;)").clicked() {
|
||||||
|
app.layout_csv_atual.delimitador = ';';
|
||||||
|
}
|
||||||
|
if ui.selectable_label(app.layout_csv_atual.delimitador == '\t', "Tabulação (Tab)").clicked() {
|
||||||
|
app.layout_csv_atual.delimitador = '\t';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Encoding
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Encoding:");
|
||||||
|
egui::ComboBox::from_id_salt("combo_encoding")
|
||||||
|
.selected_text(&app.layout_csv_atual.encoding)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
if ui.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8").clicked() {
|
||||||
|
app.layout_csv_atual.encoding = "utf-8".to_string();
|
||||||
|
}
|
||||||
|
if ui.selectable_label(app.layout_csv_atual.encoding == "windows-1252", "Windows-1252 (Latin-1)").clicked() {
|
||||||
|
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Linha cabeçalho
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
|
||||||
|
let mut val = app.layout_csv_atual.linha_cabecalho;
|
||||||
|
ui.add(egui::DragValue::new(&mut val).range(0..=100));
|
||||||
|
app.layout_csv_atual.linha_cabecalho = val;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Mapeamento de Colunas (índice base 0)");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
campo_indice(ui, "Número (obrigatório):", &mut app.layout_csv_atual.indice_numero);
|
||||||
|
campo_indice(ui, "Série (obrigatório):", &mut app.layout_csv_atual.indice_serie);
|
||||||
|
|
||||||
|
campo_indice_opcional(ui, "Valor (opcional):", &mut app.layout_csv_atual.indice_valor);
|
||||||
|
campo_indice_opcional(ui, "Data (opcional):", &mut app.layout_csv_atual.indice_data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Configurações XLSX");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Aba:");
|
||||||
|
if app.abas_xlsx.is_empty() {
|
||||||
|
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
|
||||||
|
} else {
|
||||||
|
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||||
|
egui::ComboBox::from_id_salt("combo_aba")
|
||||||
|
.selected_text(&aba_atual)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for aba in &app.abas_xlsx.clone() {
|
||||||
|
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||||
|
app.layout_xlsx_atual.aba = aba.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
campo_letra_linha(ui, "Número (obrigatório):", &mut app.layout_xlsx_atual.pos_numero);
|
||||||
|
campo_letra_linha(ui, "Série (obrigatório):", &mut app.layout_xlsx_atual.pos_serie);
|
||||||
|
campo_letra_linha_opcional(ui, "Valor (opcional):", &mut app.layout_xlsx_atual.pos_valor);
|
||||||
|
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(label);
|
||||||
|
ui.add(egui::DragValue::new(valor).range(0..=999usize));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let mut ativo = valor.is_some();
|
||||||
|
if ui.checkbox(&mut ativo, label).changed() {
|
||||||
|
*valor = if ativo { Some(0) } else { None };
|
||||||
|
}
|
||||||
|
if let Some(v) = valor {
|
||||||
|
ui.add(egui::DragValue::new(v).range(0..=999usize));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(label);
|
||||||
|
ui.text_edit_singleline(valor);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let mut ativo = valor.is_some();
|
||||||
|
if ui.checkbox(&mut ativo, label).changed() {
|
||||||
|
*valor = if ativo { Some(String::new()) } else { None };
|
||||||
|
}
|
||||||
|
if let Some(v) = valor {
|
||||||
|
ui.text_edit_singleline(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
|
||||||
|
fn validar_config(app: &App) -> (bool, Vec<String>) {
|
||||||
|
let mut erros = Vec::new();
|
||||||
|
|
||||||
|
match &app.tipo_arquivo_atual {
|
||||||
|
TipoArquivo::Csv => {
|
||||||
|
let c = &app.layout_csv_atual;
|
||||||
|
// Verificar índices duplicados
|
||||||
|
let mut indices: Vec<(String, usize)> = vec![
|
||||||
|
("Numero".to_string(), c.indice_numero),
|
||||||
|
("Serie".to_string(), c.indice_serie),
|
||||||
|
];
|
||||||
|
if let Some(v) = c.indice_valor {
|
||||||
|
indices.push(("Valor".to_string(), v));
|
||||||
|
}
|
||||||
|
if let Some(d) = c.indice_data {
|
||||||
|
indices.push(("Data".to_string(), d));
|
||||||
|
}
|
||||||
|
verificar_duplicados(&indices, &mut erros);
|
||||||
|
}
|
||||||
|
TipoArquivo::Xlsx => {
|
||||||
|
let c = &app.layout_xlsx_atual;
|
||||||
|
if c.aba.trim().is_empty() {
|
||||||
|
erros.push("Selecione uma aba".to_string());
|
||||||
|
}
|
||||||
|
if c.pos_numero.trim().is_empty() {
|
||||||
|
erros.push("Posição do campo Numero é obrigatória".to_string());
|
||||||
|
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_numero)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
erros.push(format!("Posição Numero inválida: '{}'", c.pos_numero));
|
||||||
|
}
|
||||||
|
if c.pos_serie.trim().is_empty() {
|
||||||
|
erros.push("Posição do campo Serie é obrigatória".to_string());
|
||||||
|
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_serie)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
erros.push(format!("Posição Serie inválida: '{}'", c.pos_serie));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let valido = erros.is_empty();
|
||||||
|
(valido, erros)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
||||||
|
for i in 0..indices.len() {
|
||||||
|
for j in (i + 1)..indices.len() {
|
||||||
|
if indices[i].1 == indices[j].1 {
|
||||||
|
erros.push(format!(
|
||||||
|
"Campos '{}' e '{}' mapeados para o mesmo índice {}",
|
||||||
|
indices[i].0, indices[j].0, indices[i].1
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn executar_importacao(app: &mut App) {
|
||||||
|
let caminho = match &app.caminho_arquivo {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultado = match app.tipo_arquivo_atual.clone() {
|
||||||
|
TipoArquivo::Csv => {
|
||||||
|
importar_csv(&caminho, &app.layout_csv_atual)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
TipoArquivo::Xlsx => {
|
||||||
|
importar_xlsx(&caminho, &app.layout_xlsx_atual)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match resultado {
|
||||||
|
Ok(res) => {
|
||||||
|
if res.notas.is_empty() {
|
||||||
|
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let avisos = res.avisos.clone();
|
||||||
|
app.notas_importadas = res.notas;
|
||||||
|
app.avisos_importacao = if avisos.tem_avisos() {
|
||||||
|
Some(avisos.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Executar análise
|
||||||
|
app.executar_analise();
|
||||||
|
|
||||||
|
// Exibir avisos consolidados após análise
|
||||||
|
if let Some(av) = &app.avisos_importacao {
|
||||||
|
if av.tem_avisos() {
|
||||||
|
let linhas = av.linhas_para_exibir().join("\n");
|
||||||
|
app.exibir_aviso("Avisos de Importação", linhas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx};
|
||||||
|
use crate::domain::entities::layout::TipoArquivo;
|
||||||
|
use crate::ui::app::{App, EstadoApp};
|
||||||
|
use egui::{Context, Ui};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Renderiza a tela de importação de arquivos.
|
||||||
|
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||||
|
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
// --- Seleção de arquivo ---
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Arquivo:");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let nome = if app.nome_arquivo.is_empty() {
|
||||||
|
"Nenhum arquivo selecionado".to_string()
|
||||||
|
} else {
|
||||||
|
app.nome_arquivo.clone()
|
||||||
|
};
|
||||||
|
ui.label(nome);
|
||||||
|
|
||||||
|
if ui.button("📂 Selecionar arquivo...").clicked() {
|
||||||
|
if let Some(caminho) = rfd::FileDialog::new()
|
||||||
|
.add_filter("Planilhas", &["csv", "xlsx"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
on_arquivo_selecionado(app, caminho);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
|
||||||
|
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||||
|
let opcoes_layout: Vec<(i64, String)> = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.tipo() == tipo_atual)
|
||||||
|
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||||
|
.collect();
|
||||||
|
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Layout:");
|
||||||
|
egui::ComboBox::from_id_salt("combo_layouts_import")
|
||||||
|
.selected_text(if nome_layout_atual.is_empty() {
|
||||||
|
"— Selecionar layout —"
|
||||||
|
} else {
|
||||||
|
&nome_layout_atual
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (id, nome) in &opcoes_layout {
|
||||||
|
if ui.selectable_label(nome_layout_atual == *nome, nome.as_str()).clicked() {
|
||||||
|
app.nome_layout_atual = nome.clone();
|
||||||
|
if let Some(layout) = app.layouts_salvos.iter().find(|l| l.id() == Some(*id)) {
|
||||||
|
let layout = layout.clone();
|
||||||
|
aplicar_layout(app, &layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui.button("⚙ Gerenciar Layouts").clicked() {
|
||||||
|
app.estado = EstadoApp::GerenciandoLayouts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
if app.caminho_arquivo.is_some() {
|
||||||
|
if ui.button("▶ Configurar Colunas").clicked() {
|
||||||
|
app.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renderiza a tela de seleção de aba (XLSX).
|
||||||
|
pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||||
|
ui.heading("Selecionar Aba da Planilha");
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
let (abas, caminho) = match &app.estado {
|
||||||
|
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label("Selecione a aba a processar:");
|
||||||
|
|
||||||
|
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||||
|
for aba in &abas {
|
||||||
|
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||||
|
app.layout_xlsx_atual.aba = aba.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||||
|
if ui.button("▶ Configurar Colunas").clicked() {
|
||||||
|
app.nome_arquivo = caminho
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
app.caminho_arquivo = Some(caminho);
|
||||||
|
app.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("← Voltar").clicked() {
|
||||||
|
app.estado = EstadoApp::Importando;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
||||||
|
let extensao = caminho
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
app.nome_arquivo = caminho
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
match extensao.as_str() {
|
||||||
|
"csv" => {
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
|
app.caminho_arquivo = Some(caminho);
|
||||||
|
app.notas_importadas.clear();
|
||||||
|
}
|
||||||
|
"xlsx" => {
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
|
match listar_abas_xlsx(&caminho) {
|
||||||
|
Ok(info) => {
|
||||||
|
app.abas_xlsx = info.abas.clone();
|
||||||
|
app.estado = EstadoApp::SelecionandoAba {
|
||||||
|
abas: info.abas,
|
||||||
|
caminho: caminho.clone(),
|
||||||
|
};
|
||||||
|
app.notas_importadas.clear();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.exibir_erro("Formato de arquivo não suportado. Use CSV ou XLSX.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
|
||||||
|
match layout {
|
||||||
|
crate::domain::entities::layout::Layout::Csv { config, .. } => {
|
||||||
|
app.layout_csv_atual = config.clone();
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
|
}
|
||||||
|
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
|
||||||
|
app.layout_xlsx_atual = config.clone();
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
use crate::application::usecases::layouts::{
|
||||||
|
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||||
|
};
|
||||||
|
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo};
|
||||||
|
use crate::domain::errors::ErroLayout;
|
||||||
|
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||||
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
|
/// Renderiza a tela de gerenciamento de layouts.
|
||||||
|
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||||
|
ui.heading("Gerenciar Layouts");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Voltar").clicked() {
|
||||||
|
app.estado = EstadoApp::Importando;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Salvar layout atual
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.label("Salvar Layout Atual");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Nome:");
|
||||||
|
ui.text_edit_singleline(&mut app.nome_layout_atual);
|
||||||
|
if ui.button("💾 Salvar").clicked() {
|
||||||
|
salvar_layout_atual(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Layouts CSV
|
||||||
|
let layouts_csv: Vec<_> = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.tipo() == TipoArquivo::Csv)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let layouts_xlsx: Vec<_> = app
|
||||||
|
.layouts_salvos
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
|
||||||
|
ui.add_space(12.0);
|
||||||
|
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Importar de JSON
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Importar layout de arquivo JSON:");
|
||||||
|
if ui.button("📥 Importar JSON").clicked() {
|
||||||
|
if let Some(caminho) = rfd::FileDialog::new()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
match std::fs::read_to_string(&caminho) {
|
||||||
|
Ok(conteudo) => importar_json(app, &conteudo),
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao ler arquivo JSON: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_secao_layouts(
|
||||||
|
ui: &mut Ui,
|
||||||
|
ctx: &Context,
|
||||||
|
app: &mut App,
|
||||||
|
titulo: &str,
|
||||||
|
layouts: &[Layout],
|
||||||
|
) {
|
||||||
|
ui.label(egui::RichText::new(titulo).strong());
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
if layouts.is_empty() {
|
||||||
|
ui.label("(nenhum layout salvo)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for layout in layouts {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(layout.nome());
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
// Excluir
|
||||||
|
if let Some(id) = layout.id() {
|
||||||
|
if ui.button("🗑 Excluir").clicked() {
|
||||||
|
app.exibir_confirmacao(
|
||||||
|
"Confirmar exclusão",
|
||||||
|
format!("Deseja excluir o layout '{}'?", layout.nome()),
|
||||||
|
AcaoModal::ConfirmarExclusaoLayout(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exportar
|
||||||
|
if ui.button("📤 Exportar JSON").clicked() {
|
||||||
|
match exportar_layout_json(layout) {
|
||||||
|
Ok((conteudo, nome_sugerido)) => {
|
||||||
|
if let Some(caminho) = rfd::FileDialog::new()
|
||||||
|
.set_file_name(&nome_sugerido)
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.save_file()
|
||||||
|
{
|
||||||
|
if let Err(e) = std::fs::write(&caminho, &conteudo) {
|
||||||
|
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carregar
|
||||||
|
if ui.button("📂 Carregar").clicked() {
|
||||||
|
aplicar_layout(app, layout);
|
||||||
|
app.nome_layout_atual = layout.nome().to_string();
|
||||||
|
app.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn salvar_layout_atual(app: &mut App) {
|
||||||
|
if app.nome_layout_atual.trim().is_empty() {
|
||||||
|
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = match app.tipo_arquivo_atual.clone() {
|
||||||
|
TipoArquivo::Csv => Layout::Csv {
|
||||||
|
id: None,
|
||||||
|
nome: app.nome_layout_atual.trim().to_string(),
|
||||||
|
config: app.layout_csv_atual.clone(),
|
||||||
|
},
|
||||||
|
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||||
|
id: None,
|
||||||
|
nome: app.nome_layout_atual.trim().to_string(),
|
||||||
|
config: app.layout_xlsx_atual.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(conn) = &app.conn {
|
||||||
|
match salvar_layout(conn, &layout) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.recarregar_layouts();
|
||||||
|
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn importar_json(app: &mut App, conteudo: &str) {
|
||||||
|
if let Some(conn) = &app.conn {
|
||||||
|
match importar_layout_json(conn, conteudo, false, None) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.recarregar_layouts();
|
||||||
|
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
||||||
|
}
|
||||||
|
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||||
|
// Exibir opções: sobrescrever ou cancelar
|
||||||
|
app.exibir_aviso(
|
||||||
|
"Conflito de nome",
|
||||||
|
format!(
|
||||||
|
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
|
||||||
|
nome
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// TODO: implementar fluxo completo de sobrescrever com entrada de novo nome
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
||||||
|
match layout {
|
||||||
|
Layout::Csv { config, .. } => {
|
||||||
|
app.layout_csv_atual = config.clone();
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
|
}
|
||||||
|
Layout::Xlsx { config, .. } => {
|
||||||
|
app.layout_xlsx_atual = config.clone();
|
||||||
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod configuracao_colunas;
|
||||||
|
pub mod import;
|
||||||
|
pub mod layouts;
|
||||||
|
pub mod resultado;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||||
|
use crate::domain::{
|
||||||
|
entities::resultado_analise::ResultadoAnalise,
|
||||||
|
services::parser_monetario::formatar_valor_br,
|
||||||
|
};
|
||||||
|
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||||
|
use crate::ui::app::{App, EstadoApp};
|
||||||
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
|
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||||
|
|
||||||
|
/// Renderiza a tela de resultados.
|
||||||
|
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||||
|
// Extrair resultado do estado (sem mover)
|
||||||
|
let resultado = match &app.estado {
|
||||||
|
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.heading("Resultado da Análise");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Nova Análise").clicked() {
|
||||||
|
app.estado = EstadoApp::Importando;
|
||||||
|
app.notas_importadas.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||||
|
app.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("📄 Exportar PDF").clicked() {
|
||||||
|
exportar_para_pdf(app, &resultado);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Controle de itens por página
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Itens por página:");
|
||||||
|
for &opcao in OPCOES_PAGINA {
|
||||||
|
if ui
|
||||||
|
.selectable_label(app.itens_por_pagina == opcao, opcao.to_string())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app.itens_por_pagina = opcao;
|
||||||
|
app.pagina_faltantes = 0;
|
||||||
|
app.pagina_duplicatas = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
// Totais
|
||||||
|
renderizar_totais(ui, &resultado);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Faltantes
|
||||||
|
renderizar_faltantes(ui, app, &resultado);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Duplicatas
|
||||||
|
renderizar_duplicatas(ui, app, &resultado);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
||||||
|
ui.label(egui::RichText::new("Totais").heading().strong());
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
ui.label(format!(
|
||||||
|
"Total Geral: R$ {}",
|
||||||
|
formatar_valor_br(&resultado.soma_total)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut series: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
||||||
|
series.sort();
|
||||||
|
|
||||||
|
for serie in series {
|
||||||
|
let soma = &resultado.soma_por_serie[serie];
|
||||||
|
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
|
||||||
|
ui.label(format!(
|
||||||
|
" Série {}: {} nota(s) — R$ {}",
|
||||||
|
serie,
|
||||||
|
total_notas,
|
||||||
|
formatar_valor_br(soma)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
|
let total_faltantes = resultado.total_faltantes();
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"Notas Faltantes ({} total)",
|
||||||
|
total_faltantes
|
||||||
|
))
|
||||||
|
.heading()
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
if total_faltantes == 0 {
|
||||||
|
ui.label("✔ Nenhuma nota faltante.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
|
||||||
|
series.sort();
|
||||||
|
|
||||||
|
for serie in series {
|
||||||
|
let faltantes = &resultado.faltantes_por_serie[serie];
|
||||||
|
if faltantes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label(format!(
|
||||||
|
"Série {} — {} faltante(s):",
|
||||||
|
serie,
|
||||||
|
faltantes.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
// Paginação
|
||||||
|
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
|
if app.pagina_faltantes >= total_paginas {
|
||||||
|
app.pagina_faltantes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
||||||
|
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||||
|
|
||||||
|
for numero in &faltantes[inicio..fim] {
|
||||||
|
ui.label(format!(" • {}", numero));
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_paginas > 1 {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("◀").clicked() && app.pagina_faltantes > 0 {
|
||||||
|
app.pagina_faltantes -= 1;
|
||||||
|
}
|
||||||
|
ui.label(format!(
|
||||||
|
"Página {} / {}",
|
||||||
|
app.pagina_faltantes + 1,
|
||||||
|
total_paginas
|
||||||
|
));
|
||||||
|
if ui.button("▶").clicked() && app.pagina_faltantes + 1 < total_paginas {
|
||||||
|
app.pagina_faltantes += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
|
let total_dup = resultado.total_duplicatas();
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"Notas Duplicadas ({} grupo(s))",
|
||||||
|
total_dup
|
||||||
|
))
|
||||||
|
.heading()
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
if total_dup == 0 {
|
||||||
|
ui.label("✔ Nenhuma nota duplicada.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
|
series.sort();
|
||||||
|
|
||||||
|
for serie in series {
|
||||||
|
let duplicatas = &resultado.duplicadas_por_serie[serie];
|
||||||
|
if duplicatas.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label(format!(
|
||||||
|
"Série {} — {} grupo(s) duplicado(s):",
|
||||||
|
serie,
|
||||||
|
duplicatas.len()
|
||||||
|
));
|
||||||
|
|
||||||
|
let total_paginas =
|
||||||
|
(duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
|
if app.pagina_duplicatas >= total_paginas {
|
||||||
|
app.pagina_duplicatas = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inicio = app.pagina_duplicatas * app.itens_por_pagina;
|
||||||
|
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||||
|
|
||||||
|
for (numero, count) in &duplicatas[inicio..fim] {
|
||||||
|
ui.label(format!(
|
||||||
|
" • NF {} / Série {} — {} ocorrências",
|
||||||
|
numero, serie, count
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_paginas > 1 {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("◀").clicked() && app.pagina_duplicatas > 0 {
|
||||||
|
app.pagina_duplicatas -= 1;
|
||||||
|
}
|
||||||
|
ui.label(format!(
|
||||||
|
"Página {} / {}",
|
||||||
|
app.pagina_duplicatas + 1,
|
||||||
|
total_paginas
|
||||||
|
));
|
||||||
|
if ui.button("▶").clicked() && app.pagina_duplicatas + 1 < total_paginas {
|
||||||
|
app.pagina_duplicatas += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
||||||
|
if let Some(caminho) = rfd::FileDialog::new()
|
||||||
|
.set_file_name("relatorio.pdf")
|
||||||
|
.add_filter("PDF", &["pdf"])
|
||||||
|
.save_file()
|
||||||
|
{
|
||||||
|
let gerador = GenpdfGenerator;
|
||||||
|
let nome_layout = if app.nome_layout_atual.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(app.nome_layout_atual.as_str())
|
||||||
|
};
|
||||||
|
|
||||||
|
match exportar_pdf(
|
||||||
|
&gerador,
|
||||||
|
resultado,
|
||||||
|
&app.nome_arquivo,
|
||||||
|
nome_layout,
|
||||||
|
&caminho,
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user