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:
@@ -0,0 +1,9 @@
|
||||
target
|
||||
*.txt
|
||||
*.csv
|
||||
*.xlsx
|
||||
.DS_Store
|
||||
relatorio.csv
|
||||
sped_gui.log
|
||||
config.ini
|
||||
RELATORIO_SPED.xlsx
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"python.testing.unittestArgs": [
|
||||
"-v",
|
||||
"-s",
|
||||
".",
|
||||
"-p",
|
||||
"*test.py"
|
||||
],
|
||||
"python.testing.pytestEnabled": false,
|
||||
"python.testing.unittestEnabled": true
|
||||
}
|
||||
Generated
+4387
File diff suppressed because it is too large
Load Diff
+32
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "sped_automatico"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chardetng = "0.1"
|
||||
encoding_rs = "0.8"
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.9"
|
||||
ini = "1.3.0"
|
||||
calamine = { version = "0.30.1"}
|
||||
chrono = "0.4"
|
||||
iced = { version = "0.10", features = ["tokio"] }
|
||||
rfd = "0.9"
|
||||
winreg = "0.10"
|
||||
|
||||
[[bin]]
|
||||
name = "gui"
|
||||
path = "src/bin/gui.rs"
|
||||
windows-subsystem = "windows"
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 0
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -0,0 +1,22 @@
|
||||
# sped_automatico (Rust protótipo)
|
||||
|
||||
Protótipo mínimo em Rust do projeto `sped-automatico`.
|
||||
|
||||
Requisitos
|
||||
- Rust toolchain (rustup + cargo)
|
||||
|
||||
Build e execução (PowerShell no Windows)
|
||||
|
||||
```powershell
|
||||
cd d:\\PROJECTS\\sped-automatico
|
||||
cargo build --release
|
||||
# ou em modo dev
|
||||
cargo run -- --config config_motoradio.ini
|
||||
```
|
||||
|
||||
O protótipo lê `config_motoradio.ini`, carrega o `ModeloArquivo`, detecta a codificação, lista os estados `|E200|` encontrados e lista folhas do XLSX indicado em `CaminhoPlanilha`.
|
||||
|
||||
Próximos passos
|
||||
- Implementar utilitários de leitura/escrita com recodificação ISO-8859-1
|
||||
- Portar lógica de inserção dos registros E250 e atualização dos finais (9900/9990/9999)
|
||||
- Adicionar testes de integração e casos com seus arquivos reais
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import configparser
|
||||
import openpyxl
|
||||
import chardet
|
||||
|
||||
DEFAULT_ENCODING = 'ISO-8859-1'
|
||||
CONFIG_FILE = 'config_motoradio.ini'
|
||||
|
||||
|
||||
def detect_encoding(file_path):
|
||||
with open(file_path, 'rb') as file:
|
||||
raw_data = file.read()
|
||||
result = chardet.detect(raw_data)
|
||||
return result['encoding']
|
||||
|
||||
def read_file(file_path, encoding=DEFAULT_ENCODING):
|
||||
"""
|
||||
Lê o conteúdo completo de um arquivo de texto.
|
||||
|
||||
Args:
|
||||
file_path (str): Caminho do arquivo.
|
||||
encoding (str): Codificação do arquivo.
|
||||
|
||||
Returns:
|
||||
str: Conteúdo do arquivo como string.
|
||||
"""
|
||||
with open(file_path, 'r', encoding=encoding) as f:
|
||||
return f.read()
|
||||
|
||||
def read_lines(file_path, encoding=DEFAULT_ENCODING):
|
||||
with open(file_path, 'r', encoding=encoding) as f:
|
||||
return f.readlines()
|
||||
|
||||
def write_file(file_path, content, encoding=DEFAULT_ENCODING):
|
||||
with open(file_path, 'w', encoding=encoding) as f:
|
||||
f.write(content)
|
||||
|
||||
def write_lines(file_path, lines, encoding=DEFAULT_ENCODING):
|
||||
with open(file_path, 'w', encoding=encoding) as f:
|
||||
f.writelines(lines)
|
||||
|
||||
class Config:
|
||||
def __init__(self, config_file=CONFIG_FILE):
|
||||
self.config = configparser.ConfigParser()
|
||||
self.config.read(config_file)
|
||||
self.codigo_st = self.config['DEFAULT']['Codigo_ST']
|
||||
self.codigo_receita = self.config['DEFAULT']['Codigo_Receita']
|
||||
self.mes_apuracao = self.config['DEFAULT']['Mes_Apuracao']
|
||||
self.modelo_arquivo = self.config['DEFAULT']['ModeloArquivo']
|
||||
self.caminho_planilha = self.config['DEFAULT']['CaminhoPlanilha']
|
||||
|
||||
def contar_linhas_com_e(linhas):
|
||||
linha_completa = ''.join(linhas) # Junta todas as linhas em uma única string
|
||||
linhas_separadas = linha_completa.split('\n') # Separa novamente por linhas
|
||||
return sum(1 for linha in linhas_separadas if linha.startswith('|E'))
|
||||
|
||||
def substituir_valor(arquivo, antigo, novo):
|
||||
with open(arquivo, 'r', encoding='ISO-8859-1') as file:
|
||||
linhas = file.readlines()
|
||||
|
||||
linhas = [linha.replace(antigo, novo) for linha in linhas]
|
||||
|
||||
with open(arquivo, 'w', encoding='ISO-8859-1') as file:
|
||||
file.writelines(linhas)
|
||||
|
||||
def find_e200_states(content):
|
||||
lines = content.split('\n')
|
||||
states = set()
|
||||
for line in lines:
|
||||
if line.startswith('|E200|'):
|
||||
parts = line.split('|')
|
||||
if len(parts) > 2:
|
||||
state_code = parts[2]
|
||||
states.add(state_code)
|
||||
return list(states)
|
||||
|
||||
def count_9900_lines(content):
|
||||
lines = content.split('\n')
|
||||
count = 0
|
||||
for line in lines:
|
||||
if '|9900|' in line and '|9900|9900|' not in line:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def count_E250_lines(content):
|
||||
lines = content.split('\n')
|
||||
count = 0
|
||||
for line in lines:
|
||||
if '|E250|' in line:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def count_lines(file_path, encoding='ISO-8859-1'):
|
||||
with open(file_path, 'r', encoding=encoding) as file:
|
||||
return sum(1 for _ in file)
|
||||
|
||||
def substituir_ultimas_linhas(arquivo, novas_linhas):
|
||||
with open(arquivo, 'r', encoding='ISO-8859-1') as file:
|
||||
linhas = file.readlines()
|
||||
|
||||
# Remover as três últimas linhas
|
||||
linhas = linhas[:-2]
|
||||
|
||||
# Remover todas as linhas que contenham "|9900|9900|"
|
||||
linhas = [linha for linha in linhas if '|9900|9900|' not in linha]
|
||||
|
||||
# Adicionar as novas linhas
|
||||
linhas.extend(novas_linhas)
|
||||
|
||||
# Escrever de volta no arquivo
|
||||
with open(arquivo, 'w', encoding='ISO-8859-1') as file:
|
||||
file.writelines(linhas)
|
||||
|
||||
def substituir_linha_e990(arquivo, conteudo):
|
||||
# Abrir o arquivo para leitura
|
||||
with open(arquivo, 'r') as file:
|
||||
lines = file.readlines()
|
||||
|
||||
# Abrir o arquivo para escrita
|
||||
with open(arquivo, 'w') as file:
|
||||
count = 1
|
||||
for i, line in enumerate(lines):
|
||||
if '|E990|' in line:
|
||||
if count == 1:
|
||||
file.write(f'|E990|{contar_linhas_com_e(conteudo)}|' + '\n')
|
||||
count += 1
|
||||
# Pular a próxima linha
|
||||
continue
|
||||
file.write(line)
|
||||
|
||||
def inserir_linha_apos_9900_e210(arquivo, conteudo):
|
||||
with open(arquivo, 'r', encoding='ISO-8859-1') as file:
|
||||
linhas = file.readlines()
|
||||
|
||||
# Contar e remover a linha que começa com '|9900|E250|', se existir
|
||||
linhas_removidas = sum(1 for linha in linhas if linha.startswith('|9900|E250|'))
|
||||
linhas = [linha for linha in linhas if not linha.startswith('|9900|E250|')]
|
||||
|
||||
cont_E250 = count_E250_lines(conteudo) - linhas_removidas
|
||||
|
||||
nova_linha = f'|9900|E250|{cont_E250}|'
|
||||
for i, linha in enumerate(linhas):
|
||||
if '|9900|E210|' in linha:
|
||||
linhas.insert(i + 1, nova_linha + '\n')
|
||||
break
|
||||
|
||||
with open(arquivo, 'w', encoding='ISO-8859-1') as file:
|
||||
file.writelines(linhas)
|
||||
|
||||
def find_e2xx_state_lines(content, state_code):
|
||||
lines = content.split('\n')
|
||||
return [(index, line) for index, line in enumerate(lines) if line.startswith('|E2') and f'|{state_code}' in line]
|
||||
|
||||
def process_sheet(sheet, codigo_receita, mes_apuracao):
|
||||
new_values = []
|
||||
for linha in sheet.iter_rows(min_row=2):
|
||||
valor = linha[39].value
|
||||
valor = str(valor).replace(".", ",")
|
||||
vencimento = linha[1].value
|
||||
if vencimento is None:
|
||||
print('Vencimento é None')
|
||||
continue
|
||||
vencimento = vencimento.strftime("%d%m%Y")
|
||||
if valor == '0':
|
||||
print('Valor zerado')
|
||||
continue
|
||||
new_values.append(f'|E250|999|{valor}|{vencimento}|{codigo_receita}|||||{mes_apuracao.replace("/", "")}|')
|
||||
return new_values
|
||||
|
||||
def process_file(conteudo, config, estado):
|
||||
e2xx_state_lines = find_e2xx_state_lines(conteudo, estado)
|
||||
lines = conteudo.split('\n')
|
||||
|
||||
workbook = openpyxl.load_workbook(config.caminho_planilha)
|
||||
sheet_estado = workbook[estado]
|
||||
|
||||
new_values = process_sheet(sheet_estado, config.codigo_receita, config.mes_apuracao)
|
||||
|
||||
for index, line in e2xx_state_lines:
|
||||
for new_line in new_values:
|
||||
lines.insert(index + 2, new_line)
|
||||
|
||||
write_file(config.modelo_arquivo, '\n'.join(lines))
|
||||
|
||||
def processar_estados(estados, config):
|
||||
conteudo = read_file(config.modelo_arquivo)
|
||||
conteudo = "\n".join([linha for linha in conteudo.split("\n") if "|E250|999|" not in linha])
|
||||
write_file(config.modelo_arquivo, conteudo)
|
||||
for estado in estados:
|
||||
if estado != 'SP':
|
||||
print(f'Processando estado {estado}')
|
||||
conteudo = read_file(config.modelo_arquivo)
|
||||
process_file(conteudo, config, estado)
|
||||
|
||||
def main():
|
||||
import os
|
||||
config = Config()
|
||||
conteudo = read_file(config.modelo_arquivo)
|
||||
estados = find_e200_states(conteudo)
|
||||
processar_estados(estados, config)
|
||||
|
||||
conteudo = read_file(config.modelo_arquivo)
|
||||
substituir_linha_e990(config.modelo_arquivo, conteudo)
|
||||
inserir_linha_apos_9900_e210(config.modelo_arquivo, conteudo)
|
||||
substituir_valor(config.modelo_arquivo, '|04601|', '|046-2|')
|
||||
|
||||
# Remova as chamadas antigas de substituir_ultimas_linhas e use apenas:
|
||||
atualizar_registros_finais(config.modelo_arquivo)
|
||||
|
||||
# Salvar arquivo processado
|
||||
nome_original = config.modelo_arquivo
|
||||
nome_base, ext = os.path.splitext(nome_original)
|
||||
nome_processado = f"{nome_base}_processado{ext}"
|
||||
try:
|
||||
conteudo_final = read_file(nome_original)
|
||||
write_file(nome_processado, conteudo_final)
|
||||
print(f"Arquivo processado salvo como: {nome_processado}")
|
||||
except Exception as e:
|
||||
print(f"Erro ao salvar arquivo processado: {e}")
|
||||
|
||||
def atualizar_registros_finais(arquivo):
|
||||
"""
|
||||
Atualiza os registros finais do arquivo SPED, removendo duplicidades e
|
||||
recalculando as quantidades dos registros 9900, 9990 e 9999.
|
||||
|
||||
Args:
|
||||
arquivo (str): Caminho do arquivo a ser processado.
|
||||
|
||||
Efeito:
|
||||
O arquivo é sobrescrito com os registros finais corrigidos.
|
||||
"""
|
||||
with open(arquivo, 'r', encoding='ISO-8859-1') as file:
|
||||
linhas = file.readlines()
|
||||
|
||||
# Remove registros finais antigos para evitar duplicidade
|
||||
linhas = [linha for linha in linhas if not (
|
||||
linha.startswith('|9900|9900|') or
|
||||
linha.startswith('|9900|E990|') or
|
||||
linha.startswith('|9990|') or
|
||||
linha.startswith('|9999|')
|
||||
)]
|
||||
|
||||
# Contagem correta dos registros 9900
|
||||
total_9900 = sum(1 for linha in linhas if linha.startswith('|9900|') and not linha.startswith('|9900|9900|'))
|
||||
|
||||
# Bloco 9: começa em |9001| e termina em |9990|
|
||||
bloco9_indices = [i for i, linha in enumerate(linhas) if linha.startswith('|9001|')]
|
||||
if bloco9_indices:
|
||||
inicio_bloco9 = bloco9_indices[0]
|
||||
# Procura o fim do bloco 9 (último |9990|, se existir)
|
||||
fim_bloco9 = len(linhas)
|
||||
bloco9_linhas = linhas[inicio_bloco9:fim_bloco9]
|
||||
total_bloco9 = len(bloco9_linhas) + 4 # +4 para os registros finais que serão adicionados
|
||||
else:
|
||||
total_bloco9 = 4 # só os finais
|
||||
|
||||
total_linhas_arquivo = len(linhas) + 4 # +4 para os registros finais
|
||||
|
||||
# Adiciona os registros finais corretos
|
||||
linhas.extend([
|
||||
f'|9900|9900|{total_9900 + 2}|\n', # +2 para E990 e 9900
|
||||
'|9900|E990|1|\n',
|
||||
f'|9990|{total_bloco9}|\n',
|
||||
f'|9999|{total_linhas_arquivo}|\n'
|
||||
])
|
||||
|
||||
with open(arquivo, 'w', encoding='ISO-8859-1') as file:
|
||||
file.writelines(linhas)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
// build.rs: embute icon.ico no executável Windows usando winres
|
||||
fn main() {
|
||||
if cfg!(target_os = "windows") {
|
||||
// caminho relativo ao root do workspace
|
||||
let icon_path = "icon.ico";
|
||||
let mut res = winres::WindowsResource::new();
|
||||
// apenas tenta definir se o arquivo existir
|
||||
if std::path::Path::new(icon_path).exists() {
|
||||
res.set_icon(icon_path);
|
||||
} else {
|
||||
// se não existir, continua sem erro
|
||||
println!("cargo:warning=icon.ico não encontrado no root; ignorando embed de ícone");
|
||||
}
|
||||
let _ = res.compile();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(®) {
|
||||
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
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user