From be6b4f5ef316c50be89f4ee5e55f22df407ae8be Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 19:35:54 -0300 Subject: [PATCH 01/10] =?UTF-8?q?feat(pipeline):=20permite=20configura?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20modo=20via=20var=20de=20ambiente=20e=20exp?= =?UTF-8?q?=C3=B5e=20run=5Fetl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autosinapi/__init__.py | 98 ++++++++++++++++++++++++++++++++++++ tools/autosinapi_pipeline.py | 5 +- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/autosinapi/__init__.py b/autosinapi/__init__.py index c4083fa..00b0aed 100644 --- a/autosinapi/__init__.py +++ b/autosinapi/__init__.py @@ -29,4 +29,102 @@ "DownloadError", "ProcessingError", "DatabaseError", + "run_etl" ] + +import os +import logging +from contextlib import contextmanager +from typing import Dict, Any + +# Import the Pipeline class (assuming it's in tools/autosinapi_pipeline.py) +# We need to be careful with relative imports here. +# Since autosinapi_pipeline.py is in 'tools' directory, and autosinapi/__init__.py is in 'autosinapi' directory, +# we need to import it correctly. +# The user's original call was `autosinapi.run_etl`, implying `run_etl` is part of the `autosinapi` package. +# So, the Pipeline class should be imported from within the autosinapi package structure. +# If tools/autosinapi_pipeline.py is meant to be a standalone script, then importing it directly might be problematic. +# However, the user wants `autosinapi.run_etl` to work. +# Let's assume for now that 'tools.autosinapi_pipeline' can be imported. +# If it fails, I'll need to re-evaluate the import strategy. +try: + from tools.autosinapi_pipeline import Pipeline, setup_logging +except ImportError: + # Fallback if tools is not directly in the python path + import sys + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'tools'))) + from autosinapi_pipeline import Pipeline, setup_logging + + +# Configure a logger for this module +logger = logging.getLogger(__name__) + +@contextmanager +def set_env_vars(env_vars: Dict[str, str]): + """Temporarily sets environment variables.""" + original_env = {key: os.getenv(key) for key in env_vars} + for key, value in env_vars.items(): + os.environ[key] = str(value) # Ensure value is string for env vars + try: + yield + finally: + for key, value in original_env.items(): + if value is None: + del os.environ[key] + else: + os.environ[key] = value + +def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str = 'local', log_level: str = 'INFO'): + """ + Executa o pipeline ETL do AutoSINAPI. + + Args: + db_config (Dict[str, Any]): Dicionário de configuração do banco de dados. + sinapi_config (Dict[str, Any]): Dicionário de configuração do SINAPI. + mode (str): Modo de operação do pipeline ('local' ou 'server'). Padrão é 'local'. + log_level (str): Nível de log para a execução do pipeline (e.g., 'INFO', 'DEBUG', 'WARNING'). Padrão é 'INFO'. + """ + # Validate inputs + if not isinstance(db_config, dict): + raise ValueError("db_config deve ser um dicionário.") + if not isinstance(sinapi_config, dict): + raise ValueError("sinapi_config deve ser um dicionário.") + if mode not in ['local', 'server']: + raise ValueError("mode deve ser 'local' ou 'server'.") + if log_level.upper() not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: + raise ValueError(f"log_level inválido: {log_level}. Use 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.") + + # Prepare environment variables + env_vars_to_set = { + 'DOCKER_ENV': 'true', # Assuming API runs in a docker-like environment + 'POSTGRES_HOST': db_config.get('host'), + 'POSTGRES_PORT': db_config.get('port'), + 'POSTGRES_DB': db_config.get('database'), + 'POSTGRES_USER': db_config.get('user'), + 'POSTGRES_PASSWORD': db_config.get('password'), + 'AUTOSINAPI_YEAR': sinapi_config.get('year'), + 'AUTOSINAPI_MONTH': sinapi_config.get('month'), + 'AUTOSINAPI_TYPE': sinapi_config.get('type', 'REFERENCIA'), + 'AUTOSINAPI_POLICY': sinapi_config.get('duplicate_policy', 'substituir'), + 'AUTOSINAPI_MODE': mode # Pass the mode + } + + # Filter out None values + env_vars_to_set = {k: v for k, v in env_vars_to_set.items() if v is not None} + + # Set up logging for the pipeline run + # The setup_logging function in autosinapi_pipeline.py takes debug_mode. + # We need to map log_level to debug_mode. + debug_mode = (log_level.upper() == 'DEBUG') + setup_logging(debug_mode=debug_mode) + + try: + with set_env_vars(env_vars_to_set): + logger.info(f"Iniciando execução do pipeline com modo: {mode} e nível de log: {log_level}") + pipeline = Pipeline() # Pipeline will read from env vars + pipeline.run() + logger.info("Pipeline executado com sucesso.") + except Exception as e: + logger.error(f"Erro ao executar o pipeline: {e}", exc_info=True) + raise # Re-raise the exception to indicate task failure + diff --git a/tools/autosinapi_pipeline.py b/tools/autosinapi_pipeline.py index 3a6bf70..c2875e6 100644 --- a/tools/autosinapi_pipeline.py +++ b/tools/autosinapi_pipeline.py @@ -142,7 +142,8 @@ def _get_sinapi_config(self): 'month': self.config['default_month'], 'type': self.config.get('workbook_type_name', 'REFERENCIA'), 'file_format': self.config.get('default_format', 'XLSX'), - 'duplicate_policy': self.config.get('duplicate_policy', 'substituir') + 'duplicate_policy': self.config.get('duplicate_policy', 'substituir'), + 'mode': os.getenv('AUTOSINAPI_MODE', 'local') # Add this line } def _find_and_normalize_zip(self, download_path: Path, standardized_name: str) -> Path: @@ -218,7 +219,7 @@ def run(self): self.logger.info("========= INICIANDO PIPELINE AUTOSINAPI =========") self.logger.info("======================================================") try: - config = Config(db_config=self.db_config, sinapi_config=self.sinapi_config, mode='local') + config = Config(db_config=self.db_config, sinapi_config=self.sinapi_config, mode=self.sinapi_config['mode']) self.logger.info("Configuração validada com sucesso.") self.logger.debug(f"Configurações SINAPI para esta execução: {config.sinapi_config}") From 39343d710c382a5d639ba55bf02e91434295a254 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 20:45:18 -0300 Subject: [PATCH 02/10] refactor(etl-pipeline, error-handling): Implementa tratamento de erros estruturado e valores de retorno. --- autosinapi/__init__.py | 67 ++++-- .../etl_pipeline.py | 214 ++++++++++++------ 2 files changed, 183 insertions(+), 98 deletions(-) rename tools/autosinapi_pipeline.py => autosinapi/etl_pipeline.py (64%) diff --git a/autosinapi/__init__.py b/autosinapi/__init__.py index 00b0aed..0515140 100644 --- a/autosinapi/__init__.py +++ b/autosinapi/__init__.py @@ -37,23 +37,7 @@ from contextlib import contextmanager from typing import Dict, Any -# Import the Pipeline class (assuming it's in tools/autosinapi_pipeline.py) -# We need to be careful with relative imports here. -# Since autosinapi_pipeline.py is in 'tools' directory, and autosinapi/__init__.py is in 'autosinapi' directory, -# we need to import it correctly. -# The user's original call was `autosinapi.run_etl`, implying `run_etl` is part of the `autosinapi` package. -# So, the Pipeline class should be imported from within the autosinapi package structure. -# If tools/autosinapi_pipeline.py is meant to be a standalone script, then importing it directly might be problematic. -# However, the user wants `autosinapi.run_etl` to work. -# Let's assume for now that 'tools.autosinapi_pipeline' can be imported. -# If it fails, I'll need to re-evaluate the import strategy. -try: - from tools.autosinapi_pipeline import Pipeline, setup_logging -except ImportError: - # Fallback if tools is not directly in the python path - import sys - sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'tools'))) - from autosinapi_pipeline import Pipeline, setup_logging +from .etl_pipeline import PipelineETL, setup_logging # Configure a logger for this module @@ -86,13 +70,33 @@ def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str """ # Validate inputs if not isinstance(db_config, dict): - raise ValueError("db_config deve ser um dicionário.") + return { + "status": "failed", + "message": "Erro de validação: db_config deve ser um dicionário.", + "tables_updated": [], + "records_inserted": 0 + } if not isinstance(sinapi_config, dict): - raise ValueError("sinapi_config deve ser um dicionário.") + return { + "status": "failed", + "message": "Erro de validação: sinapi_config deve ser um dicionário.", + "tables_updated": [], + "records_inserted": 0 + } if mode not in ['local', 'server']: - raise ValueError("mode deve ser 'local' ou 'server'.") + return { + "status": "failed", + "message": "Erro de validação: mode deve ser 'local' ou 'server'.", + "tables_updated": [], + "records_inserted": 0 + } if log_level.upper() not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']: - raise ValueError(f"log_level inválido: {log_level}. Use 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.") + return { + "status": "failed", + "message": f"Erro de validação: log_level inválido: {log_level}. Use 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'.", + "tables_updated": [], + "records_inserted": 0 + } # Prepare environment variables env_vars_to_set = { @@ -120,11 +124,24 @@ def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str try: with set_env_vars(env_vars_to_set): - logger.info(f"Iniciando execução do pipeline com modo: {mode} e nível de log: {log_level}") - pipeline = Pipeline() # Pipeline will read from env vars - pipeline.run() + logger.info(f"Iniciando execução do pipeline com modo: {mode}" + f"e nível de log: {log_level}") + pipeline = PipelineETL() # Pipeline will read from env vars + result = pipeline.run() logger.info("Pipeline executado com sucesso.") + return result except Exception as e: logger.error(f"Erro ao executar o pipeline: {e}", exc_info=True) - raise # Re-raise the exception to indicate task failure + # Re-raise the exception to indicate task failure, or return a structured error + # based on the user's request for run_etl to return a dictionary on failure. + # Since pipeline.run() already returns a dictionary on failure, + # this outer exception block should only catch errors *before* pipeline.run() is called + # or unexpected errors not caught by pipeline.run(). + # For consistency, we'll return a structured error here too. + return { + "status": "failed", + "message": f"Erro inesperado antes ou durante a inicialização do pipeline: {e}", + "tables_updated": [], + "records_inserted": 0 + } diff --git a/tools/autosinapi_pipeline.py b/autosinapi/etl_pipeline.py similarity index 64% rename from tools/autosinapi_pipeline.py rename to autosinapi/etl_pipeline.py index c2875e6..3fc97b9 100644 --- a/tools/autosinapi_pipeline.py +++ b/autosinapi/etl_pipeline.py @@ -29,7 +29,8 @@ from autosinapi.core.downloader import Downloader from autosinapi.core.processor import Processor from autosinapi.core.database import Database -from autosinapi.exceptions import AutoSinapiError +from autosinapi.exceptions import AutoSinapiError, ConfigurationError, DownloadError, ProcessingError, DatabaseError +from autosinapi.core.pre_processor import convert_excel_sheets_to_csv # Configuração do logger principal logger = logging.getLogger("autosinapi") @@ -65,7 +66,7 @@ def setup_logging(debug_mode=False): if not debug_mode: logging.getLogger("urllib3").setLevel(logging.WARNING) -class Pipeline: +class PipelineETL: def __init__(self, config_path: str = None): self.logger = logging.getLogger("autosinapi.pipeline") self.config = self._load_config(config_path) @@ -79,12 +80,12 @@ def _load_config(self, config_path: str): try: with open(config_path, 'r') as f: return json.load(f) - except FileNotFoundError: + except FileNotFoundError as e: self.logger.error(f"Arquivo de configuração não encontrado: {config_path}", exc_info=True) - raise - except json.JSONDecodeError: + raise ConfigurationError(f"Arquivo de configuração não encontrado: {config_path}") from e + except json.JSONDecodeError as e: self.logger.error(f"Erro ao decodificar o arquivo JSON de configuração: {config_path}", exc_info=True) - raise + raise ConfigurationError(f"Erro ao decodificar o arquivo JSON de configuração: {config_path}") from e else: self.logger.info("Carregando configuração a partir de variáveis de ambiente.") return { @@ -102,7 +103,7 @@ def _get_db_config(self): required_vars = ["POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"] missing_vars = [v for v in required_vars if not os.getenv(v)] if missing_vars: - raise AutoSinapiError( + raise ConfigurationError( f"Variáveis de ambiente para o banco de dados não encontradas: {missing_vars}. " f"Verifique se o arquivo 'tools/docker/.env' existe e está preenchido corretamente." ) @@ -133,14 +134,20 @@ def _get_db_config(self): } except Exception as e: self.logger.error(f"Erro CRÍTICO ao ler ou processar o arquivo de secrets '{secrets_path}'. Detalhes: {e}", exc_info=True) - raise + raise ConfigurationError(f"Erro ao ler ou processar o arquivo de secrets '{secrets_path}': {e}") from e def _get_sinapi_config(self): return { 'state': self.config.get('default_state', 'BR'), 'year': self.config['default_year'], 'month': self.config['default_month'], + # Esta informação 'type' = 'workbook_type_name' precisa ser verificada com a lógica do DataModel + # e dos arquivos reais, pois o Workbook pode não fazer sentido no contexto + # refatorado atual, está mantido por enquanto para compatibilidade + # e assumimos que o valor padrão é 'REFERENCIA' se não fornecido. + # Talvez faça mais sentido definir se é Desonerado, Onerado ou Sem Encargos 'type': self.config.get('workbook_type_name', 'REFERENCIA'), + 'file_format': self.config.get('default_format', 'XLSX'), 'duplicate_policy': self.config.get('duplicate_policy', 'substituir'), 'mode': os.getenv('AUTOSINAPI_MODE', 'local') # Add this line @@ -168,24 +175,25 @@ def _unzip_file(self, zip_path: Path) -> Path: zip_ref.extractall(extraction_path) self.logger.info("Arquivo descompactado com sucesso.") return extraction_path - except zipfile.BadZipFile: + except zipfile.BadZipFile as e: self.logger.error(f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido.", exc_info=True) - raise + raise ProcessingError(f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido.") from e - def _run_pre_processing(self): + def _run_pre_processing(self, referencia_file_path: Path, extraction_path: Path): self.logger.info("FASE PRE: Iniciando pré-processamento de planilhas para CSV.") - script_path = "tools/pre_processador.py" + sheets_to_convert = ['CSD', 'CCD', 'CSE'] + output_dir = extraction_path.parent / "csv_temp" + try: - if not os.path.exists(script_path): - raise FileNotFoundError(f"Script de pré-processamento não encontrado em '{script_path}'") - - result = os.system(f"python {script_path}") - if result != 0: - raise AutoSinapiError(f"O script de pré-processamento '{script_path}' falhou com código de saída {result}.") + convert_excel_sheets_to_csv( + xlsx_full_path=referencia_file_path, + sheets_to_convert=sheets_to_convert, + output_dir=output_dir + ) self.logger.info("Pré-processamento de planilhas concluído com sucesso.") - except Exception as e: - self.logger.error(f"Erro ao executar o script de pré-processamento: {e}", exc_info=True) - raise + except ProcessingError as e: + self.logger.error(f"Erro durante o pré-processamento: {e}", exc_info=True) + raise # Re-raise the ProcessingError def _sync_catalog_status(self, db: Database): self.logger.info("Iniciando Fase 2: Sincronização de Status dos Catálogos.") @@ -212,12 +220,14 @@ def _sync_catalog_status(self, db: Database): self.logger.info(f"Status do catálogo de composições sincronizado. Itens desativados: {num_composicoes_updated}") except Exception as e: self.logger.error(f"Erro ao sincronizar status dos catálogos: {e}", exc_info=True) - raise AutoSinapiError(f"Erro em '_sync_catalog_status': {e}") + raise DatabaseError(f"Erro ao sincronizar status dos catálogos: {e}") from e def run(self): self.logger.info("======================================================") self.logger.info("========= INICIANDO PIPELINE AUTOSINAPI =========") self.logger.info("======================================================") + tables_updated = [] + records_inserted = 0 try: config = Config(db_config=self.db_config, sinapi_config=self.sinapi_config, mode=self.sinapi_config['mode']) self.logger.info("Configuração validada com sucesso.") @@ -228,7 +238,10 @@ def run(self): db = Database(config.db_config) self.logger.info("Recriando tabelas do banco de dados para garantir conformidade.") - db.create_tables() + try: + db.create_tables() + except Exception as e: + raise DatabaseError(f"Erro ao recriar tabelas do banco de dados: {e}") from e year = config.sinapi_config['year'] month = config.sinapi_config['month'] @@ -241,42 +254,64 @@ def run(self): if not local_zip_path: self.logger.info("Arquivo não encontrado localmente. Iniciando download...") - file_content = downloader.get_sinapi_data(save_path=download_path) - local_zip_path = download_path / standardized_name - with open(local_zip_path, 'wb') as f: - f.write(file_content.getbuffer()) - self.logger.info(f"Download concluído e salvo em: {local_zip_path}") + try: + file_content = downloader.get_sinapi_data(save_path=download_path) + local_zip_path = download_path / standardized_name + with open(local_zip_path, 'wb') as f: + f.write(file_content.getbuffer()) + self.logger.info(f"Download concluído e salvo em: {local_zip_path}") + except Exception as e: + raise DownloadError(f"Erro durante o download dos dados do SINAPI: {e}") from e - extraction_path = self._unzip_file(local_zip_path) + try: + extraction_path = self._unzip_file(local_zip_path) + except Exception as e: + raise ProcessingError(f"Erro ao descompactar o arquivo: {e}") from e # --- PRÉ-PROCESSAMENTO PARA CSV --- - self._run_pre_processing() + try: + self._run_pre_processing(referencia_file_path, extraction_path) + except Exception as e: + raise ProcessingError(f"Erro durante o pré-processamento: {e}") from e # --- FIM DO PRÉ-PROCESSAMENTO --- all_excel_files = list(extraction_path.glob('*.xlsx')) if not all_excel_files: - raise FileNotFoundError(f"Nenhum arquivo .xlsx encontrado em {extraction_path}") + raise ProcessingError(f"Nenhum arquivo .xlsx encontrado em {extraction_path}") manutencoes_file_path = next((f for f in all_excel_files if "Manuten" in f.name), None) referencia_file_path = next((f for f in all_excel_files if "Referência" in f.name), None) if manutencoes_file_path: self.logger.info(f"FASE 1: Processamento de Manutenções ({manutencoes_file_path.name})") - manutencoes_df = processor.process_manutencoes(str(manutencoes_file_path)) - db.save_data(manutencoes_df, 'manutencoes_historico', policy='append') - self.logger.info("Histórico de manutenções carregado com sucesso.") - self._sync_catalog_status(db) # FASE 2 + try: + manutencoes_df = processor.process_manutencoes(str(manutencoes_file_path)) + db.save_data(manutencoes_df, 'manutencoes_historico', policy='append') + tables_updated.append("manutencoes_historico") + records_inserted += len(manutencoes_df) + self.logger.info("Histórico de manutenções carregado com sucesso.") + self._sync_catalog_status(db) # FASE 2 + except Exception as e: + raise ProcessingError(f"Erro ao processar ou salvar dados de manutenções: {e}") from e else: self.logger.warning("Arquivo de Manutenções não encontrado. Pulando Fases 1 e 2.") if not referencia_file_path: self.logger.warning("Arquivo de Referência não encontrado. Finalizando pipeline.") - return + return { + "status": "success", + "message": f"Pipeline concluído. Arquivo de Referência não encontrado para {month}/{year}.", + "tables_updated": tables_updated, + "records_inserted": records_inserted + } self.logger.info(f"FASE 3: Processamento do Arquivo de Referência ({referencia_file_path.name})") self.logger.info("Processando catálogos, dados mensais e estrutura de composições...") - processed_data = processor.process_catalogo_e_precos(str(referencia_file_path)) - structure_dfs = processor.process_composicao_itens(str(referencia_file_path)) + try: + processed_data = processor.process_catalogo_e_precos(str(referencia_file_path)) + structure_dfs = processor.process_composicao_itens(str(referencia_file_path)) + except Exception as e: + raise ProcessingError(f"Erro ao processar catálogos ou estrutura de composições: {e}") from e if 'insumos' in processed_data: existing_insumos_df = processed_data['insumos'] @@ -327,52 +362,85 @@ def get_detail(code, column): missing_composicoes_df = pd.DataFrame({ 'codigo': missing_composicao_codes, 'descricao': [get_detail(code, 'descricao') for code in missing_composicao_codes], - 'unidade': [get_detail(code, 'unidade') for code in missing_composicao_codes] + 'unidade': [get_detail(code, 'unidade') for code in missing_composicao_codes], }) processed_data['composicoes'] = pd.concat([existing_composicoes_df, missing_composicoes_df], ignore_index=True) self.logger.info("Iniciando carga de dados no banco de dados na ordem correta...") - if 'insumos' in processed_data and not processed_data['insumos'].empty: - db.save_data(processed_data['insumos'], 'insumos', policy='upsert', pk_columns=['codigo']) - self.logger.info("Catálogo de insumos (incluindo placeholders) carregado.") - if 'composicoes' in processed_data and not processed_data['composicoes'].empty: - db.save_data(processed_data['composicoes'], 'composicoes', policy='upsert', pk_columns=['codigo']) - self.logger.info("Catálogo de composições (incluindo placeholders) carregado.") - - db.truncate_table('composicao_insumos') - db.truncate_table('composicao_subcomposicoes') - db.save_data(structure_dfs['composicao_insumos'], 'composicao_insumos', policy='append') - db.save_data(structure_dfs['composicao_subcomposicoes'], 'composicao_subcomposicoes', policy='append') - self.logger.info("Estrutura de composições carregada com sucesso.") - - precos_carregados = False - if 'precos_insumos_mensal' in processed_data and not processed_data['precos_insumos_mensal'].empty: - processed_data['precos_insumos_mensal']['data_referencia'] = pd.to_datetime(data_referencia) - db.save_data(processed_data['precos_insumos_mensal'], 'precos_insumos_mensal', policy='append') - precos_carregados = True - else: - self.logger.warning("Nenhum dado de PREÇOS DE INSUMOS foi encontrado ou processado. Pulando esta etapa.") - - custos_carregados = False - if 'custos_composicoes_mensal' in processed_data and not processed_data['custos_composicoes_mensal'].empty: - processed_data['custos_composicoes_mensal']['data_referencia'] = pd.to_datetime(data_referencia) - db.save_data(processed_data['custos_composicoes_mensal'], 'custos_composicoes_mensal', policy='append') - custos_carregados = True - else: - self.logger.warning("Nenhum dado de CUSTOS DE COMPOSIÇÕES foi encontrado ou processado. Pulando esta etapa.") - - if precos_carregados or custos_carregados: - self.logger.info("Dados mensais (preços/custos) carregados com sucesso.") - else: - self.logger.warning("Nenhuma informação de preços ou custos foi carregada nesta execução.") + try: + if 'insumos' in processed_data and not processed_data['insumos'].empty: + db.save_data(processed_data['insumos'], 'insumos', policy='upsert', pk_columns=['codigo']) + tables_updated.append("insumos") + records_inserted += len(processed_data['insumos']) + self.logger.info("Catálogo de insumos (incluindo placeholders) carregado.") + if 'composicoes' in processed_data and not processed_data['composicoes'].empty: + db.save_data(processed_data['composicoes'], 'composicoes', policy='upsert', pk_columns=['codigo']) + tables_updated.append("composicoes") + records_inserted += len(processed_data['composicoes']) + self.logger.info("Catálogo de composições (incluindo placeholders) carregado.") + + db.truncate_table('composicao_insumos') + db.truncate_table('composicao_subcomposicoes') + db.save_data(structure_dfs['composicao_insumos'], 'composicao_insumos', policy='append') + tables_updated.append("composicao_insumos") + records_inserted += len(structure_dfs['composicao_insumos']) + db.save_data(structure_dfs['composicao_subcomposicoes'], 'composicao_subcomposicoes', policy='append') + tables_updated.append("composicao_subcomposicoes") + records_inserted += len(structure_dfs['composicao_subcomposicoes']) + self.logger.info("Estrutura de composições carregada com sucesso.") + + precos_carregados = False + if 'precos_insumos_mensal' in processed_data and not processed_data['precos_insumos_mensal'].empty: + processed_data['precos_insumos_mensal']['data_referencia'] = pd.to_datetime(data_referencia) + db.save_data(processed_data['precos_insumos_mensal'], 'precos_insumos_mensal', policy='append') + tables_updated.append("precos_insumos_mensal") + records_inserted += len(processed_data['precos_insumos_mensal']) + precos_carregados = True + else: + self.logger.warning("Nenhum dado de PREÇOS DE INSUMOS foi encontrado ou processado. Pulando esta etapa.") + + custos_carregados = False + if 'custos_composicoes_mensal' in processed_data and not processed_data['custos_composicoes_mensal'].empty: + processed_data['custos_composicoes_mensal']['data_referencia'] = pd.to_datetime(data_referencia) + db.save_data(processed_data['custos_composicoes_mensal'], 'custos_composicoes_mensal', policy='append') + tables_updated.append("custos_composicoes_mensal") + records_inserted += len(processed_data['custos_composicoes_mensal']) + custos_carregados = True + else: + self.logger.warning("Nenhum dado de CUSTOS DE COMPOSIÇÕES foi encontrado ou processado. Pulando esta etapa.") + + if precos_carregados or custos_carregados: + self.logger.info("Dados mensais (preços/custos) carregados com sucesso.") + else: + self.logger.warning("Nenhuma informação de preços ou custos foi carregada nesta execução.") + except Exception as e: + raise DatabaseError(f"Erro durante a carga de dados no banco de dados: {e}") from e self.logger.info("Pipeline AutoSINAPI concluído com sucesso!") + return { + "status": "success", + "message": f"Dados de {month}/{year} populados com sucesso.", + "tables_updated": list(set(tables_updated)), # Use set to avoid duplicates + "records_inserted": records_inserted + } except AutoSinapiError as e: self.logger.error(f"Erro de negócio no pipeline AutoSINAPI: {e}", exc_info=True) + return { + "status": "failed", + "message": str(e), + "tables_updated": tables_updated, + "records_inserted": records_inserted + } except Exception as e: self.logger.error(f"Ocorreu um erro inesperado e fatal no pipeline: {e}", exc_info=True) + return { + "status": "failed", + "message": f"Erro inesperado e fatal: {e}", + "tables_updated": tables_updated, + "records_inserted": records_inserted + } def main(): parser = argparse.ArgumentParser(description="Pipeline de ETL para dados do SINAPI.") @@ -383,7 +451,7 @@ def main(): setup_logging(debug_mode=True) try: - pipeline = Pipeline(config_path=args.config) + pipeline = PipelineETL(config_path=args.config) pipeline.run() except Exception: logger.critical("Pipeline encerrado devido a um erro fatal.") From 53d921b9a92903600b0d1e489e8b38a225da2059 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 20:46:03 -0300 Subject: [PATCH 03/10] =?UTF-8?q?refactor(pre-processing):=20Refatora=20pr?= =?UTF-8?q?e=5Fprocessador.py=20para=20m=C3=B3dulo=20import=C3=A1vel.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- autosinapi/core/pre_processor.py | 69 +++++++++++++++++++++++++++++++ tools/pre_processador.py | 71 -------------------------------- 2 files changed, 69 insertions(+), 71 deletions(-) create mode 100644 autosinapi/core/pre_processor.py delete mode 100644 tools/pre_processador.py diff --git a/autosinapi/core/pre_processor.py b/autosinapi/core/pre_processor.py new file mode 100644 index 0000000..6532fa9 --- /dev/null +++ b/autosinapi/core/pre_processor.py @@ -0,0 +1,69 @@ +import pandas as pd +import os +import logging +from pathlib import Path +from autosinapi.exceptions import ProcessingError + +logger = logging.getLogger(__name__) + +def convert_excel_sheets_to_csv( + xlsx_full_path: Path, + sheets_to_convert: list[str], + output_dir: Path +): + """ + Converts specific sheets from an XLSX file to CSV, ensuring formulas are read as text. + + Args: + xlsx_full_path (Path): The full path to the XLSX file. + sheets_to_convert (list[str]): A list of sheet names to convert. + output_dir (Path): The directory where the CSV files will be saved. + """ + logger.info(f"Iniciando pré-processamento do arquivo: {xlsx_full_path}") + + if not xlsx_full_path.exists(): + raise ProcessingError(f"Arquivo XLSX não encontrado: {xlsx_full_path}") + + output_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Diretório de saída para CSVs: {output_dir}") + + for sheet in sheets_to_convert: + try: + logger.info(f"Processando planilha: '{sheet}'...") + df = pd.read_excel( + xlsx_full_path, + sheet_name=sheet, + header=None, + engine='openpyxl', + engine_kwargs={'data_only': False} + ) + + csv_output_path = output_dir / f"{sheet}.csv" + df.to_csv(csv_output_path, index=False, header=False, sep=';') + logger.info(f"Planilha '{sheet}' convertida com sucesso para '{csv_output_path}' (separador: ;)") + + except Exception as e: + raise ProcessingError(f"Falha ao processar a planilha '{sheet}'. Erro: {e}") from e + +if __name__ == "__main__": + # This part is for testing the module directly + # Example usage (will not be used by etl_pipeline.py directly) + # You would need to set up a dummy Excel file and output directory for this to run. + DUMMY_BASE_PATH = Path("./downloads/2025_07/SINAPI-2025-07-formato-xlsx") + DUMMY_XLSX_FILENAME = "SINAPI_Referência_2025_07.xlsx" + DUMMY_SHEETS_TO_CONVERT = ['CSD', 'CCD', 'CSE'] + DUMMY_OUTPUT_DIR = DUMMY_BASE_PATH / ".." / "csv_temp" + + # Create dummy files/dirs for testing if needed + # DUMMY_BASE_PATH.mkdir(parents=True, exist_ok=True) + # (Create a dummy SINAPI_Referência_2025_07.xlsx here for testing) + + try: + convert_excel_sheets_to_csv( + DUMMY_BASE_PATH / DUMMY_XLSX_FILENAME, + DUMMY_SHEETS_TO_CONVERT, + DUMMY_OUTPUT_DIR + ) + print("Pré-processamento de teste concluído com sucesso.") + except ProcessingError as e: + print(f"Erro durante o pré-processamento de teste: {e}") diff --git a/tools/pre_processador.py b/tools/pre_processador.py deleted file mode 100644 index 257789c..0000000 --- a/tools/pre_processador.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -pre_processador.py: Script para Pré-processamento de Planilhas SINAPI. - -Este script é responsável por pré-processar planilhas específicas dos arquivos -Excel do SINAPI, convertendo-as para o formato CSV. O objetivo principal é -garantir que os dados, especialmente aqueles que contêm fórmulas, sejam lidos -como texto simples, evitando problemas de interpretação e garantindo a -integridade dos dados antes do processamento principal pelo `Processor`. - -Ele identifica as planilhas necessárias, lê o conteúdo do Excel e salva as -informações em arquivos CSV temporários, que serão posteriormente consumidos -pelo pipeline ETL do AutoSINAPI. -""" -import pandas as pd -import os -import logging - -# Configuração básica do logger -logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s') - -# --- CONFIGURAÇÃO --- -# Caminho base para os arquivos descompactados -BASE_PATH = "downloads/2025_07/SINAPI-2025-07-formato-xlsx" -# Arquivo XLSX de referência -XLSX_FILENAME = "SINAPI_Referência_2025_07.xlsx" -# Planilhas que precisam de pré-processamento -SHEETS_TO_CONVERT = ['CSD', 'CCD', 'CSE'] -# Diretório de saída para os CSVs -OUTPUT_DIR = os.path.join(BASE_PATH, "..", "csv_temp") - -def pre_process_sheets(): - """ - Converte planilhas específicas de um arquivo XLSX para CSV, garantindo que as fórmulas sejam lidas como texto. - """ - xlsx_full_path = os.path.join(BASE_PATH, XLSX_FILENAME) - logging.info(f"Iniciando pré-processamento do arquivo: {xlsx_full_path}") - - if not os.path.exists(xlsx_full_path): - logging.error(f"Arquivo XLSX não encontrado. Abortando.") - return - - # Cria o diretório de saída se não existir - os.makedirs(OUTPUT_DIR, exist_ok=True) - logging.info(f"Diretório de saída para CSVs: {OUTPUT_DIR}") - - for sheet in SHEETS_TO_CONVERT: - try: - logging.info(f"Processando planilha: '{sheet}'...") - # Lê a planilha específica, forçando a leitura de fórmulas como texto - df = pd.read_excel( - xlsx_full_path, - sheet_name=sheet, - header=None, - engine='openpyxl', - engine_kwargs={'data_only': False} - ) - - # Define o caminho de saída para o CSV - csv_output_path = os.path.join(OUTPUT_DIR, f"{sheet}.csv") - - # Salva o DataFrame como CSV usando ponto e vírgula como separador - df.to_csv(csv_output_path, index=False, header=False, sep=';') - logging.info(f"Planilha '{sheet}' convertida com sucesso para '{csv_output_path}' (separador: ;)") - - except Exception as e: - logging.error(f"Falha ao processar a planilha '{sheet}'. Erro: {e}") - - logging.info("Pré-processamento concluído.") - -if __name__ == "__main__": - pre_process_sheets() \ No newline at end of file From 38a6ce4eca73db7fbce9520d47434f631fc9d2b4 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 20:46:37 -0300 Subject: [PATCH 04/10] chore(docker): Atualiza Dockerfile para nova arquitetura do pipeline ETL. --- tools/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile index cf637dc..8fd61ce 100644 --- a/tools/docker/Dockerfile +++ b/tools/docker/Dockerfile @@ -18,4 +18,4 @@ RUN pip install --no-cache-dir psycopg2-binary RUN pip install --no-cache-dir . # Define o comando padrao para executar o pipeline -CMD ["python", "tools/autosinapi_pipeline.py"] \ No newline at end of file +CMD ["python", "-m", "autosinapi.etl_pipeline"] \ No newline at end of file From b0dc0be76f81ebceaafa51ad63d0c215182b7491 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 20:47:02 -0300 Subject: [PATCH 05/10] =?UTF-8?q?test(pipeline-tests):=20Corrige=20testes?= =?UTF-8?q?=20de=20integra=C3=A7=C3=A3o=20do=20pipeline=20para=20nova=20es?= =?UTF-8?q?trutura=20de=20retorno.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_file_input.py | 150 ++++++++++++++++++++++++++++++++------- tests/test_pipeline.py | 77 ++++++++++++++------ 2 files changed, 177 insertions(+), 50 deletions(-) diff --git a/tests/test_file_input.py b/tests/test_file_input.py index e377712..141c61c 100644 --- a/tests/test_file_input.py +++ b/tests/test_file_input.py @@ -8,54 +8,62 @@ import pandas as pd import pytest -from tools.autosinapi_pipeline import Pipeline +from autosinapi.etl_pipeline import PipelineETL @pytest.fixture def mock_pipeline(mocker, tmp_path): """Fixture para mockar o pipeline e suas dependências.""" - mocker.patch("tools.autosinapi_pipeline.setup_logging") + mocker.patch("autosinapi.etl_pipeline.setup_logging") # Cria um diretório de extração falso extraction_path = tmp_path / "extraction" extraction_path.mkdir() # Cria um arquivo de referência falso dentro do diretório - (extraction_path / "SINAPI_Referência_2023_01.xlsx").touch() + referencia_file_path = extraction_path / "SINAPI_Referência_20_23_01.xlsx" + referencia_file_path.touch() + + with patch("autosinapi.core.database.Database") as mock_db, patch( + "autosinapi.core.downloader.Downloader" + ) as mock_downloader, patch( + "autosinapi.core.processor.Processor" + ) as mock_processor, patch( + "autosinapi.core.pre_processor.convert_excel_sheets_to_csv" + ) as mock_convert_excel_sheets_to_csv: - with patch("tools.autosinapi_pipeline.Database") as mock_db: mock_db_instance = MagicMock() mock_db.return_value = mock_db_instance - with patch("tools.autosinapi_pipeline.Downloader") as mock_downloader: - mock_downloader_instance = MagicMock() - mock_downloader.return_value = mock_downloader_instance + mock_downloader_instance = MagicMock() + mock_downloader.return_value = mock_downloader_instance - with patch("tools.autosinapi_pipeline.Processor") as mock_processor: - mock_processor_instance = MagicMock() - mock_processor.return_value = mock_processor_instance + mock_processor_instance = MagicMock() + mock_processor.return_value = mock_processor_instance - pipeline = Pipeline(config_path=None) + pipeline = PipelineETL(config_path=None) - mocker.patch.object(pipeline, "_run_pre_processing") - mocker.patch.object(pipeline, "_sync_catalog_status") - mocker.patch.object( - pipeline, "_unzip_file", return_value=extraction_path - ) - mocker.patch.object( - pipeline, "_find_and_normalize_zip", return_value=Path("mocked.zip") - ) + mocker.patch.object(pipeline, "_run_pre_processing") + mocker.patch.object(pipeline, "_sync_catalog_status") + mocker.patch.object( + pipeline, "_unzip_file", return_value=extraction_path + ) + mocker.patch.object( + pipeline, "_find_and_normalize_zip", return_value=Path("mocked.zip") + ) - yield ( - pipeline, - mock_db_instance, - mock_downloader_instance, - mock_processor_instance, - ) + yield ( + pipeline, + mock_db_instance, + mock_downloader_instance, + mock_processor_instance, + mock_convert_excel_sheets_to_csv, + referencia_file_path + ) def test_direct_file_input(tmp_path, mock_pipeline): """Testa o pipeline com input direto de arquivo.""" - pipeline, mock_db, _, mock_processor = mock_pipeline + pipeline, mock_db, _, mock_processor, mock_convert_excel_sheets_to_csv, referencia_file_path = mock_pipeline test_file = tmp_path / "test_sinapi.xlsx" df = pd.DataFrame( @@ -108,10 +116,98 @@ def test_direct_file_input(tmp_path, mock_pipeline): ), } - pipeline.run() + result = pipeline.run() # Capture the result mock_processor.process_catalogo_e_precos.assert_called() mock_db.save_data.assert_called() + assert result["status"] == "success" + assert "populados com sucesso" in result["message"] + assert result["records_inserted"] > 0 + mock_convert_excel_sheets_to_csv.assert_called_once_with( + xlsx_full_path=referencia_file_path, + sheets_to_convert=['CSD', 'CCD', 'CSE'], + output_dir=referencia_file_path.parent.parent / "csv_temp" + ) + + +def test_fallback_to_download(mock_pipeline): + """Testa o fallback para download quando arquivo não é fornecido.""" + pipeline, _, mock_downloader, _, _, _ = mock_pipeline + + db_config = { + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", + } + sinapi_config = {"state": "SP", "month": "01", "year": "2023", "type": "insumos"} + + with patch.object( + pipeline, + "_load_config", + return_value={ + "secrets_path": "dummy_path", + "default_year": "2023", + "default_month": "01", + }, + ): + with patch.object(pipeline, "_get_db_config", return_value=db_config): + with patch.object( + pipeline, "_get_sinapi_config", return_value=sinapi_config + ): + pipeline._find_and_normalize_zip.return_value = None + + result = pipeline.run() # Capture the result + + mock_downloader.get_sinapi_data.assert_called_once() + assert result["status"] == "success" + assert "populados com sucesso" in result["message"] + assert result["records_inserted"] > 0 + + +def test_invalid_input_file(mock_pipeline): + """Testa erro ao fornecer arquivo inválido.""" + pipeline, _, _, _, _, _ = mock_pipeline + + db_config = { + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", + } + sinapi_config = { + "state": "SP", + "month": "01", + "year": "2023", + "type": "insumos", + "input_file": "arquivo_inexistente.xlsx", + } + + with patch.object( + pipeline, + "_load_config", + return_value={ + "secrets_path": "dummy_path", + "default_year": "2023", + "default_month": "01", + }, + ): + with patch.object(pipeline, "_get_db_config", return_value=db_config): + with patch.object( + pipeline, "_get_sinapi_config", return_value=sinapi_config + ): + pipeline._unzip_file.side_effect = FileNotFoundError( + "Arquivo não encontrado" + ) + + result = pipeline.run() # Capture the result + + assert result["status"] == "failed" + assert "Arquivo não encontrado" in result["message"] + assert result["tables_updated"] == [] + assert result["records_inserted"] == 0 def test_fallback_to_download(mock_pipeline): diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index c5807da..9e06efb 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,7 +8,7 @@ import pytest from autosinapi.exceptions import DatabaseError, DownloadError, ProcessingError -from tools.autosinapi_pipeline import Pipeline +from autosinapi.etl_pipeline import PipelineETL @pytest.fixture @@ -38,19 +38,22 @@ def sinapi_config(): @pytest.fixture def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): """Fixture para mockar o pipeline e suas dependências.""" - mocker.patch("tools.autosinapi_pipeline.setup_logging") + mocker.patch("autosinapi.etl_pipeline.setup_logging") # Cria um diretório de extração falso extraction_path = tmp_path / "extraction" extraction_path.mkdir() # Cria um arquivo de referência falso dentro do diretório - (extraction_path / "SINAPI_Referência_2025_08.xlsx").touch() + referencia_file_path = extraction_path / "SINAPI_Referência_2025_08.xlsx" + referencia_file_path.touch() - with patch("tools.autosinapi_pipeline.Database") as mock_db, patch( - "tools.autosinapi_pipeline.Downloader" + with patch("autosinapi.core.database.Database") as mock_db, patch( + "autosinapi.core.downloader.Downloader" ) as mock_downloader, patch( - "tools.autosinapi_pipeline.Processor" - ) as mock_processor: + "autosinapi.core.processor.Processor" + ) as mock_processor, patch( + "autosinapi.core.pre_processor.convert_excel_sheets_to_csv" + ) as mock_convert_excel_sheets_to_csv: # New mock for the new pre_processor function mock_db_instance = MagicMock() mock_db.return_value = mock_db_instance @@ -61,7 +64,7 @@ def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): mock_processor_instance = MagicMock() mock_processor.return_value = mock_processor_instance - pipeline = Pipeline(config_path=None) + pipeline = PipelineETL(config_path=None) # Changed to PipelineETL mocker.patch.object(pipeline, "_get_db_config", return_value=db_config) mocker.patch.object(pipeline, "_get_sinapi_config", return_value=sinapi_config) @@ -79,7 +82,11 @@ def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): pipeline, "_find_and_normalize_zip", return_value=MagicMock() ) mocker.patch.object(pipeline, "_unzip_file", return_value=extraction_path) - mocker.patch.object(pipeline, "_run_pre_processing") + # The _run_pre_processing method now calls convert_excel_sheets_to_csv, + # so we mock the underlying function directly. + # We also need to ensure _run_pre_processing is called with the correct arguments. + # For simplicity, we'll mock the method itself and ensure it's called. + mocker.patch.object(pipeline, "_run_pre_processing") # Keep this mock for the method call mocker.patch.object(pipeline, "_sync_catalog_status") yield ( @@ -87,12 +94,14 @@ def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): mock_db_instance, mock_downloader_instance, mock_processor_instance, + mock_convert_excel_sheets_to_csv, # Yield the new mock + referencia_file_path # Yield the path for assertions ) def test_run_etl_success(mock_pipeline): """Testa o fluxo completo do ETL com sucesso.""" - pipeline, mock_db, _, mock_processor = mock_pipeline + pipeline, mock_db, _, mock_processor, mock_convert_excel_sheets_to_csv, referencia_file_path = mock_pipeline mock_processor.process_catalogo_e_precos.return_value = { "insumos": pd.DataFrame( @@ -113,44 +122,66 @@ def test_run_etl_success(mock_pipeline): ), } - pipeline.run() + result = pipeline.run() # Capture the result mock_db.create_tables.assert_called_once() mock_processor.process_catalogo_e_precos.assert_called() assert mock_db.save_data.call_count > 0 + mock_convert_excel_sheets_to_csv.assert_called_once_with( + xlsx_full_path=referencia_file_path, + sheets_to_convert=['CSD', 'CCD', 'CSE'], + output_dir=referencia_file_path.parent.parent / "csv_temp" # Adjust path as per etl_pipeline.py + ) + + assert result["status"] == "success" + assert "populados com sucesso" in result["message"] + assert "insumos" in result["tables_updated"] + assert "composicoes" in result["tables_updated"] + assert "composicao_insumos" in result["tables_updated"] + assert "composicao_subcomposicoes" in result["tables_updated"] + assert result["records_inserted"] > 0 -def test_run_etl_download_error(mock_pipeline, caplog): +def test_run_etl_download_error(mock_pipeline): """Testa falha no download.""" - pipeline, _, mock_downloader, _ = mock_pipeline + pipeline, _, mock_downloader, _, _, _ = mock_pipeline # Unpack all yielded values pipeline._find_and_normalize_zip.return_value = None mock_downloader.get_sinapi_data.side_effect = DownloadError("Network error") - pipeline.run() + result = pipeline.run() # Capture the result - assert "Erro de negócio no pipeline AutoSINAPI: Network error" in caplog.text + assert result["status"] == "failed" + assert "Network error" in result["message"] + assert result["tables_updated"] == [] + assert result["records_inserted"] == 0 -def test_run_etl_processing_error(mock_pipeline, caplog): +def test_run_etl_processing_error(mock_pipeline): """Testa falha no processamento.""" - pipeline, _, _, mock_processor = mock_pipeline + pipeline, _, _, mock_processor, _, _ = mock_pipeline # Unpack all yielded values mock_processor.process_catalogo_e_precos.side_effect = ProcessingError( "Invalid format" ) - pipeline.run() + result = pipeline.run() # Capture the result - assert "Erro de negócio no pipeline AutoSINAPI: Invalid format" in caplog.text + assert result["status"] == "failed" + assert "Invalid format" in result["message"] + assert result["tables_updated"] == [] + assert result["records_inserted"] == 0 -def test_run_etl_database_error(mock_pipeline, caplog): +def test_run_etl_database_error(mock_pipeline): """Testa falha no banco de dados.""" - pipeline, mock_db, _, _ = mock_pipeline + pipeline, mock_db, _, _, _, _ = mock_pipeline # Unpack all yielded values mock_db.create_tables.side_effect = DatabaseError("Connection failed") - pipeline.run() + result = pipeline.run() # Capture the result - assert "Erro de negócio no pipeline AutoSINAPI: Connection failed" in caplog.text + assert result["status"] == "failed" + assert "Connection failed" in result["message"] + assert result["tables_updated"] == [] + assert result["records_inserted"] == 0 From 8e20c84c98fe58511cb570f284cb6beb45c707ea Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Wed, 3 Sep 2025 20:54:21 -0300 Subject: [PATCH 06/10] =?UTF-8?q?chore(docs,=20ci):=20Atualiza=20arquivos?= =?UTF-8?q?=20de=20documenta=C3=A7=C3=A3o=20e=20workflow=20de=20CI=20(ajus?= =?UTF-8?q?tes=20menores).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/tests.yml | 2 +- docs/DataModel.md | 85 +++++++++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 724dd59..8543f1e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,5 +59,5 @@ jobs: - name: Lint with flake8 run: | - flake8 autosinapi tests --count --select=E9,F63,F7,F82 --show-source --statistics --ignore=E203,W503 + flake8 autosinapi tests --count --select=E9,F63,F7,F82 --show-source --statistics --ignore=E203,W503 flake8 autosinapi tests --count --max-complexity=10 --max-line-length=88 --statistics --ignore=E203,W503 diff --git a/docs/DataModel.md b/docs/DataModel.md index 2dbdaf1..591710d 100644 --- a/docs/DataModel.md +++ b/docs/DataModel.md @@ -202,35 +202,84 @@ Esta fase processa o arquivo principal do SINAPI, operando sobre catálogos cujo 3. **Carregar Dados Mensais (INSERT):** * Inserir os DataFrames de preços e custos em suas respectivas tabelas. Utilizar `ON CONFLICT DO NOTHING` para segurança em re-execuções. -## 4\. Diretrizes para a API e Consultas +## 4\. Diretrizes para API e Consultas -O modelo de dados permite a criação de endpoints poderosos e performáticos. +O modelo de dados robusto criado pelo `autoSINAPI` serve como uma base poderosa tanto para o uso programático (toolkit) quanto para a criação de APIs RESTful performáticas. Esta seção descreve a interface principal do toolkit e exemplifica endpoints que podem ser construídos sobre os dados processados. -#### Exemplo 1: Obter o custo de uma composição +### 4.1. Interface Programática (Toolkit) - * **Endpoint:** `GET /custo_composicao` - * **Parâmetros:** `codigo`, `uf`, `data_referencia`, `regime` - * **Lógica:** A consulta pode buscar o registro na tabela `custos_composicoes_mensal` e juntar com `composicoes` para alertar o usuário sobre o `status` do item. +A função `run_etl` é a interface pública principal para executar o pipeline de forma programática. -#### Exemplo 2: Explodir a estrutura completa de uma composição +| Parâmetro | Tipo | Descrição | Padrão | +| :--- | :--- | :--- | :--- | +| **`db_config`** | `Dict` | Dicionário com as credenciais de conexão do PostgreSQL. | *Obrigatório* | +| **`sinapi_config`** | `Dict` | Dicionário com as configurações de referência dos dados SINAPI. | *Obrigatório* | +| **`mode`** | `str` | Modo de operação: `'local'` (baixa os arquivos) ou `'server'` (usa arquivos locais). | `'local'` | +| **`log_level`** | `str` | Nível de detalhe dos logs (`'INFO'`, `'DEBUG'`, etc.). | `'INFO'` | - * **Endpoint:** `GET /composicao/{codigo}/estrutura` - * **Lógica:** Uma consulta (potencialmente recursiva) na `VIEW vw_composicao_itens_unificados` pode montar toda a árvore de dependências de uma composição. +----- -#### Exemplo 3: Rastrear o histórico de um insumo +#### **Estrutura do `db_config`** - * **Endpoint:** `GET /insumo/{codigo}/historico` - * **Lógica:** Uma consulta direta na tabela `manutencoes_historico`, ordenada pela data de referência. +```python +{ + "host": "seu_host_db", # Ex: "localhost" ou "db" (para Docker) + "port": 5432, # Porta do PostgreSQL + "database": "seu_db_name", # Nome do banco de dados + "user": "seu_usuario", # Usuário do banco de dados + "password": "sua_senha" # Senha do usuário +} +``` - +*Todos os campos são obrigatórios.* -```sql -SELECT * FROM manutencoes_historico -WHERE item_codigo = :codigo AND tipo_item = 'INSUMO' -ORDER BY data_referencia DESC; +----- + +#### **Estrutura do `sinapi_config`** + +```python +{ + "year": 2023, # Ano de referência (obrigatório) + "month": 7, # Mês de referência (obrigatório) + "type": "REFERENCIA", # Tipo de caderno ("REFERENCIA" ou "DESONERADO") + "duplicate_policy": "substituir" # Política de duplicatas ("substituir" ou "append") +} ``` ---- +*`year` e `month` são obrigatórios. Os demais possuem valores padrão.* + +----- + +### 4.2. Exemplos de Casos de Uso (API REST) + +A estrutura do banco de dados permite a criação de endpoints de API poderosos para consultar os dados de forma eficiente. + +#### **Exemplo 1: Obter o custo de uma composição** + +| | | +| :--- | :--- | +| **Endpoint** | `GET /custo_composicao` | +| **Parâmetros** | `codigo`, `uf`, `data_referencia`, `regime` | +| **Lógica** | Busca direta na tabela `custos_composicoes_mensal`, com um `JOIN` opcional na tabela `composicoes` para verificar o `status` do item (ativo/inativo). | + +\ + +#### **Exemplo 2: Explodir a estrutura completa de uma composição** + +| | | +| :--- | :--- | +| **Endpoint** | `GET /composicao/{codigo}/estrutura` | +| **Lógica** | Utiliza a view `vw_composicao_itens_unificados` para montar a árvore completa de insumos e subcomposições de um item. Uma consulta recursiva (CTE) é ideal para esta finalidade. | + +\ + +#### **Exemplo 3: Rastrear o histórico de um insumo** + +| | | +| :--- | :--- | +| **Endpoint** | `GET /insumo/{codigo}/historico` | +| **Lógica** | Consulta direta na tabela `manutencoes_historico` para retornar todas as manutenções (inclusão, exclusão, alteração) de um insumo específico, ordenadas por data. | +| **Exemplo SQL** | `sql
SELECT * FROM manutencoes_historico
WHERE item_codigo = :codigo AND tipo_item = 'INSUMO'
ORDER BY data_referencia DESC;
` | ## 5. Conclusão From 0b0175bdceb43d0b57a0b57e66add0bec8bd2201 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Thu, 4 Sep 2025 14:47:32 -0300 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20Revis=C3=A3o=20e=20estabiliza?= =?UTF-8?q?=C3=A7=C3=A3o=20da=20su=C3=ADte=20de=20testes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Realizada revisão inicial da suíte de testes para garantir a validade e consistência após refatorações no core do módulo. - `tests/test_file_input.py`: Refatorado para remover testes duplicados, simplificar o fixture `mock_pipeline` com `MagicMock` e ajustar asserções para refletir os retornos consistentes do `etl_pipeline`. - `tests/core/test_downloader.py`: Validado e confirmado sua estabilidade. Todos os testes existentes foram executados e passaram com sucesso, indicando que o módulo `Downloader` e seus testes permanecem funcionais e relevantes. - Outros arquivos de teste (`tests/core/test_database.py`, `tests/core/test_processor.py`, `tests/test_config.py`, `tests/test_pipeline.py`): Incluídos no stage para refletir a revisão geral da suíte de testes, embora modificações diretas significativas não tenham sido aplicadas a todos eles nesta etapa. Esta etapa visa preparar a base de testes para futuras modificações, garantindo que apenas testes válidos e funcionais estejam presentes. --- tests/core/test_database.py | 24 ++- tests/core/test_downloader.py | 103 ++++++------ tests/core/test_processor.py | 27 +++- tests/test_config.py | 4 +- tests/test_file_input.py | 294 ++++++++++++---------------------- tests/test_pipeline.py | 4 +- 6 files changed, 201 insertions(+), 255 deletions(-) diff --git a/tests/core/test_database.py b/tests/core/test_database.py index 0b0563f..df6b280 100644 --- a/tests/core/test_database.py +++ b/tests/core/test_database.py @@ -8,6 +8,7 @@ import pytest from sqlalchemy.exc import SQLAlchemyError +from autosinapi.config import Config from autosinapi.core.database import Database from autosinapi.exceptions import DatabaseError @@ -25,12 +26,19 @@ def db_config(): @pytest.fixture -def database(db_config): +def sinapi_config(): + """Fixture com configuração SINAPI mínima para testes.""" + return {"state": "SP", "month": "01", "year": "2023", "type": "REFERENCIA"} + + +@pytest.fixture +def database(db_config, sinapi_config): """Fixture que cria uma instância do Database com engine mockada.""" with patch("autosinapi.core.database.create_engine") as mock_create_engine: mock_engine = MagicMock() mock_create_engine.return_value = mock_engine - db = Database(db_config) + config = Config(db_config, sinapi_config, mode="server") + db = Database(config) db._engine = mock_engine yield db, mock_engine @@ -47,22 +55,24 @@ def sample_df(): ) -def test_connect_success(db_config): +def test_connect_success(db_config, sinapi_config): """Testa conexão bem-sucedida com o banco.""" with patch("autosinapi.core.database.create_engine") as mock_create_engine: mock_engine = MagicMock() mock_create_engine.return_value = mock_engine - db = Database(db_config) + config = Config(db_config, sinapi_config, mode="server") + db = Database(config) assert db._engine is not None mock_create_engine.assert_called_once() -def test_connect_failure(db_config): +def test_connect_failure(db_config, sinapi_config): """Testa falha na conexão com o banco.""" with patch("autosinapi.core.database.create_engine") as mock_create_engine: mock_create_engine.side_effect = SQLAlchemyError("Connection failed") with pytest.raises(DatabaseError, match="Erro ao conectar"): - Database(db_config) + config = Config(db_config, sinapi_config, mode="server") + Database(config) def test_save_data_success(database, sample_df): @@ -85,4 +95,4 @@ def test_save_data_failure(database, sample_df): mock_engine.connect.return_value.__enter__.return_value = mock_conn with pytest.raises(DatabaseError, match="Erro ao inserir dados"): - db.save_data(sample_df, "test_table", policy="append") + db.save_data(sample_df, "test_table", policy="append") \ No newline at end of file diff --git a/tests/core/test_downloader.py b/tests/core/test_downloader.py index e304ea4..fee3d0d 100644 --- a/tests/core/test_downloader.py +++ b/tests/core/test_downloader.py @@ -8,79 +8,92 @@ import pytest import requests +from autosinapi.config import Config from autosinapi.core.downloader import Downloader from autosinapi.exceptions import DownloadError # Fixtures +@pytest.fixture +def valid_db_config(): + """Fixture com configuração de banco de dados válida.""" + return { + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", + } + + @pytest.fixture def sinapi_config(): + """Fixture com configuração SINAPI básica.""" return {"state": "SP", "month": "01", "year": "2023", "type": "REFERENCIA"} @pytest.fixture def mock_response(): + """Fixture para mock de resposta HTTP.""" response = Mock() response.content = b"test content" response.raise_for_status = Mock() return response +@pytest.fixture +def downloader(valid_db_config, sinapi_config): + """Fixture que cria uma instância do Downloader com config mockada.""" + config = Config(db_config=valid_db_config, sinapi_config=sinapi_config, mode="server") + return Downloader(config) + + # Testes de URL Building -def test_build_url_referencia(sinapi_config): +def test_build_url_referencia(downloader): """Testa construção de URL para planilha referencial.""" - downloader = Downloader(sinapi_config, "server") url = downloader._build_url() - assert "SINAPI_REFERENCIA_01_2023.zip" in url - assert url.startswith( - "https://www.caixa.gov.br/Downloads/sinapi-a-vista-composicoes" - ) + assert url.startswith("https://www.caixa.gov.br/Downloads/sinapi-a-vista-composicoes") -def test_build_url_desonerado(): +def test_build_url_desonerado(valid_db_config): """Testa construção de URL para planilha desonerada.""" - config = {"state": "SP", "month": "12", "year": "2023", "type": "DESONERADO"} - downloader = Downloader(config, "server") + sinapi_cfg = {"state": "SP", "month": "12", "year": "2023", "type": "DESONERADO"} + config = Config(db_config=valid_db_config, sinapi_config=sinapi_cfg, mode="server") + downloader = Downloader(config) url = downloader._build_url() - assert "SINAPI_DESONERADO_12_2023.zip" in url -def test_build_url_invalid_type(): +def test_build_url_invalid_type(valid_db_config): """Testa erro ao construir URL com tipo inválido.""" - config = {"state": "SP", "month": "01", "year": "2023", "type": "INVALIDO"} - downloader = Downloader(config, "server") - + sinapi_cfg = {"state": "SP", "month": "01", "year": "2023", "type": "INVALIDO"} + config = Config(db_config=valid_db_config, sinapi_config=sinapi_cfg, mode="server") + downloader = Downloader(config) with pytest.raises(ValueError, match="Tipo de planilha inválido"): downloader._build_url() -def test_build_url_zero_padding(): +def test_build_url_zero_padding(valid_db_config): """Testa padding com zeros nos números.""" - config = { - "state": "SP", - "month": 1, # Número sem zero - "year": 2023, - "type": "REFERENCIA", - } - downloader = Downloader(config, "server") + sinapi_cfg = {"state": "SP", "month": 1, "year": 2023, "type": "REFERENCIA"} + config = Config(db_config=valid_db_config, sinapi_config=sinapi_cfg, mode="server") + downloader = Downloader(config) url = downloader._build_url() - assert "SINAPI_REFERENCIA_01_2023.zip" in url -# Testes +# Testes de Funcionalidade @patch("autosinapi.core.downloader.requests.Session") -def test_successful_download(mock_session, sinapi_config, mock_response): +def test_successful_download(mock_session, valid_db_config, sinapi_config, mock_response): """Deve realizar download com sucesso.""" - # Configura o mock session = Mock() session.get.return_value = mock_response mock_session.return_value = session - # Executa o download - downloader = Downloader(sinapi_config, "server") + config = Config(db_config=valid_db_config, sinapi_config=sinapi_config, mode="server") + downloader = Downloader(config) + result = downloader.get_sinapi_data() assert isinstance(result, BytesIO) assert result.getvalue() == b"test content" @@ -88,45 +101,41 @@ def test_successful_download(mock_session, sinapi_config, mock_response): @patch("autosinapi.core.downloader.requests.Session") -def test_download_network_error(mock_session, sinapi_config): +def test_download_network_error(mock_session, valid_db_config, sinapi_config): """Deve tratar erro de rede corretamente.""" - # Configura o mock para simular erro session = Mock() session.get.side_effect = requests.ConnectionError("Network error") mock_session.return_value = session - # Verifica se levanta a exceção correta - with pytest.raises(DownloadError) as exc_info: - downloader = Downloader(sinapi_config, "server") + config = Config(db_config=valid_db_config, sinapi_config=sinapi_config, mode="server") + downloader = Downloader(config) + + with pytest.raises(DownloadError, match="Erro no download: Network error"): downloader.get_sinapi_data() - assert "Network error" in str(exc_info.value) @patch("autosinapi.core.downloader.requests.Session") -def test_local_mode_save(mock_session, sinapi_config, mock_response, tmp_path): +def test_local_mode_save(mock_session, valid_db_config, sinapi_config, mock_response, tmp_path): """Deve salvar arquivo localmente em modo local.""" - # Configura o mock session = Mock() session.get.return_value = mock_response mock_session.return_value = session - # Cria caminho temporário para teste save_path = tmp_path / "test.xlsx" - - # Executa o download em modo local - downloader = Downloader(sinapi_config, "local") + + # Cria config para modo local + config = Config(db_config=valid_db_config, sinapi_config=sinapi_config, mode="local") + downloader = Downloader(config) + result = downloader.get_sinapi_data(save_path=save_path) - # Verifica se salvou o arquivo + assert save_path.exists() assert save_path.read_bytes() == b"test content" - - # Verifica se também retornou o conteúdo em memória assert isinstance(result, BytesIO) assert result.getvalue() == b"test content" -def test_context_manager(sinapi_config): +def test_context_manager(downloader): """Deve funcionar corretamente como context manager.""" - with Downloader(sinapi_config, "server") as downloader: - assert isinstance(downloader, Downloader) - # A sessão será fechada automaticamente ao sair do contexto + with downloader as d: + assert isinstance(d, Downloader) \ No newline at end of file diff --git a/tests/core/test_processor.py b/tests/core/test_processor.py index 3ea0a55..e21151a 100644 --- a/tests/core/test_processor.py +++ b/tests/core/test_processor.py @@ -7,13 +7,32 @@ import pandas as pd import pytest +from autosinapi.config import Config from autosinapi.core.processor import Processor @pytest.fixture -def processor(): - """Fixture que cria um processador com configurações básicas.""" - config = {"year": 2025, "month": 8, "type": "REFERENCIA"} +def db_config(): + """Fixture com configuração de teste do banco de dados.""" + return { + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", + } + + +@pytest.fixture +def sinapi_config(): + """Fixture com configuração SINAPI mínima para testes.""" + return {"state": "SP", "month": 8, "year": 2025, "type": "REFERENCIA"} + + +@pytest.fixture +def processor(db_config, sinapi_config): + """Fixture que cria um processador com configurações completas.""" + config = Config(db_config, sinapi_config, mode="server") p = Processor(config) p.logger.setLevel(logging.DEBUG) return p @@ -89,4 +108,4 @@ def test_process_composicao_itens(processor, tmp_path): assert "composicao_subcomposicoes" in result assert len(result["composicao_insumos"]) == 1 assert len(result["composicao_subcomposicoes"]) == 1 - assert result["composicao_insumos"].iloc[0]["insumo_filho_codigo"] == 1234 + assert result["composicao_insumos"].iloc[0]["insumo_filho_codigo"] == 1234 \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 8064fa1..a7c60e5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,10 +34,10 @@ def test_valid_config(valid_db_config, valid_sinapi_config): assert config.sinapi_config == valid_sinapi_config -def test_invalid_mode(): +def test_invalid_mode(valid_db_config, valid_sinapi_config): """Deve levantar erro para modo inválido.""" with pytest.raises(ConfigurationError) as exc_info: - Config({}, {}, "invalid") + Config(valid_db_config, valid_sinapi_config, "invalid") assert "Modo inválido" in str(exc_info.value) diff --git a/tests/test_file_input.py b/tests/test_file_input.py index 141c61c..f4a7b42 100644 --- a/tests/test_file_input.py +++ b/tests/test_file_input.py @@ -4,6 +4,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from io import BytesIO import pandas as pd import pytest @@ -16,33 +17,73 @@ def mock_pipeline(mocker, tmp_path): """Fixture para mockar o pipeline e suas dependências.""" mocker.patch("autosinapi.etl_pipeline.setup_logging") + # Mock do objeto Config + mock_config = MagicMock() + mock_config.DOWNLOAD_DIR = tmp_path / "downloads" + mock_config.YEAR = "2023" + mock_config.MONTH = "01" + mock_config.STATE = "SP" + mock_config.TYPE = "insumos" + mock_config.DB_HOST = "localhost" + mock_config.DB_PORT = 5432 + mock_config.DB_NAME = "test_db" + mock_config.DB_USER = "test_user" + mock_config.DB_PASSWORD = "test_pass" + mock_config.REFERENCE_FILE_KEYWORD = "Referencia" + mock_config.MAINTENANCE_FILE_KEYWORD = "Manuten" + mock_config.MAINTENANCE_DEACTIVATION_KEYWORD = "%DESATIVAÇÃO%" + mock_config.DB_TABLE_MANUTENCOES = "manutencoes_historico" + mock_config.DB_TABLE_INSUMOS = "insumos" + mock_config.DB_TABLE_COMPOSICOES = "composicoes" + mock_config.DB_TABLE_COMPOSICAO_INSUMOS = "composicao_insumos" + mock_config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES = "composicao_subcomposicoes" + mock_config.DB_TABLE_PRECOS_INSUMOS = "precos_insumos_mensal" + mock_config.DB_TABLE_CUSTOS_COMPOSICOES = "custos_composicoes_mensal" + mock_config.ITEM_TYPE_INSUMO = "INSUMO" + mock_config.ITEM_TYPE_COMPOSICAO = "COMPOSICAO" + mock_config.SHEETS_TO_CONVERT = ['CSD', 'CCD', 'CSE'] + mock_config.sinapi_config = {"state": "SP", "month": "01", "year": "2023", "type": "insumos"} # Adicionado para o test_fallback_to_download + + # Patch para que PipelineETL use o mock_config + mocker.patch("autosinapi.etl_pipeline.Config", return_value=mock_config) + # Cria um diretório de extração falso extraction_path = tmp_path / "extraction" extraction_path.mkdir() # Cria um arquivo de referência falso dentro do diretório - referencia_file_path = extraction_path / "SINAPI_Referência_20_23_01.xlsx" - referencia_file_path.touch() - - with patch("autosinapi.core.database.Database") as mock_db, patch( - "autosinapi.core.downloader.Downloader" - ) as mock_downloader, patch( - "autosinapi.core.processor.Processor" - ) as mock_processor, patch( + referencia_file_name = f"SINAPI_{mock_config.REFERENCE_FILE_KEYWORD}_20_23_01.xlsx" + referencia_file_path = extraction_path / referencia_file_name + # Create a dummy Excel file with required sheets + with pd.ExcelWriter(referencia_file_path) as writer: + for sheet_name in mock_config.SHEETS_TO_CONVERT: + pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}).to_excel(writer, sheet_name=sheet_name, index=False) + # Add other sheets that might be processed by processor.process_catalogo_e_precos and process_composicao_itens + pd.DataFrame({"codigo": [1,2], "descricao": ["a","b"]}).to_excel(writer, sheet_name="ISD", index=False) + pd.DataFrame({"codigo": [1,2], "descricao": ["a","b"]}).to_excel(writer, sheet_name="Analítico", index=False) + + with patch("autosinapi.etl_pipeline.Database") as mock_db_class, patch( + "autosinapi.etl_pipeline.Downloader" + ) as mock_downloader_class, patch( + "autosinapi.etl_pipeline.Processor" + ) as mock_processor_class, patch( "autosinapi.core.pre_processor.convert_excel_sheets_to_csv" ) as mock_convert_excel_sheets_to_csv: mock_db_instance = MagicMock() - mock_db.return_value = mock_db_instance + mock_db_class.return_value = mock_db_instance mock_downloader_instance = MagicMock() - mock_downloader.return_value = mock_downloader_instance + mock_downloader_class.return_value = mock_downloader_instance + mock_downloader_instance.get_sinapi_data.return_value = BytesIO(b"dummy zip content") mock_processor_instance = MagicMock() - mock_processor.return_value = mock_processor_instance + mock_processor_class.return_value = mock_processor_instance - pipeline = PipelineETL(config_path=None) + pipeline = PipelineETL(config_path=None) # config_path=None is fine as Config is mocked - mocker.patch.object(pipeline, "_run_pre_processing") + + spy_run_pre_processing = mocker.spy(pipeline, "_run_pre_processing") + spy_run = mocker.spy(pipeline, "run") mocker.patch.object(pipeline, "_sync_catalog_status") mocker.patch.object( pipeline, "_unzip_file", return_value=extraction_path @@ -57,13 +98,16 @@ def mock_pipeline(mocker, tmp_path): mock_downloader_instance, mock_processor_instance, mock_convert_excel_sheets_to_csv, - referencia_file_path + referencia_file_path, + mock_config, # Pass mock_config to the test + spy_run_pre_processing, # Pass spy_run_pre_processing to the test + spy_run # Add spy_run to the yield ) def test_direct_file_input(tmp_path, mock_pipeline): """Testa o pipeline com input direto de arquivo.""" - pipeline, mock_db, _, mock_processor, mock_convert_excel_sheets_to_csv, referencia_file_path = mock_pipeline + pipeline, mock_db, mock_downloader, mock_processor, mock_convert_excel_sheets_to_csv, referencia_file_path, mock_config, spy_run_pre_processing, spy_run = mock_pipeline test_file = tmp_path / "test_sinapi.xlsx" df = pd.DataFrame( @@ -76,209 +120,73 @@ def test_direct_file_input(tmp_path, mock_pipeline): ) df.to_excel(test_file, index=False) - db_config = { - "host": "localhost", - "port": 5432, - "database": "test_db", - "user": "test_user", - "password": "test_pass", - } - sinapi_config = { - "state": "SP", - "month": "01", - "year": "2023", - "type": "insumos", - "input_file": str(test_file), + # Set the input_file directly on the mocked sinapi_config + mock_config.sinapi_config["input_file"] = str(test_file) + + mock_processor.process_catalogo_e_precos.return_value = {"insumos": df} + mock_processor.process_composicao_itens.return_value = { + "composicao_insumos": pd.DataFrame(columns=["insumo_filho_codigo"]), + "composicao_subcomposicoes": pd.DataFrame(), + "parent_composicoes_details": pd.DataFrame( + columns=["codigo", "descricao", "unidade"] + ), + "child_item_details": pd.DataFrame( + columns=["codigo", "tipo", "descricao", "unidade"] + ), } - with patch.object( - pipeline, - "_load_config", - return_value={ - "secrets_path": "dummy_path", - "default_year": "2023", - "default_month": "01", - }, - ): - with patch.object(pipeline, "_get_db_config", return_value=db_config): - with patch.object( - pipeline, "_get_sinapi_config", return_value=sinapi_config - ): - mock_processor.process_catalogo_e_precos.return_value = {"insumos": df} - mock_processor.process_composicao_itens.return_value = { - "composicao_insumos": pd.DataFrame(columns=["insumo_filho_codigo"]), - "composicao_subcomposicoes": pd.DataFrame(), - "parent_composicoes_details": pd.DataFrame( - columns=["codigo", "descricao", "unidade"] - ), - "child_item_details": pd.DataFrame( - columns=["codigo", "tipo", "descricao", "unidade"] - ), - } - - result = pipeline.run() # Capture the result + result = pipeline.run() # Capture the result mock_processor.process_catalogo_e_precos.assert_called() mock_db.save_data.assert_called() - assert result["status"] == "success" + spy_run_pre_processing.assert_called_once() + assert result["status"] == "SUCESSO" assert "populados com sucesso" in result["message"] assert result["records_inserted"] > 0 mock_convert_excel_sheets_to_csv.assert_called_once_with( xlsx_full_path=referencia_file_path, - sheets_to_convert=['CSD', 'CCD', 'CSE'], + sheets_to_convert=mock_config.SHEETS_TO_CONVERT, output_dir=referencia_file_path.parent.parent / "csv_temp" ) -def test_fallback_to_download(mock_pipeline): +def test_fallback_to_download(mock_pipeline, mocker): """Testa o fallback para download quando arquivo não é fornecido.""" - pipeline, _, mock_downloader, _, _, _ = mock_pipeline - - db_config = { - "host": "localhost", - "port": 5432, - "database": "test_db", - "user": "test_user", - "password": "test_pass", - } - sinapi_config = {"state": "SP", "month": "01", "year": "2023", "type": "insumos"} - - with patch.object( - pipeline, - "_load_config", - return_value={ - "secrets_path": "dummy_path", - "default_year": "2023", - "default_month": "01", - }, - ): - with patch.object(pipeline, "_get_db_config", return_value=db_config): - with patch.object( - pipeline, "_get_sinapi_config", return_value=sinapi_config - ): - pipeline._find_and_normalize_zip.return_value = None - - result = pipeline.run() # Capture the result + pipeline, _, mock_downloader, _, _, _, mock_config, spy_run_pre_processing, spy_run = mock_pipeline + spy_find_and_normalize_zip = mocker.spy(pipeline, "_find_and_normalize_zip") + + # Ensure input_file is not set in the mocked sinapi_config + if "input_file" in mock_config.sinapi_config: + del mock_config.sinapi_config["input_file"] + + pipeline._find_and_normalize_zip.return_value = None + + result = pipeline.run() # Capture the result mock_downloader.get_sinapi_data.assert_called_once() - assert result["status"] == "success" + spy_find_and_normalize_zip.assert_called_once() + assert result["status"] == "SUCESSO" assert "populados com sucesso" in result["message"] assert result["records_inserted"] > 0 -def test_invalid_input_file(mock_pipeline): +def test_invalid_input_file(mock_pipeline, mocker): """Testa erro ao fornecer arquivo inválido.""" - pipeline, _, _, _, _, _ = mock_pipeline - - db_config = { - "host": "localhost", - "port": 5432, - "database": "test_db", - "user": "test_user", - "password": "test_pass", - } - sinapi_config = { - "state": "SP", - "month": "01", - "year": "2023", - "type": "insumos", - "input_file": "arquivo_inexistente.xlsx", - } + pipeline, _, _, _, _, _, mock_config, spy_run_pre_processing, spy_run = mock_pipeline - with patch.object( - pipeline, - "_load_config", - return_value={ - "secrets_path": "dummy_path", - "default_year": "2023", - "default_month": "01", - }, - ): - with patch.object(pipeline, "_get_db_config", return_value=db_config): - with patch.object( - pipeline, "_get_sinapi_config", return_value=sinapi_config - ): - pipeline._unzip_file.side_effect = FileNotFoundError( - "Arquivo não encontrado" - ) - - result = pipeline.run() # Capture the result - - assert result["status"] == "failed" - assert "Arquivo não encontrado" in result["message"] - assert result["tables_updated"] == [] - assert result["records_inserted"] == 0 + # Set an invalid input_file in the mocked sinapi_config + mock_config.sinapi_config["input_file"] = "arquivo_inexistente.xlsx" + pipeline._unzip_file.side_effect = FileNotFoundError( + "Arquivo não encontrado" + ) -def test_fallback_to_download(mock_pipeline): - """Testa o fallback para download quando arquivo não é fornecido.""" - pipeline, _, mock_downloader, _ = mock_pipeline - - db_config = { - "host": "localhost", - "port": 5432, - "database": "test_db", - "user": "test_user", - "password": "test_pass", - } - sinapi_config = {"state": "SP", "month": "01", "year": "2023", "type": "insumos"} - - with patch.object( - pipeline, - "_load_config", - return_value={ - "secrets_path": "dummy_path", - "default_year": "2023", - "default_month": "01", - }, - ): - with patch.object(pipeline, "_get_db_config", return_value=db_config): - with patch.object( - pipeline, "_get_sinapi_config", return_value=sinapi_config - ): - pipeline._find_and_normalize_zip.return_value = None - - pipeline.run() + result = pipeline.run() # Capture the result - mock_downloader.get_sinapi_data.assert_called_once() + assert result["status"] == "FALHA" + assert "Arquivo não encontrado" in result["message"] + assert result["tables_updated"] == [] + assert result["records_inserted"] == 0 -def test_invalid_input_file(mock_pipeline, caplog): - """Testa erro ao fornecer arquivo inválido.""" - pipeline, _, _, _ = mock_pipeline - - db_config = { - "host": "localhost", - "port": 5432, - "database": "test_db", - "user": "test_user", - "password": "test_pass", - } - sinapi_config = { - "state": "SP", - "month": "01", - "year": "2023", - "type": "insumos", - "input_file": "arquivo_inexistente.xlsx", - } - with patch.object( - pipeline, - "_load_config", - return_value={ - "secrets_path": "dummy_path", - "default_year": "2023", - "default_month": "01", - }, - ): - with patch.object(pipeline, "_get_db_config", return_value=db_config): - with patch.object( - pipeline, "_get_sinapi_config", return_value=sinapi_config - ): - pipeline._unzip_file.side_effect = FileNotFoundError( - "Arquivo não encontrado" - ) - - pipeline.run() - - assert "Arquivo não encontrado" in caplog.text diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9e06efb..981e19b 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -70,7 +70,7 @@ def mock_pipeline(mocker, db_config, sinapi_config, tmp_path): mocker.patch.object(pipeline, "_get_sinapi_config", return_value=sinapi_config) mocker.patch.object( pipeline, - "_load_config", + "_load_base_config", # Changed from "_load_config" return_value={ "secrets_path": "dummy", "default_year": sinapi_config["year"], @@ -184,4 +184,4 @@ def test_run_etl_database_error(mock_pipeline): assert result["status"] == "failed" assert "Connection failed" in result["message"] assert result["tables_updated"] == [] - assert result["records_inserted"] == 0 + assert result["records_inserted"] == 0 \ No newline at end of file From f824f244c77f0a74a5fb94da5f3c7c14934313aa Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Thu, 4 Sep 2025 20:23:26 -0300 Subject: [PATCH 08/10] refactor(etl): reestrutura a interface e componentes do pipeline ETL --- autosinapi/__init__.py | 75 +++- autosinapi/config.py | 138 ++++++- autosinapi/core/database.py | 414 +++++++------------- autosinapi/core/downloader.py | 136 ++++--- autosinapi/core/pre_processor.py | 56 ++- autosinapi/core/processor.py | 406 ++++++++++--------- autosinapi/etl_pipeline.py | 645 +++++++++++++++++-------------- 7 files changed, 997 insertions(+), 873 deletions(-) diff --git a/autosinapi/__init__.py b/autosinapi/__init__.py index 0515140..98f6091 100644 --- a/autosinapi/__init__.py +++ b/autosinapi/__init__.py @@ -34,6 +34,7 @@ import os import logging +import uuid # Added for run_id generation from contextlib import contextmanager from typing import Dict, Any @@ -58,28 +59,68 @@ def set_env_vars(env_vars: Dict[str, str]): else: os.environ[key] = value -def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str = 'local', log_level: str = 'INFO'): - """ - Executa o pipeline ETL do AutoSINAPI. - - Args: - db_config (Dict[str, Any]): Dicionário de configuração do banco de dados. - sinapi_config (Dict[str, Any]): Dicionário de configuração do SINAPI. - mode (str): Modo de operação do pipeline ('local' ou 'server'). Padrão é 'local'. - log_level (str): Nível de log para a execução do pipeline (e.g., 'INFO', 'DEBUG', 'WARNING'). Padrão é 'INFO'. - """ - # Validate inputs - if not isinstance(db_config, dict): +def run_etl(db_config: Dict[str, Any] = None, sinapi_config: Dict[str, Any] = None, mode: str = 'local', log_level: str = 'INFO'): + # Generate a unique run_id for this execution + run_id = str(uuid.uuid4())[:8] + + # Read skip_download from environment variable + skip_download_env = os.getenv('AUTOSINAPI_SKIP_DOWNLOAD', 'False').lower() + skip_download = (skip_download_env == 'true' or skip_download_env == '1') + + # If configs are not provided, try to load from environment variables + if db_config is None: + try: + db_config = { + 'host': os.getenv('POSTGRES_HOST', 'db'), + 'port': int(os.getenv('POSTGRES_PORT', 5432)), + 'database': os.getenv('POSTGRES_DB'), + 'user': os.getenv('POSTGRES_USER'), + 'password': os.getenv('POSTGRES_PASSWORD') + } + # Basic validation for required DB vars + if not all(db_config.get(k) for k in ['database', 'user', 'password']): + raise ValueError("Variáveis de ambiente do banco de dados incompletas.") + except (ValueError, TypeError) as e: + logger.error(f"Erro ao carregar db_config de variáveis de ambiente: {e}", exc_info=True) + return { + "status": "failed", + "message": f"Erro de configuração do banco de dados: {e}. Verifique as variáveis de ambiente POSTGRES_.", + "tables_updated": [], + "records_inserted": 0 + } + + if sinapi_config is None: + try: + sinapi_config = { + 'year': int(os.getenv('AUTOSINAPI_YEAR')), + 'month': int(os.getenv('AUTOSINAPI_MONTH')), + 'type': os.getenv('AUTOSINAPI_TYPE', 'REFERENCIA'), + 'duplicate_policy': os.getenv('AUTOSINAPI_POLICY', 'substituir') + } + # Basic validation for required SINAPI vars + if not all(sinapi_config.get(k) for k in ['year', 'month']): + raise ValueError("Variáveis de ambiente SINAPI incompletas.") + except (ValueError, TypeError) as e: + logger.error(f"Erro ao carregar sinapi_config de variáveis de ambiente: {e}", exc_info=True) + return { + "status": "failed", + "message": f"Erro de configuração SINAPI: {e}. Verifique as variáveis de ambiente AUTOSINAPI_.", + "tables_updated": [], + "records_inserted": 0 + } + + # Validate inputs (after potentially loading from env vars) + if not isinstance(db_config, dict) or not db_config: return { "status": "failed", - "message": "Erro de validação: db_config deve ser um dicionário.", + "message": "Erro de validação: db_config inválido ou vazio.", "tables_updated": [], "records_inserted": 0 } - if not isinstance(sinapi_config, dict): + if not isinstance(sinapi_config, dict) or not sinapi_config: return { "status": "failed", - "message": "Erro de validação: sinapi_config deve ser um dicionário.", + "message": "Erro de validação: sinapi_config inválido ou vazio.", "tables_updated": [], "records_inserted": 0 } @@ -120,13 +161,13 @@ def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str # The setup_logging function in autosinapi_pipeline.py takes debug_mode. # We need to map log_level to debug_mode. debug_mode = (log_level.upper() == 'DEBUG') - setup_logging(debug_mode=debug_mode) + setup_logging(run_id=run_id, debug_mode=debug_mode) try: with set_env_vars(env_vars_to_set): logger.info(f"Iniciando execução do pipeline com modo: {mode}" f"e nível de log: {log_level}") - pipeline = PipelineETL() # Pipeline will read from env vars + pipeline = PipelineETL(debug_mode=debug_mode, run_id=run_id) # Pass run_id to PipelineETL result = pipeline.run() logger.info("Pipeline executado com sucesso.") return result diff --git a/autosinapi/config.py b/autosinapi/config.py index c54c681..d1ae8f4 100644 --- a/autosinapi/config.py +++ b/autosinapi/config.py @@ -3,10 +3,6 @@ Este módulo define a classe `Config`, responsável por centralizar, validar e gerenciar todas as configurações necessárias para a execução do pipeline de ETL. - -A classe garante que todas as chaves obrigatórias para a conexão com o banco de dados -e para os parâmetros do SINAPI sejam fornecidas, levantando um erro claro em caso de -configurações ausentes. """ from typing import Any, Dict @@ -17,43 +13,147 @@ class Config: """Gerenciador de configurações do AutoSINAPI.""" + # --- Seção de Constantes Padrão --- + # Usado como fallback se não for fornecida uma configuração customizada. + # Permite que o comportamento do pipeline seja extensivamente personalizado. + DEFAULT_CONSTANTS = { + # --- Constantes do Downloader --- + "BASE_URL": "https://www.caixa.gov.br/Downloads/sinapi-a-vista-composicoes", + "VALID_TYPES": ["REFERENCIA", "DESONERADO"], + "TIMEOUT": 30, + "ALLOWED_LOCAL_FILE_EXTENSIONS": [".xlsx", ".xls"], + "DOWNLOAD_FILENAME_TEMPLATE": "SINAPI_{type}_{month}_{year}", + "DOWNLOAD_FILE_EXTENSION": ".zip", + + # --- Constantes do ETL Pipeline --- + "REFERENCE_FILE_KEYWORD": "Referência", + "MAINTENANCE_FILE_KEYWORD": "Manuten", + "MAINTENANCE_DEACTIVATION_KEYWORD": "%DESATIVAÇÃO%", + + "TEMP_CSV_DIR": "csv_temp", + "ZIP_FILENAME_TEMPLATE": "SINAPI-{year}-{month}-formato-xlsx.zip", + "DB_POLICY_APPEND": "append", + "DB_POLICY_UPSERT": "upsert", + "DEFAULT_PLACEHOLDER_UNIT": "UN", + "PLACEHOLDER_INSUMO_DESC_TEMPLATE": "INSUMO_DESCONHECIDO_{code}", + "PLACEHOLDER_COMPOSICAO_DESC_TEMPLATE": "COMPOSICAO_DESCONHECIDA_{code}", + "STATUS_SUCCESS": "SUCESSO", + "STATUS_SUCCESS_NO_DATA": "SUCESSO (SEM DADOS)", + "STATUS_FAILURE": "FALHA", + + # --- Constantes do Pre-Processor --- + "SHEETS_TO_CONVERT": ['CSD', 'CCD', 'CSE'], + "PREPROCESSOR_CSV_SEPARATOR": ";", + + # --- Constantes do Processor --- + "COMPOSICAO_ITENS_SHEET_KEYWORD": "Analítico", + "COMPOSICAO_ITENS_SHEET_EXCLUDE_KEYWORD": "Custo", + "MANUTENCOES_HEADER_KEYWORDS": ["REFERENCIA", "TIPO", "CODIGO", "DESCRICAO", "MANUTENCAO"], + "CUSTOS_HEADER_KEYWORDS": ["Código da Composição", "Descrição", "Unidade"], + "SHEET_MAP": { + "ISD": ("precos", "NAO_DESONERADO"), "ICD": ("precos", "DESONERADO"), + "ISE": ("precos", "SEM_ENCARGOS"), "CSD": ("custos", "NAO_DESONERADO"), + "CCD": ("custos", "DESONERADO"), "CSE": ("custos", "SEM_ENCARGOS"), + }, + "ID_COL_STANDARDIZE_MAP": { + "CODIGO_DO_INSUMO": "CODIGO", "DESCRICAO_DO_INSUMO": "DESCRICAO", + "CODIGO_DA_COMPOSICAO": "CODIGO", "DESCRICAO_DA_COMPOSICAO": "DESCRICAO", + }, + "MANUTENCOES_COL_MAP": { + "REFERENCIA": "data_referencia", "TIPO": "tipo_item", "CODIGO": "item_codigo", + "DESCRICAO": "descricao_item", "MANUTENCAO": "tipo_manutencao", + }, + "ORIGINAL_COLS": { + "TIPO_ITEM": "TIPO_ITEM", "CODIGO_COMPOSICAO": "CODIGO_DA_COMPOSICAO", + "CODIGO_ITEM": "CODIGO_DO_ITEM", "COEFICIENTE": "COEFICIENTE", + "DESCRICAO_ITEM": "DESCRICAO", "UNIDADE_ITEM": "UNIDADE", + }, + + "HEADER_SEARCH_LIMIT": 20, + "MANUTENCOES_SHEET_INDEX": 0, + "MANUTENCOES_DATE_FORMAT": "%m/%Y", + "COMPOSICAO_ITENS_HEADER_ROW": 9, + "PRECOS_HEADER_ROW": 9, + "CUSTOS_CODIGO_REGEX": r",(\d+)\)$", + "UNPIVOT_VALUE_PRECO": "preco_mediano", + "UNPIVOT_VALUE_CUSTO": "custo_total", + "FINAL_CATALOG_COLUMNS": { + "CODIGO": "codigo", "DESCRICAO": "descricao", "UNIDADE": "unidade" + }, + + # --- Constantes do Database --- + "DB_TABLE_INSUMOS": "insumos", + "DB_TABLE_COMPOSICOES": "composicoes", + "DB_TABLE_MANUTENCOES": "manutencoes_historico", + "DB_TABLE_COMPOSICAO_INSUMOS": "composicao_insumos", + "DB_TABLE_COMPOSICAO_SUBCOMPOSICOES": "composicao_subcomposicoes", + "DB_TABLE_PRECOS_INSUMOS": "precos_insumos_mensal", + "DB_TABLE_CUSTOS_COMPOSICOES": "custos_composicoes_mensal", + "ITEM_TYPE_INSUMO": "INSUMO", + "ITEM_TYPE_COMPOSICAO": "COMPOSICAO", + "DB_DIALECT": "postgresql", + "DB_TEMP_TABLE_PREFIX": "temp_", + "DB_DEFAULT_ITEM_STATUS": "ATIVO", + "DB_POLICY_REPLACE": "substituir", + } + REQUIRED_DB_KEYS = {"host", "port", "database", "user", "password"} REQUIRED_SINAPI_KEYS = {"state", "month", "year", "type"} - OPTIONAL_SINAPI_KEYS = {"input_file"} # Arquivo XLSX local opcional def __init__( - self, db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str + self, db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str, custom_constants: Dict[str, Any] = None ): """ - Inicializa as configurações do AutoSINAPI. + Inicializa e valida todas as configurações do AutoSINAPI. Args: - db_config: Configurações do banco de dados - sinapi_config: Configurações do SINAPI - mode: Modo de operação ('server' ou 'local') - - Raises: - ConfigurationError: Se as configurações forem inválidas + db_config: Dicionário com as configurações do banco de dados. + sinapi_config: Dicionário com os parâmetros da extração SINAPI. + mode: Modo de operação ('server' ou 'local'). + custom_constants: Dicionário opcional para sobrescrever as constantes padrão. """ + # Valida e armazena configurações brutas + self._validate_db_config(db_config) + self._validate_sinapi_config(sinapi_config) + self.db_config = db_config + self.sinapi_config = sinapi_config + + # Valida e define o modo de operação self.mode = self._validate_mode(mode) - self.db_config = self._validate_db_config(db_config) - self.sinapi_config = self._validate_sinapi_config(sinapi_config) + + # --- Expõe as configurações como atributos de alto nível --- + self.DOWNLOAD_DIR = "./downloads" + self.YEAR = sinapi_config["year"] + self.MONTH = sinapi_config["month"] + self.STATE = sinapi_config["state"] + self.TYPE = sinapi_config["type"] + self.DB_HOST = db_config["host"] + self.DB_PORT = db_config["port"] + self.DB_NAME = db_config["database"] + self.DB_USER = db_config["user"] + self.DB_PASSWORD = db_config["password"] + + # --- Carrega as constantes (customizadas ou padrão) --- + # Isso permite que o usuário personalize nomes de tabelas, arquivos, etc. + constants = self.DEFAULT_CONSTANTS.copy() + if custom_constants: + constants.update(custom_constants) + + for key, value in constants.items(): + setattr(self, key, value) def _validate_mode(self, mode: str) -> str: - """Valida o modo de operação.""" if mode not in ("server", "local"): raise ConfigurationError(f"Modo inválido: {mode}. Use 'server' ou 'local'") return mode def _validate_db_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - """Valida as configurações do banco de dados.""" missing = self.REQUIRED_DB_KEYS - set(config.keys()) if missing: raise ConfigurationError(f"Configurações de banco ausentes: {missing}") return config def _validate_sinapi_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - """Valida as configurações do SINAPI.""" missing = self.REQUIRED_SINAPI_KEYS - set(config.keys()) if missing: raise ConfigurationError(f"Configurações do SINAPI ausentes: {missing}") @@ -61,10 +161,8 @@ def _validate_sinapi_config(self, config: Dict[str, Any]) -> Dict[str, Any]: @property def is_server_mode(self) -> bool: - """Retorna True se o modo for 'server'.""" return self.mode == "server" @property def is_local_mode(self) -> bool: - """Retorna True se o modo for 'local'.""" return self.mode == "local" diff --git a/autosinapi/core/database.py b/autosinapi/core/database.py index d1b6440..8b88d39 100644 --- a/autosinapi/core/database.py +++ b/autosinapi/core/database.py @@ -1,16 +1,51 @@ -""" -Módulo de Banco de Dados do AutoSINAPI. - -Este módulo encapsula toda a interação com o banco de dados PostgreSQL. -Ele é responsável por: -- Criar a conexão com o banco de dados usando SQLAlchemy. -- Definir e criar o esquema de tabelas e views (DDL). -- Salvar os dados processados (DataFrames) nas tabelas, com diferentes - políticas de inserção (append, upsert, replace). -- Executar queries de consulta e de modificação de forma segura. +# autosinapi/core/database.py (versão refatorada) -A classe `Database` abstrai a complexidade do SQL e do SQLAlchemy, fornecendo -uma interface clara e de alto nível para o restante da aplicação. +""" +database.py: Módulo de Interação com o Banco de Dados. + +Este módulo encapsula toda a lógica de comunicação com o banco de dados +PostgreSQL. Ele é responsável por criar o esquema de tabelas, inserir os dados +processados e gerenciar as transações, garantindo a integridade e a +consistência dos dados. + +**Classe `Database`:** + +- **Inicialização:** Recebe um objeto `Config`, do qual extrai todas as + informações de conexão (host, port, user, password, dbname), o dialeto do + banco (`postgresql`), e nomes de tabelas, além de outras constantes + relacionadas ao banco. + +- **Entradas:** + - Recebe DataFrames do Pandas, que são o produto final do módulo `Processor`. + - Recebe o nome da tabela de destino e uma `policy` (política de + salvamento) que dita como os dados devem ser inseridos. + +- **Transformações/Processos:** + - **Gerenciamento de Conexão:** Utiliza `SQLAlchemy` para criar e gerenciar + um pool de conexões com o banco de dados. + - **Criação de Esquema (`create_tables`):** Executa instruções DDL (Data + Definition Language) para apagar (DROP) e recriar (CREATE) todas as + tabelas, views e relacionamentos necessários. O status padrão de um item + (`ATIVO`) é definido a partir do `Config`. + - **Políticas de Carga de Dados (`save_data`):** + - **`append`:** Insere novos registros, ignorando conflitos de chave + primária. Ideal para dados que não mudam, como histórico. + - **`upsert`:** Insere novos registros ou atualiza os existentes com base + na chave primária. Usado para atualizar catálogos de insumos e + composições. + - **`replace`:** Remove registros de um período específico (mês/ano) + antes de inserir os novos dados (não implementado no código fornecido). + - **Uso de Tabelas Temporárias:** Para operações de `append` e `upsert` em + larga escala, os dados são primeiro carregados em uma tabela temporária + (com prefixo definido no `Config`) e depois transferidos para a tabela + final com uma única instrução SQL, garantindo melhor desempenho e + atomicidade. + +- **Saídas:** + - A classe não retorna dados, mas modifica o estado do banco de dados, + populando-o com as informações processadas do SINAPI. + - Levanta exceções (`DatabaseError`) em caso de falhas de conexão ou + execução de queries para que o pipeline possa tratar o erro. """ import logging @@ -24,196 +59,110 @@ class Database: - def __init__(self, db_config: Dict[str, Any]): + def __init__(self, config): self.logger = logging.getLogger("autosinapi.database") - if not self.logger.hasHandlers(): - handler = logging.StreamHandler() - formatter = logging.Formatter("[%(levelname)s] %(message)s") - handler.setFormatter(formatter) - self.logger.addHandler(handler) - self.logger.setLevel(logging.INFO) - self.config = db_config + self.config = config self._engine = self._create_engine() def _create_engine(self) -> Engine: try: url = ( - f"postgresql://{self.config['user']}:{self.config['password']}" - f"@{self.config['host']}:{self.config['port']}" - f"/{self.config['database']}" + f"{self.config.DB_DIALECT}://{self.config.DB_USER}:{self.config.DB_PASSWORD}@" + f"{self.config.DB_HOST}:{self.config.DB_PORT}/{self.config.DB_NAME}" ) self.logger.info( - f"Tentando conectar ao banco de dados em: " - f"postgresql://{self.config['user']}:***" - f"@{self.config['host']}:{self.config['port']}/" - f"{self.config['database']}" + f"Conectando ao banco de dados: " + f"{self.config.DB_DIALECT}://{self.config.DB_USER}:***@" + f"{self.config.DB_HOST}:{self.config.DB_PORT}/{self.config.DB_NAME}" ) return create_engine(url) except Exception as e: - self.logger.error( - "----------------- ERRO ORIGINAL DE CONEXÃO -----------------" - ) - self.logger.error(f"TIPO DE ERRO: {type(e).__name__}") - self.logger.error(f"MENSAGEM: {e}") - self.logger.error( - "------------------------------------------------------------" - ) - raise DatabaseError("Erro ao conectar com o banco de dados") + self.logger.error(f"Falha ao criar conexão com o banco de dados: {e}", exc_info=True) + raise DatabaseError(f"Erro ao conectar com o banco de dados: {e}") from e def create_tables(self): - """ - Cria as tabelas do modelo de dados do SINAPI no banco PostgreSQL, - recriando-as para garantir conformidade com o modelo. - """ - # Drop all related objects to ensure a clean slate - drop_statements = """ + """Cria as tabelas do modelo de dados do SINAPI no banco.""" + drop_statements = f""" DROP VIEW IF EXISTS vw_composicao_itens_unificados; - DROP TABLE IF EXISTS composicao_subcomposicoes CASCADE; - DROP TABLE IF EXISTS composicao_insumos CASCADE; - DROP TABLE IF EXISTS custos_composicoes_mensal CASCADE; - DROP TABLE IF EXISTS precos_insumos_mensal CASCADE; - DROP TABLE IF EXISTS manutencoes_historico CASCADE; - DROP TABLE IF EXISTS composicoes CASCADE; - DROP TABLE IF EXISTS insumos CASCADE; - DROP TABLE IF EXISTS composicao_itens CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_COMPOSICAO_INSUMOS} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_CUSTOS_COMPOSICOES} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_PRECOS_INSUMOS} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_MANUTENCOES} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_COMPOSICOES} CASCADE; + DROP TABLE IF EXISTS {self.config.DB_TABLE_INSUMOS} CASCADE; """ - ddl = """ - CREATE TABLE insumos ( - codigo INTEGER PRIMARY KEY, - descricao TEXT NOT NULL, - unidade VARCHAR, - classificacao TEXT, - status VARCHAR DEFAULT 'ATIVO' + ddl = f""" + CREATE TABLE {self.config.DB_TABLE_INSUMOS} ( + codigo INTEGER PRIMARY KEY, descricao TEXT NOT NULL, unidade VARCHAR, classificacao TEXT, status VARCHAR DEFAULT '{self.config.DB_DEFAULT_ITEM_STATUS}' ); - - CREATE TABLE composicoes ( - codigo INTEGER PRIMARY KEY, - descricao TEXT NOT NULL, - unidade VARCHAR, - grupo VARCHAR, - status VARCHAR DEFAULT 'ATIVO' + CREATE TABLE {self.config.DB_TABLE_COMPOSICOES} ( + codigo INTEGER PRIMARY KEY, descricao TEXT NOT NULL, unidade VARCHAR, grupo VARCHAR, status VARCHAR DEFAULT '{self.config.DB_DEFAULT_ITEM_STATUS}' ); - - CREATE TABLE precos_insumos_mensal ( - insumo_codigo INTEGER NOT NULL, - uf CHAR(2) NOT NULL, - data_referencia DATE NOT NULL, - regime VARCHAR NOT NULL, - preco_mediano NUMERIC, - PRIMARY KEY ( - insumo_codigo, - uf, - data_referencia, - regime - ), - FOREIGN KEY (insumo_codigo) REFERENCES insumos(codigo) ON DELETE CASCADE + CREATE TABLE {self.config.DB_TABLE_PRECOS_INSUMOS} ( + insumo_codigo INTEGER NOT NULL, uf CHAR(2) NOT NULL, data_referencia DATE NOT NULL, regime VARCHAR NOT NULL, preco_mediano NUMERIC, + PRIMARY KEY (insumo_codigo, uf, data_referencia, regime), + FOREIGN KEY (insumo_codigo) REFERENCES {self.config.DB_TABLE_INSUMOS}(codigo) ON DELETE CASCADE ); - - CREATE TABLE custos_composicoes_mensal ( - composicao_codigo INTEGER NOT NULL, - uf CHAR(2) NOT NULL, - data_referencia DATE NOT NULL, - regime VARCHAR NOT NULL, - custo_total NUMERIC, - PRIMARY KEY ( - composicao_codigo, - uf, - data_referencia, - regime - ), - FOREIGN KEY (composicao_codigo) - REFERENCES composicoes(codigo) ON DELETE CASCADE + CREATE TABLE {self.config.DB_TABLE_CUSTOS_COMPOSICOES} ( + composicao_codigo INTEGER NOT NULL, uf CHAR(2) NOT NULL, data_referencia DATE NOT NULL, regime VARCHAR NOT NULL, custo_total NUMERIC, + PRIMARY KEY (composicao_codigo, uf, data_referencia, regime), + FOREIGN KEY (composicao_codigo) REFERENCES {self.config.DB_TABLE_COMPOSICOES}(codigo) ON DELETE CASCADE ); - - CREATE TABLE composicao_insumos ( - composicao_pai_codigo INTEGER NOT NULL, - insumo_filho_codigo INTEGER NOT NULL, - coeficiente NUMERIC, + CREATE TABLE {self.config.DB_TABLE_COMPOSICAO_INSUMOS} ( + composicao_pai_codigo INTEGER NOT NULL, insumo_filho_codigo INTEGER NOT NULL, coeficiente NUMERIC, PRIMARY KEY (composicao_pai_codigo, insumo_filho_codigo), - FOREIGN KEY (composicao_pai_codigo) - REFERENCES composicoes(codigo) ON DELETE CASCADE, - FOREIGN KEY (insumo_filho_codigo) - REFERENCES insumos(codigo) ON DELETE CASCADE + FOREIGN KEY (composicao_pai_codigo) REFERENCES {self.config.DB_TABLE_COMPOSICOES}(codigo) ON DELETE CASCADE, + FOREIGN KEY (insumo_filho_codigo) REFERENCES {self.config.DB_TABLE_INSUMOS}(codigo) ON DELETE CASCADE ); - - CREATE TABLE composicao_subcomposicoes ( - composicao_pai_codigo INTEGER NOT NULL, - composicao_filho_codigo INTEGER NOT NULL, - coeficiente NUMERIC, + CREATE TABLE {self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES} ( + composicao_pai_codigo INTEGER NOT NULL, composicao_filho_codigo INTEGER NOT NULL, coeficiente NUMERIC, PRIMARY KEY (composicao_pai_codigo, composicao_filho_codigo), - FOREIGN KEY (composicao_pai_codigo) REFERENCES composicoes(codigo) - ON DELETE CASCADE, - FOREIGN KEY (composicao_filho_codigo) REFERENCES composicoes(codigo) - ON DELETE CASCADE + FOREIGN KEY (composicao_pai_codigo) REFERENCES {self.config.DB_TABLE_COMPOSICOES}(codigo) ON DELETE CASCADE, + FOREIGN KEY (composicao_filho_codigo) REFERENCES {self.config.DB_TABLE_COMPOSICOES}(codigo) ON DELETE CASCADE ); - - CREATE TABLE manutencoes_historico ( - item_codigo INTEGER NOT NULL, - tipo_item VARCHAR NOT NULL, - data_referencia DATE NOT NULL, - tipo_manutencao TEXT NOT NULL, - descricao_item TEXT, + CREATE TABLE {self.config.DB_TABLE_MANUTENCOES} ( + item_codigo INTEGER NOT NULL, tipo_item VARCHAR NOT NULL, data_referencia DATE NOT NULL, tipo_manutencao TEXT NOT NULL, descricao_item TEXT, PRIMARY KEY (item_codigo, tipo_item, data_referencia, tipo_manutencao) ); - CREATE OR REPLACE VIEW vw_composicao_itens_unificados AS - SELECT - composicao_pai_codigo, - insumo_filho_codigo AS item_codigo, - 'INSUMO' AS tipo_item, - coeficiente - FROM - composicao_insumos + SELECT composicao_pai_codigo, insumo_filho_codigo AS item_codigo, '{self.config.ITEM_TYPE_INSUMO}' AS tipo_item, coeficiente FROM {self.config.DB_TABLE_COMPOSICAO_INSUMOS} UNION ALL - SELECT - composicao_pai_codigo, - composicao_filho_codigo AS item_codigo, - 'COMPOSICAO' AS tipo_item, - coeficiente - FROM - composicao_subcomposicoes; + SELECT composicao_pai_codigo, composicao_filho_codigo AS item_codigo, '{self.config.ITEM_TYPE_COMPOSICAO}' AS tipo_item, coeficiente FROM {self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES}; """ + trans = None try: with self._engine.connect() as conn: trans = conn.begin() self.logger.info("Recriando o esquema do banco de dados...") - # Drop old tables and view for stmt in drop_statements.split(";"): - if stmt.strip(): - conn.execute(text(stmt)) - # Create new tables and view + if stmt.strip(): conn.execute(text(stmt)) for stmt in ddl.split(";"): - if stmt.strip(): - conn.execute(text(stmt)) + if stmt.strip(): conn.execute(text(stmt)) trans.commit() self.logger.info("Esquema do banco de dados recriado com sucesso.") except Exception as e: - trans.rollback() - raise DatabaseError(f"Erro ao recriar as tabelas: {str(e)}") + if trans: + trans.rollback() + self.logger.error(f"Erro ao recriar tabelas: {e}", exc_info=True) + raise DatabaseError(f"Erro ao recriar as tabelas: {str(e)}") from e - def save_data( - self, data: pd.DataFrame, table_name: str, policy: str, **kwargs - ) -> None: - """ - Salva os dados no banco, aplicando a política de duplicatas. - """ + def save_data(self, data: pd.DataFrame, table_name: str, policy: str, **kwargs): if data.empty: - self.logger.warning( - f"DataFrame para a tabela \'{table_name}\' está vazio. " - f"Nenhum dado será salvo." - ) + self.logger.warning(f"DataFrame para a tabela '{table_name}' está vazio. Nenhum dado será salvo.") return - if policy.lower() == "substituir": + self.logger.info(f"Salvando dados na tabela '{table_name}' com política '{policy.upper()}'.") + if policy.lower() == self.config.DB_POLICY_REPLACE: year = kwargs.get("year") month = kwargs.get("month") if not year or not month: raise DatabaseError("Política 'substituir' requer 'year' e 'month'.") self._replace_data(data, table_name, year, month) - elif policy.lower() == "append": + elif policy.lower() == self.config.DB_POLICY_APPEND: self._append_data(data, table_name) - elif policy.lower() == "upsert": + elif policy.lower() == self.config.DB_POLICY_UPSERT: pk_columns = kwargs.get("pk_columns") if not pk_columns: raise DatabaseError("Política 'upsert' requer 'pk_columns'.") @@ -222,69 +171,41 @@ def save_data( raise DatabaseError(f"Política de duplicatas desconhecida: {policy}") def _append_data(self, data: pd.DataFrame, table_name: str): - """Insere dados, ignorando conflitos de chave primária.""" - self.logger.info( - f"Inserindo {len(data)} registros em '{table_name}' " - f"(política: append/ignore)." - ) - + self.logger.info(f"Inserindo {len(data)} registros em '{table_name}' (política: append/ignore).") + temp_table_name = f"{self.config.DB_TEMP_TABLE_PREFIX}{table_name}" with self._engine.connect() as conn: - data.to_sql( - name=f"temp_{table_name}", - con=conn, - if_exists="replace", - index=False - ) - - pk_cols_query = text( - f""" - SELECT a.attname - FROM pg_index i - JOIN pg_attribute a ON a.attrelid = i.indrelid - AND a.attnum = ANY(i.indkey) - WHERE i.indrelid = '"{table_name}"'::regclass - AND i.indisprimary; - """ - ) - + data.to_sql(name=temp_table_name, con=conn, if_exists="replace", index=False) + pk_cols_query = text(f""" + SELECT a.attname FROM pg_index i + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = '{table_name}'::regclass AND i.indisprimary; + """) trans = conn.begin() try: pk_cols_result = conn.execute(pk_cols_query).fetchall() if not pk_cols_result: - raise DatabaseError( - f"Nenhuma chave primária encontrada para a tabela " - f"{table_name}." - ) + raise DatabaseError(f"Nenhuma chave primária encontrada para a tabela {table_name}.") + pk_cols = [row[0] for row in pk_cols_result] pk_cols_str = ", ".join(pk_cols) - - cols = ", ".join([f'"{c}"' for c in data.columns]) - - insert_query = f""" - INSERT INTO "{table_name}" ({cols}) - SELECT {cols} FROM "temp_{table_name}" - ON CONFLICT ({pk_cols_str}) DO NOTHING; - """ + cols = ", ".join([f'\"{c}\"' for c in data.columns]) + + insert_query = f''' + INSERT INTO \"{table_name}\" ({cols}) + SELECT {cols} FROM \"{temp_table_name}\" + ON CONFLICT ({pk_cols_str}) DO NOTHING; + ''' conn.execute(text(insert_query)) - conn.execute(text(f'DROP TABLE "temp_{table_name}" CASCADE')) + conn.execute(text(f'DROP TABLE "{temp_table_name}" CASCADE')) trans.commit() except Exception as e: trans.rollback() - raise DatabaseError( - f"Erro ao inserir dados em {table_name}: {str(e)}" - ) + self.logger.error(f"Erro ao inserir dados em {table_name}: {e}", exc_info=True) + raise DatabaseError(f"Erro ao inserir dados em {table_name}: {str(e)}") from e def _replace_data(self, data: pd.DataFrame, table_name: str, year: str, month: str): - """Substitui os dados de um determinado período.""" - self.logger.info( - f"Substituindo dados em '{table_name}' " - f"para o período {year}-{month}." - ) - delete_query = text( - f"""DELETE FROM "{table_name}" WHERE """ - f"""TO_CHAR(data_referencia, 'YYYY-MM') = :ref""" - ) - + self.logger.info(f"Substituindo dados em '{table_name}' para o período {year}-{month}.") + delete_query = text(f'DELETE FROM "{table_name}" WHERE TO_CHAR(data_referencia, \'YYYY-MM\') = :ref') with self._engine.connect() as conn: trans = conn.begin() try: @@ -293,60 +214,49 @@ def _replace_data(self, data: pd.DataFrame, table_name: str, year: str, month: s trans.commit() except Exception as e: trans.rollback() - raise DatabaseError(f"Erro ao substituir dados: {str(e)}") + self.logger.error(f"Erro ao substituir dados em {table_name}: {e}", exc_info=True) + raise DatabaseError(f"Erro ao substituir dados: {str(e)}") from e def _upsert_data(self, data: pd.DataFrame, table_name: str, pk_columns: list): - """Executa um UPSERT (INSERT ON CONFLICT UPDATE).""" - self.logger.info( - f"Executando UPSERT de {len(data)} registros em '{table_name}'." - ) - + self.logger.info(f"Executando UPSERT de {len(data)} registros em '{table_name}'.") + temp_table_name = f"{self.config.DB_TEMP_TABLE_PREFIX}{table_name}" with self._engine.connect() as conn: - data.to_sql( - name=f"temp_{table_name}", - con=conn, - if_exists="replace", - index=False - ) - - cols = ", ".join([f'"{c}"' for c in data.columns]) + data.to_sql(name=temp_table_name, con=conn, if_exists="replace", index=False) + cols = ", ".join([f'\"{c}\"' for c in data.columns]) pk_cols_str = ", ".join(pk_columns) - update_cols = ", ".join( - [f'"{c}" = EXCLUDED."{c}"' for c in data.columns if c not in pk_columns] - ) - + update_cols = ", ".join([f'\"{c}\" = EXCLUDED.\"{c}\"' for c in data.columns if c not in pk_columns]) + if not update_cols: self._append_data(data, table_name) return - query = f""" - INSERT INTO "{table_name}" ({cols}) - SELECT {cols} FROM "temp_{table_name}" - ON CONFLICT ({pk_cols_str}) DO UPDATE SET {update_cols}; - """ - + query = f''' + INSERT INTO \"{table_name}\" ({cols}) + SELECT {cols} FROM \"{temp_table_name}\" + ON CONFLICT ({pk_cols_str}) DO UPDATE SET {update_cols}; + ''' trans = conn.begin() try: conn.execute(text(query)) - conn.execute(text(f'DROP TABLE "temp_{table_name}" CASCADE')) + conn.execute(text(f'DROP TABLE "{temp_table_name}" CASCADE')) trans.commit() except Exception as e: trans.rollback() - raise DatabaseError(f"Erro no UPSERT para {table_name}: {str(e)}") + self.logger.error(f"Erro no UPSERT para {table_name}: {e}", exc_info=True) + raise DatabaseError(f"Erro no UPSERT para {table_name}: {str(e)}") from e def truncate_table(self, table_name: str): - """Executa TRUNCATE em uma tabela para limpá-la antes de uma nova carga.""" self.logger.info(f"Limpando tabela: {table_name}") + query = f'TRUNCATE TABLE "{table_name}" RESTART IDENTITY CASCADE' try: with self._engine.connect() as conn: trans = conn.begin() - conn.execute( - text(f'TRUNCATE TABLE "{table_name}" RESTART IDENTITY CASCADE') - ) + conn.execute(text(query)) trans.commit() except Exception as e: trans.rollback() - raise DatabaseError(f"Erro ao truncar a tabela {table_name}: {str(e)}") + self.logger.error(f"Falha ao truncar tabela {table_name}. Query: '{query}'", exc_info=True) + raise DatabaseError(f"Erro ao truncar a tabela {table_name}: {str(e)}") from e def execute_query(self, query: str, params: Dict[str, Any] = None) -> pd.DataFrame: try: @@ -354,24 +264,10 @@ def execute_query(self, query: str, params: Dict[str, Any] = None) -> pd.DataFra result = conn.execute(text(query), params or {}) return pd.DataFrame(result.fetchall(), columns=result.keys()) except Exception as e: - self.logger.error( - "----------------- ERRO ORIGINAL DE EXECUÇÃO " - "(QUERY) -----------------" - ) - self.logger.error(f"TIPO DE ERRO: {type(e).__name__}") - self.logger.error(f"MENSAGEM: {e}") - self.logger.error(f"QUERY: {query}") - self.logger.error( - "-------------------------------------------" - "--------------------------" - ) - raise DatabaseError(f"Erro ao executar query: {str(e)}") + self.logger.error(f"Erro ao executar query. Query: '{query}'", exc_info=True) + raise DatabaseError(f"Erro ao executar query: {str(e)}") from e def execute_non_query(self, query: str, params: Dict[str, Any] = None) -> int: - """ - Executa uma query que não retorna resultados (INSERT, UPDATE, DELETE, DDL). - Retorna o número de linhas afetadas. - """ try: with self._engine.connect() as conn: trans = conn.begin() @@ -380,21 +276,11 @@ def execute_non_query(self, query: str, params: Dict[str, Any] = None) -> int: return result.rowcount except Exception as e: trans.rollback() - self.logger.error( - "----------------- ERRO ORIGINAL DE EXECUÇÃO (NON-QUERY)" - " -----------------" - ) - self.logger.error(f"TIPO DE ERRO: {type(e).__name__}") - self.logger.error(f"MENSAGEM: {e}") - self.logger.error(f"QUERY: {query}") - self.logger.error( - "-------------------------------------------------------" - "----------------" - ) - raise DatabaseError(f"Erro ao executar non-query: {str(e)}") + self.logger.error(f"Erro ao executar non-query. Query: '{query}'", exc_info=True) + raise DatabaseError(f"Erro ao executar non-query: {str(e)}") from e def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - self._engine.dispose() + self._engine.dispose() \ No newline at end of file diff --git a/autosinapi/core/downloader.py b/autosinapi/core/downloader.py index 8a9ffae..c75eaf0 100644 --- a/autosinapi/core/downloader.py +++ b/autosinapi/core/downloader.py @@ -1,44 +1,68 @@ -""" -Módulo de Download do AutoSINAPI. - -Este módulo é responsável por obter os arquivos de dados do SINAPI. Ele abstrai -a origem dos dados, que pode ser tanto um download direto do site da Caixa -Econômica Federal quanto um arquivo local fornecido pelo usuário. +# autosinapi/core/downloader.py -A classe `Downloader` gerencia a sessão HTTP, constrói as URLs de download -com base nas configurações e trata os erros de rede, garantindo que o pipeline -receba um stream de bytes do arquivo a ser processado. +""" +downloader.py: Módulo de Obtenção de Dados do AutoSINAPI. + +Este módulo é responsável por abstrair a origem dos arquivos de dados do SINAPI. +Ele fornece uma interface unificada para obter os dados, que podem vir de um +download direto do site da Caixa Econômica Federal ou de um arquivo local +fornecido pelo usuário. + +**Classe `Downloader`:** + +- **Inicialização:** Recebe um objeto `Config` que contém todos os parâmetros + necessários para a operação, como a URL base, templates de nome de arquivo, + tipos de planilha válidos e configurações de timeout. + +- **Entradas:** + - O método principal `get_sinapi_data` pode receber um `file_path` + opcional. Se fornecido, o módulo lê o arquivo local. Caso contrário, + ele constrói a URL de download com base nos parâmetros `YEAR`, `MONTH` e + `TYPE` presentes no objeto `Config`. + +- **Transformações/Processos:** + - **Construção de URL:** Monta a URL completa para o download do arquivo + `.zip` do SINAPI, utilizando o template e os parâmetros definidos no + `Config`. + - **Requisição HTTP:** Gerencia uma sessão `requests` para realizar o + download do arquivo, tratando exceções de rede (como timeouts ou erros de + HTTP) de forma robusta. + - **Leitura Local:** Valida se o arquivo local fornecido existe e se possui + uma extensão permitida (definida no `Config`). + +- **Saídas:** + - O método `get_sinapi_data` retorna um objeto `BinaryIO` (especificamente + `io.BytesIO`), que é um stream de bytes do conteúdo do arquivo (seja ele + baixado ou lido localmente). Este formato é ideal para ser + consumido pelos próximos estágios do pipeline (como o `unzip` no + `etl_pipeline.py`) sem a necessidade de salvar arquivos intermediários + em disco, embora também suporte salvar o arquivo baixado se configurado. """ +import logging from io import BytesIO from pathlib import Path -from typing import BinaryIO, Dict, Optional, Union +from typing import BinaryIO, Optional, Union import requests +from ..config import Config from ..exceptions import DownloadError class Downloader: """ Classe responsável por obter os arquivos SINAPI, seja por download ou input direto. - - Suporta dois modos de obtenção: - 1. Download direto do servidor SINAPI - 2. Leitura de arquivo local fornecido pelo usuário """ - def __init__(self, sinapi_config: Dict[str, str], mode: str): + def __init__(self, config: Config): """ Inicializa o downloader. - - Args: - sinapi_config: Configurações do SINAPI - mode: Modo de operação ('server' ou 'local') """ - self.config = sinapi_config - self.mode = mode + self.config = config + self.logger = logging.getLogger(__name__) self._session = requests.Session() + self.logger.info("Downloader inicializado.") def get_sinapi_data( self, @@ -47,89 +71,77 @@ def get_sinapi_data( ) -> BinaryIO: """ Obtém os dados do SINAPI, seja por download ou arquivo local. - - Args: - file_path: Caminho opcional para arquivo XLSX local - save_path: Caminho opcional para salvar o arquivo baixado (modo local) - - Returns: - BytesIO: Stream com o conteúdo do arquivo - - Raises: - DownloadError: Se houver erro no download ou leitura do arquivo """ if file_path: + self.logger.info("Modo de obtenção: Leitura de arquivo local.") return self._read_local_file(file_path) + + self.logger.info("Modo de obtenção: Download do servidor SINAPI.") return self._download_file(save_path) def _read_local_file(self, file_path: Union[str, Path]) -> BinaryIO: """Lê um arquivo XLSX local.""" + self.logger.debug(f"Lendo arquivo local em: {file_path}") try: path = Path(file_path) if not path.exists(): raise FileNotFoundError(f"Arquivo não encontrado: {path}") - if path.suffix.lower() not in {".xlsx", ".xls"}: - raise ValueError("Formato inválido. Use arquivos .xlsx ou .xls") - return BytesIO(path.read_bytes()) + # MODIFICADO: Usa constante do config para as extensões permitidas + if path.suffix.lower() not in self.config.ALLOWED_LOCAL_FILE_EXTENSIONS: + raise ValueError(f"Formato inválido. Use arquivos dos tipos: {self.config.ALLOWED_LOCAL_FILE_EXTENSIONS}") + + content = BytesIO(path.read_bytes()) + self.logger.info(f"Arquivo local '{path.name}' lido com sucesso.") + return content except Exception as e: + self.logger.error(f"Erro ao ler o arquivo local '{file_path}': {e}", exc_info=True) raise DownloadError(f"Erro ao ler arquivo local: {str(e)}") def _download_file(self, save_path: Optional[Path] = None) -> BinaryIO: """ Realiza o download do arquivo SINAPI. - - Args: - save_path: Caminho para salvar o arquivo (apenas em modo local) - - Returns: - BytesIO: Stream com o conteúdo do arquivo - - Raises: - DownloadError: Se houver erro no download """ try: url = self._build_url() - response = self._session.get(url, timeout=30) + self.logger.info(f"Realizando download de: {url}") + response = self._session.get(url, timeout=self.config.TIMEOUT) response.raise_for_status() content = BytesIO(response.content) + self.logger.info(f"Download de {url} concluído com sucesso ({len(content.getvalue())} bytes).") - if self.mode == "local" and save_path: + if self.config.is_local_mode and save_path: + self.logger.debug(f"Salvando arquivo baixado em: {save_path}") save_path.write_bytes(response.content) return content except requests.RequestException as e: + self.logger.error(f"Falha no download de {url}: {e}", exc_info=True) raise DownloadError(f"Erro no download: {str(e)}") def _build_url(self) -> str: """ Constrói a URL do arquivo SINAPI com base nas configurações. - - Returns: - str: URL completa para download do arquivo """ - base_url = "https://www.caixa.gov.br/Downloads/sinapi-a-vista-composicoes" - - # Formata ano e mês com zeros à esquerda - ano = str(self.config["year"]).zfill(4) - mes = str(self.config["month"]).zfill(2) + ano = str(self.config.YEAR).zfill(4) + mes = str(self.config.MONTH).zfill(2) - # Determina o tipo de planilha - tipo = self.config.get("type", "REFERENCIA").upper() - if tipo not in ["REFERENCIA", "DESONERADO"]: + tipo = self.config.TYPE.upper() + if tipo not in self.config.VALID_TYPES: raise ValueError(f"Tipo de planilha inválido: {tipo}") - # Constrói a URL - file_name = f"SINAPI_{tipo}_{mes}_{ano}" - url = f"{base_url}/{file_name}.zip" + # MODIFICADO: Usa template do config para o nome do arquivo e extensão + file_name = self.config.DOWNLOAD_FILENAME_TEMPLATE.format(type=tipo, month=mes, year=ano) + url = f"{self.config.BASE_URL}/{file_name}{self.config.DOWNLOAD_FILE_EXTENSION}" + + self.logger.debug(f"URL construída: {url}") return url def __enter__(self): - """Permite uso do contexto 'with'.""" return self def __exit__(self, exc_type, exc_val, exc_tb): - """Fecha a sessão HTTP ao sair do contexto.""" - self._session.close() + self.logger.debug("Fechando sessão HTTP do Downloader.") + self._session.close() \ No newline at end of file diff --git a/autosinapi/core/pre_processor.py b/autosinapi/core/pre_processor.py index 6532fa9..31831c8 100644 --- a/autosinapi/core/pre_processor.py +++ b/autosinapi/core/pre_processor.py @@ -1,7 +1,49 @@ +# autosinapi/core/pre_processor.py + +""" +pre_processor.py: Módulo de Pré-processamento de Arquivos. + +Este módulo oferece funcionalidades para otimizar a leitura de grandes arquivos +Excel antes da etapa principal de transformação. Sua principal função é converter +planilhas específicas e de alto volume de um arquivo `.xlsx` em arquivos `.csv` +separados. Isso melhora significativamente o desempenho da leitura de dados no +módulo `processor`, que pode ler CSVs de forma muito mais eficiente que +planilhas Excel complexas. + +**Função `convert_excel_sheets_to_csv`:** + +- **Entradas:** + - `xlsx_full_path (Path)`: O caminho completo para o arquivo Excel de + origem (ex: `SINAPI_Referência_AAAA_MM.xlsx`). + - `sheets_to_convert (list[str])`: Uma lista de nomes das planilhas que + devem ser convertidas (ex: `['CSD', 'CCD', 'CSE']`). + - `output_dir (Path)`: O diretório onde os arquivos CSV resultantes serão + salvos. + - `config (Config)`: O objeto de configuração do pipeline, do qual extrai + parâmetros como o separador do CSV (`PREPROCESSOR_CSV_SEPARATOR`). + +- **Transformações/Processos:** + - Itera sobre a lista de planilhas a serem convertidas. + - Para cada nome de planilha, lê os dados brutos do arquivo Excel + utilizando `pandas.read_excel`. + - Salva o conteúdo da planilha em um novo arquivo `.csv` no diretório de + saída especificado. O nome do arquivo CSV será o mesmo da planilha + (ex: `CSD.csv`). + - Utiliza o separador definido no objeto `config` ao criar o arquivo CSV, + garantindo consistência. + +- **Saídas:** + - A função não possui um valor de retorno explícito (`None`). + - Seu resultado são os arquivos `.csv` criados no `output_dir`, que + serão consumidos posteriormente pela classe `Processor`. +""" + import pandas as pd import os import logging from pathlib import Path + +from autosinapi.config import Config from autosinapi.exceptions import ProcessingError logger = logging.getLogger(__name__) @@ -9,15 +51,11 @@ def convert_excel_sheets_to_csv( xlsx_full_path: Path, sheets_to_convert: list[str], - output_dir: Path + output_dir: Path, + config: Config ): """ - Converts specific sheets from an XLSX file to CSV, ensuring formulas are read as text. - - Args: - xlsx_full_path (Path): The full path to the XLSX file. - sheets_to_convert (list[str]): A list of sheet names to convert. - output_dir (Path): The directory where the CSV files will be saved. + Converts specific sheets from an XLSX file to CSV, using settings from the config object. """ logger.info(f"Iniciando pré-processamento do arquivo: {xlsx_full_path}") @@ -39,8 +77,8 @@ def convert_excel_sheets_to_csv( ) csv_output_path = output_dir / f"{sheet}.csv" - df.to_csv(csv_output_path, index=False, header=False, sep=';') - logger.info(f"Planilha '{sheet}' convertida com sucesso para '{csv_output_path}' (separador: ;)") + df.to_csv(csv_output_path, index=False, header=False, sep=config.PREPROCESSOR_CSV_SEPARATOR) + logger.info(f"Planilha '{sheet}' convertida com sucesso para '{csv_output_path}' (separador: {config.PREPROCESSOR_CSV_SEPARATOR})") except Exception as e: raise ProcessingError(f"Falha ao processar a planilha '{sheet}'. Erro: {e}") from e diff --git a/autosinapi/core/processor.py b/autosinapi/core/processor.py index 45e7608..6d8eda0 100644 --- a/autosinapi/core/processor.py +++ b/autosinapi/core/processor.py @@ -1,15 +1,52 @@ -""" -Módulo de Processamento do AutoSINAPI. - -Este módulo é responsável por todas as etapas de transformação e limpeza dos dados -brutos do SINAPI, obtidos pelo módulo `downloader`. Ele lida com a leitura de -arquivos Excel, padronização de nomes de colunas, tratamento de valores ausentes, -e a estruturação dos dados em DataFrames do Pandas para que estejam prontos -para inserção no banco de dados pelo módulo `database`. +# autosinapi/core/processor.py -A classe `Processor` encapsula a lógica de negócio para interpretar as planilhas -do SINAPI, extrair informações relevantes e aplicar as regras de negócio -necessárias para a consistência dos dados. +""" +processor.py: Módulo de Transformação de Dados do AutoSINAPI. + +Este módulo é o coração da lógica de transformação do pipeline. Ele é +responsável por converter os dados brutos das planilhas Excel do SINAPI, +obtidas pelo `downloader`, em um conjunto de DataFrames estruturados, limpos e +prontos para serem carregados no banco de dados. + +**Classe `Processor`:** + +- **Inicialização:** Recebe um objeto `Config`, que fornece acesso a todas as + constantes de negócio necessárias para a interpretação dos arquivos, como + palavras-chave para encontrar cabeçalhos, nomes de colunas, mapas de planilhas, + números de linha fixos e expressões regulares. + +- **Entradas:** + - Recebe os caminhos (`xlsx_path`) para os arquivos Excel de "Manutenções" + e "Referência" descompactados. + +- **Transformações/Processos:** + - **Busca Dinâmica de Cabeçalho:** Implementa uma função (`_find_header_row`) + para localizar a linha inicial de uma tabela dentro de uma planilha com base + em um conjunto de palavras-chave, tornando o processo resiliente a pequenas + mudanças de layout. + - **Leitura e Limpeza:** Lê as planilhas (tanto Excel quanto CSVs + pré-processados) e aplica uma série de limpezas: normalização de nomes + de colunas, padronização de tipos de dados e tratamento de valores + ausentes. + - **Unpivot:** Transforma tabelas de preços e custos, que originalmente têm + os estados (UFs) como colunas, para um formato "longo" (tidy data), com + uma única coluna para "uf" e outra para o valor (preço ou custo). + - **Extração de Catálogos:** Extrai os catálogos de insumos e composições + a partir de múltiplas planilhas de preços e custos, consolidando-os em + DataFrames únicos e sem duplicatas. + - **Extração de Estrutura:** Processa a complexa planilha "Analítico" para + mapear as relações pai-filho entre composições, insumos e + subcomposições, gerando os dados para as tabelas de relacionamento. + +- **Saídas:** + - O método `process_manutencoes` retorna um único DataFrame com o histórico + de manutenções. + - O método `process_catalogo_e_precos` retorna um dicionário de DataFrames + contendo os catálogos (`insumos`, `composicoes`) e os dados mensais + (`precos_insumos_mensal`, `custos_composicoes_mensal`). + - O método `process_composicao_itens` retorna um dicionário de DataFrames + com os relacionamentos (`composicao_insumos`, `composicao_subcomposicoes`) + e detalhes extraídos da estrutura analítica. """ import logging @@ -20,22 +57,18 @@ import pandas as pd +from ..config import Config from ..exceptions import ProcessingError -# Configuração do logger para este módulo -logger = logging.getLogger(__name__) - class Processor: - def __init__(self, sinapi_config: Dict[str, Any]): - self.config = sinapi_config - self.logger = logger - self.logger.info("[__init__] Processador inicializado.") + def __init__(self, config: Config): + self.config = config + self.logger = logging.getLogger(__name__) + self.logger.info("Processador inicializado.") def _find_header_row(self, df: pd.DataFrame, keywords: List[str]) -> int: - self.logger.debug( - f"[_find_header_row] Procurando cabeçalho com keywords: {keywords}" - ) + self.logger.debug(f"Procurando cabeçalho com keywords: {keywords}") def normalize_text(text_val): s = str(text_val).strip() @@ -50,10 +83,10 @@ def normalize_text(text_val): return s for i, row in df.iterrows(): - if i > 20: # Limite de busca para evitar varrer o arquivo inteiro + if i > self.config.HEADER_SEARCH_LIMIT: self.logger.warning( - "[_find_header_row] Limite de busca por cabeçalho (20 linhas)" - "atingido. Cabeçalho não encontrado." + f"Limite de busca por cabeçalho ({self.config.HEADER_SEARCH_LIMIT} linhas)" + f" atingido em {keywords}. Cabeçalho não encontrado." ) break @@ -65,31 +98,23 @@ def normalize_text(text_val): row_str = " ".join(normalized_row_values) normalized_keywords = [normalize_text(k) for k in keywords] - self.logger.debug( - f"[_find_header_row] Linha {i} normalizada para busca: {row_str}" - ) + self.logger.debug(f"Linha {i} normalizada para busca: {row_str}") if all(nk in row_str for nk in normalized_keywords): - self.logger.info( - f"[_find_header_row] Cabeçalho encontrado na linha {i}." - ) + self.logger.info(f"Cabeçalho encontrado na linha {i} para {keywords}.") return i except Exception as e: self.logger.error( - f"[_find_header_row] Erro ao processar a linha {i} " - f"para encontrar o cabeçalho: {e}", + f"Erro ao processar a linha {i} para encontrar o cabeçalho: {e}", exc_info=True, ) continue - self.logger.error( - f"[_find_header_row] Cabeçalho com as keywords {keywords} " - f"não foi encontrado." - ) + self.logger.error(f"Cabeçalho com as keywords {keywords} não foi encontrado.") return None def _normalize_cols(self, df: pd.DataFrame) -> pd.DataFrame: - self.logger.debug("[_normalize_cols] Normalizando nomes das colunas...") + self.logger.debug("Normalizando nomes das colunas...") new_cols = {} for col in df.columns: s = str(col).strip() @@ -103,33 +128,25 @@ def _normalize_cols(self, df: pd.DataFrame) -> pd.DataFrame: s = re.sub(r"[^A-Z0-9_]", "", s) new_cols[col] = s - self.logger.debug( - f"[_normalize_cols] Mapeamento de colunas normalizadas: {new_cols}" - ) + self.logger.debug(f"Mapeamento de colunas normalizadas: {new_cols}") return df.rename(columns=new_cols) def _unpivot_data( self, df: pd.DataFrame, id_vars: List[str], value_name: str ) -> pd.DataFrame: - self.logger.debug( - f"[_unpivot_data] Iniciando unpivot para '{value_name}' " - f"com id_vars: {id_vars}" - ) + self.logger.debug(f"Iniciando unpivot para '{value_name}' com id_vars: {id_vars}") uf_cols = [ col for col in df.columns if len(str(col)) == 2 and str(col).isalpha() ] if not uf_cols: self.logger.warning( - f"[_unpivot_data] Nenhuma coluna de UF foi identificada " - f"para o unpivot na planilha de {value_name}." - f" O DataFrame pode ficar vazio." + f"Nenhuma coluna de UF foi identificada para o unpivot" + f" na planilha de {value_name}. O DataFrame pode ficar vazio." ) return pd.DataFrame(columns=id_vars + ["uf", value_name]) - self.logger.debug( - f"[_unpivot_data] Colunas de UF identificadas para unpivot: {uf_cols}" - ) + self.logger.debug(f"Colunas de UF identificadas para unpivot: {uf_cols}") long_df = df.melt( id_vars=id_vars, value_vars=uf_cols, var_name="uf", value_name=value_name @@ -137,58 +154,39 @@ def _unpivot_data( long_df = long_df.dropna(subset=[value_name]) long_df[value_name] = pd.to_numeric(long_df[value_name], errors="coerce") - self.logger.debug( - f"[_unpivot_data] DataFrame após unpivot. Head:\n{long_df.head().to_string()}" - ) + self.logger.debug(f"DataFrame após unpivot. Head:\n{long_df.head().to_string()}") return long_df def _standardize_id_columns(self, df: pd.DataFrame) -> pd.DataFrame: - self.logger.debug( - "[_standardize_id_columns] Padronizando colunas de ID (CODIGO, DESCRICAO)..." - ) - rename_map = { - "CODIGO_DO_INSUMO": "CODIGO", - "DESCRICAO_DO_INSUMO": "DESCRICAO", - "CODIGO_DA_COMPOSICAO": "CODIGO", - "DESCRICAO_DA_COMPOSICAO": "DESCRICAO", - } + self.logger.debug("Padronizando colunas de ID (CODIGO, DESCRICAO)...") + rename_map = self.config.ID_COL_STANDARDIZE_MAP actual_rename_map = {k: v for k, v in rename_map.items() if k in df.columns} if actual_rename_map: - self.logger.debug( - f"[_standardize_id_columns] Mapeamento de renomeação de ID aplicado: {actual_rename_map}" - ) + self.logger.debug(f"Mapeamento de renomeação de ID aplicado: {actual_rename_map}") return df.rename(columns=actual_rename_map) def process_manutencoes(self, xlsx_path: str) -> pd.DataFrame: - self.logger.info( - f"[process_manutencoes] Processando arquivo de manutenções: {xlsx_path}" - ) + self.logger.info(f"Processando arquivo de manutenções: {xlsx_path}") try: - df_raw = pd.read_excel(xlsx_path, sheet_name=0, header=None) + df_raw = pd.read_excel(xlsx_path, sheet_name=self.config.MANUTENCOES_SHEET_INDEX, header=None) header_row = self._find_header_row( - df_raw, ["REFERENCIA", "TIPO", "CODIGO", "DESCRICAO", "MANUTENCAO"] + df_raw, self.config.MANUTENCOES_HEADER_KEYWORDS ) if header_row is None: raise ProcessingError( f"Cabeçalho não encontrado no arquivo de manutenções: {xlsx_path}" ) - - df = pd.read_excel(xlsx_path, sheet_name=0, header=header_row) + + df = pd.read_excel(xlsx_path, sheet_name=self.config.MANUTENCOES_SHEET_INDEX, header=header_row) df = self._normalize_cols(df) - col_map = { - "REFERENCIA": "data_referencia", - "TIPO": "tipo_item", - "CODIGO": "item_codigo", - "DESCRICAO": "descricao_item", - "MANUTENCAO": "tipo_manutencao", - } + col_map = self.config.MANUTENCOES_COL_MAP df = df.rename( columns={k: v for k, v in col_map.items() if k in df.columns} ) - + df["data_referencia"] = pd.to_datetime( - df["data_referencia"], errors="coerce", format="%m/%Y" + df["data_referencia"], errors="coerce", format=self.config.MANUTENCOES_DATE_FORMAT ).dt.date df["item_codigo"] = pd.to_numeric( df["item_codigo"], errors="coerce" @@ -196,55 +194,58 @@ def process_manutencoes(self, xlsx_path: str) -> pd.DataFrame: df["tipo_item"] = df["tipo_item"].str.upper().str.strip() df["tipo_manutencao"] = df["tipo_manutencao"].str.upper().str.strip() - self.logger.info( - "[process_manutencoes] Processamento de manutenções concluído com sucesso." - ) + self.logger.info("Processamento de manutenções concluído com sucesso.") return df[list(col_map.values())] except Exception as e: self.logger.error( - f"[process_manutencoes] Falha crítica ao processar arquivo de manutenções. Erro: {e}", + f"Falha crítica ao processar arquivo de manutenções. Erro: {e}", exc_info=True, ) - raise ProcessingError(f"Erro em 'process_manutencoes': {e}") + raise ProcessingError(f"Erro em 'process_manutencoes': {e}") from e def process_composicao_itens(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: - self.logger.info( - f"[process_composicao_itens] Processando estrutura de itens de composição de: {xlsx_path}" - ) + self.logger.info(f"Processando estrutura de itens de composição de: {xlsx_path}") try: xls = pd.ExcelFile(xlsx_path) - sheet_SINAPI_name = next( - (s for s in xls.sheet_names if "Analítico" in s and "Custo" not in s), - None, - ) + sheet_SINAPI_name = next(( + s for s in xls.sheet_names if self.config.COMPOSICAO_ITENS_SHEET_KEYWORD in s and self.config.COMPOSICAO_ITENS_SHEET_EXCLUDE_KEYWORD not in s + ), None) if not sheet_SINAPI_name: raise ProcessingError( - f"Aba 'Analítico' não encontrada no arquivo: {xlsx_path}" + f"Aba '{self.config.COMPOSICAO_ITENS_SHEET_KEYWORD}' não encontrada no arquivo: {xlsx_path}" ) - self.logger.info( - f"[process_composicao_itens] Lendo aba: {sheet_SINAPI_name}" - ) - df = pd.read_excel(xlsx_path, sheet_name=sheet_SINAPI_name, header=9) + self.logger.info(f"Lendo aba de composição: {sheet_SINAPI_name}") + df = pd.read_excel(xlsx_path, + sheet_name=sheet_SINAPI_name, + header=self.config.COMPOSICAO_ITENS_HEADER_ROW + ) df = self._normalize_cols(df) + cols = self.config.ORIGINAL_COLS subitens = df[ - df["TIPO_ITEM"].str.upper().isin(["INSUMO", "COMPOSICAO"]) + df[cols["TIPO_ITEM"]].str.upper().isin([ + self.config.ITEM_TYPE_INSUMO, + self.config.ITEM_TYPE_COMPOSICAO + ]) ].copy() subitens["composicao_pai_codigo"] = pd.to_numeric( - subitens["CODIGO_DA_COMPOSICAO"], errors="coerce" + subitens[cols["CODIGO_COMPOSICAO"]], errors="coerce" ).astype("Int64") subitens["item_codigo"] = pd.to_numeric( - subitens["CODIGO_DO_ITEM"], errors="coerce" + subitens[cols["CODIGO_ITEM"]], errors="coerce" ).astype("Int64") - subitens["tipo_item"] = subitens["TIPO_ITEM"].str.upper().str.strip() + subitens["tipo_item"] = subitens[cols["TIPO_ITEM"]].str.upper().str.strip() subitens["coeficiente"] = pd.to_numeric( - subitens["COEFICIENTE"].astype(str).str.replace(",", "."), + subitens[cols["COEFICIENTE"]].astype(str).str.replace(",", "."), errors="coerce", ) subitens.rename( - columns={"DESCRICAO": "item_descricao", "UNIDADE": "item_unidade"}, + columns={ + cols["DESCRICAO_ITEM"]: "item_descricao", + cols["UNIDADE_ITEM"]: "item_unidade" + }, inplace=True, ) @@ -256,11 +257,16 @@ def process_composicao_itens(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: subset=["composicao_pai_codigo", "item_codigo", "tipo_item"] ) - insumos_df = subitens[subitens["tipo_item"] == "INSUMO"] - composicoes_df = subitens[subitens["tipo_item"] == "COMPOSICAO"] + insumos_df = subitens[ + subitens["tipo_item"] == self.config.ITEM_TYPE_INSUMO + ] + composicoes_df = subitens[ + subitens["tipo_item"] == self.config.ITEM_TYPE_COMPOSICAO + ] self.logger.info( - f"[process_composicao_itens] Encontrados {len(insumos_df)} links insumo-composição e {len(composicoes_df)} links subcomposição-composição." + f"Encontrados {len(insumos_df)} links insumo-composição" + f" e {len(composicoes_df)} links subcomposição-composição." ) composicao_insumos = insumos_df[ @@ -271,14 +277,18 @@ def process_composicao_itens(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: ].rename(columns={"item_codigo": "composicao_filho_codigo"}) parent_composicoes_df = df[ - df["CODIGO_DA_COMPOSICAO"].notna() - & ~df["TIPO_ITEM"].str.upper().isin(["INSUMO", "COMPOSICAO"]) + df[cols["CODIGO_COMPOSICAO"]].notna() + & ~df[ + cols["TIPO_ITEM"]].str.upper().isin([ + self.config.ITEM_TYPE_INSUMO, + self.config.ITEM_TYPE_COMPOSICAO + ]) ].copy() parent_composicoes_df = parent_composicoes_df.rename( columns={ - "CODIGO_DA_COMPOSICAO": "codigo", - "DESCRICAO": "descricao", - "UNIDADE": "unidade", + cols["CODIGO_COMPOSICAO"]: "codigo", + cols["DESCRICAO_ITEM"]: "descricao", + cols["UNIDADE_ITEM"]: "unidade", } ) parent_composicoes_df = parent_composicoes_df[ @@ -302,113 +312,114 @@ def process_composicao_itens(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: ) return { - "composicao_insumos": composicao_insumos, - "composicao_subcomposicoes": composicao_subcomposicoes, + self.config.DB_TABLE_COMPOSICAO_INSUMOS: composicao_insumos, + self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES: composicao_subcomposicoes, "parent_composicoes_details": parent_composicoes_df, "child_item_details": child_item_details, } except Exception as e: self.logger.error( - f"[process_composicao_itens] Falha crítica ao processar estrutura de composições. Erro: {e}", + f"Falha crítica ao processar estrutura de composições. Erro: {e}", exc_info=True, ) - raise ProcessingError(f"Erro em 'process_composicao_itens': {e}") + raise ProcessingError(f"Erro em 'process_composicao_itens': {e}") from e def _process_precos_sheet( self, xls: pd.ExcelFile, sheet_name: str ) -> Tuple[pd.DataFrame, pd.DataFrame]: - """Processa uma aba de preços de insumos ou catálogo de insumos.""" - df = pd.read_excel(xls, sheet_name=sheet_name, header=9) - df = self._normalize_cols(df) - df = self._standardize_id_columns(df) - - catalogo_df = pd.DataFrame() - if "CODIGO" in df.columns and "DESCRICAO" in df.columns: - catalogo_df = df[["CODIGO", "DESCRICAO", "UNIDADE"]].copy() - - long_df = self._unpivot_data(df, ["CODIGO"], "preco_mediano") - return long_df, catalogo_df + self.logger.debug(f"Processando aba de preços: {sheet_name}") + try: + df = pd.read_excel(xls, sheet_name=sheet_name, header=self.config.PRECOS_HEADER_ROW) + df = self._normalize_cols(df) + df = self._standardize_id_columns(df) + + catalogo_df = pd.DataFrame() + if "CODIGO" in df.columns and "DESCRICAO" in df.columns: + catalogo_df = df[["CODIGO", "DESCRICAO", "UNIDADE"]].copy() + self.logger.debug(f"Extraídos {len(catalogo_df)} registros de catálogo da aba {sheet_name}.") + + long_df = self._unpivot_data(df, ["CODIGO"], self.config.UNPIVOT_VALUE_PRECO) + self.logger.debug(f"Extraídos {len(long_df)} registros de preços da aba {sheet_name}.") + return long_df, catalogo_df + except Exception as e: + self.logger.error(f"Erro ao processar aba de preços '{sheet_name}': {e}", exc_info=True) + raise ProcessingError(f"Erro em '_process_precos_sheet': {e}") from e def _process_custos_sheet( self, xlsx_path: str, process_key: str ) -> Tuple[pd.DataFrame, pd.DataFrame]: - """Processa uma aba de custos de composição a partir de um CSV.""" - csv_dir = Path(xlsx_path).parent.parent / "csv_temp" + csv_dir = Path(xlsx_path).parent.parent / self.config.TEMP_CSV_DIR csv_path = csv_dir / f"{process_key}.csv" - self.logger.info( - f"Lendo dados de custo do arquivo CSV pré-processado: {csv_path}" - ) + self.logger.info(f"Lendo dados de custo do arquivo CSV pré-processado: {csv_path}") if not csv_path.exists(): - raise FileNotFoundError(f"Arquivo CSV não encontrado: {csv_path}.") + raise FileNotFoundError(f"Arquivo CSV de custos não encontrado: {csv_path}.") - df_raw = pd.read_csv(csv_path, header=None, low_memory=False, sep=";") - header_row = self._find_header_row( - df_raw, ["Código da Composição", "Descrição", "Unidade"] - ) - if header_row is None: - self.logger.warning(f"Cabeçalho não encontrado em {csv_path.name}. Pulando.") - return pd.DataFrame(), pd.DataFrame() + try: + df_raw = pd.read_csv(csv_path, header=None, low_memory=False, sep=";") + header_row = self._find_header_row( + df_raw, self.config.CUSTOS_HEADER_KEYWORDS + ) + if header_row is None: + self.logger.warning(f"Cabeçalho não encontrado em {csv_path.name}. Pulando.") + return pd.DataFrame(), pd.DataFrame() - # Constrói o cabeçalho multi-nível e lê os dados - header_df = df_raw.iloc[header_row - 1 : header_row + 1].copy() + header_df = df_raw.iloc[header_row - 1 : header_row + 1].copy() - def clean_level0(val): - s_val = str(val) - return s_val if len(s_val) == 2 and s_val.isalpha() else pd.NA + def clean_level0(val): + s_val = str(val) + return s_val if len(s_val) == 2 and s_val.isalpha() else pd.NA - header_df.iloc[0] = header_df.iloc[0].apply(clean_level0).ffill() - new_cols = [ - f"{h0}_{h1}" if pd.notna(h0) else str(h1) - for h0, h1 in zip(header_df.iloc[0], header_df.iloc[1]) - ] - df = df_raw.iloc[header_row + 1 :].copy() - df.columns = new_cols - df.dropna(how="all", inplace=True) - - # Normalização e extração de código - df = self._normalize_cols(df) - df = self._standardize_id_columns(df) - if "CODIGO" in df.columns: - df["CODIGO"] = df["CODIGO"].astype(str).str.extract(r",(\d+)\)$")[0] - df["CODIGO"] = pd.to_numeric(df["CODIGO"], errors="coerce") - df.dropna(subset=["CODIGO"], inplace=True) - if not df.empty: - df["CODIGO"] = df["CODIGO"].astype("Int64") - - # Extração de catálogo e custos - catalogo_df = pd.DataFrame() - if "CODIGO" in df.columns and "DESCRICAO" in df.columns: - catalogo_df = df[["CODIGO", "DESCRICAO", "UNIDADE"]].copy() - - cost_cols = { - col.split("_")[0]: col - for col in df.columns - if "CUSTO" in col and len(col.split("_")[0]) == 2 - } - if "CODIGO" in df.columns and cost_cols: - df_costs = df[["CODIGO"] + list(cost_cols.values())].copy() - df_costs = df_costs.rename( - columns=lambda x: x.split("_")[0] if "CUSTO" in x else x - ) - long_df = self._unpivot_data(df_costs, ["CODIGO"], "custo_total") - return long_df, catalogo_df + header_df.iloc[0] = header_df.iloc[0].apply(clean_level0).ffill() + new_cols = [ + f"{h0}_{h1}" if pd.notna(h0) else str(h1) + for h0, h1 in zip(header_df.iloc[0], header_df.iloc[1]) + ] + df = df_raw.iloc[header_row + 1 :].copy() + df.columns = new_cols + df.dropna(how="all", inplace=True) + + df = self._normalize_cols(df) + df = self._standardize_id_columns(df) + if "CODIGO" in df.columns: + df["CODIGO"] = df["CODIGO"].astype(str).str.extract(self.config.CUSTOS_CODIGO_REGEX)[0] + df["CODIGO"] = pd.to_numeric(df["CODIGO"], errors="coerce") + df.dropna(subset=["CODIGO"], inplace=True) + if not df.empty: + df["CODIGO"] = df["CODIGO"].astype("Int64") + + catalogo_df = pd.DataFrame() + if "CODIGO" in df.columns and "DESCRICAO" in df.columns: + catalogo_df = df[["CODIGO", "DESCRICAO", "UNIDADE"]].copy() + + cost_cols = { + col.split("_")[0]: col + for col in df.columns + if "CUSTO" in col and len(col.split("_")[0]) == 2 + } + if "CODIGO" in df.columns and cost_cols: + df_costs = df[["CODIGO"] + list(cost_cols.values())].copy() + df_costs = df_costs.rename( + columns=lambda x: x.split("_")[0] if "CUSTO" in x else x + ) + long_df = self._unpivot_data(df_costs, ["CODIGO"], self.config.UNPIVOT_VALUE_CUSTO) + return long_df, catalogo_df - self.logger.warning(f"Não foi possível extrair custos da aba '{process_key}'.") - return pd.DataFrame(), pd.DataFrame() + self.logger.warning(f"Não foi possível extrair custos da aba '{process_key}'.") + return pd.DataFrame(), pd.DataFrame() + except Exception as e: + self.logger.error(f"Erro ao processar aba de custos '{csv_path.name}': {e}", exc_info=True) + raise ProcessingError(f"Erro em '_process_custos_sheet': {e}") from e def _aggregate_final_dataframes( self, all_dfs: Dict, temp_insumos: List, temp_composicoes: List ) -> Dict: - """Agrega os DataFrames temporários nos resultados finais.""" self.logger.info("Agregando e finalizando DataFrames...") if temp_insumos: all_insumos = pd.concat( temp_insumos, ignore_index=True ).drop_duplicates(subset=["CODIGO"]) all_dfs["insumos"] = all_insumos.rename( - columns={ - "CODIGO": "codigo", "DESCRICAO": "descricao", "UNIDADE": "unidade" - } + columns=self.config.FINAL_CATALOG_COLUMNS ) self.logger.info( f"Catálogo de insumos finalizado com {len(all_insumos)} registros únicos." @@ -418,15 +429,12 @@ def _aggregate_final_dataframes( temp_composicoes, ignore_index=True ).drop_duplicates(subset=["CODIGO"]) all_dfs["composicoes"] = all_composicoes.rename( - columns={ - "CODIGO": "codigo", "DESCRICAO": "descricao", "UNIDADE": "unidade" - } + columns=self.config.FINAL_CATALOG_COLUMNS ) self.logger.info( f"Catálogo de composições finalizado com {len(all_composicoes)} registros únicos." ) - # Concatena dados mensais if "precos_insumos_mensal" in all_dfs: df_concat = pd.concat(all_dfs["precos_insumos_mensal"], ignore_index=True) all_dfs["precos_insumos_mensal"] = df_concat @@ -447,14 +455,7 @@ def process_catalogo_e_precos(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: ) xls = pd.ExcelFile(xlsx_path) all_dfs = {} - sheet_map = { - "ISD": ("precos", "NAO_DESONERADO"), - "ICD": ("precos", "DESONERADO"), - "ISE": ("precos", "SEM_ENCARGOS"), - "CSD": ("custos", "NAO_DESONERADO"), - "CCD": ("custos", "DESONERADO"), - "CSE": ("custos", "SEM_ENCARGOS"), - } + sheet_map = self.config.SHEET_MAP temp_insumos, temp_composicoes = [], [] for sheet_name in xls.sheet_names: @@ -481,7 +482,6 @@ def process_catalogo_e_precos(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: if not catalogo_df.empty: temp_composicoes.append(catalogo_df) - # Adiciona dados mensais processados ao dicionário if not long_df.empty: long_df["regime"] = regime table, code = ( @@ -495,10 +495,8 @@ def process_catalogo_e_precos(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: except Exception as e: self.logger.error( - f"Falha CRÍTICA ao processar a aba '{sheet_name}'. " - f"Esta aba será ignorada. Erro: {e}", + f"Falha CRÍTICA ao processar a aba '{sheet_name}'. Esta aba será ignorada. Erro: {e}", exc_info=True, ) - return self._aggregate_final_dataframes(all_dfs, temp_insumos, temp_composicoes) - + return self._aggregate_final_dataframes(all_dfs, temp_insumos, temp_composicoes) \ No newline at end of file diff --git a/autosinapi/etl_pipeline.py b/autosinapi/etl_pipeline.py index 3fc97b9..a8a25ab 100644 --- a/autosinapi/etl_pipeline.py +++ b/autosinapi/etl_pipeline.py @@ -1,79 +1,142 @@ +# autosinapi/etl_pipeline.py + """ -autosinapi_pipeline.py: Script Principal para Execução do Pipeline ETL do AutoSINAPI. - -Este script atua como o orquestrador central para o processo de Extração, -Transformação e Carga (ETL) dos dados do SINAPI. Ele é responsável por: - -1. **Configuração:** Carregar as configurações de execução (ano, mês, tipo de - caderno, etc.) a partir de um arquivo JSON ou variáveis de ambiente. -2. **Download:** Utilizar o módulo `autosinapi.core.downloader` para obter - os arquivos brutos do SINAPI. -3. **Processamento:** Empregar o módulo `autosinapi.core.processor` para - transformar e limpar os dados brutos em um formato estruturado. -4. **Carga:** Usar o módulo `autosinapi.core.database` para carregar os dados - processados no banco de dados PostgreSQL. -5. **Logging:** Configurar e gerenciar o sistema de logging para registrar - o progresso e quaisquer erros durante a execução do pipeline. - -Este script suporta diferentes modos de operação (local e servidor) e é a -interface principal para a execução do AutoSINAPI como uma ferramenta CLI. +etl_pipeline.py: Orquestrador Principal do Pipeline ETL do AutoSINAPI. + +Este módulo contém a classe `PipelineETL`, que atua como o ponto de entrada e +orquestrador central para todo o processo de Extração, Transformação e Carga (ETL) +dos dados do SINAPI. + +**Responsabilidades:** + +1. **Inicialização e Configuração:** + - Recebe um `run_id` único para rastrear a execução. + - Carrega as configurações a partir de variáveis de ambiente ou de um + arquivo de configuração JSON opcional. + - Instancia e centraliza o objeto `Config`, que contém todas as + constantes e parâmetros operacionais (nomes de arquivos, políticas de + banco de dados, etc.). + - Configura um sistema de logging detalhado, associando todas as mensagens + ao `run_id` da execução. + +2. **Orquestração do Fluxo (ETL):** + - **Extração (Fase 1):** Utiliza a classe `Downloader` para obter o + arquivo de referência do SINAPI, seja fazendo o download do site da Caixa + ou lendo um arquivo local. Gerencia a descompactação dos arquivos. + - **Transformação (Fase 2):** + - Invoca o `pre_processor` para converter planilhas Excel de alto + volume em arquivos CSV, otimizando a leitura. + - Utiliza a classe `Processor` para ler os arquivos de Manutenções e + de Referência, transformando os dados brutos em DataFrames + estruturados e limpos. + - Aplica uma lógica robusta de "placeholders" para garantir a + integridade referencial, criando registros temporários para insumos + ou composições que são referenciados na estrutura mas não + existem no catálogo principal. + - **Carga (Fase 3):** + - Utiliza a classe `Database` para carregar os DataFrames processados + no banco de dados PostgreSQL. + - Gerencia a ordem de inserção e as políticas de salvamento (APPEND, + UPSERT) para cada tabela, conforme definido no objeto `Config`. + - Sincroniza o status dos itens (ATIVO/DESATIVADO) com base nos + dados do arquivo de manutenções. + +**Retorno:** +- A execução do método `run()` retorna um dicionário contendo o sumário da + operação, incluindo o status final (`SUCESSO` ou `FALHA`), uma mensagem + descritiva, a lista de tabelas atualizadas e o total de registros inseridos. """ + +import argparse import json import logging -import argparse import os +import uuid import zipfile from pathlib import Path +from typing import Dict, List, Tuple + import pandas as pd + from autosinapi.config import Config -from autosinapi.core.downloader import Downloader -from autosinapi.core.processor import Processor from autosinapi.core.database import Database -from autosinapi.exceptions import AutoSinapiError, ConfigurationError, DownloadError, ProcessingError, DatabaseError +from autosinapi.core.downloader import Downloader from autosinapi.core.pre_processor import convert_excel_sheets_to_csv +from autosinapi.core.processor import Processor +from autosinapi.exceptions import ( + AutoSinapiError, + ConfigurationError, + ProcessingError, +) -# Configuração do logger principal logger = logging.getLogger("autosinapi") -def setup_logging(debug_mode=False): - """Configura o sistema de logging de forma centralizada.""" + +class RunIdFilter(logging.Filter): + def __init__(self, run_id): + super().__init__() + self.run_id = run_id + + def filter(self, record): + record.run_id = self.run_id + return True + + +def setup_logging(run_id: str, debug_mode=False): level = logging.DEBUG if debug_mode else logging.INFO log_file_path = Path("./logs/etl_pipeline.log") log_file_path.parent.mkdir(parents=True, exist_ok=True) - for handler in logger.handlers[:]: logger.removeHandler(handler) - - file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(message)s') - stream_formatter_info = logging.Formatter('[%(levelname)s] %(message)s') - stream_formatter_debug = logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s') - - file_handler = logging.FileHandler(log_file_path, mode='w') + run_id_filter = RunIdFilter(run_id) + file_formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] [%(run_id)s] %(name)s: %(message)s" + ) + stream_formatter_info = logging.Formatter("[%(levelname)s] [%(run_id)s] %(message)s") + stream_formatter_debug = logging.Formatter( + "%(asctime)s [%(levelname)s] [%(run_id)s] %(name)s: %(message)s" + ) + file_handler = logging.FileHandler(log_file_path, mode="a") file_handler.setFormatter(file_formatter) file_handler.setLevel(level) - + file_handler.addFilter(run_id_filter) stream_handler = logging.StreamHandler() - if debug_mode: - stream_handler.setFormatter(stream_formatter_debug) - else: - stream_handler.setFormatter(stream_formatter_info) + stream_handler.setFormatter( + stream_formatter_debug if debug_mode else stream_formatter_info + ) stream_handler.setLevel(level) - + stream_handler.addFilter(run_id_filter) logger.addHandler(file_handler) logger.addHandler(stream_handler) logger.setLevel(level) - if not debug_mode: logging.getLogger("urllib3").setLevel(logging.WARNING) class PipelineETL: - def __init__(self, config_path: str = None): + def __init__(self, run_id: str, config_path: str = None, custom_constants: dict = None, debug_mode: bool = False): + self.run_id = run_id + setup_logging(run_id=self.run_id, debug_mode=debug_mode) + self.logger = logging.getLogger("autosinapi.pipeline") - self.config = self._load_config(config_path) - self.db_config = self._get_db_config() - self.sinapi_config = self._get_sinapi_config() + self.logger.info(f"Iniciando nova execução do pipeline. Run ID: {self.run_id}") - def _load_config(self, config_path: str): + try: + base_config = self._load_base_config(config_path) + db_cfg = self._get_db_config(base_config) + sinapi_cfg = self._get_sinapi_config(base_config) + + self.config = Config( + db_config=db_cfg, + sinapi_config=sinapi_cfg, + mode=os.getenv('AUTOSINAPI_MODE', 'local'), + custom_constants=custom_constants + ) + self.config.RUN_ID = self.run_id + except ConfigurationError as e: + self.logger.critical(f"Erro fatal de configuração: {e}", exc_info=True) + raise + + def _load_base_config(self, config_path: str): self.logger.debug(f"Tentando carregar configuração. Caminho fornecido: {config_path}") if config_path: self.logger.info(f"Carregando configuração do arquivo: {config_path}") @@ -81,10 +144,8 @@ def _load_config(self, config_path: str): with open(config_path, 'r') as f: return json.load(f) except FileNotFoundError as e: - self.logger.error(f"Arquivo de configuração não encontrado: {config_path}", exc_info=True) raise ConfigurationError(f"Arquivo de configuração não encontrado: {config_path}") from e except json.JSONDecodeError as e: - self.logger.error(f"Erro ao decodificar o arquivo JSON de configuração: {config_path}", exc_info=True) raise ConfigurationError(f"Erro ao decodificar o arquivo JSON de configuração: {config_path}") from e else: self.logger.info("Carregando configuração a partir de variáveis de ambiente.") @@ -96,10 +157,12 @@ def _load_config(self, config_path: str): "duplicate_policy": os.getenv("AUTOSINAPI_POLICY", "substituir"), } - def _get_db_config(self): + def _get_db_config(self, base_config): self.logger.debug("Extraindo configurações do banco de dados.") if os.getenv("DOCKER_ENV"): - self.logger.info("Modo Docker detectado. Lendo configuração do DB a partir de variáveis de ambiente.") + self.logger.info( + "Modo Docker detectado. Lendo configuração do DB a partir de variáveis de ambiente." + ) required_vars = ["POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD"] missing_vars = [v for v in required_vars if not os.getenv(v)] if missing_vars: @@ -115,7 +178,7 @@ def _get_db_config(self): 'password': os.getenv("POSTGRES_PASSWORD"), } try: - secrets_path = self.config['secrets_path'] + secrets_path = base_config['secrets_path'] with open(secrets_path, 'r') as f: content = f.read() @@ -133,37 +196,34 @@ def _get_db_config(self): 'password': db_config['DB_PASSWORD'], } except Exception as e: - self.logger.error(f"Erro CRÍTICO ao ler ou processar o arquivo de secrets '{secrets_path}'. Detalhes: {e}", exc_info=True) raise ConfigurationError(f"Erro ao ler ou processar o arquivo de secrets '{secrets_path}': {e}") from e - def _get_sinapi_config(self): + def _get_sinapi_config(self, base_config): return { - 'state': self.config.get('default_state', 'BR'), - 'year': self.config['default_year'], - 'month': self.config['default_month'], - # Esta informação 'type' = 'workbook_type_name' precisa ser verificada com a lógica do DataModel - # e dos arquivos reais, pois o Workbook pode não fazer sentido no contexto - # refatorado atual, está mantido por enquanto para compatibilidade - # e assumimos que o valor padrão é 'REFERENCIA' se não fornecido. - # Talvez faça mais sentido definir se é Desonerado, Onerado ou Sem Encargos - 'type': self.config.get('workbook_type_name', 'REFERENCIA'), - - 'file_format': self.config.get('default_format', 'XLSX'), - 'duplicate_policy': self.config.get('duplicate_policy', 'substituir'), - 'mode': os.getenv('AUTOSINAPI_MODE', 'local') # Add this line + 'state': base_config.get('default_state', 'BR'), + 'year': base_config['default_year'], + 'month': base_config['default_month'], + 'type': base_config.get('workbook_type_name', 'REFERENCIA'), + 'file_format': base_config.get('default_format', 'XLSX'), + 'duplicate_policy': base_config.get('duplicate_policy', 'substituir'), + 'mode': os.getenv('AUTOSINAPI_MODE', 'local') } def _find_and_normalize_zip(self, download_path: Path, standardized_name: str) -> Path: - self.logger.info(f"Procurando por arquivo .zip em: {download_path}") + self.logger.debug(f"Procurando por arquivo .zip em: {download_path}") for file in download_path.glob('*.zip'): - self.logger.info(f"Arquivo .zip encontrado: {file.name}") + self.logger.debug(f"Arquivo .zip encontrado: {file.name}") if file.name.upper() != standardized_name.upper(): new_path = download_path / standardized_name - self.logger.info(f"Renomeando '{file.name}' para o padrão: '{standardized_name}'") + self.logger.info( + f"Renomeando '{file.name}' para o padrão: '{standardized_name}'" + ) file.rename(new_path) return new_path return file - self.logger.warning("Nenhum arquivo .zip encontrado localmente.") + self.logger.info( + "Nenhum arquivo .zip correspondente encontrado localmente." + ) return None def _unzip_file(self, zip_path: Path) -> Path: @@ -173,288 +233,279 @@ def _unzip_file(self, zip_path: Path) -> Path: try: with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(extraction_path) - self.logger.info("Arquivo descompactado com sucesso.") + self.logger.info(f"Arquivo descompactado com sucesso em {extraction_path}") return extraction_path except zipfile.BadZipFile as e: - self.logger.error(f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido.", exc_info=True) - raise ProcessingError(f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido.") from e + raise ProcessingError( + f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido." + ) from e - def _run_pre_processing(self, referencia_file_path: Path, extraction_path: Path): - self.logger.info("FASE PRE: Iniciando pré-processamento de planilhas para CSV.") - sheets_to_convert = ['CSD', 'CCD', 'CSE'] - output_dir = extraction_path.parent / "csv_temp" + def _execute_phase_1_acquisition(self, downloader: Downloader) -> Path: + """ + Executa a Fase 1: Aquisição e descompactação dos dados do SINAPI. + Retorna o caminho para o diretório com os arquivos extraídos. + """ + year = str(self.config.YEAR) + month = str(self.config.MONTH).zfill(2) + self.logger.info(f"[FASE 1] Iniciando obtenção de dados para {month}/{year}.") + + download_path = Path(os.path.join(self.config.DOWNLOAD_DIR, f"{year}_{month}")) + download_path.mkdir(parents=True, exist_ok=True) + + standardized_name = self.config.ZIP_FILENAME_TEMPLATE.format(year=year, month=month) + local_zip_path = self._find_and_normalize_zip(download_path, standardized_name) + + if not local_zip_path: + self.logger.info("Arquivo não encontrado localmente. Iniciando download...") + file_content = downloader.get_sinapi_data(save_path=download_path) + local_zip_path = download_path / standardized_name + with open(local_zip_path, 'wb') as f: + f.write(file_content.getbuffer()) + self.logger.info(f"Download concluído e salvo em: {local_zip_path}") + + extraction_path = self._unzip_file(local_zip_path) + self.logger.info("[FASE 1] Obtenção de dados concluída com sucesso.") + return extraction_path + + def _process_maintenance_data(self, processor: Processor, db: Database, file_path: Path) -> Tuple[int, str]: + """ + Processa e carrega os dados de manutenção, sincronizando o status dos catálogos. + Retorna o número de registros inseridos e o nome da tabela atualizada. + """ + self.logger.info(f"Processando arquivo de Manutenções: {file_path.name}") + manutencoes_df = processor.process_manutencoes(str(file_path)) + + if not manutencoes_df.empty: + db.save_data(manutencoes_df, self.config.DB_TABLE_MANUTENCOES, policy=self.config.DB_POLICY_APPEND) + self.logger.info(f"{len(manutencoes_df)} registros de manutenção carregados. Sincronizando status...") + self._sync_catalog_status(db) + return len(manutencoes_df), self.config.DB_TABLE_MANUTENCOES + + self.logger.info("Nenhum dado de manutenção para processar.") + return 0, None + + def _handle_missing_items_placeholders(self, processed_data: Dict, structure_dfs: Dict) -> Dict: + """ + Verifica inconsistências de dados e cria placeholders para itens ausentes. + Retorna o dicionário `processed_data` atualizado. + """ + # Tratamento para insumos ausentes + existing_insumos_df = processed_data.get('insumos', pd.DataFrame(columns=['codigo', 'descricao', 'unidade'])) + all_child_insumo_codes = structure_dfs[self.config.DB_TABLE_COMPOSICAO_INSUMOS]['insumo_filho_codigo'].unique() + existing_insumo_codes_set = set(existing_insumos_df['codigo'].values) + missing_insumo_codes = [code for code in all_child_insumo_codes if code not in existing_insumo_codes_set] + + if missing_insumo_codes: + self.logger.warning(f"Encontrados {len(missing_insumo_codes)} insumos na estrutura que não estão no catálogo. Criando placeholders...") + insumo_details_df = structure_dfs['child_item_details'][ + (structure_dfs['child_item_details']['codigo'].isin(missing_insumo_codes)) & + (structure_dfs['child_item_details']['tipo'] == self.config.ITEM_TYPE_INSUMO) + ].drop_duplicates(subset=['codigo']).set_index('codigo') + + missing_insumos_data = { + 'codigo': missing_insumo_codes, + 'descricao': [insumo_details_df.loc[code, 'descricao'] if code in insumo_details_df.index else self.config.PLACEHOLDER_INSUMO_DESC_TEMPLATE.format(code=code) for code in missing_insumo_codes], + 'unidade': [insumo_details_df.loc[code, 'unidade'] if code in insumo_details_df.index else self.config.DEFAULT_PLACEHOLDER_UNIT for code in missing_insumo_codes] + } + missing_insumos_df = pd.DataFrame(missing_insumos_data) + processed_data['insumos'] = pd.concat([existing_insumos_df, missing_insumos_df], ignore_index=True) + + # Tratamento para composições ausentes + existing_composicoes_df = processed_data.get('composicoes', pd.DataFrame(columns=['codigo', 'descricao', 'unidade'])) + parent_codes = structure_dfs['parent_composicoes_details'].set_index('codigo') + child_codes = structure_dfs['child_item_details'][ + structure_dfs['child_item_details']['tipo'] == self.config.ITEM_TYPE_COMPOSICAO + ].drop_duplicates(subset=['codigo']).set_index('codigo') + + all_composicao_codes_in_structure = set(parent_codes.index) | set(child_codes.index) + existing_composicao_codes_set = set(existing_composicoes_df['codigo'].values) + missing_composicao_codes = list(all_composicao_codes_in_structure - existing_composicao_codes_set) + + if missing_composicao_codes: + self.logger.warning(f"Encontradas {len(missing_composicao_codes)} composições na estrutura que não estão no catálogo. Criando placeholders...") + def get_detail(code, column): + if code in parent_codes.index: return parent_codes.loc[code, column] + if code in child_codes.index: return child_codes.loc[code, column] + return self.config.PLACEHOLDER_COMPOSICAO_DESC_TEMPLATE.format(code=code) if column == 'descricao' else self.config.DEFAULT_PLACEHOLDER_UNIT + + missing_composicoes_df = pd.DataFrame({ + 'codigo': missing_composicao_codes, + 'descricao': [get_detail(code, 'descricao') for code in missing_composicao_codes], + 'unidade': [get_detail(code, 'unidade') for code in missing_composicao_codes] + }) + processed_data['composicoes'] = pd.concat([existing_composicoes_df, missing_composicoes_df], ignore_index=True) + + return processed_data + def _execute_phase_3_load_data(self, db: Database, processed_data: Dict, structure_dfs: Dict, data_referencia: str) -> Tuple[int, List[str]]: + """ + Executa a Fase 3: Carga dos dados processados no banco de dados. + Retorna o total de registros inseridos e a lista de tabelas atualizadas nesta fase. + """ + self.logger.info("[FASE 3] Iniciando carga de dados no banco.") + records_loaded = 0 + tables_loaded = [] + + # Carrega catálogos + for catalog_name in ['insumos', 'composicoes']: + if catalog_name in processed_data and not processed_data[catalog_name].empty: + table_name = getattr(self.config, f"DB_TABLE_{catalog_name.upper()}") + df = processed_data[catalog_name] + db.save_data(df, table_name, policy=self.config.DB_POLICY_UPSERT, pk_columns=['codigo']) + tables_loaded.append(table_name) + records_loaded += len(df) + + # Carrega estrutura + db.truncate_table(self.config.DB_TABLE_COMPOSICAO_INSUMOS) + db.truncate_table(self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES) + + for structure_name in [self.config.DB_TABLE_COMPOSICAO_INSUMOS, self.config.DB_TABLE_COMPOSICAO_SUBCOMPOSICOES]: + if structure_name in structure_dfs and not structure_dfs[structure_name].empty: + df = structure_dfs[structure_name] + db.save_data(df, structure_name, policy=self.config.DB_POLICY_APPEND) + tables_loaded.append(structure_name) + records_loaded += len(df) + + # Carrega dados mensais + for monthly_data_key in ['precos_insumos_mensal', 'custos_composicoes_mensal']: + if monthly_data_key in processed_data and not processed_data[monthly_data_key].empty: + table_name = getattr(self.config, f"DB_TABLE_{monthly_data_key.upper().replace('_MENSAL', '')}") + df = processed_data[monthly_data_key] + df['data_referencia'] = pd.to_datetime(data_referencia) + db.save_data(df, table_name, policy=self.config.DB_POLICY_APPEND) + tables_loaded.append(table_name) + records_loaded += len(df) + + self.logger.info("[FASE 3] Carga de dados concluída.") + return records_loaded, tables_loaded + + # --- MÉTODOS DE SINCRONIZAÇÃO E PRÉ-PROCESSAMENTO (inalterados) --- + def _run_pre_processing(self, referencia_file_path: Path, extraction_path: Path): + # ... (código inalterado) ... + self.logger.info("Iniciando pré-processamento de planilhas para CSV.") + output_dir = extraction_path.parent / self.config.TEMP_CSV_DIR try: convert_excel_sheets_to_csv( xlsx_full_path=referencia_file_path, - sheets_to_convert=sheets_to_convert, - output_dir=output_dir + sheets_to_convert=self.config.SHEETS_TO_CONVERT, + output_dir=output_dir, + config=self.config ) self.logger.info("Pré-processamento de planilhas concluído com sucesso.") except ProcessingError as e: self.logger.error(f"Erro durante o pré-processamento: {e}", exc_info=True) - raise # Re-raise the ProcessingError + raise def _sync_catalog_status(self, db: Database): - self.logger.info("Iniciando Fase 2: Sincronização de Status dos Catálogos.") - sql_update = """ + # ... (código inalterado) ... + self.logger.info("Sincronizando status dos catálogos (insumos/composições).") + sql_update = f""" WITH latest_maintenance AS ( SELECT - item_codigo, - tipo_item, - tipo_manutencao, + item_codigo, tipo_item, tipo_manutencao, ROW_NUMBER() OVER(PARTITION BY item_codigo, tipo_item ORDER BY data_referencia DESC) as rn - FROM manutencoes_historico + FROM {self.config.DB_TABLE_MANUTENCOES} ) - UPDATE {table} + UPDATE {{table}} SET status = 'DESATIVADO' WHERE codigo IN ( SELECT item_codigo FROM latest_maintenance - WHERE rn = 1 AND tipo_item = '{item_type}' AND tipo_manutencao ILIKE '%DESATIVAÇÃO%' + WHERE rn = 1 AND tipo_item = '{{item_type}}' AND tipo_manutencao ILIKE '{self.config.MAINTENANCE_DEACTIVATION_KEYWORD}' ); """ try: - num_insumos_updated = db.execute_non_query(sql_update.format(table="insumos", item_type="INSUMO")) + num_insumos_updated = db.execute_non_query(sql_update.format(table=self.config.DB_TABLE_INSUMOS, item_type=self.config.ITEM_TYPE_INSUMO)) self.logger.info(f"Status do catálogo de insumos sincronizado. Itens desativados: {num_insumos_updated}") - num_composicoes_updated = db.execute_non_query(sql_update.format(table="composicoes", item_type="COMPOSICAO")) + num_composicoes_updated = db.execute_non_query(sql_update.format(table=self.config.DB_TABLE_COMPOSICOES, item_type=self.config.ITEM_TYPE_COMPOSICAO)) self.logger.info(f"Status do catálogo de composições sincronizado. Itens desativados: {num_composicoes_updated}") except Exception as e: self.logger.error(f"Erro ao sincronizar status dos catálogos: {e}", exc_info=True) raise DatabaseError(f"Erro ao sincronizar status dos catálogos: {e}") from e + def run(self): - self.logger.info("======================================================") - self.logger.info("========= INICIANDO PIPELINE AUTOSINAPI =========") - self.logger.info("======================================================") + """ + Método principal que orquestra a execução completa do pipeline ETL. + """ tables_updated = [] records_inserted = 0 + status = self.config.STATUS_FAILURE + message = "Ocorreu um erro inesperado." + try: - config = Config(db_config=self.db_config, sinapi_config=self.sinapi_config, mode=self.sinapi_config['mode']) self.logger.info("Configuração validada com sucesso.") - self.logger.debug(f"Configurações SINAPI para esta execução: {config.sinapi_config}") + downloader = Downloader(self.config) + processor = Processor(self.config) + db = Database(self.config) - downloader = Downloader(config.sinapi_config, config.mode) - processor = Processor(config.sinapi_config) - db = Database(config.db_config) + # Fase 0: Preparação do Banco de Dados + self.logger.info("[FASE 0] Preparando banco de dados...") + db.create_tables() + self.logger.info("[FASE 0] Banco de dados preparado com sucesso.") - self.logger.info("Recriando tabelas do banco de dados para garantir conformidade.") - try: - db.create_tables() - except Exception as e: - raise DatabaseError(f"Erro ao recriar tabelas do banco de dados: {e}") from e - - year = config.sinapi_config['year'] - month = config.sinapi_config['month'] - data_referencia = f"{year}-{month}-01" - - download_path = Path(f"./downloads/{year}_{month}") - download_path.mkdir(parents=True, exist_ok=True) - standardized_name = f"SINAPI-{year}-{month}-formato-xlsx.zip" - local_zip_path = self._find_and_normalize_zip(download_path, standardized_name) - - if not local_zip_path: - self.logger.info("Arquivo não encontrado localmente. Iniciando download...") - try: - file_content = downloader.get_sinapi_data(save_path=download_path) - local_zip_path = download_path / standardized_name - with open(local_zip_path, 'wb') as f: - f.write(file_content.getbuffer()) - self.logger.info(f"Download concluído e salvo em: {local_zip_path}") - except Exception as e: - raise DownloadError(f"Erro durante o download dos dados do SINAPI: {e}") from e - - try: - extraction_path = self._unzip_file(local_zip_path) - except Exception as e: - raise ProcessingError(f"Erro ao descompactar o arquivo: {e}") from e - - # --- PRÉ-PROCESSAMENTO PARA CSV --- - try: - self._run_pre_processing(referencia_file_path, extraction_path) - except Exception as e: - raise ProcessingError(f"Erro durante o pré-processamento: {e}") from e - # --- FIM DO PRÉ-PROCESSAMENTO --- + # Fase 1: Aquisição de Dados + extraction_path = self._execute_phase_1_acquisition(downloader) + # Fase 2: Processamento de Arquivos + self.logger.info("[FASE 2] Iniciando processamento dos arquivos.") all_excel_files = list(extraction_path.glob('*.xlsx')) if not all_excel_files: raise ProcessingError(f"Nenhum arquivo .xlsx encontrado em {extraction_path}") - manutencoes_file_path = next((f for f in all_excel_files if "Manuten" in f.name), None) - referencia_file_path = next((f for f in all_excel_files if "Referência" in f.name), None) + manutencoes_file_path = next((f for f in all_excel_files if self.config.MAINTENANCE_FILE_KEYWORD in f.name), None) + referencia_file_path = next((f for f in all_excel_files if self.config.REFERENCE_FILE_KEYWORD in f.name), None) + # Processa manutenções (se existirem) if manutencoes_file_path: - self.logger.info(f"FASE 1: Processamento de Manutenções ({manutencoes_file_path.name})") - try: - manutencoes_df = processor.process_manutencoes(str(manutencoes_file_path)) - db.save_data(manutencoes_df, 'manutencoes_historico', policy='append') - tables_updated.append("manutencoes_historico") - records_inserted += len(manutencoes_df) - self.logger.info("Histórico de manutenções carregado com sucesso.") - self._sync_catalog_status(db) # FASE 2 - except Exception as e: - raise ProcessingError(f"Erro ao processar ou salvar dados de manutenções: {e}") from e + count, table = self._process_maintenance_data(processor, db, manutencoes_file_path) + if table: + records_inserted += count + tables_updated.append(table) else: - self.logger.warning("Arquivo de Manutenções não encontrado. Pulando Fases 1 e 2.") + self.logger.warning("Arquivo de Manutenções não encontrado. Sincronização de status pulada.") + # Processa arquivo de referência (se existir) if not referencia_file_path: self.logger.warning("Arquivo de Referência não encontrado. Finalizando pipeline.") - return { - "status": "success", - "message": f"Pipeline concluído. Arquivo de Referência não encontrado para {month}/{year}.", - "tables_updated": tables_updated, - "records_inserted": records_inserted - } - - self.logger.info(f"FASE 3: Processamento do Arquivo de Referência ({referencia_file_path.name})") - self.logger.info("Processando catálogos, dados mensais e estrutura de composições...") - try: + status = self.config.STATUS_SUCCESS_NO_DATA + message = "Pipeline finalizado sem dados para processar." + else: + self._run_pre_processing(referencia_file_path, extraction_path) + processed_data = processor.process_catalogo_e_precos(str(referencia_file_path)) structure_dfs = processor.process_composicao_itens(str(referencia_file_path)) - except Exception as e: - raise ProcessingError(f"Erro ao processar catálogos ou estrutura de composições: {e}") from e - - if 'insumos' in processed_data: - existing_insumos_df = processed_data['insumos'] - else: - existing_insumos_df = pd.DataFrame(columns=['codigo', 'descricao', 'unidade']) - - all_child_insumo_codes = structure_dfs['composicao_insumos']['insumo_filho_codigo'].unique() - existing_insumo_codes_set = set(existing_insumos_df['codigo'].values) - missing_insumo_codes = [code for code in all_child_insumo_codes if code not in existing_insumo_codes_set] - - if missing_insumo_codes: - self.logger.warning(f"Encontrados {len(missing_insumo_codes)} insumos na estrutura que não estão no catálogo. Criando placeholders com detalhes...") - insumo_details_df = structure_dfs['child_item_details'][ - (structure_dfs['child_item_details']['codigo'].isin(missing_insumo_codes)) & - (structure_dfs['child_item_details']['tipo'] == 'INSUMO') - ].drop_duplicates(subset=['codigo']).set_index('codigo') - - missing_insumos_data = { - 'codigo': missing_insumo_codes, - 'descricao': [insumo_details_df.loc[code, 'descricao'] if code in insumo_details_df.index else f"INSUMO_DESCONHECIDO_{code}" for code in missing_insumo_codes], - 'unidade': [insumo_details_df.loc[code, 'unidade'] if code in insumo_details_df.index else "UN" for code in missing_insumo_codes] - } - missing_insumos_df = pd.DataFrame(missing_insumos_data) - processed_data['insumos'] = pd.concat([existing_insumos_df, missing_insumos_df], ignore_index=True) - - if 'composicoes' in processed_data: - existing_composicoes_df = processed_data['composicoes'] - else: - existing_composicoes_df = pd.DataFrame(columns=['codigo', 'descricao', 'unidade']) - - parent_codes = structure_dfs['parent_composicoes_details'].set_index('codigo') - child_codes = structure_dfs['child_item_details'][ - structure_dfs['child_item_details']['tipo'] == 'COMPOSICAO' - ].drop_duplicates(subset=['codigo']).set_index('codigo') - - all_composicao_codes_in_structure = set(parent_codes.index) | set(child_codes.index) - existing_composicao_codes_set = set(existing_composicoes_df['codigo'].values) - missing_composicao_codes = list(all_composicao_codes_in_structure - existing_composicao_codes_set) - - if missing_composicao_codes: - self.logger.warning(f"Encontradas {len(missing_composicao_codes)} composições (pai/filha) na estrutura que não estão no catálogo. Criando placeholders com detalhes...") - def get_detail(code, column): - if code in parent_codes.index: return parent_codes.loc[code, column] - if code in child_codes.index: return child_codes.loc[code, column] - return f"COMPOSICAO_DESCONHECIDA_{code}" if column == 'descricao' else 'UN' - - missing_composicoes_df = pd.DataFrame({ - 'codigo': missing_composicao_codes, - 'descricao': [get_detail(code, 'descricao') for code in missing_composicao_codes], - 'unidade': [get_detail(code, 'unidade') for code in missing_composicao_codes], - }) - processed_data['composicoes'] = pd.concat([existing_composicoes_df, missing_composicoes_df], ignore_index=True) - - self.logger.info("Iniciando carga de dados no banco de dados na ordem correta...") + processed_data = self._handle_missing_items_placeholders(processed_data, structure_dfs) + + self.logger.info("[FASE 2] Processamento de arquivos concluído.") - try: - if 'insumos' in processed_data and not processed_data['insumos'].empty: - db.save_data(processed_data['insumos'], 'insumos', policy='upsert', pk_columns=['codigo']) - tables_updated.append("insumos") - records_inserted += len(processed_data['insumos']) - self.logger.info("Catálogo de insumos (incluindo placeholders) carregado.") - if 'composicoes' in processed_data and not processed_data['composicoes'].empty: - db.save_data(processed_data['composicoes'], 'composicoes', policy='upsert', pk_columns=['codigo']) - tables_updated.append("composicoes") - records_inserted += len(processed_data['composicoes']) - self.logger.info("Catálogo de composições (incluindo placeholders) carregado.") + # Fase 3: Carga de Dados + data_referencia = f"{self.config.YEAR}-{str(self.config.MONTH).zfill(2)}-01" + count, tables = self._execute_phase_3_load_data(db, processed_data, structure_dfs, data_referencia) + records_inserted += count + tables_updated.extend(tables) - db.truncate_table('composicao_insumos') - db.truncate_table('composicao_subcomposicoes') - db.save_data(structure_dfs['composicao_insumos'], 'composicao_insumos', policy='append') - tables_updated.append("composicao_insumos") - records_inserted += len(structure_dfs['composicao_insumos']) - db.save_data(structure_dfs['composicao_subcomposicoes'], 'composicao_subcomposicoes', policy='append') - tables_updated.append("composicao_subcomposicoes") - records_inserted += len(structure_dfs['composicao_subcomposicoes']) - self.logger.info("Estrutura de composições carregada com sucesso.") - - precos_carregados = False - if 'precos_insumos_mensal' in processed_data and not processed_data['precos_insumos_mensal'].empty: - processed_data['precos_insumos_mensal']['data_referencia'] = pd.to_datetime(data_referencia) - db.save_data(processed_data['precos_insumos_mensal'], 'precos_insumos_mensal', policy='append') - tables_updated.append("precos_insumos_mensal") - records_inserted += len(processed_data['precos_insumos_mensal']) - precos_carregados = True - else: - self.logger.warning("Nenhum dado de PREÇOS DE INSUMOS foi encontrado ou processado. Pulando esta etapa.") - - custos_carregados = False - if 'custos_composicoes_mensal' in processed_data and not processed_data['custos_composicoes_mensal'].empty: - processed_data['custos_composicoes_mensal']['data_referencia'] = pd.to_datetime(data_referencia) - db.save_data(processed_data['custos_composicoes_mensal'], 'custos_composicoes_mensal', policy='append') - tables_updated.append("custos_composicoes_mensal") - records_inserted += len(processed_data['custos_composicoes_mensal']) - custos_carregados = True - else: - self.logger.warning("Nenhum dado de CUSTOS DE COMPOSIÇÕES foi encontrado ou processado. Pulando esta etapa.") - - if precos_carregados or custos_carregados: - self.logger.info("Dados mensais (preços/custos) carregados com sucesso.") - else: - self.logger.warning("Nenhuma informação de preços ou custos foi carregada nesta execução.") - except Exception as e: - raise DatabaseError(f"Erro durante a carga de dados no banco de dados: {e}") from e - - self.logger.info("Pipeline AutoSINAPI concluído com sucesso!") - return { - "status": "success", - "message": f"Dados de {month}/{year} populados com sucesso.", - "tables_updated": list(set(tables_updated)), # Use set to avoid duplicates - "records_inserted": records_inserted - } + status = self.config.STATUS_SUCCESS + message = "Dados populados com sucesso." except AutoSinapiError as e: - self.logger.error(f"Erro de negócio no pipeline AutoSINAPI: {e}", exc_info=True) - return { - "status": "failed", - "message": str(e), - "tables_updated": tables_updated, - "records_inserted": records_inserted - } + self.logger.error(f"Erro de negócio no pipeline: {e}", exc_info=True) + message = f"Erro de negócio: {e}" except Exception as e: - self.logger.error(f"Ocorreu um erro inesperado e fatal no pipeline: {e}", exc_info=True) - return { - "status": "failed", - "message": f"Erro inesperado e fatal: {e}", - "tables_updated": tables_updated, - "records_inserted": records_inserted - } + self.logger.critical(f"Ocorreu um erro inesperado e fatal no pipeline: {e}", exc_info=True) + message = f"Erro inesperado: {e}" + finally: + # --- Sumário da Execução --- + self.logger.info("=" * 50) + self.logger.info(f"========= PIPELINE FINALIZADO (Run ID: {self.run_id}) =========") + self.logger.info(f"Status Final: {status}") + self.logger.info(f"Total de Registros Inseridos: {records_inserted}") + self.logger.info(f"Tabelas Atualizadas: {list(set(tables_updated))}") + self.logger.info("=" * 50) -def main(): - parser = argparse.ArgumentParser(description="Pipeline de ETL para dados do SINAPI.") - parser.add_argument('--config', type=str, help='Caminho para o arquivo de configuração JSON.') - parser.add_argument('-v', '--verbose', action='store_true', help='Habilita logging em nível DEBUG.') - args = parser.parse_args() - - setup_logging(debug_mode=True) - - try: - pipeline = PipelineETL(config_path=args.config) - pipeline.run() - except Exception: - logger.critical("Pipeline encerrado devido a um erro fatal.") - -if __name__ == "__main__": - main() + return { + "status": status, + "message": message, + "tables_updated": list(set(tables_updated)), + "records_inserted": records_inserted, + } \ No newline at end of file From bb2c782ed3b7b2aa13e41ad110addaf4fb077154 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Thu, 4 Sep 2025 20:23:36 -0300 Subject: [PATCH 09/10] =?UTF-8?q?docs(data-model):=20atualiza=20documenta?= =?UTF-8?q?=C3=A7=C3=A3o=20do=20modelo=20de=20dados?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DataModel.md | 157 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/docs/DataModel.md b/docs/DataModel.md index 591710d..3e27058 100644 --- a/docs/DataModel.md +++ b/docs/DataModel.md @@ -208,48 +208,167 @@ O modelo de dados robusto criado pelo `autoSINAPI` serve como uma base poderosa ### 4.1. Interface Programática (Toolkit) -A função `run_etl` é a interface pública principal para executar o pipeline de forma programática. +A maneira recomendada de interagir com o pacote é através da função `run_etl`, localizada no nível raiz do pacote (`from autosinapi import run_etl`). Ela atua como uma interface de alto nível que simplifica a execução de todo o pipeline, gerenciando a configuração, a execução e o retorno de resultados de forma padronizada. + +Existem duas formas principais de fornecer as configurações para a função `run_etl`: + +1. **Via Dicionários Python:** Ideal para integrar o `autoSINAPI` em outras aplicações Python, como APIs, scripts de automação ou notebooks de análise. +2. **Via Variáveis de Ambiente:** Perfeito para ambientes automatizados, contêineres (Docker) e pipelines de CI/CD, onde as configurações são injetadas no ambiente de execução. + +----- + +#### **Parâmetros da Função `run_etl`** | Parâmetro | Tipo | Descrição | Padrão | | :--- | :--- | :--- | :--- | -| **`db_config`** | `Dict` | Dicionário com as credenciais de conexão do PostgreSQL. | *Obrigatório* | -| **`sinapi_config`** | `Dict` | Dicionário com as configurações de referência dos dados SINAPI. | *Obrigatório* | -| **`mode`** | `str` | Modo de operação: `'local'` (baixa os arquivos) ou `'server'` (usa arquivos locais). | `'local'` | -| **`log_level`** | `str` | Nível de detalhe dos logs (`'INFO'`, `'DEBUG'`, etc.). | `'INFO'` | +| **`db_config`** | `Dict` | Dicionário com as credenciais de conexão do PostgreSQL. Se `None`, tentará carregar a partir de variáveis de ambiente (`POSTGRES_*`). | `None` | +| **`sinapi_config`**| `Dict` | Dicionário com as configurações de referência dos dados SINAPI. Se `None`, tentará carregar a partir de variáveis de ambiente (`AUTOSINAPI_*`). | `None` | +| **`mode`** | `str` | Modo de operação: `'local'` (baixa os arquivos) ou `'server'` (usa arquivos locais, útil em ambientes onde o download é feito por outro processo). | `'local'` | +| **`log_level`** | `str` | Nível de detalhe dos logs. Opções: `'DEBUG'`, `'INFO'`, `'WARNING'`, `'ERROR'`, `'CRITICAL'`. | `'INFO'` | ----- -#### **Estrutura do `db_config`** +#### **Estrutura dos Dicionários de Configuração** + +**1. Dicionário `db_config`** +*Todos os campos são obrigatórios ao usar este método.* ```python { - "host": "seu_host_db", # Ex: "localhost" ou "db" (para Docker) - "port": 5432, # Porta do PostgreSQL - "database": "seu_db_name", # Nome do banco de dados - "user": "seu_usuario", # Usuário do banco de dados - "password": "sua_senha" # Senha do usuário + # Endereço do servidor de banco de dados. + # Ex: "localhost" para uma máquina local ou "db" em um ambiente Docker Compose. + "host": "seu_host_db", + + # Porta em que o PostgreSQL está escutando. A padrão é 5432. + "port": 5432, + + # O nome do banco de dados que será utilizado pelo pipeline. + "database": "seu_db_name", + + # Nome de usuário com permissões para criar tabelas e inserir dados. + "user": "seu_usuario", + + # Senha correspondente ao usuário. + "password": "sua_senha" } ``` -*Todos os campos são obrigatórios.* +**2. Dicionário `sinapi_config`** +*`year` e `month` são obrigatórios. Os demais possuem valores padrão.* + +```python +{ + # Ano de referência dos dados do SINAPI a serem processados. + "year": 2025, + + # Mês de referência (número inteiro de 1 a 12). + "month": 7, + + # Tipo de caderno SINAPI. Padrão: "REFERENCIA". + # Opções: "REFERENCIA", "DESONERADO". + "type": "REFERENCIA", + + # Política para lidar com dados de um período já existente. (ainda não implementado) + # Padrão: "substituir". Opções: "substituir", "append". + "duplicate_policy": "substituir" +} +``` ----- -#### **Estrutura do `sinapi_config`** +#### **Exemplos de Interação** + +**Exemplo 1: Execução programática via Dicionários** + +Este é o método ideal para usar o `autoSINAPI` como uma biblioteca dentro de outra aplicação Python. ```python -{ - "year": 2023, # Ano de referência (obrigatório) - "month": 7, # Mês de referência (obrigatório) - "type": "REFERENCIA", # Tipo de caderno ("REFERENCIA" ou "DESONERADO") - "duplicate_policy": "substituir" # Política de duplicatas ("substituir" ou "append") +from autosinapi import run_etl + +# 1. Defina as configurações do banco de dados +db_settings = { + "host": "localhost", + "port": 5432, + "database": "sinapi_db", + "user": "postgres", + "password": "mysecretpassword" +} + +# 2. Defina as configurações do SINAPI para o período desejado +sinapi_settings = { + "year": 2025, + "month": 7 } + +# 3. Execute o pipeline e capture o resultado +print("Iniciando o pipeline ETL do SINAPI...") +result = run_etl( + db_config=db_settings, + sinapi_config=sinapi_settings, + log_level='DEBUG' # Use DEBUG para ver logs mais detalhados +) + +# 4. Verifique o resultado da execução +print("\n--- Resultado da Execução ---") +print(f"Status: {result['status']}") +print(f"Mensagem: {result['message']}") +print(f"Registros Inseridos: {result['records_inserted']}") +print(f"Tabelas Atualizadas: {result['tables_updated']}") ``` -*`year` e `month` são obrigatórios. Os demais possuem valores padrão.* +**Exemplo 2: Execução via Variáveis de Ambiente** + +Este método é ideal para scripts de automação e ambientes de contêiner. Primeiro, configure as variáveis de ambiente no seu terminal. + +*No Linux ou macOS:* + +```bash +export POSTGRES_HOST=localhost +export POSTGRES_PORT=5432 +export POSTGRES_DB=sinapi_db +export POSTGRES_USER=postgres +export POSTGRES_PASSWORD=mysecretpassword +export AUTOSINAPI_YEAR=2025 +export AUTOSINAPI_MONTH=7 +``` + +*No Windows (Prompt de Comando):* + +```cmd +set POSTGRES_HOST=localhost +set POSTGRES_DB=sinapi_db +... (e assim por diante) +``` + +Em seguida, o script Python para executar o pipeline se torna extremamente simples: + +```python +from autosinapi import run_etl + +# A função run_etl irá carregar todas as configurações +# automaticamente a partir das variáveis de ambiente definidas. +print("Iniciando o pipeline ETL do SINAPI a partir de variáveis de ambiente...") +result = run_etl() + +# O resultado é tratado da mesma forma +print("\n--- Resultado da Execução ---") +print(f"Status: {result['status']}") +# ... etc ... +``` ----- +#### **Estrutura do Retorno** + +A função `run_etl` sempre retorna um dicionário com a seguinte estrutura, permitindo que a aplicação que a chamou saiba exatamente o que aconteceu. + +| Chave | Tipo | Descrição | +| :--- | :--- | :--- | +| **`status`** | `str` | O status final da execução. Ex: `"SUCESSO"`, `"FALHA"`, `"SUCESSO (SEM DADOS)"`. | +| **`message`** | `str` | Uma mensagem descritiva sobre o resultado da execução. | +| **`records_inserted`**| `int` | O número total de registros inseridos no banco de dados durante a execução. | +| **`tables_updated`** | `List[str]` | Uma lista com os nomes de todas as tabelas que foram modificadas. | + ### 4.2. Exemplos de Casos de Uso (API REST) A estrutura do banco de dados permite a criação de endpoints de API poderosos para consultar os dados de forma eficiente. From c6178400e2383d5a11f8785241bd3a14e2a7abc9 Mon Sep 17 00:00:00 2001 From: Lucas Antonio Date: Thu, 4 Sep 2025 20:23:50 -0300 Subject: [PATCH 10/10] =?UTF-8?q?chore(docker):=20atualiza=20configura?= =?UTF-8?q?=C3=A7=C3=B5es=20e=20scripts=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/docker/Dockerfile | 12 ++++++++++-- tools/docker/Makefile | 24 ++++++++++++++++++------ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile index 8fd61ce..fe9b426 100644 --- a/tools/docker/Dockerfile +++ b/tools/docker/Dockerfile @@ -7,15 +7,23 @@ RUN apt-get update && apt-get install -y git # Define o diretorio de trabalho dentro do container WORKDIR /app -# Copia todo o contexto do projeto para o diretorio de trabalho +# Copia os arquivos de configuração do projeto para o diretorio de trabalho +COPY pyproject.toml /app/ +COPY setup.py /app/ +COPY autosinapi/ /app/autosinapi/ + +# Copia o restante do contexto do projeto COPY . /app +# Desinstala o pacote autosinapi se existir, para garantir uma instalação limpa +RUN pip uninstall -y autosinapi || true + # Atualiza o pip e instala o driver do postgres explicitamente RUN pip install --no-cache-dir --upgrade pip RUN pip install --no-cache-dir psycopg2-binary # Instala as dependencias do projeto -RUN pip install --no-cache-dir . +RUN pip install --no-cache-dir --force-reinstall . # Define o comando padrao para executar o pipeline CMD ["python", "-m", "autosinapi.etl_pipeline"] \ No newline at end of file diff --git a/tools/docker/Makefile b/tools/docker/Makefile index 6195dbb..a390f14 100644 --- a/tools/docker/Makefile +++ b/tools/docker/Makefile @@ -1,7 +1,7 @@ # Makefile para gerenciar o ambiente Docker do AutoSINAPI # Fornece atalhos para os comandos mais comuns do docker-compose. -.PHONY: help build build-no-cache up run down app-down db-down adminer-down app-start db-start adminer-start clean clean-app clean-db clean-adminer shell logs logs-app logs-db logs-adminer +.PHONY: help build build-no-cache up run run-local down app-down db-down adminer-down app-start db-start adminer-start clean clean-app clean-db clean-adminer shell logs logs-app logs-db logs-adminer # Garante que as variaveis do .env sejam carregadas include .env @@ -14,7 +14,8 @@ help: @echo " make build - (Re)constroi a imagem da aplicacao usando o cache." @echo " make build-no-cache - Forca a reconstrucao da imagem do zero (use apos adicionar dependencias)." @echo " make up - Sobe todos os servicos (db, app, adminer) em background." - @echo " make run - Executa o pipeline de ETL dentro do container 'app' que ja esta rodando." + @echo " make run - Executa o pipeline de ETL (com download) dentro do container 'app'." + @echo " make run-local - Executa o pipeline de ETL (sem download) usando arquivos locais." @echo " make down - Para e remove os conteineres." @echo " make app-down - Para o container app" @echo " make db-down - Para o container db" @@ -52,10 +53,21 @@ up: @echo "=> Iniciando todos os servicos em background..." docker-compose up -d -# Executa o pipeline dentro do container 'app' que ja esta rodando +# ============================================================================= +# COMANDOS DE EXECUÇÃO +# ============================================================================= + +# Executa o pipeline com download run: - @echo "=> Executando o pipeline do AutoSINAPI via 'exec'..." - docker-compose exec app python tools/autosinapi_pipeline.py + @echo "=> Executando o pipeline do AutoSINAPI (com download) via 'exec'..." + docker-compose exec -e AUTOSINAPI_SKIP_DOWNLOAD=False app python -c "from autosinapi import run_etl; run_etl(mode='server', log_level='INFO')" + +# Executa o pipeline sem download, usando arquivos locais +run-local: + @echo "=> Executando o pipeline em MODO LOCAL (sem download) via 'exec'..." + docker-compose exec -e AUTOSINAPI_SKIP_DOWNLOAD=True app python -c "from autosinapi import run_etl; run_etl(mode='server', log_level='INFO')" + +# ============================================================================= # Para e remove os conteineres de todos os serviços down: @@ -127,4 +139,4 @@ logs-adminer: # Abre um shell interativo no conteiner da aplicacao shell: @echo "=> Abrindo shell interativo no conteiner 'app'..." - docker-compose exec app bash \ No newline at end of file + docker-compose exec app bash