feat: implement main application structure with UI and layout management
- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs. - Created module structure for UI components in `src/ui/mod.rs`. - Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`. - Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files. - Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts. - Created result display screen in `src/ui/screens/resultado.rs` to show analysis results. - Introduced modular organization for screens in `src/ui/screens/mod.rs`.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user