feat: Implement GUI for SPED Automático with configuration management and theme support

- Added a new GUI application using the Iced library for user interaction.
- Implemented configuration loading from an INI file, including fields for model file, spreadsheet path, and reporting month.
- Introduced theme management with options for auto, light, and dark modes based on system preferences.
- Added functionality to browse for model and spreadsheet files.
- Implemented validation and formatting for the reporting month input.
- Integrated asynchronous processing of the SPED generation logic, with user feedback during execution.
- Created a logging mechanism to track application events and errors.
- Added tests for core functionalities including file reading/writing and data processing.
This commit is contained in:
FelipeCN
2025-11-17 13:42:08 -03:00
commit 8f81024a6b
12 changed files with 5992 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 256 256"
role="img"
aria-label="SPED Automático icon"
version="1.1"
id="svg11"
sodipodi:docname="icon.svg"
inkscape:version="1.4.2 (f4327f4, 2025-05-13)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview11"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.9335938"
inkscape:cx="127.74141"
inkscape:cy="128"
inkscape:window-width="1366"
inkscape:window-height="697"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg11" />
<defs
id="defs4">
<linearGradient
id="g1"
x1="0"
y1="0"
x2="1"
y2="1">
<stop
offset="0%"
stop-color="#2B9AF3"
id="stop1" />
<stop
offset="100%"
stop-color="#0066CC"
id="stop2" />
</linearGradient>
<linearGradient
id="g2"
x1="-0.58073085"
y1="-0.43049202"
x2="-0.58073085"
y2="172.6273"
gradientTransform="scale(0.86098405,1.1614617)"
gradientUnits="userSpaceOnUse">
<stop
offset="0%"
stop-color="#FFFFFF"
stop-opacity="0.95"
id="stop3" />
<stop
offset="100%"
stop-color="#F3F3F3"
stop-opacity="0.95"
id="stop4" />
</linearGradient>
</defs>
<!-- Background rounded square -->
<rect
x="8"
y="8"
width="240"
height="240"
rx="28"
ry="28"
fill="url(#g1)"
id="rect4" />
<!-- Paper / document -->
<g
transform="translate(55.135354,26.448485)"
id="g8">
<rect
x="0"
y="0"
width="148"
height="200"
rx="12"
ry="12"
fill="url(#g2)"
stroke-opacity="0.12"
stroke="#000000"
id="rect5"
style="fill:url(#g2)" />
<!-- folded corner -->
<path
d="m 128,0 20,20 h -20 z"
fill="#eaeaea"
opacity="0.9"
id="path5" />
<!-- lines -->
<rect
x="16"
y="28"
width="116"
height="8"
rx="4"
fill="#d0d6dd"
opacity="0.9"
id="rect6" />
<rect
x="16"
y="52"
width="96"
height="8"
rx="4"
fill="#d0d6dd"
opacity="0.9"
id="rect7" />
<rect
x="16"
y="76"
width="80"
height="8"
rx="4"
fill="#d0d6dd"
opacity="0.9"
id="rect8" />
</g>
<!-- Gear / settings overlay -->
<!-- Check mark to indicate validated model -->
<g
transform="translate(90,132) scale(1.1)"
id="g11">
<path
d="m -2.6042241,-1.4104683 14.0000001,14.0000003 32,-32"
fill="none"
stroke="#0a8a44"
stroke-width="10"
stroke-linecap="round"
stroke-linejoin="round"
id="path10" />
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>

After

Width:  |  Height:  |  Size: 3.3 KiB

+374
View File
@@ -0,0 +1,374 @@
#![windows_subsystem = "windows"]
use iced::{executor, Application, Command, Element, Length, Settings};
use iced::widget::{Button, Column, Container, Row, Scrollable, Text, TextInput, Space, Radio};
use iced::subscription;
use std::fs;
use std::fmt::Debug;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThemeMode {
Auto,
Light,
Dark,
}
impl FromStr for ThemeMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"auto" => Ok(ThemeMode::Auto),
"light" => Ok(ThemeMode::Light),
"dark" => Ok(ThemeMode::Dark),
_ => Err(()),
}
}
}
impl std::fmt::Display for ThemeMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ThemeMode::Auto => write!(f, "Auto"),
ThemeMode::Light => write!(f, "Light"),
ThemeMode::Dark => write!(f, "Dark"),
}
}
}
// Detecta preferência do sistema (Windows via registry). Retorna true se preferir dark.
#[cfg(target_os = "windows")]
fn system_prefers_dark() -> bool {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
if let Ok(personalize) = hkcu.open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") {
if let Ok(val) = personalize.get_value::<u32, _>("AppsUseLightTheme") {
return val == 0; // 0 == dark
}
}
false
}
#[cfg(not(target_os = "windows"))]
fn system_prefers_dark() -> bool { false }
// Formata/valida o mês de apuração para MM/YYYY.
// Aceita entradas como "8/2025", "08-2025", "202508", "082025", "2025-08" e normaliza para "MM/YYYY".
fn format_mes_apuracao(input: &str) -> Option<String> {
let s = input.trim();
if s.is_empty() { return None; }
// separa por não dígitos
let parts: Vec<&str> = s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).collect();
let (month_str, year_str): (String, String);
if parts.len() == 2 {
// pode ser (MM, YYYY) ou (YYYY, MM)
if parts[0].len() <= 2 && parts[1].len() == 4 {
month_str = parts[0].to_string();
year_str = parts[1].to_string();
} else if parts[0].len() == 4 && parts[1].len() <= 2 {
month_str = parts[1].to_string();
year_str = parts[0].to_string();
} else {
return None;
}
} else if parts.len() == 1 {
let digits = parts[0];
match digits.len() {
6 => {
// Ambíguo: pode ser MMYYYY ou YYYYMM. Vamos checar se os 4 primeiros formam um ano razoável.
let first4 = &digits[..4];
if let Ok(y) = first4.parse::<i32>() {
if (1900..=2100).contains(&y) {
// YYYYMM
year_str = first4.to_string();
month_str = digits[4..6].to_string();
} else {
// MMYYYY
month_str = digits[..2].to_string();
year_str = digits[2..6].to_string();
}
} else {
month_str = digits[..2].to_string();
year_str = digits[2..6].to_string();
}
}
5 => {
// exemplo 82025 -> 8 / 2025
month_str = digits[..1].to_string();
year_str = digits[1..5].to_string();
}
_ => return None,
}
} else {
return None;
}
// parse e valida
if let (Ok(month), Ok(year)) = (month_str.parse::<u32>(), year_str.parse::<u32>()) {
if (1..=12).contains(&month) && (1900..=9999).contains(&year) {
return Some(format!("{:02}/{:04}", month, year));
}
}
None
}
#[derive(Debug, Clone)]
enum Message {
ModeloChanged(String),
PlanilhaChanged(String),
MesChanged(String),
ThemeChanged(ThemeMode),
BrowseModelo,
BrowsePlanilha,
SaveConfig,
RunProcess,
RunFinished(Result<String, String>),
}
struct GuiApp {
modelo_value: String,
planilha_value: String,
mes_value: String,
theme_mode: ThemeMode,
running: bool,
last_message: Option<String>,
}
impl Application for GuiApp {
type Executor = executor::Default;
type Message = Message;
type Flags = (String,);
type Theme = iced::Theme;
fn new(flags: Self::Flags) -> (Self, Command<Self::Message>) {
// flags 0: path para o INI (padrão: config.ini)
let ini_path = flags.0.clone();
let mut modelo = String::new();
let mut planilha = String::new();
let mut mes = String::new();
let mut theme_mode = ThemeMode::Auto;
if let Ok(contents) = fs::read_to_string(&ini_path) {
// simples parsing da seção DEFAULT: procura por chaves no arquivo
for line in contents.lines() {
let l = line.trim();
if l.starts_with('#') || l.is_empty() || l.starts_with('[') { continue; }
if let Some(eq) = l.find('=') {
let key = l[..eq].trim();
let val = l[eq+1..].trim();
match key {
"ModeloArquivo" | "Modelo_Arquivo" => if modelo.is_empty() { modelo = val.to_string(); },
"CaminhoPlanilha" | "Caminho_Planilha" => if planilha.is_empty() { planilha = val.to_string(); },
"Mes_Apuracao" | "MesApuracao" => if mes.is_empty() { mes = val.to_string(); },
"ThemeMode" | "Theme_Mode" => if let Ok(tm) = ThemeMode::from_str(val) { theme_mode = tm; },
_ => {}
}
}
}
}
(
GuiApp {
modelo_value: modelo,
planilha_value: planilha,
mes_value: mes,
theme_mode,
running: false,
last_message: None,
},
Command::none(),
)
}
fn title(&self) -> String {
String::from("SPED Automático - GUI")
}
fn theme(&self) -> Self::Theme {
match self.theme_mode {
ThemeMode::Auto => if system_prefers_dark() { iced::Theme::Dark } else { iced::Theme::Light },
ThemeMode::Light => iced::Theme::Light,
ThemeMode::Dark => iced::Theme::Dark,
}
}
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
match message {
Message::ModeloChanged(s) => if !self.running { self.modelo_value = s },
Message::PlanilhaChanged(s) => if !self.running { self.planilha_value = s },
Message::MesChanged(s) => if !self.running { self.mes_value = s },
Message::BrowseModelo => {
if let Some(path) = rfd::FileDialog::new().add_filter("Arquivo", &["*", "*.*"]).pick_file() {
self.modelo_value = path.to_string_lossy().to_string();
}
}
Message::BrowsePlanilha => {
if let Some(path) = rfd::FileDialog::new().add_filter("Excel", &["xlsx"]).pick_file() {
self.planilha_value = path.to_string_lossy().to_string();
}
}
Message::SaveConfig => {
if !self.running {
// validar/formatar Mes_Apuracao
if let Some(mes_fmt) = format_mes_apuracao(&self.mes_value) {
let ini_path = "config.ini";
let content = format!("[DEFAULT]\nModeloArquivo={}\nCaminhoPlanilha={}\nMes_Apuracao={}\nThemeMode={}\nCodigo_ST=999\nCodigo_Receita=100099\n",
&self.modelo_value, &self.planilha_value, &mes_fmt, self.theme_mode.to_string());
let _ = fs::write(ini_path, content);
self.last_message = Some("Configuração salva.".to_string());
self.mes_value = mes_fmt; // atualiza campo com formato normalizado
} else {
let _ = rfd::MessageDialog::new().set_title("Erro").set_description("Mes_Apuracao inválido. Use MM/YYYY (ex: 08/2025).").show();
}
}
}
Message::ThemeChanged(tm) => {
if !self.running {
self.theme_mode = tm;
}
}
Message::RunProcess => {
if !self.running {
// Chama a lógica interna da biblioteca de forma assíncrona
self.running = true;
self.last_message = Some("Executando...".to_string());
let cfg = "config.ini".to_string();
return Command::perform(async move { sped_automatico::run_from_config(&cfg) }, |res| match res { Ok(s) => Message::RunFinished(Ok(s)), Err(e) => Message::RunFinished(Err(format!("{}", e))) });
}
}
Message::RunFinished(res) => {
// marca fim da execução e mostra diálogos
self.running = false;
match res {
Ok(out_path) => {
let msg = format!("Processamento finalizado. Saída: {}", out_path);
self.last_message = Some(msg.clone());
// diálogo de sucesso
let _ = rfd::MessageDialog::new().set_title("Sucesso").set_description(&msg).show();
}
Err(e) => {
let msg = format!("Erro no processamento: {}", e);
self.last_message = Some(msg.clone());
let _ = rfd::MessageDialog::new().set_title("Erro").set_description(&msg).show();
}
}
}
}
Command::none()
}
fn view(&self) -> Element<'_, Self::Message> {
// Label column width
let label_width = Length::Fixed(140.0);
// Modelo
let modelo_row = Row::new()
.spacing(8)
.push(Text::new("ModeloArquivo:").width(label_width))
.push(TextInput::new("Caminho do arquivo modelo", &self.modelo_value)
.on_input(Message::ModeloChanged)
.width(Length::Fill))
.push(Button::new(Text::new("..."))
.on_press(Message::BrowseModelo));
// Planilha
let planilha_row = Row::new()
.spacing(8)
.push(Text::new("CaminhoPlanilha:").width(label_width))
.push(TextInput::new("Arquivo .xlsx", &self.planilha_value)
.on_input(Message::PlanilhaChanged)
.width(Length::Fill))
.push(Button::new(Text::new("..."))
.on_press(Message::BrowsePlanilha));
// Mes
let mes_row = Row::new()
.spacing(8)
.push(Text::new("Mes_Apuracao:").width(label_width))
.push(TextInput::new("MM/YYYY", &self.mes_value)
.on_input(Message::MesChanged)
.width(Length::Fixed(140.0)));
// Buttons aligned to right
let buttons = Row::new()
.spacing(10)
.push(Space::with_width(Length::Fill))
.push(Button::new(Text::new("Salvar")).on_press(Message::SaveConfig))
.push(Space::with_width(Length::Fixed(8.0)))
.push(Button::new(Text::new("Executar")).on_press(Message::RunProcess));
// Theme selector (Radio buttons)
let theme_row = Row::new()
.spacing(8)
.push(Text::new("Tema:"))
.push(Radio::new("Auto", ThemeMode::Auto, Some(self.theme_mode), |t| Message::ThemeChanged(t)))
.push(Radio::new("Light", ThemeMode::Light, Some(self.theme_mode), |t| Message::ThemeChanged(t)))
.push(Radio::new("Dark", ThemeMode::Dark, Some(self.theme_mode), |t| Message::ThemeChanged(t)));
let content = Column::new()
.spacing(12)
.padding(16)
.push(Text::new("Configuração SPED").size(24))
.push(modelo_row)
.push(planilha_row)
.push(mes_row)
.push(theme_row)
.push(Space::with_height(Length::Fixed(8.0)))
.push(buttons);
// Banner / status
let status = if self.running {
Text::new("Executando...").size(16)
} else if let Some(ref m) = self.last_message {
Text::new(m.clone()).size(14)
} else {
Text::new("").size(14)
};
// Scrollable container to adapt to small windows and use full width on large ones
let scroll = Scrollable::new(content).width(Length::Fill).height(Length::Fill);
Container::new(Column::new().push(status).push(scroll))
.width(Length::Fill)
.height(Length::Fill)
.center_x()
.center_y()
.into()
}
fn subscription(&self) -> subscription::Subscription<Self::Message> {
subscription::Subscription::none()
}
}
fn main() {
// Inicializar logger para salvar em arquivo
if let Ok(file) = std::fs::File::create("sped_gui.log") {
env_logger::Builder::from_default_env()
.target(env_logger::Target::Pipe(Box::new(file)))
.filter_level(log::LevelFilter::Info)
.init();
} else {
env_logger::init();
}
let ini_path = "config.ini".to_string();
let mut settings: Settings< (String,) > = Settings { flags: (ini_path,), ..Settings::default() };
// Ajuste do tamanho inicial da janela para evitar aparência esticada verticalmente
settings.window = iced::window::Settings {
size: (900, 600),
min_size: None,
max_size: None,
resizable: true,
decorations: true,
transparent: false,
visible: true,
position: iced::window::Position::Centered,
level: iced::window::Level::Normal,
platform_specific: Default::default(),
icon: None,
};
GuiApp::run(settings).unwrap();
}
+609
View File
@@ -0,0 +1,609 @@
use anyhow::Result;
use chardetng::EncodingDetector;
use encoding_rs::{Encoding, UTF_8};
use std::fs;
use std::path::Path;
use calamine::Range;
use chrono::{NaiveDate, Duration};
use calamine::open_workbook_auto;
use calamine::Reader;
pub fn detect_encoding(bytes: &[u8]) -> Option<&'static Encoding> {
let mut detector = EncodingDetector::new();
detector.feed(bytes, true);
let encoding = detector.guess(None, true);
Some(encoding)
}
pub fn decode_bytes_to_string(bytes: &[u8]) -> Result<String> {
if let Some(encoding) = detect_encoding(bytes) {
let (cow, _actual, _had_errors) = encoding.decode(bytes);
Ok(cow.into_owned())
} else {
Ok(String::from_utf8_lossy(bytes).into_owned())
}
}
/// Lê um arquivo detectando a codificação e retornando uma String UTF-8
pub fn read_file_to_string<P: AsRef<Path>>(path: P) -> Result<String> {
let bytes = fs::read(path)?;
decode_bytes_to_string(&bytes)
}
/// Escreve uma string no caminho especificado usando a codificação dada (ex: "iso-8859-1").
/// Se a codificação não for encontrada, usa UTF-8.
pub fn write_string_with_encoding<P: AsRef<Path>>(path: P, content: &str, encoding_label: Option<&str>) -> Result<()> {
let label = encoding_label.unwrap_or("iso-8859-1");
let encoding = Encoding::for_label(label.as_bytes()).unwrap_or(UTF_8);
let (bytes, _, _) = encoding.encode(content);
fs::write(path, bytes.as_ref())?;
Ok(())
}
/// Converte um DataType do calamine para string apropriada para o campo valor
/// - Floats serão formatados com vírgula como separador decimal
/// - Strings retornadas como estão
/// Converte uma representação textual (vinda de uma célula via `to_string()`) em um valor
/// formatado para o SPED (ex: números com vírgula e datas no formato ddMMyyyy).
pub fn datatype_to_val_str_from_str(s: &str) -> Option<String> {
let s_trim = s.trim();
if s_trim.is_empty() {
return None;
}
// Detectar formatos comuns:
// - se contém ',' e '.', assumimos formato europeu: '.' thousands, ',' decimal
// - caso contrário, normalizamos ',' -> '.' (decimal)
let normalized = if s_trim.contains(',') && s_trim.contains('.') {
let tmp = s_trim.replace('.', ""); // remover thousands
tmp.replace(',', ".")
} else {
s_trim.replace(',', ".")
};
// Se após normalização houver um ponto decimal, tratar como float
if normalized.contains('.') {
if let Ok(f) = normalized.parse::<f64>() {
return Some(format!("{:.2}", f).replace('.', ","));
}
} else {
// sem ponto decimal: pode ser inteiro ou serial de data do Excel
if let Ok(i) = normalized.parse::<i64>() {
if i > 1000 {
let base = NaiveDate::from_ymd_opt(1899, 12, 30).unwrap();
if let Some(date) = base.checked_add_signed(Duration::days(i)) {
return Some(date.format("%d%m%Y").to_string());
}
}
return Some(format!("{}", i));
}
}
// caso contrário retornar a string tal como está
Some(s_trim.to_string())
}
/// Processa uma sheet (calamine::Range) e retorna Vec<String> com linhas no formato E250
/// Assumimos que a coluna 39 (zero-based 39 -> 40a coluna) contém o valor, e coluna 1 contém a data
/// Processa uma sheet específica do calamine (Range<DataType>) e retorna linhas E250
pub fn process_sheet<T: std::fmt::Display + calamine::CellType>(range: &Range<T>, codigo_receita: &str, mes_apuracao: &str) -> Vec<String> {
// Agrega os valores da planilha por estado e retorna UM único |E250| com o total
let mut total: f64 = 0.0;
let mut any = false;
let mut chosen_vencimento: Option<String> = None;
for row in range.rows().skip(1) {
let valor_dt_opt = if row.len() > 39 { row.get(39) } else { None };
let vencimento_dt_opt = if row.len() > 1 { row.get(1) } else { None };
let valor_str_opt = valor_dt_opt
.map(|v| v.to_string())
.and_then(|s| datatype_to_val_str_from_str(&s[..]));
if let Some(valor_str) = valor_str_opt {
if valor_str == "0" || valor_str == "0,00" || valor_str.trim().is_empty() {
continue;
}
// tentar parse do valor formatado (ex: "1.234,56" ou "1234,56")
let normalized = valor_str.replace('.', "").replace(',', ".");
if let Ok(v) = normalized.parse::<f64>() {
total += v;
any = true;
}
// escolher vencimento: a primeira data válida encontrada
if chosen_vencimento.is_none() {
if let Some(venc_dt) = vencimento_dt_opt
.map(|v| v.to_string())
.and_then(|s| datatype_to_val_str_from_str(&s[..]))
{
chosen_vencimento = Some(venc_dt);
}
}
}
}
if !any {
return Vec::new();
}
// formatar total para o SPED (vírgula decimal)
let valor_formatted = format!("{:.2}", total).replace('.', ",");
let venc = chosen_vencimento.unwrap_or_else(|| "01012000".to_string());
let linha = format!("|E250|999|{}|{}|{}|||||{}|", valor_formatted, venc, codigo_receita, mes_apuracao.replace('/', ""));
vec![linha]
}
/// Processa uma única linha representada por slice de Strings (útil para testes).
/// Retorna Some(linha_e250) ou None se a linha não deve produzir E250.
pub fn process_row_values(row: &[String], codigo_receita: &str, mes_apuracao: &str) -> Option<String> {
let valor_str = if row.len() > 39 {
datatype_to_val_str_from_str(&row[39])
} else {
None
};
let valor_str = valor_str.unwrap_or_else(|| "0".to_string());
if valor_str == "0" || valor_str == "0,00" || valor_str.trim().is_empty() {
return None;
}
let vencimento = if row.len() > 1 {
match datatype_to_val_str_from_str(&row[1]) {
Some(s) => s,
None => return None,
}
} else {
return None;
};
let valor_formatted = valor_str.replace('.', ",");
let linha = format!("|E250|999|{}|{}|{}|||||{}|", valor_formatted, vencimento, codigo_receita, mes_apuracao.replace('/', ""));
Some(linha)
}
pub fn find_e200_states(content: &str) -> Vec<String> {
content
.lines()
.filter_map(|line| {
if line.starts_with("|E200|") {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() > 2 {
return Some(parts[2].to_string());
}
}
None
})
.collect()
}
/// Encontra linhas que começam com |E2 e contêm o código do estado
pub fn find_e2xx_state_lines(content: &str, state_code: &str) -> Vec<(usize, String)> {
content
.lines()
.enumerate()
.filter_map(|(i, line)| {
if line.starts_with("|E2") && line.contains(&format!("|{}", state_code)) {
Some((i, line.to_string()))
} else {
None
}
})
.collect()
}
/// Extrai um valor numérico do registro |E210| dentro do bloco do estado, se existir.
/// Retorna uma string formatada como no SPED (ex: "75,8")
pub fn get_e210_value_for_state(content: &str, state_code: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
// localizar |E200|state
let mut e200_index: Option<usize> = None;
for (i, &line) in lines.iter().enumerate() {
if line.starts_with("|E200|") && line.contains(&format!("|{}|", state_code)) {
e200_index = Some(i);
break;
}
}
let e200_index = e200_index?;
// procurar o primeiro |E210| após e200_index
for i in (e200_index + 1)..lines.len() {
let line = lines[i];
if line.starts_with("|E210|") {
// campos separados por '|'
let parts: Vec<&str> = line.split('|').collect();
for part in parts.iter().skip(2) {
let p = part.trim();
if p.is_empty() { continue; }
// tentar normalizar para float: trocar ',' por '.' e remover possíveis pontos de milhar
let norm = p.replace('.', "").replace(',', ".");
if let Ok(_v) = norm.parse::<f64>() {
// retornar no formato SPED (vírgula)
return Some(p.to_string());
}
}
break;
}
if line.starts_with("|E200|") || line.starts_with("|E990|") {
break;
}
}
None
}
/// Insere um conjunto de linhas `new_values` após cada ocorrência de E2xx do estado
/// Retorna o novo conteúdo como String
pub fn insert_e250_lines(content: &str, state_code: &str, new_values: &[String]) -> String {
let mut out: Vec<String> = Vec::new();
let lines: Vec<&str> = content.lines().collect();
// Encontrar início do bloco E200 deste estado
let mut e200_index: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
if line.starts_with("|E200|") && line.contains(&format!("|{}", state_code)) {
e200_index = Some(i);
break;
}
}
// Se não houver E200 para o estado, nada a fazer
let e200_index = match e200_index {
Some(i) => i,
None => return content.to_string(),
};
// localizar fim do bloco: próximo |E200| (de outro estado) ou |E990| ou fim do arquivo
let mut region_end = lines.len();
for (i, line) in lines.iter().enumerate().skip(e200_index + 1) {
if line.starts_with("|E200|") || line.starts_with("|E990|") {
region_end = i;
break;
}
}
// localizar última E210 dentro do bloco (se existir). E210 não contém UF, então
// consideramos qualquer linha que comece com |E210| dentro do bloco
let mut last_e210_index: Option<usize> = None;
for (i, line) in lines.iter().enumerate().take(region_end).skip(e200_index + 1) {
if line.starts_with("|E210|") {
last_e210_index = Some(i);
}
}
let insert_abs = if let Some(idx) = last_e210_index {
idx + 1
} else {
e200_index + 1
};
// Se o bloco já contém qualquer |E250|, não alteramos nada (preserva arquivo modelo_validado)
let mut block_has_e250 = false;
for i in (e200_index + 1)..region_end {
if lines[i].starts_with("|E250|") {
block_has_e250 = true;
break;
}
}
// Copiar linhas, removendo E250s dentro do bloco do estado somente se não houver E250 no bloco;
// inserir new_values no insert_abs
for (i, &line) in lines.iter().enumerate() {
// quando atingirmos o ponto de inserção absoluto, inserir as novas linhas
if i == insert_abs {
if !block_has_e250 {
for nv in new_values {
out.push(nv.clone());
}
}
}
// se estamos dentro do bloco do estado e a linha é E250 e vamos inserir novos E250s, pular (remoção)
if !block_has_e250 && i > e200_index && i < region_end && line.starts_with("|E250|") {
continue;
}
out.push(line.to_string());
}
// caso insert_abs seja equal a lines.len() (inserção no fim), já ocorreu no loop anterior
out.join("\n")
}
/// Conta linhas que começam com |9900| e não são o registro agregado |9900|9900|
pub fn count_9900_lines(content: &str) -> usize {
content
.lines()
.filter(|line| line.starts_with("|9900|") && !line.starts_with("|9900|9900|"))
.count()
}
/// Atualiza registros finais (9900, E990, 9990, 9999) de forma similar ao app.py
pub fn atualizar_registros_finais_from_lines(lines: &[String]) -> Vec<String> {
// Remover registros finais antigos (9900/9990/9999/E990) para recalculá-los
let mut filtered: Vec<String> = lines
.iter()
.filter(|l| !(l.starts_with("|9900|") || l.starts_with("|9990|") || l.starts_with("|9999|") || l.starts_with("|E990|")))
.cloned()
.collect();
// Inserir novo |E990|: contar linhas do bloco E e inserir antes do próximo bloco (G001) ou fim
if let Some(e_start) = filtered.iter().position(|l| l.starts_with("|E001|")) {
// fim do bloco E é o índice do primeiro G001 (ou fim do arquivo)
let e_end = filtered.iter().position(|l| l.starts_with("|G001|")).unwrap_or(filtered.len());
// número de linhas do bloco E incluindo o próprio E990 = (e_end - e_start) + 1
let e_count_including_e990 = (e_end - e_start) + 1;
let e990_line = format!("|E990|{}|", e_count_including_e990);
// inserir em e_end (antes de G001)
if e_end <= filtered.len() {
filtered.insert(e_end, e990_line);
} else {
filtered.push(e990_line);
}
}
// Contar ocorrências por registro (preservando a ordem de primeira aparição)
use std::collections::HashMap;
let mut counts: HashMap<String, usize> = HashMap::new();
let mut order: Vec<String> = Vec::new();
for l in &filtered {
if let Some(rest) = l.strip_prefix('|') {
if let Some(pos) = rest.find('|') {
let reg = rest[..pos].to_string();
if !order.contains(&reg) {
order.push(reg.clone());
}
*counts.entry(reg).or_insert(0) += 1;
}
}
}
// (debug logs removed)
// Verificar se o modelo original já tinha um agregado 9900; se sim, preservar a lista original
let original_had_9900_aggregate = lines.iter().any(|l| l.starts_with("|9900|9900|"));
// Reunir as linhas 9900 que serão adicionadas
let mut new_9900_lines: Vec<String> = Vec::new();
if original_had_9900_aggregate {
// extrair do arquivo original todas as linhas |9900| (excluindo o agregado) na ordem original
let mut original_9900_regs: std::collections::HashSet<String> = std::collections::HashSet::new();
for l in lines.iter() {
if l.starts_with("|9900|") && !l.starts_with("|9900|9900|") {
// extrair o nome do registro desta linha 9900
if let Some(rest) = l.strip_prefix("|9900|") {
if let Some(pos) = rest.find('|') {
let reg = rest[..pos].to_string();
original_9900_regs.insert(reg.clone());
}
}
new_9900_lines.push(l.clone());
}
}
// Adicionar registros que estão em filtered mas não estavam no 9900 original (como E250 inserido)
for reg in &order {
if !original_9900_regs.contains(reg) {
if let Some(cnt) = counts.get(reg) {
new_9900_lines.push(format!("|9900|{}|{}|", reg, cnt));
}
}
}
} else {
// Preparar as linhas 9900 que serão adicionadas (sem tocar ainda no `filtered`)
for reg in &order {
if let Some(cnt) = counts.get(reg) {
new_9900_lines.push(format!("|9900|{}|{}|", reg, cnt));
}
}
// garantir presença de E990 na listagem 9900 (se não estava entre os registros contados)
if !order.contains(&"E990".to_string()) {
new_9900_lines.push("|9900|E990|1|".to_string());
}
// dentro do 9900 os registros 9990/9999 são registrados apenas como presença (1)
new_9900_lines.push("|9900|9990|1|".to_string());
new_9900_lines.push("|9900|9999|1|".to_string());
}
// Anexar as novas linhas 9900 ao filtered
for l in new_9900_lines.iter() {
filtered.push(l.clone());
}
// calcular quantas entradas 9900 existem (excluindo o agregado que vamos inserir agora)
let count_9900_entries = filtered.iter().filter(|l| l.starts_with("|9900|")).count();
// inserir o registro agregado 9900 (número de 9900 escritos incluindo o próprio agregado)
filtered.push(format!("|9900|9900|{}|", count_9900_entries + 1));
// Recalcular os totais finais agora que temos o vetor completo (exceto |9990| e |9999| externos)
let total_final_len = filtered.len() + 2; // +2 para os registros finais externos |9990| e |9999|
// calcular total do bloco 9 (se existir 9001)
let total_bloco9 = if let Some(inicio) = filtered.iter().position(|l| l.starts_with("|9001|")) {
total_final_len - inicio
} else {
0
};
// por fim, adicionar 9990 e 9999 com os valores calculados
filtered.push(format!("|9990|{}|", total_bloco9));
filtered.push(format!("|9999|{}|", total_final_len));
filtered
}
/// Processa conforme um arquivo de configuração INI simples (mesma lógica do binário `main`)
/// Retorna o caminho do arquivo de saída escrito em caso de sucesso.
pub fn run_from_config(config_path: &str) -> Result<String> {
// leitura simples do INI (mesma lógica que o main.rs)
let ini_text = fs::read_to_string(config_path)?;
fn read_default_value(ini_text: &str, key: &str) -> Option<String> {
let mut in_default = false;
for line in ini_text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_default = line.eq_ignore_ascii_case("[DEFAULT]") || line.eq_ignore_ascii_case("[default]");
continue;
}
if in_default && !line.is_empty() && !line.starts_with(';') && line.contains('=') {
let mut parts = line.splitn(2, '=');
let k = parts.next()?.trim();
let v = parts.next()?.trim();
if k.eq_ignore_ascii_case(key) {
return Some(v.to_string());
}
}
}
None
}
let modelo_arquivo = read_default_value(&ini_text, "ModeloArquivo").ok_or_else(|| anyhow::anyhow!("ModeloArquivo ausente no INI"))?;
let caminho_planilha = read_default_value(&ini_text, "CaminhoPlanilha").ok_or_else(|| anyhow::anyhow!("CaminhoPlanilha ausente no INI"))?;
let codigo_receita = read_default_value(&ini_text, "CodigoReceita")
.or_else(|| read_default_value(&ini_text, "Codigo_Receita"))
.unwrap_or_else(|| "100099".to_string());
let mes_apuracao = read_default_value(&ini_text, "MesApuracao")
.or_else(|| read_default_value(&ini_text, "Mes_Apuracao"))
.unwrap_or_else(|| "01/2025".to_string());
let bytes = fs::read(&modelo_arquivo)?;
let mut content = decode_bytes_to_string(&bytes)?;
let estados = find_e200_states(&content);
log::info!("Estados encontrados (E200): {:?}", estados);
match open_workbook_auto(&caminho_planilha) {
Ok(mut workbook) => {
let sheet_names = workbook.sheet_names().to_vec();
log::info!("Folhas na planilha: {:?}", sheet_names);
for estado in estados.iter() {
// Primeiro tenta processar a planilha (prioridade)
if let Ok(range) = workbook.worksheet_range(estado) {
log::info!("Estado '{}': processando planilha...", estado);
let new_e250s = process_sheet(&range, &codigo_receita, &mes_apuracao);
log::info!("Geradas {} linhas E250 para estado {}", new_e250s.len(), estado);
if !new_e250s.is_empty() {
content = insert_e250_lines(&content, estado, &new_e250s);
} else {
log::warn!("Planilha '{}' processada, mas nenhuma linha E250 gerada", estado);
}
} else {
// Se não há planilha, mantém o estado como está no arquivo original
log::info!("Estado '{}': sem planilha - mantendo arquivo original intacto para este estado", estado);
}
}
let linhas: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let updated = atualizar_registros_finais_from_lines(&linhas);
// Remove extensão do arquivo original e adiciona sufixo
let mut out_path = modelo_arquivo.clone();
if let Some(pos) = out_path.rfind('.') {
out_path.truncate(pos);
}
out_path.push_str("_processado.txt");
let mut out_content = updated.join("\n");
if !out_content.ends_with('\n') {
out_content.push('\n');
}
write_string_with_encoding(&out_path, &out_content, Some("iso-8859-1"))?;
return Ok(out_path);
}
Err(e) => Err(anyhow::anyhow!("Erro ao abrir planilha: {}", e)),
}
}
#[cfg(test)]
mod sped_tests {
use super::*;
#[test]
fn test_insert_e250_and_update_finais() {
let content = "|E200|SP|\n|E210|something|\n|E250|old|\n|9900|something|\n|9001|x|\n";
let new_values = vec!["|E250|999|10,00|01012025|123|".to_string()];
let after = insert_e250_lines(content, "SP", &new_values);
assert!(after.contains("|E250|999|10,00|01012025|123|"));
let lines: Vec<String> = after.lines().map(|s| s.to_string()).collect();
let updated = atualizar_registros_finais_from_lines(&lines);
// ver se adicionou os finais
assert!(updated.iter().any(|l| l.starts_with("|9900|9900|")));
assert!(updated.iter().any(|l| l.starts_with("|9999|")));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
#[test]
fn test_read_write_iso8859_1() {
// preparar arquivo temporário
let mut path = env::temp_dir();
path.push("sped_test_iso.txt");
let original = "Olá, São Paulo — ç ão";
// escrever bytes em ISO-8859-1 diretamente
let encoding = Encoding::for_label(b"iso-8859-1").unwrap_or(UTF_8);
let (bytes, _, _) = encoding.encode(original);
fs::write(&path, bytes.as_ref()).expect("escrever temp falhou");
// ler usando nossa função (que detecta e decodifica)
let decoded = read_file_to_string(&path).expect("leitura falhou");
assert!(decoded.contains("São Paulo"));
// reescrever usando nossa função (mantendo ISO-8859-1)
let new_content = "Teste recodificação: ç õ á";
write_string_with_encoding(&path, new_content, Some("iso-8859-1")).expect("escrever recod falhou");
// ler os bytes e decodificar novamente
let decoded2 = read_file_to_string(&path).expect("leitura2 falhou");
assert!(decoded2.contains("recodificação"));
assert!(decoded2.contains("ç"));
// cleanup
let _ = fs::remove_file(&path);
}
#[test]
fn test_datatype_to_val_str_from_str_float_and_int_and_date() {
// float with dot
assert_eq!(datatype_to_val_str_from_str("1234.5"), Some("1234,50".to_string()));
// float with comma
assert_eq!(datatype_to_val_str_from_str("1.234,5"), Some("1234,50".to_string()));
// integer
assert_eq!(datatype_to_val_str_from_str("42"), Some("42".to_string()));
// excel serial date: example 44197 -> 2021-01-01 (check via base 1899-12-30)
// 44197 days after 1899-12-30 -> 2021-01-01 -> ddMMyyyy = 01012021
assert_eq!(datatype_to_val_str_from_str("44197"), Some("01012021".to_string()));
// empty
assert_eq!(datatype_to_val_str_from_str(" "), None);
// normal text
assert_eq!(datatype_to_val_str_from_str("abc"), Some("abc".to_string()));
}
#[test]
fn test_process_row_values_bounds_and_empty_cells() {
// linha curta: sem coluna 39
let row_short: Vec<String> = vec!["h1".to_string(), "01012021".to_string()];
let out = process_row_values(&row_short, "123", "01/2025");
assert!(out.is_none());
// linha com coluna 39 presente e valor válido
let mut row_full: Vec<String> = vec!["c0".to_string(); 40];
row_full[1] = "01012021".to_string();
row_full[39] = "100.50".to_string();
let out2 = process_row_values(&row_full, "123", "01/2025");
assert!(out2.is_some());
let l = out2.unwrap();
assert!(l.contains("100,50"));
}
}
+115
View File
@@ -0,0 +1,115 @@
use anyhow::{Context, Result};
use calamine::{open_workbook_auto, Reader};
use clap::Parser;
use std::fs;
use sped_automatico::{decode_bytes_to_string, find_e200_states, process_sheet, insert_e250_lines, atualizar_registros_finais_from_lines, write_string_with_encoding};
#[derive(Parser, Debug)]
#[command(author, version, about = "Protótipo Rust do sped-automatico", long_about = None)]
struct Args {
/// Arquivo de config INI (padrão: config_motoradio.ini)
#[arg(short, long)]
config: Option<String>,
}
fn main() -> Result<()> {
env_logger::init();
let args = Args::parse();
let config_path = args.config.unwrap_or_else(|| "config_motoradio.ini".to_string());
// Ler INI (parser mínimo para a seção DEFAULT)
let ini_text = fs::read_to_string(&config_path).context(format!("Falha ao ler {}", &config_path))?;
fn read_default_value(ini_text: &str, key: &str) -> Option<String> {
let mut in_default = false;
for line in ini_text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_default = line.eq_ignore_ascii_case("[DEFAULT]") || line.eq_ignore_ascii_case("[default]");
continue;
}
if in_default && !line.is_empty() && !line.starts_with(';') && line.contains('=') {
let mut parts = line.splitn(2, '=');
let k = parts.next()?.trim();
let v = parts.next()?.trim();
if k.eq_ignore_ascii_case(key) {
return Some(v.to_string());
}
}
}
None
}
let modelo_arquivo = read_default_value(&ini_text, "ModeloArquivo").context("ModeloArquivo ausente no INI")?;
let caminho_planilha = read_default_value(&ini_text, "CaminhoPlanilha").context("CaminhoPlanilha ausente no INI")?;
// Tentar múltiplas variações de chave (com e sem underscore)
let codigo_receita = read_default_value(&ini_text, "CodigoReceita")
.or_else(|| read_default_value(&ini_text, "Codigo_Receita"))
.unwrap_or_else(|| "100099".to_string());
let mes_apuracao = read_default_value(&ini_text, "MesApuracao")
.or_else(|| read_default_value(&ini_text, "Mes_Apuracao"))
.unwrap_or_else(|| "01/2025".to_string());
println!("Modelo arquivo: {}", modelo_arquivo);
println!("Planilha xlsx: {}", caminho_planilha);
// Detectar encoding e ler arquivo modelo
let bytes = fs::read(&modelo_arquivo).context("Erro ao ler arquivo modelo")?;
let mut content = decode_bytes_to_string(&bytes).context("Erro ao decodificar arquivo modelo")?;
let estados = find_e200_states(&content);
println!("Estados encontrados (E200): {:?}", estados);
// Abrir XLSX e iterar sobre todos os estados encontrados, acumulando inserções
match open_workbook_auto(&caminho_planilha) {
Ok(mut workbook) => {
println!("Folhas na planilha:");
for sheet_name in workbook.sheet_names().to_owned() {
println!(" - {}", sheet_name);
}
// Para cada estado, tentar encontrar a folha correspondente e gerar E250s
for estado in estados.iter() {
match workbook.worksheet_range(estado) {
Ok(range) => {
let rows = range.height();
println!("Sheet '{}' tem {} linhas (inclui cabeçalho)", estado, rows);
let new_e250s = process_sheet(&range, &codigo_receita, &mes_apuracao);
println!("Geradas {} linhas E250 para estado {}", new_e250s.len(), estado);
if !new_e250s.is_empty() {
// Inserir no modelo atual (acumulando mudanças)
content = insert_e250_lines(&content, estado, &new_e250s);
}
}
Err(_) => {
println!("Não encontrei a folha do estado '{}' na planilha", estado);
}
}
}
// Após processar todos os estados, atualizar os registros finais e escrever arquivo
let linhas: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let updated = atualizar_registros_finais_from_lines(&linhas);
// escrever arquivo de saída (mesma codificação: iso-8859-1)
let mut out_path = modelo_arquivo.clone();
// Remove extensão do arquivo original e adiciona sufixo
if let Some(pos) = out_path.rfind('.') {
out_path.truncate(pos);
}
out_path.push_str("_processado.txt");
let mut out_content = updated.join("\n");
if !out_content.ends_with('\n') {
out_content.push('\n');
}
write_string_with_encoding(&out_path, &out_content, Some("iso-8859-1"))?;
println!("Escrevi saída em {}", out_path);
}
Err(e) => println!("Erro ao abrir planilha: {}", e),
}
Ok(())
}