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/autosinapi/__init__.py b/autosinapi/__init__.py
index c4083fa..98f6091 100644
--- a/autosinapi/__init__.py
+++ b/autosinapi/__init__.py
@@ -29,4 +29,160 @@
"DownloadError",
"ProcessingError",
"DatabaseError",
+ "run_etl"
]
+
+import os
+import logging
+import uuid # Added for run_id generation
+from contextlib import contextmanager
+from typing import Dict, Any
+
+from .etl_pipeline import PipelineETL, 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] = 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 inválido ou vazio.",
+ "tables_updated": [],
+ "records_inserted": 0
+ }
+ if not isinstance(sinapi_config, dict) or not sinapi_config:
+ return {
+ "status": "failed",
+ "message": "Erro de validação: sinapi_config inválido ou vazio.",
+ "tables_updated": [],
+ "records_inserted": 0
+ }
+ if mode not in ['local', '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']:
+ 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 = {
+ '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(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(debug_mode=debug_mode, run_id=run_id) # Pass run_id to PipelineETL
+ 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)
+ # 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/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
new file mode 100644
index 0000000..31831c8
--- /dev/null
+++ b/autosinapi/core/pre_processor.py
@@ -0,0 +1,107 @@
+# 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__)
+
+def convert_excel_sheets_to_csv(
+ xlsx_full_path: Path,
+ sheets_to_convert: list[str],
+ output_dir: Path,
+ config: Config
+):
+ """
+ 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}")
+
+ 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=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
+
+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/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
new file mode 100644
index 0000000..a8a25ab
--- /dev/null
+++ b/autosinapi/etl_pipeline.py
@@ -0,0 +1,511 @@
+# autosinapi/etl_pipeline.py
+
+"""
+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 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.database import Database
+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,
+)
+
+logger = logging.getLogger("autosinapi")
+
+
+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)
+ 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()
+ 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, 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.logger.info(f"Iniciando nova execução do pipeline. Run ID: {self.run_id}")
+
+ 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}")
+ try:
+ with open(config_path, 'r') as f:
+ return json.load(f)
+ except FileNotFoundError as e:
+ raise ConfigurationError(f"Arquivo de configuração não encontrado: {config_path}") from e
+ except json.JSONDecodeError as e:
+ 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 {
+ "secrets_path": os.getenv("AUTOSINAPI_SECRETS_PATH", "tools/sql_access.secrets"),
+ "default_year": os.getenv("AUTOSINAPI_YEAR"),
+ "default_month": os.getenv("AUTOSINAPI_MONTH"),
+ "workbook_type_name": os.getenv("AUTOSINAPI_TYPE", "REFERENCIA"),
+ "duplicate_policy": os.getenv("AUTOSINAPI_POLICY", "substituir"),
+ }
+
+ 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."
+ )
+ 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 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."
+ )
+ return {
+ 'host': os.getenv("POSTGRES_HOST", "db"),
+ 'port': os.getenv("POSTGRES_PORT", 5432),
+ 'database': os.getenv("POSTGRES_DB"),
+ 'user': os.getenv("POSTGRES_USER"),
+ 'password': os.getenv("POSTGRES_PASSWORD"),
+ }
+ try:
+ secrets_path = base_config['secrets_path']
+ with open(secrets_path, 'r') as f:
+ content = f.read()
+
+ db_config = {}
+ for line in content.splitlines():
+ if '=' in line:
+ key, value = line.split('=', 1)
+ db_config[key.strip()] = value.strip().strip("'")
+
+ return {
+ 'host': db_config['DB_HOST'],
+ 'port': db_config['DB_PORT'],
+ 'database': db_config['DB_NAME'],
+ 'user': db_config['DB_USER'],
+ 'password': db_config['DB_PASSWORD'],
+ }
+ except Exception as e:
+ raise ConfigurationError(f"Erro ao ler ou processar o arquivo de secrets '{secrets_path}': {e}") from e
+
+ def _get_sinapi_config(self, base_config):
+ return {
+ '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.debug(f"Procurando por arquivo .zip em: {download_path}")
+ for file in download_path.glob('*.zip'):
+ 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}'"
+ )
+ file.rename(new_path)
+ return new_path
+ return file
+ self.logger.info(
+ "Nenhum arquivo .zip correspondente encontrado localmente."
+ )
+ return None
+
+ def _unzip_file(self, zip_path: Path) -> Path:
+ extraction_path = zip_path.parent / zip_path.stem
+ self.logger.info(f"Descompactando '{zip_path.name}' para: {extraction_path}")
+ extraction_path.mkdir(parents=True, exist_ok=True)
+ try:
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
+ zip_ref.extractall(extraction_path)
+ self.logger.info(f"Arquivo descompactado com sucesso em {extraction_path}")
+ return extraction_path
+ except zipfile.BadZipFile as e:
+ raise ProcessingError(
+ f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido."
+ ) from e
+
+ 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=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
+
+ def _sync_catalog_status(self, db: Database):
+ # ... (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,
+ ROW_NUMBER() OVER(PARTITION BY item_codigo, tipo_item ORDER BY data_referencia DESC) as rn
+ FROM {self.config.DB_TABLE_MANUTENCOES}
+ )
+ 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 '{self.config.MAINTENANCE_DEACTIVATION_KEYWORD}'
+ );
+ """
+ try:
+ 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=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):
+ """
+ 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:
+ self.logger.info("Configuração validada com sucesso.")
+ downloader = Downloader(self.config)
+ processor = Processor(self.config)
+ db = Database(self.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.")
+
+ # 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 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:
+ 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. 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.")
+ 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))
+
+ processed_data = self._handle_missing_items_placeholders(processed_data, structure_dfs)
+
+ self.logger.info("[FASE 2] Processamento de arquivos concluído.")
+
+ # 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)
+
+ status = self.config.STATUS_SUCCESS
+ message = "Dados populados com sucesso."
+
+ except AutoSinapiError as e:
+ 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.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)
+
+ return {
+ "status": status,
+ "message": message,
+ "tables_updated": list(set(tables_updated)),
+ "records_inserted": records_inserted,
+ }
\ No newline at end of file
diff --git a/docs/DataModel.md b/docs/DataModel.md
index 2dbdaf1..3e27058 100644
--- a/docs/DataModel.md
+++ b/docs/DataModel.md
@@ -202,35 +202,203 @@ 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 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.
-#### Exemplo 2: Explodir a estrutura completa de uma composição
+Existem duas formas principais de fornecer as configurações para a função `run_etl`:
- * **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.
+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.
-#### Exemplo 3: Rastrear o histórico de um insumo
+-----
- * **Endpoint:** `GET /insumo/{codigo}/historico`
- * **Lógica:** Uma consulta direta na tabela `manutencoes_historico`, ordenada pela data de referência.
+#### **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. 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'` |
-```sql
-SELECT * FROM manutencoes_historico
-WHERE item_codigo = :codigo AND tipo_item = 'INSUMO'
-ORDER BY data_referencia DESC;
+-----
+
+#### **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
+{
+ # 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"
+}
+```
+
+**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"
+}
+```
+
+-----
+
+#### **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
+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']}")
+```
+
+**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.
+
+#### **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
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 e377712..f4a7b42 100644
--- a/tests/test_file_input.py
+++ b/tests/test_file_input.py
@@ -4,58 +4,110 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
+from io import BytesIO
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")
+
+ # 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
- (extraction_path / "SINAPI_Referência_2023_01.xlsx").touch()
+ 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:
- 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
-
- with patch("tools.autosinapi_pipeline.Processor") as mock_processor:
- mock_processor_instance = MagicMock()
- mock_processor.return_value = mock_processor_instance
-
- pipeline = Pipeline(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")
- )
-
- yield (
- pipeline,
- mock_db_instance,
- mock_downloader_instance,
- mock_processor_instance,
- )
+ mock_db_class.return_value = mock_db_instance
+
+ mock_downloader_instance = MagicMock()
+ 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_class.return_value = mock_processor_instance
+
+ pipeline = PipelineETL(config_path=None) # config_path=None is fine as Config is mocked
+
+
+ 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
+ )
+ mocker.patch.object(
+ pipeline, "_find_and_normalize_zip", return_value=Path("mocked.zip")
+ )
+
+ yield (
+ pipeline,
+ mock_db_instance,
+ mock_downloader_instance,
+ mock_processor_instance,
+ mock_convert_excel_sheets_to_csv,
+ 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_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(
@@ -68,121 +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"]
- ),
- }
-
- pipeline.run()
+ result = pipeline.run() # Capture the result
mock_processor.process_catalogo_e_precos.assert_called()
mock_db.save_data.assert_called()
+ 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=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
-
- pipeline.run()
+ 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()
+ 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, caplog):
+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
+
+ # 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"
+ )
+
+ result = pipeline.run() # Capture the result
+
+ assert result["status"] == "FALHA"
+ assert "Arquivo não encontrado" in result["message"]
+ assert result["tables_updated"] == []
+ assert result["records_inserted"] == 0
+
+
- 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 c5807da..981e19b 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,13 +64,13 @@ 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)
mocker.patch.object(
pipeline,
- "_load_config",
+ "_load_base_config", # Changed from "_load_config"
return_value={
"secrets_path": "dummy",
"default_year": sinapi_config["year"],
@@ -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
\ No newline at end of file
diff --git a/tools/autosinapi_pipeline.py b/tools/autosinapi_pipeline.py
deleted file mode 100644
index 3a6bf70..0000000
--- a/tools/autosinapi_pipeline.py
+++ /dev/null
@@ -1,391 +0,0 @@
-"""
-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.
-"""
-import json
-import logging
-import argparse
-import os
-import zipfile
-from pathlib import Path
-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
-
-# Configuração do logger principal
-logger = logging.getLogger("autosinapi")
-
-def setup_logging(debug_mode=False):
- """Configura o sistema de logging de forma centralizada."""
- 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')
- file_handler.setFormatter(file_formatter)
- file_handler.setLevel(level)
-
- stream_handler = logging.StreamHandler()
- if debug_mode:
- stream_handler.setFormatter(stream_formatter_debug)
- else:
- stream_handler.setFormatter(stream_formatter_info)
- stream_handler.setLevel(level)
-
- logger.addHandler(file_handler)
- logger.addHandler(stream_handler)
- logger.setLevel(level)
-
- if not debug_mode:
- logging.getLogger("urllib3").setLevel(logging.WARNING)
-
-class Pipeline:
- def __init__(self, config_path: str = None):
- 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()
-
- def _load_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}")
- try:
- with open(config_path, 'r') as f:
- return json.load(f)
- except FileNotFoundError:
- self.logger.error(f"Arquivo de configuração não encontrado: {config_path}", exc_info=True)
- raise
- except json.JSONDecodeError:
- self.logger.error(f"Erro ao decodificar o arquivo JSON de configuração: {config_path}", exc_info=True)
- raise
- else:
- self.logger.info("Carregando configuração a partir de variáveis de ambiente.")
- return {
- "secrets_path": os.getenv("AUTOSINAPI_SECRETS_PATH", "tools/sql_access.secrets"),
- "default_year": os.getenv("AUTOSINAPI_YEAR"),
- "default_month": os.getenv("AUTOSINAPI_MONTH"),
- "workbook_type_name": os.getenv("AUTOSINAPI_TYPE", "REFERENCIA"),
- "duplicate_policy": os.getenv("AUTOSINAPI_POLICY", "substituir"),
- }
-
- def _get_db_config(self):
- 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.")
- 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(
- 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."
- )
- return {
- 'host': os.getenv("POSTGRES_HOST", "db"),
- 'port': os.getenv("POSTGRES_PORT", 5432),
- 'database': os.getenv("POSTGRES_DB"),
- 'user': os.getenv("POSTGRES_USER"),
- 'password': os.getenv("POSTGRES_PASSWORD"),
- }
- try:
- secrets_path = self.config['secrets_path']
- with open(secrets_path, 'r') as f:
- content = f.read()
-
- db_config = {}
- for line in content.splitlines():
- if '=' in line:
- key, value = line.split('=', 1)
- db_config[key.strip()] = value.strip().strip("'")
-
- return {
- 'host': db_config['DB_HOST'],
- 'port': db_config['DB_PORT'],
- 'database': db_config['DB_NAME'],
- 'user': db_config['DB_USER'],
- '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
-
- def _get_sinapi_config(self):
- return {
- 'state': self.config.get('default_state', 'BR'),
- 'year': self.config['default_year'],
- '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')
- }
-
- def _find_and_normalize_zip(self, download_path: Path, standardized_name: str) -> Path:
- self.logger.info(f"Procurando por arquivo .zip em: {download_path}")
- for file in download_path.glob('*.zip'):
- self.logger.info(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}'")
- file.rename(new_path)
- return new_path
- return file
- self.logger.warning("Nenhum arquivo .zip encontrado localmente.")
- return None
-
- def _unzip_file(self, zip_path: Path) -> Path:
- extraction_path = zip_path.parent / zip_path.stem
- self.logger.info(f"Descompactando '{zip_path.name}' para: {extraction_path}")
- extraction_path.mkdir(parents=True, exist_ok=True)
- try:
- with zipfile.ZipFile(zip_path, 'r') as zip_ref:
- zip_ref.extractall(extraction_path)
- self.logger.info("Arquivo descompactado com sucesso.")
- return extraction_path
- except zipfile.BadZipFile:
- self.logger.error(f"O arquivo '{zip_path.name}' não é um zip válido ou está corrompido.", exc_info=True)
- raise
-
- def _run_pre_processing(self):
- self.logger.info("FASE PRE: Iniciando pré-processamento de planilhas para CSV.")
- script_path = "tools/pre_processador.py"
- 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}.")
- 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
-
- def _sync_catalog_status(self, db: Database):
- self.logger.info("Iniciando Fase 2: Sincronização de Status dos Catálogos.")
- sql_update = """
- WITH latest_maintenance AS (
- SELECT
- 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
- )
- 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%'
- );
- """
- try:
- num_insumos_updated = db.execute_non_query(sql_update.format(table="insumos", 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"))
- 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}")
-
- def run(self):
- self.logger.info("======================================================")
- self.logger.info("========= INICIANDO PIPELINE AUTOSINAPI =========")
- self.logger.info("======================================================")
- try:
- config = Config(db_config=self.db_config, sinapi_config=self.sinapi_config, mode='local')
- self.logger.info("Configuração validada com sucesso.")
- self.logger.debug(f"Configurações SINAPI para esta execução: {config.sinapi_config}")
-
- downloader = Downloader(config.sinapi_config, config.mode)
- processor = Processor(config.sinapi_config)
- db = Database(config.db_config)
-
- self.logger.info("Recriando tabelas do banco de dados para garantir conformidade.")
- db.create_tables()
-
- 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...")
- 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)
-
- # --- PRÉ-PROCESSAMENTO PARA CSV ---
- self._run_pre_processing()
- # --- 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}")
-
- 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
- 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
-
- 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))
-
- 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...")
-
- 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.")
-
- self.logger.info("Pipeline AutoSINAPI concluído com sucesso!")
-
- except AutoSinapiError as e:
- self.logger.error(f"Erro de negócio no pipeline AutoSINAPI: {e}", exc_info=True)
- except Exception as e:
- self.logger.error(f"Ocorreu um erro inesperado e fatal no pipeline: {e}", exc_info=True)
-
-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 = Pipeline(config_path=args.config)
- pipeline.run()
- except Exception:
- logger.critical("Pipeline encerrado devido a um erro fatal.")
-
-if __name__ == "__main__":
- main()
diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile
index cf637dc..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", "tools/autosinapi_pipeline.py"]
\ No newline at end of file
+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
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