diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b12f3b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# Ignora o ambiente virtual +venv/ + +# Ignora arquivos de cache do Python +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Ignora arquivos de cache do pytest +.pytest_cache/ + +# Ignora arquivos de build do setuptools +build/ +dist/ +*.egg-info/ + +# Ignora arquivos de configuração local +.env +sql_access.secrets diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index bda5321..3037b4d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -69,4 +69,4 @@ def run_etl(db_config: dict, sinapi_config: dict, mode: str) -> dict: ## Referências - #issue_number (se houver) - [Documento de Arquitetura](docs/workPlan.md) -- [Padrões de Nomenclatura](docs/nomenclaturas.md) +- [Padrões de Contribuição](docs/CONTRIBUTING.md) diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..8172f0d --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,35 @@ +# .github/release-drafter.yml + +# Define as categorias com base nos tipos de Conventional Commits +categories: + - title: '🚀 Novas Funcionalidades' + labels: + - 'feature' + - 'feat' + - title: '🐛 Correções de Bugs' + labels: + - 'fix' + - 'bug' + - title: '🔧 Melhorias e Refatorações' + labels: + - 'refactor' + - 'chore' + - title: '📚 Documentação' + labels: + - 'docs' + +# Exclui labels que não devem aparecer no changelog +exclude-labels: + - 'skip-changelog' + +# O template do corpo da sua release. +# A variável $CHANGES será substituída pela lista categorizada de mudanças. +template: | + ## O que há de novo nesta versão? + + *Aqui você pode escrever sua copy, explicando o objetivo principal da release.* + + $CHANGES + + --- + **Agradecemos a todos os contribuidores!** 🎉 \ No newline at end of file diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml new file mode 100644 index 0000000..b195088 --- /dev/null +++ b/.github/workflows/draft-release.yml @@ -0,0 +1,21 @@ +# .github/workflows/draft-release.yml + +name: Draft a new release + +on: + push: + branches: + - develop # Roda toda vez que algo é mesclado em develop + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + with: + # (Opcional) Publica a release se a tag corresponder, + # mas para o seu caso, vamos deixar o seu 'release.yml' cuidar disso. + # A principal função aqui é apenas ATUALIZAR o rascunho. + publish: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0ce3a67 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release + +on: + push: + tags: + - 'v*' # Aciona o workflow em tags como v1.0, v1.2.3, etc. + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # Permissão necessária para a action criar a release + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Create GitHub Release and Upload Assets + uses: softprops/action-gh-release@v1 + with: + # Usa o nome da tag (ex: v1.2.0) como nome da release + name: Release ${{ github.ref_name }} + # Gera o corpo da release automaticamente a partir dos commits + generate_release_notes: true + # Faz o upload de TODOS os arquivos do diretório dist/* + files: ./dist/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish package to PyPI + run: twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..724dd59 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,63 @@ +name: Tests and Quality Checks + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.8, 3.9, "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: | + pytest --cov=autosinapi --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + fail_ci_if_error: true + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 black isort + + - name: Check formatting + run: | + black --check autosinapi tests + isort --check-only autosinapi tests + + - name: Lint with flake8 + run: | + 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/.gitignore b/.gitignore index a247a33..a56c4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,17 @@ +# ========================= +# Arquivos de build e distribuição +# ========================= +build/ +dist/ +*.egg-info/ + # ========================= # Arquivos sensíveis e temporários # ========================= # Ignora todos os arquivos com extensão .secrets em qualquer diretório **/*.secrets +**/*.env # Ignora arquivo .secrets na raiz .secrets @@ -52,8 +60,10 @@ # Ignora arquivos compactados **/*.zip -# Ignora arquivos JSON +# Ignora arquivos JSON, exceto os de exemplo **/*.json +!**/*.example.json + # ========================= # Exceções (remova o ! se não quiser versionar) @@ -82,4 +92,8 @@ Thumbs.db .DS_Store # Ignora diretórios de downloads -downloads/ \ No newline at end of file +downloads/ +tools/docker/.env +AutoSINAPI.code-workspace +.coverage +coverage.xml diff --git a/README.md b/README.md index 9709c9d..f97849b 100644 --- a/README.md +++ b/README.md @@ -1,240 +1,130 @@ -# 🔄 AutoSINAPI: Seu kit de ferramentas +# 🚀 AutoSINAPI: Acelere Suas Decisões na Construção Civil com Dados Inteligentes -**Solução open source para simplificar o acesso, tratamento e gestão dos dados do SINAPI (Sistema Nacional de Pesquisa de Custos e Índices da Construção Civil).** Seja você desenvolvedor, analista de custos ou profissional da construção, este projeto transforma dados complexos em informações estruturadas e prontas para análise! +[![Licença](https://img.shields.io/badge/licen%C3%A7a-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![Status](https://img.shields.io/badge/status-alpha-orange.svg)](https://github.com/LAMP-LUCAS/AutoSINAPI/releases) -## 🤝 Convidamos Você a Participar! +## 🚧 Cansado de Planilhas e Dados Desatualizados? Conheça o AutoSINAPI! -Quer contribuir para um projeto real que impacta o setor da construção? Não precisa ser expert! Aqui você encontra: +Para arquitetos, engenheiros e construtores, a gestão de custos e orçamentos é a espinha dorsal de qualquer projeto bem-sucedido. No entanto, a realidade muitas vezes envolve: -| Para Iniciantes 💡 | Para Desenvolvedores 🛠️ | -|----------------------------------|---------------------------------| -| 👉 [Guia Passo a Passo](https://github.com) para instalação e uso | 🚀 Implemente APIs REST e integrações com SINCRO API | -| 🧠 [Tutorial de LLMs](https://github.com/LAMP-LUCAS/AutoSINAPI/tree/postgres_data-define/docs/TUTORIAL-INICIO.md) para automação de projetos | 🏗️ Seja parte de uma revolução na construção civil! | +* **Horas Perdidas:** Coletando, organizando e atualizando manualmente dados do SINAPI. +* **Decisões Baseadas em Achismos:** A falta de dados precisos e atualizados compromete a assertividade. +* **Complexidade:** Lidar com a vasta e mutável base de dados do SINAPI é um desafio constante. -| Para TODOS 👥 | -|----------------------------------| -🌐 Participe do [FOTON](https://github.com/LAMP-LUCAS/foton) - Um ecossistema de soluções Open Source para a industria AEC | +O **AutoSINAPI** surge como a solução definitiva para transformar essa realidade. Somos uma ferramenta open-source completa, projetada para automatizar o ciclo de vida dos dados do SINAPI, desde a coleta até a análise, entregando a você **informação precisa e atualizada na palma da mão.** -> ✨ **Nosso Objetivo:** Criar uma ponte acessível entre dados brutos do SINAPI e tomadas de decisão inteligentes na construção civil, com: -> -> - ✅ **Autonomia** na atualização de bancos de dados PostgreSQL -> - 🛡️ **Segurança** no tratamento de informações -> - 🔍 **Facilidade** de consulta através de futuras APIs REST +### ✨ O Que o AutoSINAPI Oferece? + +* **Automação Inteligente:** Diga adeus à tediosa coleta manual. O AutoSINAPI baixa, processa e organiza os dados do SINAPI para você. +* **Precisão Inquestionável:** Tenha acesso a dados limpos, padronizados e prontos para uso, garantindo orçamentos mais acurados e análises confiáveis. +* **Visão Estratégica:** Libere seu tempo para focar no que realmente importa: análises estratégicas, otimização de custos e tomadas de decisão embasadas. +* **Histórico Completo:** Mantenha um registro detalhado das alterações do SINAPI ao longo do tempo, essencial para auditorias e comparações. +* **Flexibilidade:** Seja você um usuário final buscando uma solução pronta ou um desenvolvedor que precisa integrar dados SINAPI em seus sistemas, o AutoSINAPI se adapta. --- -## 🧩 O Que Fazemos Hoje +## 🛠️ Para Desenvolvedores: Robustez, Confiabilidade e Código Aberto + +Construído com as melhores práticas de engenharia de software, o AutoSINAPI é mais do que uma ferramenta; é um `toolkit` Python modular, testável e desacoplado. -| Funcionalidade | Status | Próximos Passos | -|---------------------------------|--------------|------------------------------| -| Download automático do SINAPI | ✅ Funcional | API REST para consultas | -| Tratamento de dados estruturado | ✅ Implementado | Integração com SINCRO API | -| Inserção em PostgreSQL | ✅ Operante | Dashboard de análises | -| CLI para pipeline | 🚧 Em desenvolvimento | Documentação interativa | +* **Arquitetura Modular:** Componentes bem definidos (`downloader`, `processor`, `database`) facilitam a compreensão, manutenção e extensão. +* **Testes Abrangentes:** Uma suíte de testes robusta garante a estabilidade e a confiabilidade do pipeline, mesmo com as constantes atualizações do SINAPI. +* **Integração Simplificada:** Projetado para ser facilmente consumido por outras aplicações, como APIs REST (ex: [autoSINAPI_API](https://github.com/LAMP-LUCAS/autoSINAPI_API)) ou CLIs customizadas. +* **Open Source:** Transparência total e a possibilidade de contribuir para a evolução da ferramenta. --- -## 🌟 Por Que Contribuir? - -- **Impacto direto** na gestão de custos da construção civil -- Ambiente **amigável para iniciantes** em programação -- **Aprendizado prático** com Python, PostgreSQL e automação -- Faça parte de uma comunidade que **simplifica dados complexos!** - -> "Sozinhos vamos mais rápido, juntos vamos mais longe" - Venha construir esta solução conosco! 🏗️💙 -## Objetivos +## 🚀 Como Começar com o AutoSINAPI + +Existem duas maneiras de rodar o pipeline, escolha a que melhor se adapta ao seu fluxo de trabalho. + +### Opção 1: Ambiente Docker (Recomendado) + +A forma mais simples e recomendada de usar o AutoSINAPI. Com um único comando, você sobe um ambiente completo e isolado com o banco de dados PostgreSQL e o pipeline pronto para rodar. + +**Pré-requisitos:** +- Docker e Docker Compose instalados. + +**Passo a Passo:** + +1. **Clone o repositório:** + ```bash + git clone https://github.com/LAMP-LUCAS/AutoSINAPI.git + cd AutoSINAPI + ``` + +2. **Configure o Ambiente:** + - Dentro da pasta `tools/docker/`, renomeie o arquivo `.env.example` para `.env`. + - Abra o arquivo `.env` e ajuste as variáveis conforme sua necessidade (ano, mês, senhas, etc.). + +3. **(Opcional) Adicione Arquivos Locais:** + - Se você já tiver o arquivo `.zip` do SINAPI, coloque-o dentro da pasta `tools/docker/downloads/`. O pipeline irá detectá-lo, renomeá-lo para o padrão correto (se necessário) e pulará a etapa de download. -- Automatizar o download dos dados do SINAPI -- Tratar e organizar os dados para facilitar consultas e análises -- Inserir os dados em um banco PostgreSQL, permitindo edição e atualização recorrente -- Prover scripts e ferramentas para facilitar a manutenção e evolução do processo - -## Estrutura do Projeto +4. **Execute o Pipeline:** + Ainda dentro da pasta `tools/docker/`, execute o comando: + ```bash + docker-compose up + ``` + Este comando irá construir a imagem, subir o container do banco de dados e, em seguida, rodar o container da aplicação que executará o pipeline. Ao final, os containers serão finalizados. -```plaintext -├── autosinapi_pipeline.py # Script Exemplo para download, tratamento e insersão dos arquivos SINAPI no banco de dados -├── CONFIG.json # Arquivo de configuração para automatização do pipeline -├── sinap_webscraping_download_log.json # Arquivo de registro dos downloads -├── sql_access.secrets # Arquivo de configuração do banco (exemplo) - Retirar ".example" -├── sinapi_utils.py # Módulo contendo toda lógica do projeto -├── update_requirements.py # Atualizador de dependências -├── setup.py # Configuração do módulo -├── pyproject.toml # Configuração do módulo -└── requirements.txt # Dependências do projeto -``` +### Opção 2: Ambiente Local (Avançado) -## Configuração Inicial +Para quem prefere ter controle total sobre o ambiente e não usar Docker. -### 1. Clone o repositório +**Pré-requisitos:** +- Python 3.8+ e PostgreSQL 12+ instalados e configurados na sua máquina. -```bash -git clone https://github.com/seu-usuario/AutoSINAPIpostgres.git -cd AutoSINAPIpostgres -``` +**Passo a Passo:** + +1. **Clone o repositório e instale as dependências** conforme a seção de instalação do `README.md`. +2. **Configure o acesso ao banco de dados** no arquivo `tools/sql_access.secrets`. +3. **Crie e ajuste um arquivo de configuração** (ex: `tools/meu_config.json`) a partir do `tools/CONFIG.example.json`. +4. **Execute o pipeline** via linha de comando: + ```bash + python tools/autosinapi_pipeline.py --config tools/meu_config.json + ``` + +--- + +## 🏗️ Arquitetura do Projeto + +O **AutoSINAPI** é projetado como um `toolkit` modular e desacoplado, focado em processar dados do SINAPI de forma eficiente e robusta. Sua arquitetura é dividida em componentes principais que interagem para formar um pipeline ETL completo. + +Para uma compreensão aprofundada do modelo de dados e do fluxo de execução do ETL, consulte os seguintes documentos: + +* **[Modelo de Dados Detalhado](docs/DataModel.md)**: Descreve as tabelas do banco de dados, seus relacionamentos e a estrutura dos dados. +* **[Fluxo de Execução do ETL](docs/DataModel.md#3-processo-de-etl-fluxo-de-execucao-detalhado)**: Detalha as fases do processo de Extração, Transformação e Carga, desde a obtenção dos dados até a persistência no banco de dados. + +--- -### 2. Configure o ambiente virtual Python +## Versionamento e Estratégia de Lançamento -```bash -python -m venv venv -.\venv\Scripts\activate -``` +O versionamento deste projeto é **totalmente automatizado com base nas tags do Git**. Para mais detalhes, consulte a documentação sobre o fluxo de trabalho do Git. -### 3. Instale as dependências +## 🌐 Ecossistema AutoSINAPI -```bash -python update_requirements.py # Gera requirements.txt atualizado, OPCIONAL! -pip install -r requirements.txt -``` +- **[autoSINAPI_API](https://github.com/LAMP-LUCAS/autoSINAPI_API):** API para consumir os dados do banco de dados SINAPI. -### 4. Configure o acesso ao PostgreSQL +## 🤝 Como Contribuir -- Renomeie `sql_access.secrets.example` para `sql_access.secrets` -- Edite o arquivo com suas credenciais: +O **AutoSINAPI** é um projeto open-source que cresce com a comunidade! Sua contribuição é fundamental, seja ela qual for. Cada ajuda nos impulsiona a construir uma ferramenta cada vez mais robusta e útil para todos. -```ini -DB_USER = 'seu_usuario' -DB_PASSWORD = 'sua_senha' -DB_HOST = 'localhost' -DB_PORT = '5432' -DB_NAME = 'sinapi' -DB_INITIAL_DB = 'postgres' -``` +**Como você pode ajudar?** -### 5. Configure o arquivo CONFIG.json para automatização das etapas +* **Reporte Bugs:** Encontrou um problema? Sua observação é valiosa! Abra uma [Issue no GitHub](https://github.com/LAMP-LUCAS/AutoSINAPI/issues) descrevendo o bug. Isso nos ajuda a identificar e corrigir falhas rapidamente. +* **Sugira Novas Funcionalidades:** Tem uma ideia para melhorar o AutoSINAPI? Compartilhe conosco abrindo uma [Issue de Feature Request](https://github.com/LAMP-LUCAS/AutoSINAPI/issues). +* **Contribua com Código:** Se você é desenvolvedor, suas habilidades são muito bem-vindas! Contribua com novas funcionalidades, correções de bugs ou melhorias no código. Consulte nosso guia de contribuição para começar: [Como Contribuir](docs/CONTRIBUTING.md). +* **Documentação:** Ajude a melhorar nossa documentação, tornando-a mais clara e completa. +* **Divulgue:** Compartilhe o AutoSINAPI com sua rede! Quanto mais pessoas conhecerem, maior nossa comunidade. +* **Apoie com um Cafezinho:** Gosta do projeto e quer nos ajudar a manter o ritmo? Considere fazer uma pequena doação para o "cafezinho" da equipe. Seu apoio financeiro, por menor que seja, faz uma grande diferença! +🔑 Chave Pix: `a03ffaea-d46f-4dc6-a372-2b4fa8b0385f` copie e cole no seu app bancário ou \ +📋 Use nosso link de pagamento pelo [MercadoPago](link.mercadopago.com.br/autosinapi) -- Atualmente está configurado para tratar os dados das bases à partir de 2025, substituindo os dados antigos e utilizando o arquivo XLSX REFERENCIA para insersão: +**Junte-se a nós e faça parte desta jornada!** -```ini -{ - "secrets_path": "sql_access.secrets", # arquivo com os parâmetros de conexão - "default_year": "2025", # ano da base desejada - "default_month": "01", # mês da base desejada - "default_format": "xlsx", # formato de arquivo a ser trabalhado (Atualmente só suporta XLSX) - "workbook_type_name": "REFERENCIA", # Workbook exemplo para trabalhar - "duplicate_policy": "substituir", # Política de insersão de dados novos - "backup_dir": "./backups", # Pasta para salvamento dos dados tratados antes de inserir no banco de dados - "log_level": "info", # Nível de LOG - "sheet_processors": { # Configuração de recorte de dados para cada tipo de planilha {NOME_PLANILHA: {COLUNA_RECORTE, COLUNA_CABEÇALHO}} - "ISD": {"split_id": 5, "header_id": 9}, - "CSD": {"split_id": 4, "header_id": 9}, - "ANALITICO": {"split_id": 0, "header_id": 9}, - "COEFICIENTES": {"split_id": 5, "header_id": 5}, - "MANUTENCOES": {"split_id": 0, "header_id": 5}, - "MAO_DE_OBRA": {"split_id": 4, "header_id": 5} - } -} -``` +Para detalhes sobre como configurar seu ambiente de desenvolvimento, padrões de código, fluxo de trabalho e muito mais, consulte nosso guia completo: [Como Contribuir](docs/CONTRIBUTING.md). -## Uso dos Scripts +## 📝 Licença -### 1. Download de Dados SINAPI - -O script `autosinap_pipeline.py` realiza todas as etapas necessárias para o download dos arquivos do SINAPI e insersão no banco de dados PostgreSQL: - -```bash -python autosinap_pipeline.py -``` - -Se não configurar o CONFIG.json Você será solicitado a informar: - -- Ano (YYYY) -- Mês (MM) -- Tipo de planilha (familias_e_coeficientes, Manutenções, mao_de_obra, Referência) -- Formato (xlsx é o único formato suportado até o momento) - -### >> FUTURA IMPLANTAÇÃO << CLI para o scripy PostgreSQL - -O script `autosinapi_cli_pipeline.py` processa e insere os dados no banco: - -```bash -python autosinapi_cli_pipeline.py --arquivo_xlsx --tipo_base --config -``` - -Parâmetros disponíveis: - -- `--arquivo_xlsx`: Caminho do arquivo Excel a ser processado -- `--config`: Caminho do arquivo de configuração CONFIG.json -- `--tipo_base`: Tipo de dados (insumos, composicao, analitico) -- `--user`: Usuário do PostgreSQL (opcional, usa .secrets se não informado) -- `--password`: Senha do PostgreSQL (opcional, usa .secrets se não informado) -- `--host`: Host do PostgreSQL (opcional, usa .secrets se não informado) -- `--port`: Porta do PostgreSQL (opcional, usa .secrets se não informado) -- `--dbname`: Nome do banco (opcional, usa .secrets se não informado) - -## Estrutura do Banco de Dados - -O banco PostgreSQL é organizado em schemas por tipo de dados: - -- `insumos`: Preços e informações de insumos -- `composicoes`: Composições de serviços -- `analitico`: Dados analíticos detalhados - -## Troubleshooting - -### Erros Comuns - -1. Erro de conexão PostgreSQL: - - Verifique se o PostgreSQL está rodando - - Confirme as credenciais em `sql_access.secrets` - - Verifique se o banco e schemas existem ou se foram criados corretamente pelo script `autosinapi_pipeline.py` - -2. Erro no download SINAPI: - - Verifique sua conexão com a internet - - Confirme se o arquivo existe no site da Caixa - - Verifique o formato do ano (YYYY) e mês (MM) - - ATENÇÃO: Se realizadas várias tentativas a plataforma da CEF pode bloquear seu IP, utilize próxies ou aguarde um tempo antes de tentar novamente. - -3. Erro na análise Excel: - - Confirme se o arquivo não está aberto em outro programa - - Verifique se há permissão de leitura no diretório - - Verifique se as configurações de split e header presentes no arquivo `CONFIG.json` estão corretas - -## Como contribuir - -1. Faça um fork deste repositório -2. Crie uma branch para sua feature ou correção -3. Envie um pull request detalhando as alterações propostas -4. Beba água e se possível passe um cafezinho antes de contribuir. - -## Requisitos do Sistema - -- Python 3.0+ -- PostgreSQL 12+ -- Bibliotecas Python listadas em `requirements.txt` - -## Licença - -Este projeto é open source sob os termos da GNU General Public License, versão 3 (GPLv3). Isso significa que você pode utilizar, modificar e distribuir o projeto, inclusive para fins comerciais. Contudo, se você criar derivados ou incorporar este código em outros produtos e distribuí-los, estes também deverão estar sob licença GPLv3, garantindo assim que o código-fonte continue acessível aos usuários. - -## Contato - -Sugestões, dúvidas ou colaborações são bem-vindas via issues ou pull requests. - -## Árvore de configuração do diretório - -```plaintext -📦AutoSINAPI - ┣ 📂autosinapi.egg-info - ┃ ┣ 📜dependency_links.txt - ┃ ┣ 📜PKG-INFO - ┃ ┣ 📜requires.txt - ┃ ┣ 📜SOURCES.txt - ┃ ┗ 📜top_level.txt - ┣ 📂docs # Documentação do projeto >> Irá ser implantado juntamente com um forum/comunidade em um redmine - ┣ 📂tests # Local especial para testar modificações e implantações sem quebrar todo o resto :) - ┣ 📂tools # Ferramentas que podem ser criadas utilizando este módulo - ┃ ┃ ┣ 📂downloads # local onde serão salvos os downloads do script - ┃ ┣ 📜autosinapi_pipeline.py - ┃ ┣ 📜CONFIG.json - ┃ ┣ 📜sinap_webscraping_download_log.json - ┃ ┣ 📜sql_access.secrets.example - ┃ ┗ 📜__init__.py - ┣ 📜.gitignore - ┣ 📜pyproject.toml - ┣ 📜README.md - ┣ 📜requirements.txt - ┣ 📜setup.py - ┣ 📜sinapi_utils.py - ┣ 📜update_requirements.py - ┗ 📜__init__.py -``` +Distribuído sob a licença **GNU General Public License v3.0**. diff --git a/__init__.py b/__init__.py index e69de29..2ed95af 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1 @@ +# Torna o diretório AutoSINAPI um pacote Python diff --git a/autosinapi.egg-info/PKG-INFO b/autosinapi.egg-info/PKG-INFO deleted file mode 100644 index e318214..0000000 --- a/autosinapi.egg-info/PKG-INFO +++ /dev/null @@ -1,200 +0,0 @@ -Metadata-Version: 2.4 -Name: autosinapi -Version: 0.1 -Summary: Pacote para automação do SINAPI -Author-email: "Lucas Antonio M. Pereira" -Requires-Python: >=3.0 -Description-Content-Type: text/markdown -Requires-Dist: numpy -Requires-Dist: openpyxl -Requires-Dist: pandas -Requires-Dist: requests -Requires-Dist: setuptools -Requires-Dist: sqlalchemy -Requires-Dist: tqdm -Requires-Dist: typing -Dynamic: requires-python - -# AutoSINAPIpostgres - -Este repositório tem como objetivo o desenvolvimento open source de uma solução para captação, tratamento e inserção dos dados do SINAPI (Sistema Nacional de Pesquisa de Custos e Índices da Construção Civil) em um banco de dados PostgreSQL de forma estruturada, editável e atualizável de maneira autônoma. - -## Objetivos - -- Automatizar o download dos dados do SINAPI -- Tratar e organizar os dados para facilitar consultas e análises -- Inserir os dados em um banco PostgreSQL, permitindo edição e atualização recorrente -- Prover scripts e ferramentas para facilitar a manutenção e evolução do processo - -## Estrutura do Projeto - -```plaintext -├── sinap_webscraping.py # Script para download dos arquivos SINAPI -├── rastreador_xlsx.py # Ferramenta de análise de arquivos Excel -├── sql_sinapi_insert.py # Script de inserção no PostgreSQL -├── update_requirements.py # Atualizador de dependências -├── sql_access.secrets # Arquivo de configuração do banco (exemplo) -└── requirements.txt # Dependências do projeto -``` - -## Configuração Inicial - -### 1. Clone o repositório - -```bash -git clone https://github.com/seu-usuario/AutoSINAPIpostgres.git -cd AutoSINAPIpostgres -``` - -### 2. Configure o ambiente virtual Python - -```bash -python -m venv venv -.\venv\Scripts\activate -``` - -### 3. Instale as dependências - -```bash -python update_requirements.py # Gera requirements.txt atualizado -pip install -r requirements.txt -``` - -### 4. Configure o acesso ao PostgreSQL - -- Renomeie `sql_access.secrets.example` para `sql_access.secrets` -- Edite o arquivo com suas credenciais: - -```ini -DB_USER = 'seu_usuario' -DB_PASSWORD = 'sua_senha' -DB_HOST = 'localhost' -DB_PORT = '5432' -DB_NAME = 'sinapi' -DB_INITIAL_DB = 'postgres' -``` - -## Uso dos Scripts - -### 1. Download de Dados SINAPI - -O script `sinap_webscraping.py` automatiza o download dos arquivos do SINAPI: - -```bash -python sinap_webscraping.py -``` - -Você será solicitado a informar: - -- Ano (YYYY) -- Mês (MM) -- Tipo de planilha (familias_e_coeficientes, Manutenções, mao_de_obra, Referência) -- Formato (xlsx, pdf) - -### 2. Análise de Arquivos Excel - -O `rastreador_xlsx.py` analisa os arquivos Excel baixados: - -```bash -python rastreador_xlsx.py -``` - -O script irá: - -- Escanear os arquivos Excel no diretório -- Gerar relatório de células, linhas e colunas -- Salvar logs em formatos JSON e TXT - -### 3. Inserção no PostgreSQL - -O script `sql_sinapi_insert.py` processa e insere os dados no banco: - -```bash -python sql_sinapi_insert.py --arquivo_xlsx --tipo_base -``` - -Parâmetros disponíveis: - -- `--arquivo_xlsx`: Caminho do arquivo Excel -- `--tipo_base`: Tipo de dados (insumos, composicao, analitico) -- `--user`: Usuário do PostgreSQL (opcional, usa .secrets se não informado) -- `--password`: Senha do PostgreSQL (opcional, usa .secrets se não informado) -- `--host`: Host do PostgreSQL (opcional, usa .secrets se não informado) -- `--port`: Porta do PostgreSQL (opcional, usa .secrets se não informado) -- `--dbname`: Nome do banco (opcional, usa .secrets se não informado) - -## Estrutura do Banco de Dados - -O banco PostgreSQL é organizado em schemas por tipo de dados: - -- `insumos`: Preços e informações de insumos -- `composicoes`: Composições de serviços -- `analitico`: Dados analíticos detalhados - -## Troubleshooting - -### Erros Comuns - -1. Erro de conexão PostgreSQL: - - Verifique se o PostgreSQL está rodando - - Confirme as credenciais em `sql_access.secrets` - - Verifique se o banco e schemas existem - -2. Erro no download SINAPI: - - Verifique sua conexão com a internet - - Confirme se o arquivo existe no site da Caixa - - Verifique o formato do ano (YYYY) e mês (MM) - -3. Erro na análise Excel: - - Confirme se o arquivo não está aberto em outro programa - - Verifique se há permissão de leitura no diretório - -## Como contribuir - -1. Faça um fork deste repositório -2. Crie uma branch para sua feature ou correção -3. Envie um pull request detalhando as alterações propostas - -## Requisitos do Sistema - -- Python 3.8+ -- PostgreSQL 12+ -- Bibliotecas Python listadas em `requirements.txt` - -## Licença - -Este projeto é open source sob os termos da GNU General Public License, versão 3 (GPLv3). Isso significa que você pode utilizar, modificar e distribuir o projeto, inclusive para fins comerciais. Contudo, se você criar derivados ou incorporar este código em outros produtos e distribuí-los, estes também deverão estar sob licença GPLv3, garantindo assim que o código-fonte continue acessível aos usuários. - -## Contato - -Sugestões, dúvidas ou colaborações são bem-vindas via issues ou pull requests. - - -. -├── __pycache__ -│ ├── rastreador_xlsx.cpython-312.pyc -│ ├── sinap_webscraping.cpython-312.pyc -│ └── sinapi_utils.cpython-312.pyc -├── autosinapi.egg-info -│ ├── dependency_links.txt -│ ├── PKG-INFO -│ ├── requires.txt -│ ├── SOURCES.txt -│ └── top_level.txt -├── tools -│ ├── criando-app_2025_tratando-dados.ipynb -│ ├── criando-app.ipynb -│ ├── rastreador_xlsx.py -│ ├── sinap_webscraping_download_log.json -│ ├── sinap_webscraping.py -│ ├── sql_access.secrets -│ ├── sql_sinapi_insert_2024.py -│ └── sql_sinapi_insert.py -├── venv -├── __init__.py -├── .gitignore -├── README.md -├── requirements.txt -├── setup.py -├── sinapi_utils.py -└── update_requirements.py diff --git a/autosinapi/__init__.py b/autosinapi/__init__.py index e0c9378..c4083fa 100644 --- a/autosinapi/__init__.py +++ b/autosinapi/__init__.py @@ -1,68 +1,32 @@ """ -Interface pública do AutoSINAPI Toolkit. +AutoSINAPI: Um toolkit para automação de dados do SINAPI. + +Este arquivo é o ponto de entrada do pacote `autosinapi`. Ele define a interface +pública da biblioteca, expondo as principais classes e exceções para serem +utilizadas por outras aplicações. + +O `__all__` define explicitamente quais nomes são exportados quando um cliente +usa `from autosinapi import *`. """ -from typing import Dict, Any -from datetime import datetime -from .config import Config -from .core.downloader import Downloader -from .core.processor import Processor -from .core.database import Database -from .exceptions import AutoSINAPIError -def run_etl(db_config: Dict[str, Any], sinapi_config: Dict[str, Any], mode: str = 'server') -> Dict[str, Any]: - """ - Executa o pipeline ETL do SINAPI. - - Args: - db_config: Configurações do banco de dados - sinapi_config: Configurações do SINAPI - mode: Modo de operação ('server' ou 'local') - - Returns: - Dict com status da operação: - { - 'status': 'success' ou 'error', - 'message': Mensagem descritiva, - 'details': { - 'rows_processed': número de linhas, - 'tables_updated': lista de tabelas, - 'timestamp': data/hora da execução - } - } - - Raises: - AutoSINAPIError: Se houver qualquer erro no processo - """ - try: - # Valida configurações - config = Config(db_config, sinapi_config, mode) - - # Executa pipeline - with Downloader(config.sinapi_config, config.mode) as downloader: - # Tenta usar arquivo local primeiro, se fornecido na configuração - local_file = config.sinapi_config.get('input_file') - excel_file = downloader.get_sinapi_data(file_path=local_file) - - processor = Processor(config.sinapi_config) - data = processor.process(excel_file) - - with Database(config.db_config) as db: - table_name = f"sinapi_{config.sinapi_config['state'].lower()}" - db.save_data(data, table_name) - - return { - 'status': 'success', - 'message': 'Pipeline ETL executado com sucesso', - 'details': { - 'rows_processed': len(data), - 'tables_updated': [table_name], - 'timestamp': datetime.now().isoformat() - } - } - - except AutoSINAPIError as e: - return { - 'status': 'error', - 'message': str(e), - 'details': {} - } +__version__ = "0.1.0" # A ser gerenciado pelo setuptools-scm + +from autosinapi.config import Config +from autosinapi.core.database import Database +from autosinapi.core.downloader import Downloader +from autosinapi.core.processor import Processor +from autosinapi.exceptions import (AutoSinapiError, ConfigurationError, + DatabaseError, DownloadError, + ProcessingError) + +__all__ = [ + "Config", + "Database", + "Downloader", + "Processor", + "AutoSinapiError", + "ConfigurationError", + "DownloadError", + "ProcessingError", + "DatabaseError", +] diff --git a/autosinapi/config.py b/autosinapi/config.py index a81d137..c54c681 100644 --- a/autosinapi/config.py +++ b/autosinapi/config.py @@ -1,59 +1,70 @@ """ Módulo de configuração do AutoSINAPI. -Responsável por validar e gerenciar as configurações do sistema. + +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 Dict, Any + +from typing import Any, Dict + from .exceptions import ConfigurationError + class Config: """Gerenciador de configurações do AutoSINAPI.""" - - 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): + + 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 + ): """ Inicializa 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 """ self.mode = self._validate_mode(mode) self.db_config = self._validate_db_config(db_config) self.sinapi_config = self._validate_sinapi_config(sinapi_config) - + def _validate_mode(self, mode: str) -> str: """Valida o modo de operação.""" - if mode not in ('server', 'local'): + 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}") return config - + @property def is_server_mode(self) -> bool: """Retorna True se o modo for 'server'.""" - return self.mode == 'server' - + return self.mode == "server" + @property def is_local_mode(self) -> bool: """Retorna True se o modo for 'local'.""" - return self.mode == 'local' + return self.mode == "local" diff --git a/autosinapi/core/__init__.py b/autosinapi/core/__init__.py new file mode 100644 index 0000000..4743e8f --- /dev/null +++ b/autosinapi/core/__init__.py @@ -0,0 +1,13 @@ +""" +Pacote Core do AutoSINAPI. + +Este pacote contém os módulos centrais e especializados que executam as principais +tarefas do pipeline de ETL: + +- `downloader`: Responsável pelo download dos arquivos do SINAPI. +- `processor`: Responsável pelo processamento e transformação dos dados. +- `database`: Responsável pela interação com o banco de dados. + +O `__init__.py` vazio marca este diretório como um pacote Python, permitindo +que seus módulos sejam importados de forma organizada. +""" diff --git a/autosinapi/core/database.py b/autosinapi/core/database.py index d3b5e61..d1b6440 100644 --- a/autosinapi/core/database.py +++ b/autosinapi/core/database.py @@ -1,81 +1,400 @@ """ -Módulo responsável pelas operações de banco de dados. +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. + +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. """ -from typing import Dict, Any + +import logging +from typing import Any, Dict + import pandas as pd from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine -from .exceptions import DatabaseError + +from autosinapi.exceptions import DatabaseError + class Database: - """Classe responsável pelas operações de banco de dados.""" - def __init__(self, db_config: Dict[str, Any]): - """ - Inicializa a conexão com o banco de dados. - - Args: - db_config: Configurações do banco de dados - """ + 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._engine = self._create_engine() - + def _create_engine(self) -> Engine: - """Cria a engine do SQLAlchemy.""" try: - url = (f"postgresql://{self.config['user']}:{self.config['password']}" - f"@{self.config['host']}:{self.config['port']}" - f"/{self.config['database']}") + url = ( + f"postgresql://{self.config['user']}:{self.config['password']}" + f"@{self.config['host']}:{self.config['port']}" + f"/{self.config['database']}" + ) + 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']}" + ) return create_engine(url) except Exception as e: - raise DatabaseError(f"Erro ao criar conexão: {str(e)}") - - def save_data(self, data: pd.DataFrame, table_name: str) -> None: + 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") + + def create_tables(self): """ - Salva os dados no banco. - - Args: - data: DataFrame com os dados a serem salvos - table_name: Nome da tabela - - Raises: - DatabaseError: Se houver erro na operação + 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 = """ + 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; + """ + + ddl = """ + CREATE TABLE insumos ( + codigo INTEGER PRIMARY KEY, + descricao TEXT NOT NULL, + unidade VARCHAR, + classificacao TEXT, + status VARCHAR DEFAULT 'ATIVO' + ); + + CREATE TABLE composicoes ( + codigo INTEGER PRIMARY KEY, + descricao TEXT NOT NULL, + unidade VARCHAR, + grupo VARCHAR, + status VARCHAR DEFAULT 'ATIVO' + ); + + 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 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 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 + ); + + CREATE 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 + ); + + 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, + 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 + UNION ALL + SELECT + composicao_pai_codigo, + composicao_filho_codigo AS item_codigo, + 'COMPOSICAO' AS tipo_item, + coeficiente + FROM + composicao_subcomposicoes; """ 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 + for stmt in ddl.split(";"): + 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)}") + + 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. + """ + if data.empty: + self.logger.warning( + f"DataFrame para a tabela \'{table_name}\' está vazio. " + f"Nenhum dado será salvo." + ) + return + + if policy.lower() == "substituir": + 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": + self._append_data(data, table_name) + elif policy.lower() == "upsert": + pk_columns = kwargs.get("pk_columns") + if not pk_columns: + raise DatabaseError("Política 'upsert' requer 'pk_columns'.") + self._upsert_data(data, table_name, pk_columns) + else: + 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)." + ) + + with self._engine.connect() as conn: data.to_sql( - name=table_name, - con=self._engine, - if_exists='append', + 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; + """ + ) + + 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}." + ) + 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; + """ + conn.execute(text(insert_query)) + 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)}" + ) + + 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""" + ) + + with self._engine.connect() as conn: + trans = conn.begin() + try: + conn.execute(delete_query, {"ref": f"{year}-{month}"}) + data.to_sql(name=table_name, con=conn, if_exists="append", index=False) + trans.commit() + except Exception as e: + trans.rollback() + raise DatabaseError(f"Erro ao substituir dados: {str(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}'." + ) + + 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]) + pk_cols_str = ", ".join(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}; + """ + + trans = conn.begin() + try: + conn.execute(text(query)) + 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)}") + + 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}") + try: + with self._engine.connect() as conn: + trans = conn.begin() + conn.execute( + text(f'TRUNCATE TABLE "{table_name}" RESTART IDENTITY CASCADE') + ) + trans.commit() except Exception as e: - raise DatabaseError(f"Erro ao salvar dados: {str(e)}") - + trans.rollback() + raise DatabaseError(f"Erro ao truncar a tabela {table_name}: {str(e)}") + def execute_query(self, query: str, params: Dict[str, Any] = None) -> pd.DataFrame: - """ - Executa uma query no banco. - - Args: - query: Query SQL - params: Parâmetros da query - - Returns: - DataFrame: Resultado da query - - Raises: - DatabaseError: Se houver erro na execução - """ try: with self._engine.connect() as conn: 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)}") - + + 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() + result = conn.execute(text(query), params or {}) + trans.commit() + 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)}") + def __enter__(self): - """Permite uso do contexto 'with'.""" return self - + def __exit__(self, exc_type, exc_val, exc_tb): - """Fecha a conexão ao sair do contexto.""" self._engine.dispose() diff --git a/autosinapi/core/downloader.py b/autosinapi/core/downloader.py index 061b4d1..8a9ffae 100644 --- a/autosinapi/core/downloader.py +++ b/autosinapi/core/downloader.py @@ -1,37 +1,37 @@ """ -Módulo responsável pelo download e gerenciamento dos arqu def _download_file(self, save_path: Optional[Path] = None) -> BinaryIO: - """ - Realiza o download do arquivo SINAPI do servidor. - - 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 - """ +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. + +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. """ -from typing import Dict, Optional, BinaryIO, Union -import requests + from io import BytesIO from pathlib import Path +from typing import BinaryIO, Dict, Optional, Union + +import requests + 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): """ Inicializa o downloader. - + Args: sinapi_config: Configurações do SINAPI mode: Modo de operação ('server' ou 'local') @@ -39,49 +39,51 @@ def __init__(self, sinapi_config: Dict[str, str], mode: str): self.config = sinapi_config self.mode = mode self._session = requests.Session() - - def get_sinapi_data(self, - file_path: Optional[Union[str, Path]] = None, - save_path: Optional[Path] = None) -> BinaryIO: + + def get_sinapi_data( + self, + file_path: Optional[Union[str, Path]] = None, + save_path: Optional[Path] = None, + ) -> 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: return self._read_local_file(file_path) return self._download_file(save_path) - + def _read_local_file(self, file_path: Union[str, Path]) -> BinaryIO: """Lê um arquivo XLSX local.""" 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(f"Formato inválido. Use arquivos .xlsx ou .xls") + if path.suffix.lower() not in {".xlsx", ".xls"}: + raise ValueError("Formato inválido. Use arquivos .xlsx ou .xls") return BytesIO(path.read_bytes()) except Exception as e: 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 """ @@ -89,27 +91,45 @@ def _download_file(self, save_path: Optional[Path] = None) -> BinaryIO: url = self._build_url() response = self._session.get(url, timeout=30) response.raise_for_status() - + content = BytesIO(response.content) - - if self.mode == 'local' and save_path: + + if self.mode == "local" and save_path: save_path.write_bytes(response.content) - + return content - + except requests.RequestException as e: raise DownloadError(f"Erro no download: {str(e)}") - + def _build_url(self) -> str: - """Constrói a URL do arquivo SINAPI.""" - # TODO: Implementar a lógica de construção da URL - base_url = "https://www.caixa.gov.br/Downloads/sinapi-..." - return base_url - + """ + 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) + + # Determina o tipo de planilha + tipo = self.config.get("type", "REFERENCIA").upper() + if tipo not in ["REFERENCIA", "DESONERADO"]: + 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" + + 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() diff --git a/autosinapi/core/processor.py b/autosinapi/core/processor.py index 235c071..45e7608 100644 --- a/autosinapi/core/processor.py +++ b/autosinapi/core/processor.py @@ -1,61 +1,504 @@ """ -Módulo responsável pelo processamento dos dados SINAPI. +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`. + +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. """ -from typing import Dict, Any, BinaryIO + +import logging +import re +import unicodedata +from pathlib import Path +from typing import Any, Dict, List, Tuple + import pandas as pd -from io import BytesIO -from .exceptions import ProcessingError + +from ..exceptions import ProcessingError + +# Configuração do logger para este módulo +logger = logging.getLogger(__name__) + class Processor: - """Classe responsável pelo processamento dos dados SINAPI.""" - def __init__(self, sinapi_config: Dict[str, Any]): - """ - Inicializa o processador. - - Args: - sinapi_config: Configurações do SINAPI - """ self.config = sinapi_config - - def process(self, excel_file: BinaryIO) -> pd.DataFrame: - """ - Processa o arquivo Excel do SINAPI. - - Args: - excel_file: Arquivo Excel em memória - - Returns: - DataFrame: Dados processados - - Raises: - ProcessingError: Se houver erro no processamento - """ + self.logger = logger + self.logger.info("[__init__] 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}" + ) + + def normalize_text(text_val): + s = str(text_val).strip() + s = "".join( + c + for c in unicodedata.normalize("NFD", s) + if unicodedata.category(c) != "Mn" + ) + s = re.sub( + r"[^A-Z0-9_]", "", s.upper().replace(" ", "_").replace("\n", "_") + ) + return s + + for i, row in df.iterrows(): + if i > 20: # Limite de busca para evitar varrer o arquivo inteiro + self.logger.warning( + "[_find_header_row] Limite de busca por cabeçalho (20 linhas)" + "atingido. Cabeçalho não encontrado." + ) + break + + try: + row_values = [ + str(cell) if pd.notna(cell) else "" for cell in row.values + ] + normalized_row_values = [normalize_text(cell) for cell in row_values] + 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}" + ) + + if all(nk in row_str for nk in normalized_keywords): + self.logger.info( + f"[_find_header_row] Cabeçalho encontrado na linha {i}." + ) + 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}", + exc_info=True, + ) + continue + + self.logger.error( + f"[_find_header_row] Cabeçalho com as keywords {keywords} " + f"não foi encontrado." + ) + return None + + def _normalize_cols(self, df: pd.DataFrame) -> pd.DataFrame: + self.logger.debug("[_normalize_cols] Normalizando nomes das colunas...") + new_cols = {} + for col in df.columns: + s = str(col).strip() + s = "".join( + c + for c in unicodedata.normalize("NFD", s) + if unicodedata.category(c) != "Mn" + ) + s = s.upper() + s = re.sub(r"[\s\n]+", "_", s) + s = re.sub(r"[^A-Z0-9_]", "", s) + new_cols[col] = s + + self.logger.debug( + f"[_normalize_cols] 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}" + ) + + 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." + ) + return pd.DataFrame(columns=id_vars + ["uf", value_name]) + + self.logger.debug( + f"[_unpivot_data] 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 + ) + 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()}" + ) + 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", + } + 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}" + ) + 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}" + ) + try: + df_raw = pd.read_excel(xlsx_path, sheet_name=0, header=None) + header_row = self._find_header_row( + df_raw, ["REFERENCIA", "TIPO", "CODIGO", "DESCRICAO", "MANUTENCAO"] + ) + 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 = self._normalize_cols(df) + + col_map = { + "REFERENCIA": "data_referencia", + "TIPO": "tipo_item", + "CODIGO": "item_codigo", + "DESCRICAO": "descricao_item", + "MANUTENCAO": "tipo_manutencao", + } + 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" + ).dt.date + df["item_codigo"] = pd.to_numeric( + df["item_codigo"], errors="coerce" + ).astype("Int64") + 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." + ) + 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}", + exc_info=True, + ) + raise ProcessingError(f"Erro em 'process_manutencoes': {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}" + ) try: - # Lê o arquivo Excel - df = pd.read_excel(excel_file) - - # Aplica transformações - df = self._clean_data(df) - df = self._transform_data(df) - df = self._validate_data(df) - - return df - + 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, + ) + if not sheet_SINAPI_name: + raise ProcessingError( + f"Aba 'Analítico' 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) + df = self._normalize_cols(df) + + subitens = df[ + df["TIPO_ITEM"].str.upper().isin(["INSUMO", "COMPOSICAO"]) + ].copy() + + subitens["composicao_pai_codigo"] = pd.to_numeric( + subitens["CODIGO_DA_COMPOSICAO"], errors="coerce" + ).astype("Int64") + subitens["item_codigo"] = pd.to_numeric( + subitens["CODIGO_DO_ITEM"], errors="coerce" + ).astype("Int64") + subitens["tipo_item"] = subitens["TIPO_ITEM"].str.upper().str.strip() + subitens["coeficiente"] = pd.to_numeric( + subitens["COEFICIENTE"].astype(str).str.replace(",", "."), + errors="coerce", + ) + subitens.rename( + columns={"DESCRICAO": "item_descricao", "UNIDADE": "item_unidade"}, + inplace=True, + ) + + subitens.dropna( + subset=["composicao_pai_codigo", "item_codigo", "tipo_item"], + inplace=True, + ) + subitens = subitens.drop_duplicates( + subset=["composicao_pai_codigo", "item_codigo", "tipo_item"] + ) + + insumos_df = subitens[subitens["tipo_item"] == "INSUMO"] + composicoes_df = subitens[subitens["tipo_item"] == "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." + ) + + composicao_insumos = insumos_df[ + ["composicao_pai_codigo", "item_codigo", "coeficiente"] + ].rename(columns={"item_codigo": "insumo_filho_codigo"}) + composicao_subcomposicoes = composicoes_df[ + ["composicao_pai_codigo", "item_codigo", "coeficiente"] + ].rename(columns={"item_codigo": "composicao_filho_codigo"}) + + parent_composicoes_df = df[ + df["CODIGO_DA_COMPOSICAO"].notna() + & ~df["TIPO_ITEM"].str.upper().isin(["INSUMO", "COMPOSICAO"]) + ].copy() + parent_composicoes_df = parent_composicoes_df.rename( + columns={ + "CODIGO_DA_COMPOSICAO": "codigo", + "DESCRICAO": "descricao", + "UNIDADE": "unidade", + } + ) + parent_composicoes_df = parent_composicoes_df[ + ["codigo", "descricao", "unidade"] + ].drop_duplicates(subset=["codigo"]) + + child_item_details = subitens[ + ["item_codigo", "tipo_item", "item_descricao", "item_unidade"] + ].copy() + child_item_details.rename( + columns={ + "item_codigo": "codigo", + "tipo_item": "tipo", + "item_descricao": "descricao", + "item_unidade": "unidade", + }, + inplace=True, + ) + child_item_details = child_item_details.drop_duplicates( + subset=["codigo", "tipo"] + ) + + return { + "composicao_insumos": composicao_insumos, + "composicao_subcomposicoes": composicao_subcomposicoes, + "parent_composicoes_details": parent_composicoes_df, + "child_item_details": child_item_details, + } except Exception as e: - raise ProcessingError(f"Erro no processamento: {str(e)}") - - def _clean_data(self, df: pd.DataFrame) -> pd.DataFrame: - """Remove dados inconsistentes e padroniza formatos.""" - # TODO: Implementar limpeza - return df - - def _transform_data(self, df: pd.DataFrame) -> pd.DataFrame: - """Aplica transformações nos dados.""" - # TODO: Implementar transformações - return df - - def _validate_data(self, df: pd.DataFrame) -> pd.DataFrame: - """Valida os dados processados.""" - # TODO: Implementar validações - return df + self.logger.error( + f"[process_composicao_itens] Falha crítica ao processar estrutura de composições. Erro: {e}", + exc_info=True, + ) + raise ProcessingError(f"Erro em 'process_composicao_itens': {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 + + 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_path = csv_dir / f"{process_key}.csv" + 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}.") + + 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() + + # Constrói o cabeçalho multi-nível e lê os dados + 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 + + 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 + + self.logger.warning(f"Não foi possível extrair custos da aba '{process_key}'.") + return pd.DataFrame(), pd.DataFrame() + + 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" + } + ) + self.logger.info( + f"Catálogo de insumos finalizado com {len(all_insumos)} registros únicos." + ) + if temp_composicoes: + all_composicoes = pd.concat( + temp_composicoes, ignore_index=True + ).drop_duplicates(subset=["CODIGO"]) + all_dfs["composicoes"] = all_composicoes.rename( + columns={ + "CODIGO": "codigo", "DESCRICAO": "descricao", "UNIDADE": "unidade" + } + ) + 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 + self.logger.info( + f"Tabela de preços mensais finalizada com {len(df_concat)} registros." + ) + if "custos_composicoes_mensal" in all_dfs: + df_concat = pd.concat(all_dfs["custos_composicoes_mensal"], ignore_index=True) + all_dfs["custos_composicoes_mensal"] = df_concat + self.logger.info( + f"Tabela de custos mensais finalizada com {len(df_concat)} registros." + ) + return all_dfs + + def process_catalogo_e_precos(self, xlsx_path: str) -> Dict[str, pd.DataFrame]: + self.logger.info( + f"Iniciando processamento completo de catálogos e preços de: {xlsx_path}" + ) + 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"), + } + temp_insumos, temp_composicoes = [], [] + + for sheet_name in xls.sheet_names: + process_key = next((k for k in sheet_map if k in sheet_name), None) + if not process_key: + continue + + try: + process_type, regime = sheet_map[process_key] + self.logger.info( + f"Processando aba: '{sheet_name}' (tipo: {process_type}, regime: {regime})" + ) + + long_df, catalogo_df = pd.DataFrame(), pd.DataFrame() + if process_type == "precos": + long_df, catalogo_df = self._process_precos_sheet(xls, sheet_name) + if not catalogo_df.empty: + temp_insumos.append(catalogo_df) + + elif process_type == "custos": + long_df, catalogo_df = self._process_custos_sheet( + xlsx_path, process_key + ) + 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 = ( + ("precos_insumos_mensal", "insumo_codigo") + if process_type == "precos" + else ("custos_composicoes_mensal", "composicao_codigo") + ) + long_df.rename(columns={"CODIGO": code}, inplace=True) + all_dfs.setdefault(table, []).append(long_df) + self.logger.info(f"Dados da aba '{sheet_name}' adicionados à chave '{table}'.") + + except Exception as e: + self.logger.error( + f"Falha CRÍTICA ao processar a aba '{sheet_name}'. " + f"Esta aba será ignorada. Erro: {e}", + exc_info=True, + ) + + return self._aggregate_final_dataframes(all_dfs, temp_insumos, temp_composicoes) + diff --git a/autosinapi/exceptions.py b/autosinapi/exceptions.py index 9cd575d..8d2b8ba 100644 --- a/autosinapi/exceptions.py +++ b/autosinapi/exceptions.py @@ -1,28 +1,41 @@ """ -Exceções customizadas para o AutoSINAPI Toolkit. -Todas as exceções são derivadas de AutoSINAPIError para facilitar o tratamento específico. +Módulo de exceções customizadas para o AutoSINAPI. + +Este arquivo define uma hierarquia de exceções customizadas para o projeto. +O uso de exceções específicas para cada tipo de erro (Configuração, Download, +Processamento, Banco de Dados) permite um tratamento de erros mais granular +e robusto por parte das aplicações que consomem este toolkit. + +A exceção base `AutoSinapiError` garante que todos os erros gerados pela +biblioteca possam ser capturados de forma unificada, se necessário. """ -class AutoSINAPIError(Exception): + +class AutoSinapiError(Exception): """Exceção base para todos os erros do AutoSINAPI.""" - pass -class DownloadError(AutoSINAPIError): - """Exceção levantada quando há problemas no download de arquivos SINAPI.""" pass -class ProcessingError(AutoSINAPIError): - """Exceção levantada quando há problemas no processamento das planilhas.""" + +class ConfigurationError(AutoSinapiError): + """Erro relacionado a configurações inválidas.""" + pass -class DatabaseError(AutoSINAPIError): - """Exceção levantada quando há problemas com operações no banco de dados.""" + +class DownloadError(AutoSinapiError): + """Erro durante o download de arquivos.""" + pass -class ConfigurationError(AutoSINAPIError): - """Exceção levantada quando há problemas com as configurações.""" + +class ProcessingError(AutoSinapiError): + """Erro durante o processamento dos dados.""" + pass -class ValidationError(AutoSINAPIError): - """Exceção levantada quando há problemas com validação de dados.""" + +class DatabaseError(AutoSinapiError): + """Erro relacionado a operações de banco de dados.""" + pass diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..2051d53 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,205 @@ +# Padrões de Contribuição do Projeto AutoSINAPI + +Este documento define as convenções de contribuição e nomenclatura a serem seguidas no desenvolvimento do projeto **AutoSINAPI**, garantindo consistência, legibilidade e manutenibilidade do código. + +--- + +## 1. Versionamento Semântico (SemVer) + +O versionamento do projeto segue o padrão Semantic Versioning 2.0.0. O formato da versão é `MAJOR.MINOR.PATCH`. + +- **MAJOR**: Incrementado para mudanças incompatíveis com versões anteriores (breaking changes). +- **MINOR**: Incrementado para adição de novas funcionalidades de forma retrocompatível. +- **PATCH**: Incrementado para correções de bugs de forma retrocompatível. + +### Regra Especial para Versões Iniciais (0.x.y) + +Enquanto o projeto estiver na versão Major `0`, a API é considerada instável. Neste estágio: +- Mudanças que quebram a compatibilidade (`breaking changes`) incrementam a versão **MINOR** (ex: de `v0.1.0` para `v0.2.0`). +- Novas funcionalidades ou correções de bugs que **não** quebram a compatibilidade incrementam a **PATCH** (ex: de `v0.1.0` para `v0.1.1`). + +A transição para a versão `1.0.0` marcará o primeiro lançamento estável do projeto. + +### Versões de Pré-lançamento (Alpha/Beta) + +Para versões que não estão prontas para produção, como fases de teste alfa e beta, utilizamos identificadores de pré-lançamento. O versionamento **não** recomeça após o identificador. + +- **Formato**: `MAJOR.MINOR.PATCH-identificador.N` (ex: `0.2.0-alpha.1`). +- **Alpha**: Versão em desenvolvimento inicial, potencialmente instável e para testes internos. Formato: `MAJOR.MINOR.PATCH-alpha.N` (ex: `1.2.0-alpha.1` ou `0.0.1-alpha.1`). +- **Beta**: Versão com funcionalidades completas, em fase de testes para um público restrito. Formato: `MAJOR.MINOR.PATCH-beta.N` (ex: `1.2.0-beta.1` ou `0.0.1-beta.1`). + +O `N` é um número sequencial que se inicia em `1` para cada nova build de pré-lançamento. + +**Exemplos:** +- `v0.1.0-alpha.1`: Primeira build de testes para a versão `0.1.0`. +- `v0.1.0-alpha.2`: Segunda build de testes para a versão `0.1.0`. +- `v0.2.0-alpha.1`: Primeira build de testes para a versão `0.2.0`, que inclui breaking changes em relação à `v0.1.x`. +- `v0.1.0-beta.1`: Pré-lançamento de testes para comunidade. +- `v1.0.0`: O primeiro lançamento estável. +- `v1.1.0`: Adição de suporte para um novo formato de planilha SINAPI (funcionalidade nova). +- `v1.1.1`: Correção de um bug no processamento de dados de insumos (correção de bug). +- `v2.0.0`: Mudança na estrutura do banco de dados que exige migração manual (breaking change). + +--- + +## 2. Nomenclatura de Branches (Git) + +Adotamos um fluxo de trabalho baseado no Git Flow simplificado para organizar o desenvolvimento. + +- **`main`**: Contém o código estável e de produção. Apenas merges de `release` ou `hotfix` são permitidos. + +- **`develop`**: Branch principal de desenvolvimento. Contém as últimas funcionalidades e correções que serão incluídas na próxima versão. + +- **`feature/`**: Para o desenvolvimento de novas funcionalidades. + - Criada a partir de `develop`. + - Exemplo: `feature/processar-planilha-insumos` ou `postgres_data-define` para features mais complexas. + - Após a conclusão, deve ser mesclada em `develop`. + +- **`fix/`**: Para correções de bugs não críticos. + - Criada a partir de `develop`. + - Exemplo: `fix/ajuste-parser-valor-monetario` + - Após a conclusão, deve ser mesclada em `develop`. + +- **`hotfix/`**: Para correções críticas em produção. + - Criada a partir de `main`. + - Após a conclusão, deve ser mesclada em `main` e `develop`. + - Exemplo: `hotfix/permissao-acesso-negada` + +- **`release/`**: Para preparar uma nova versão de produção (testes finais, atualização de documentação). + - Criada a partir de `develop`. + - Exemplo: `release/v1.2.0` + - Após a conclusão, deve ser mesclada em `main` e `develop`. + +--- + +## 3. Mensagens de Commit + +Utilizamos o padrão Conventional Commits para padronizar as mensagens de commit. + +**Formato:** `(): ` + +- **``**: + - `feat`: Uma nova funcionalidade. + - `fix`: Uma correção de bug. + - `docs`: Alterações na documentação. + - `style`: Alterações de formatação de código (espaços, ponto e vírgula, etc.). + - `refactor`: Refatoração de código que não altera a funcionalidade externa. + - `test`: Adição ou correção de testes. + - `chore`: Manutenção de build, ferramentas auxiliares, etc. + +- **`` (opcional)**: Onde a mudança ocorreu (ex: `import`, `settings`, `charts`). + +**Exemplos:** + +- `feat(parser): adiciona processamento de planilhas de composições` +- `fix(database): corrige tipo de dado da coluna de preço unitário` +- `docs(readme): atualiza instruções de instalação` +- `refactor(services): otimiza consulta de insumos no banco de dados` + +--- + +## 4. Fluxo de Desenvolvimento + +Para garantir um desenvolvimento organizado, eficiente e com alta qualidade, seguimos um fluxo de trabalho bem definido, que integra as convenções de nomenclatura de branches e commits já estabelecidas. + +### 4.1. Ciclo de Vida de uma Funcionalidade/Correção + +1. **Criação da Branch:** + * Para novas funcionalidades: Crie uma branch `feature/` a partir de `develop`. + * Para correções de bugs não críticos: Crie uma branch `fix/` a partir de `develop`. + * Para correções críticas em produção: Crie uma branch `hotfix/` a partir de `main`. + +2. **Desenvolvimento e Commits:** + * Desenvolva a funcionalidade ou correção na sua branch dedicada. + * Realize commits frequentes e atômicos, seguindo o padrão de [Mensagens de Commit](#3-mensagens-de-commit). Cada commit deve representar uma mudança lógica única e completa. + +3. **Testes Locais:** + * Antes de abrir um Pull Request, certifique-se de que todos os testes locais (unitários e de integração) estão passando. + * Execute os linters e formatadores de código para garantir a conformidade com os padrões do projeto. + +4. **Pull Request (PR):** + * Quando a funcionalidade ou correção estiver completa e testada localmente, abra um Pull Request da sua branch (`feature`, `fix`, `hotfix`) para a branch `develop` (ou `main` para `hotfix`). + * Utilize o template de Pull Request (`.github/pull_request_template.md`) para fornecer todas as informações necessárias, facilitando a revisão do código. + * Descreva claramente as mudanças, o problema que resolve (se for um bug) e como testar. + +5. **Revisão de Código e Merge:** + * Aguarde a revisão do código por outro(s) membro(s) da equipe. + * Enderece quaisquer comentários ou solicitações de alteração. + * Após a aprovação, a PR será mesclada na branch de destino (`develop` ou `main`). + +### 4.2. Gerenciamento de Releases (Novo Fluxo) + +O processo de release é **semi-automatizado** para combinar a eficiência da automação com o controle manual da comunicação. Um rascunho de release é atualizado continuamente e a publicação final é feita manualmente. + +1. **Desenvolvimento e Atualização Automática do Rascunho:** + * Durante o ciclo de desenvolvimento, cada vez que um Pull Request é mesclado na branch `develop`, a automação (`.github/workflows/draft-release.yml`) atualiza um **rascunho de release** na página de "Releases" do GitHub, categorizando as mudanças (`feat`, `fix`, etc.) automaticamente. + +2. **Preparação para Lançamento (Branch `release`):** + * Quando um conjunto de funcionalidades em `develop` está pronto para ser lançado, crie uma branch `release/` (ex: `release/v0.2.0-alpha.1`). + * Nesta branch, realize apenas ajustes finais, como atualização do `CHANGELOG.md` e da documentação. Após a conclusão, mescle-a em `main` e `develop`. + +3. **Edição e Publicação da Release (Passo Manual Crucial):** + * Com o código final em `main`, navegue até a seção **"Releases"** do repositório no GitHub. + * Encontre o rascunho que foi preparado automaticamente (ele terá o título "Draft"). + * Clique em "Edit". No formulário de edição: + * **Crie a tag:** No campo "Choose a tag", digite a nova tag de versão (ex: `v0.2.0-alpha.1`). + * **Escreva a "Copy":** Edite o título e o corpo da release, adicionando a sua comunicação, explicando o valor das mudanças e orientando os usuários. + * **Publique:** Clique em **"Publish release"**. + +4. **Construção e Upload Automatizados:** + * O ato de publicar a release no passo anterior **cria e envia a tag** para o repositório. + * Este push da tag dispara o segundo workflow (`.github/workflows/release.yml`), que irá construir os pacotes Python (`.whl` e `.tar.gz`) e anexá-los à release que você acabou de publicar. A publicação no PyPI permanece desativada por padrão. + +--- + +## 5. Ferramentas de Automação e Templates + +Para otimizar o fluxo de trabalho e garantir a padronização, utilizamos as seguintes ferramentas e templates no diretório `.github/` do projeto. + +### 5.1. `release-drafter.yml` e `workflows/draft-release.yml` + +- **Finalidade:** Gerenciamento automático do rascunho de release. +- **O que faz:** + * A cada merge na branch `develop`, a action `Release Drafter` é executada. + * Ela analisa os commits do Pull Request mesclado. + * Atualiza um único rascunho de release ("Draft") na página de "Releases", organizando as mudanças em categorias (`Novas Funcionalidades`, `Correções de Bugs`, etc.) com base nos padrões de Conventional Commits. +- **Benefícios:** + * Automatiza a coleta e organização do changelog, garantindo que nenhuma mudança seja esquecida. + * Prepara 90% do trabalho da release antes mesmo da decisão de lançar, economizando tempo e esforço manual. + * Mantém um panorama sempre atualizado do que entrará na próxima versão. + +### 5.2. `workflows/release.yml` + +- **Finalidade:** Construção e publicação dos pacotes (artefatos) da release. +- **O que faz:** + * É disparado **apenas quando uma nova tag de versão** (ex: `v0.2.0-alpha.1`) é criada e enviada ao repositório. + * Constrói os pacotes Python distribuíveis (`.whl` e `.tar.gz`). + * Anexa esses pacotes como artefatos à release do GitHub correspondente à tag. +- **Benefícios:** + * Garante que toda release publicada tenha os pacotes corretos e construídos de forma consistente. + * Reduz erros manuais no processo de build e upload. + +### 5.3. `pull_request_template.md` + +- **Finalidade:** Template padrão para a abertura de Pull Requests (PRs). +- **O que faz:** + * Preenche automaticamente a descrição de uma nova PR com seções pré-definidas. + * Guia o contribuidor a fornecer informações essenciais, como a descrição das mudanças, como testar e um checklist de verificação. +- **Benefícios:** + * Padroniza a comunicação em todas as PRs. + * Agiliza o processo de revisão de código, pois os revisores recebem todas as informações de forma clara e estruturada. + * Melhora a qualidade geral das contribuições. + +--- + +## 6. Nomenclatura no Código + +As nomenclaturas devem ser claras e descritivas, refletindo a funcionalidade e o propósito do código. + +### 6.1 Python (FastAPI) + +- **Módulos e Classes**: `PascalCase` (ex: `SinapiParser`, `DatabaseManager`). +- **Variáveis e Funções**: `snake_case` (ex: `file_data`, `process_spreadsheet`). +- **Constantes**: `UPPER_SNAKE_CASE` (ex: `API_VERSION`, `DB_CONNECTION_STRING`). +- **Arquivos**: `snake_case` (ex: `sinapi_parser.py`, `main.py`). +- **Pacotes**: O código deve ser organizado em pacotes e módulos lógicos (ex: `app.services`, `app.models`, `app.routers`). diff --git a/docs/DataModel.md b/docs/DataModel.md new file mode 100644 index 0000000..2dbdaf1 --- /dev/null +++ b/docs/DataModel.md @@ -0,0 +1,242 @@ +# **Modelo de Dados e ETL para o Módulo SINAPI** + +## 1\. Introdução + +### 1.1. Objetivo + +Este documento detalha a arquitetura de dados e o processo de **ETL (Extração, Transformação e Carga)** para a criação de um módulo Python OpenSource. O objetivo é processar os arquivos mensais do **SINAPI**, consolidando os dados em um banco de dados **PostgreSQL** de forma robusta, normalizada e com total rastreabilidade histórica. + +A estrutura resultante permitirá que a comunidade de engenharia e arquitetura realize consultas complexas para orçamentação, planejamento e análise histórica, seja através de uma `API` ou acessando o banco de dados localmente. + +### 1.2. Visão Geral das Fontes de Dados + +O ecossistema de dados do SINAPI é composto por dois arquivos principais, que devem ser processados em conjunto para garantir a consistência e a integridade do banco de dados: + +1. **`SINAPI_Referência_AAAA_MM.xlsx`**: Arquivo principal contendo os catálogos de preços, custos e a estrutura analítica das composições para o mês de referência. +2. **`SINAPI_manutencoes_AAAA_MM.xlsx`**: Arquivo de suporte que detalha todo o histórico de alterações (ativações, desativações, mudanças de descrição) dos insumos e composições. É a fonte da verdade para o ciclo de vida de cada item. + +## 2\. Modelo de Dados Relacional (PostgreSQL) + +O modelo é projetado para máxima integridade, performance e clareza, separando entidades de catálogo, dados de série histórica, suas relações estruturais e o histórico de eventos. + +### 2.1. Catálogo (Entidades Principais) + +Armazenam a descrição única e o **estado atual** de cada insumo e composição. + +#### Tabela `insumos` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `codigo` | `INTEGER` | **Chave Primária (PK)** | +| `descricao` | `TEXT` | Descrição completa do insumo. | +| `unidade` | `VARCHAR` | Unidade de medida (UN, M2, M3, KG). | +| `classificacao` | `TEXT` | Classificação hierárquica do insumo. | +| `status` | `VARCHAR` | `'ATIVO'` ou `'DESATIVADO'`. **Controlado pelo ETL de manutenções**. | + +#### Tabela `composicoes` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `codigo` | `INTEGER` | **Chave Primária (PK)** | +| `descricao` | `TEXT` | Descrição completa da composição. | +| `unidade` | `VARCHAR` | Unidade de medida (UN, M2, M3). | +| `grupo` | `VARCHAR` | Grupo ao qual a composição pertence. | +| `status` | `VARCHAR` | `'ATIVO'` ou `'DESATIVADO'`. **Controlado pelo ETL de manutenções**. | + +### 2.2. Dados Mensais (Série Histórica) + +Recebem novos registros a cada mês, construindo o histórico de preços e custos. + +#### Tabela `precos_insumos_mensal` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `insumo_codigo` | `INTEGER` | `FK` -\> `insumos.codigo` | +| `uf` | `CHAR(2)` | Unidade Federativa. | +| `data_referencia` | `DATE` | Primeiro dia do mês de referência. | +| `preco_mediano` | `NUMERIC` | Preço do insumo na UF/Data/Regime. | +| `regime` | `VARCHAR` | `'NAO_DESONERADO'`, `'DESONERADO'`, `'SEM_ENCARGOS'`. | +| **PK Composta** | | (`insumo_codigo`, `uf`, `data_referencia`, `regime`) | + +#### Tabela `custos_composicoes_mensal` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `composicao_codigo`| `INTEGER` | `FK` -\> `composicoes.codigo` | +| `uf` | `CHAR(2)` | Unidade Federativa. | +| `data_referencia` | `DATE` | Primeiro dia do mês de referência. | +| `custo_total` | `NUMERIC` | Custo da composição na UF/Data/Regime. | +| `regime` | `VARCHAR` | `'NAO_DESONERADO'`, `'DESONERADO'`, `'SEM_ENCARGOS'`. | +| **PK Composta** | | (`composicao_codigo`, `uf`, `data_referencia`, `regime`) | + +### 2.3. Estrutura das Composições (Relacionamentos) + +Modelam a estrutura hierárquica das composições. Devem ser totalmente recarregadas a cada mês para refletir a estrutura mais atual. + +#### Tabela `composicao_insumos` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `composicao_pai_codigo` | `INTEGER` | `FK` -\> `composicoes.codigo` | +| `insumo_filho_codigo` | `INTEGER` | `FK` -\> `insumos.codigo` | +| `coeficiente` | `NUMERIC` | Coeficiente de consumo do insumo. | +| **PK Composta** | | (`composicao_pai_codigo`, `insumo_filho_codigo`) | + +#### Tabela `composicao_subcomposicoes` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `composicao_pai_codigo` | `INTEGER` | `FK` -\> `composicoes.codigo` | +| `composicao_filho_codigo` | `INTEGER` | `FK` -\> `composicoes.codigo` | +| `coeficiente` | `NUMERIC` | Coeficiente de consumo da subcomposição. | +| **PK Composta** | | (`composicao_pai_codigo`, `composicao_filho_codigo`) | + +### 2.4. Histórico de Manutenções + +Esta tabela é o **log imutável** de todas as mudanças ocorridas nos itens do SINAPI. + +#### Tabela `manutencoes_historico` + +| Coluna | Tipo | Restrições/Descrição | +| :--- | :--- | :--- | +| `item_codigo` | `INTEGER` | Código do Insumo ou Composição. | +| `tipo_item` | `VARCHAR` | `'INSUMO'` ou `'COMPOSICAO'`. | +| `data_referencia` | `DATE` | Data do evento de manutenção (primeiro dia do mês). | +| `tipo_manutencao` | `TEXT` | Descrição da manutenção realizada (Ex: 'DESATIVAÇÃO'). | +| `descricao_item` | `TEXT` | Descrição do item no momento do evento. | +| **PK Composta** | | (`item_codigo`, `tipo_item`, `data_referencia`, `tipo_manutencao`) | + +### 2.5. Visão Unificada (View) para Simplificar Consultas + +Para facilitar a consulta de todos os itens de uma composição (sejam insumos ou subcomposições) sem a necessidade de acessar duas tabelas, uma `VIEW` deve ser criada no banco de dados. + +#### `vw_composicao_itens_unificados` + +```sql +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 +UNION ALL +SELECT + composicao_pai_codigo, + composicao_filho_codigo AS item_codigo, + 'COMPOSICAO' AS tipo_item, + coeficiente +FROM + composicao_subcomposicoes; +``` + +----- + +## 3\. Processo de ETL (Fluxo de Execução Detalhado) + +O fluxo de execução foi projetado para adotar uma abordagem **"Manutenções Primeiro"**, garantindo a máxima consistência dos dados. + +### 3.1. Parâmetros de Entrada + + * **Caminho do Arquivo de Referência:** `path/to/SINAPI_Referência_AAAA_MM.xlsx` + * **Caminho do Arquivo de Manutenções:** `path/to/SINAPI_manutencoes_AAAA_MM.xlsx` + * **Data de Referência:** Derivada do nome do arquivo (ex: `2025-07-01`). + * **String de Conexão com o Banco de Dados.** + +### **FASE 1: Processamento do Histórico de Manutenções** + +Esta fase estabelece a fonte da verdade sobre o status de cada item. + +1. **Extração:** + + * Carregar a planilha `Manutenções` do arquivo `SINAPI_manutencoes_AAAA_MM.xlsx`. + * **Atenção:** O cabeçalho está na linha 6, portanto, use `header=5` na leitura. + +2. **Transformação:** + + * Renomear as colunas para o padrão do banco de dados (ex: `Código` -\> `item_codigo`). + * Converter a coluna `Referência` (formato `MM/AAAA`) para um `DATE` válido (primeiro dia do mês, ex: `07/2025` -\> `2025-07-01`). + * Limpar e padronizar os dados textuais. + +3. **Carga:** + + * Inserir os dados transformados na tabela `manutencoes_historico`. + * Utilizar uma cláusula `ON CONFLICT DO NOTHING` na chave primária composta para evitar a duplicação de registros históricos caso o ETL seja re-executado. + +### **FASE 2: Sincronização de Status dos Catálogos** + +Esta fase utiliza os dados carregados na Fase 1 para atualizar o estado atual dos itens. + +1. **Lógica de Atualização:** Executar um script (em Python/SQL) que: + * Para cada item (`código`, `tipo`) presente na tabela `manutencoes_historico`, identifique a **manutenção mais recente** (última `data_referencia`). + * Verifique se o `tipo_manutencao` dessa última entrada indica uma desativação (ex: `tipo_manutencao ILIKE '%DESATIVAÇÃO%'`). + * Se for uma desativação, executar um `UPDATE` na tabela correspondente (`insumos` ou `composicoes`), ajustando o campo `status` para `'DESATIVADO'`. + +### **FASE 3: Processamento dos Dados de Referência (Preços, Custos e Estrutura)** + +Esta fase processa o arquivo principal do SINAPI, operando sobre catálogos cujo status já foi sincronizado. + +1. **Extração:** + + * Carregar as planilhas de referência (`ISD`, `ICD`, `ISE`, `CSD`, `CCD`, `CSE`, `Analítico`) do arquivo `SINAPI_Referência_AAAA_MM.xlsx`. + * **Atenção:** O cabeçalho dos dados começa na linha 9, portanto, use `header=9`. + +2. **Transformação:** + + * **Enriquecimento de Contexto (Regime):** Adicionar uma coluna `regime` a cada DataFrame de preço/custo, mapeando o nome da planilha para o valor (`'NAO_DESONERADO'`, `'DESONERADO'`, `'SEM_ENCARGOS'`). + * **Unpivot (Melt):** Transformar os DataFrames do formato "largo" (UFs em colunas) para o formato "longo" (UFs em linhas). + * **Consolidação:** Unir os DataFrames de mesmo tipo (insumos com insumos, composições com composições). + * **Separação dos Dados:** A partir dos DataFrames consolidados, criar os DataFrames finais para cada tabela de destino (`df_catalogo_insumos`, `df_precos_mensal`, etc.). + +3. **Carga (Ordem Crítica):** + + 1. **Carregar Catálogos (UPSERT):** + * Carregar `df_catalogo_insumos` na tabela `insumos` e `df_catalogo_composicoes` em `composicoes`. + * **Lógica:** Usar `ON CONFLICT (codigo) DO UPDATE SET descricao = EXCLUDED.descricao, ...`. + * **Importante:** Não atualizar a coluna `status` nesta etapa. Novos itens serão inseridos com o `status` default (`'ATIVO'`). + 2. **Recarregar Estrutura (TRUNCATE/INSERT):** + * Executar `TRUNCATE TABLE composicao_insumos, composicao_subcomposicoes;`. + * Inserir os novos DataFrames de estrutura. + . + 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 + +O modelo de dados permite a criação de endpoints poderosos e performáticos. + +#### Exemplo 1: Obter o custo de uma composição + + * **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. + +#### Exemplo 2: Explodir a estrutura completa de uma composição + + * **Endpoint:** `GET /composicao/{codigo}/estrutura` + * **Lógica:** Uma consulta (potencialmente recursiva) na `VIEW vw_composicao_itens_unificados` pode montar toda a árvore de dependências de uma composição. + +#### Exemplo 3: Rastrear o histórico de um insumo + + * **Endpoint:** `GET /insumo/{codigo}/historico` + * **Lógica:** Uma consulta direta na tabela `manutencoes_historico`, ordenada pela data de referência. + + + +```sql +SELECT * FROM manutencoes_historico +WHERE item_codigo = :codigo AND tipo_item = 'INSUMO' +ORDER BY data_referencia DESC; +``` + +--- + +## 5. Conclusão + +A adoção desta arquitetura de dados e fluxo de ETL resultará em um sistema: + +* **Robusto**: Capaz de lidar com a evolução dos dados do SINAPI ao longo do tempo. +* **Rastreável**: Mantém um histórico completo das alterações, fundamental para auditoria e análise comparativa. +* **Performático**: O modelo normalizado permite consultas rápidas e eficientes. +* **Flexível**: A estrutura suporta uma ampla gama de consultas, desde simples buscas de preço até análises complexas de planejamento. \ No newline at end of file diff --git a/docs/nomenclaturas.md b/docs/nomenclaturas.md deleted file mode 100644 index 81de9e7..0000000 --- a/docs/nomenclaturas.md +++ /dev/null @@ -1,124 +0,0 @@ -# Padrões de Nomenclatura do Projeto AutoSINAPI - -Este documento define as convenções de nomenclatura a serem seguidas no desenvolvimento do projeto **AutoSINAPI**, garantindo consistência, legibilidade e manutenibilidade do código. - ---- - -## 1. Versionamento Semântico (SemVer) - -O versionamento do projeto segue o padrão Semantic Versioning 2.0.0. O formato da versão é `MAJOR.MINOR.PATCH`. - -- **MAJOR**: Incrementado para mudanças incompatíveis com versões anteriores (breaking changes). -- **MINOR**: Incrementado para adição de novas funcionalidades de forma retrocompatível. -- **PATCH**: Incrementado para correções de bugs de forma retrocompatível. - -### Versões de Pré-lançamento (Alpha/Beta) - -Para versões que não estão prontas para produção, como fases de teste alfa e beta, utilizamos identificadores de pré-lançamento. - -- **Alpha**: Versão em desenvolvimento inicial, potencialmente instável e para testes internos. Formato: `MAJOR.MINOR.PATCH-alpha.N` (ex: `1.2.0-alpha.1` ou `0.0.1-alpha.1`). -- **Beta**: Versão com funcionalidades completas, em fase de testes para um público restrito. Formato: `MAJOR.MINOR.PATCH-beta.N` (ex: `1.2.0-beta.1` ou `0.0.1-beta.1`). - -O `N` é um número sequencial que se inicia em `1` para cada nova build de pré-lançamento. - -**Exemplos:** - -- `0.0.1-alpha.1`: Pré-lançamento inicial. -- `0.0.1-beta.1`: Pré-lançamento de testes. -- `1.0.0`: Lançamento inicial. -- `1.1.0`: Adição de suporte para um novo formato de planilha SINAPI (funcionalidade nova). -- `1.1.1`: Correção de um bug no processamento de dados de insumos (correção de bug). -- `2.0.0`: Mudança na estrutura do banco de dados que exige migração manual (breaking change). - ---- - -## 2. Nomenclatura de Branches (Git) - -Adotamos um fluxo de trabalho baseado no Git Flow simplificado para organizar o desenvolvimento. - -- **`main`**: Contém o código estável e de produção. Apenas merges de `release` ou `hotfix` são permitidos. -- **`develop`**: Branch principal de desenvolvimento. Contém as últimas funcionalidades e correções que serão incluídas na próxima versão. -- **`feature/`**: Para o desenvolvimento de novas funcionalidades. - - Criada a partir de `develop`. - - Exemplo: `feature/processar-planilha-insumos` ou `postgres_data-define` para features mais complexas. -- **`fix/`**: Para correções de bugs não críticos. - - Criada a partir de `develop`. - - Exemplo: `fix/ajuste-parser-valor-monetario` -- **`hotfix/`**: Para correções críticas em produção. - - Criada a partir de `main`. - - Após a conclusão, deve ser mesclada em `main` e `develop`. - - Exemplo: `hotfix/permissao-acesso-negada` -- **`release/`**: Para preparar uma nova versão de produção (testes finais, atualização de documentação). - - Criada a partir de `develop`. - - Exemplo: `release/v1.2.0` - ---- - -## 3. Mensagens de Commit - -Utilizamos o padrão Conventional Commits para padronizar as mensagens de commit. - -**Formato:** `(): ` - -- **``**: - - `feat`: Uma nova funcionalidade. - - `fix`: Uma correção de bug. - - `docs`: Alterações na documentação. - - `style`: Alterações de formatação de código (espaços, ponto e vírgula, etc.). - - `refactor`: Refatoração de código que não altera a funcionalidade externa. - - `test`: Adição ou correção de testes. - - `chore`: Manutenção de build, ferramentas auxiliares, etc. - -- **`` (opcional)**: Onde a mudança ocorreu (ex: `import`, `settings`, `charts`). - -**Exemplos:** - -- `feat(parser): adiciona processamento de planilhas de composições` -- `fix(database): corrige tipo de dado da coluna de preço unitário` -- `docs(readme): atualiza instruções de instalação` -- `refactor(services): otimiza consulta de insumos no banco de dados` - ---- - -## 4. Nomenclatura no Código - -### 4.1. CSS (Para clientes Frontend) - -- **Prefixo**: Todas as classes devem ser prefixadas com `as-` (AutoSINAPI) para evitar conflitos de estilo. -- **Metodologia**: BEM (Block, Element, Modifier). - - `as-bloco` - - `as-bloco__elemento` - - `as-bloco__elemento--modificador` - -**Exemplos:** - -- `.as-data-table` (Bloco) -- `.as-data-table__header` (Elemento) -- `.as-data-table__row--highlighted` (Modificador) - -### 4.2. Chaves de Internacionalização (I18n - se aplicável) - -As chaves de tradução devem seguir uma estrutura hierárquica para facilitar a organização. - -- **Padrão**: `auto_sinapi..` - -**Exemplos:** - -- `auto_sinapi.settings.title` -- `auto_sinapi.dashboard.tables.insumos.title` -- `auto_sinapi.errors.file_format_invalid` - -### 4.3. Python (FastAPI) - -- **Módulos e Classes**: `PascalCase` (ex: `SinapiParser`, `DatabaseManager`). -- **Variáveis e Funções**: `snake_case` (ex: `file_data`, `process_spreadsheet`). -- **Constantes**: `UPPER_SNAKE_CASE` (ex: `API_VERSION`, `DB_CONNECTION_STRING`). -- **Arquivos**: `snake_case` (ex: `sinapi_parser.py`, `main.py`). -- **Pacotes**: O código deve ser organizado em pacotes e módulos lógicos (ex: `app.services`, `app.models`, `app.routers`). - -### 4.4. JavaScript (Para clientes Frontend) - -- **Variáveis e Funções**: `camelCase` (ex: `totalAmount`, `initializeFilters`). -- **Classes**: `PascalCase` (ex: `ChartManager`). -- **Constantes**: `UPPER_SNAKE_CASE` (ex: `API_ENDPOINT`). -- **Nomes de Arquivos**: `kebab-case` ou `snake_case` (ex: `data-table.js` ou `data_table.js`). diff --git a/docs/workPlan.md b/docs/workPlan.md new file mode 100644 index 0000000..0874e68 --- /dev/null +++ b/docs/workPlan.md @@ -0,0 +1,160 @@ +# Plano de Trabalho e Roadmap do Módulo AutoSINAPI + +Este documento serve como um guia central para o desenvolvimento, acompanhamento e verificação das entregas do módulo `AutoSINAPI`. Ele define a arquitetura, a interface pública e o caminho a ser seguido. + +## 1. Objetivos e Entregas Principais + +O objetivo final é transformar o `AutoSINAPI` em uma biblioteca Python (`toolkit`) robusta, testável e desacoplada, pronta para ser consumida por outras aplicações, como uma API REST ou uma CLI. + +As entregas incluem: +- **Pipeline ETL**: Processamento completo de arquivos do SINAPI, aderente ao `DataModel.md`. +- **Cobertura de Testes**: Testes unitários e de integração automatizados. +- **Interface Pública**: Uma função `run_etl()` clara e padronizada. +- **Arquitetura Modular**: Código organizado em módulos com responsabilidades únicas (`downloader`, `processor`, `database`). +- **Documentação**: Manuais de uso, arquitetura e contribuição. + +## 2. Status Geral (Visão Macro) + +Use esta seção para um acompanhamento rápido do progresso geral. + +- [x] **Fase 1**: Refatoração do Módulo para Toolkit +- [x] **Fase 2**: Cobertura de Testes Unitários e de Integração +- [x] **Fase 3**: Documentação Profunda e Detalhada +- [ ] **Fase 4**: Empacotamento e Release Final +- [ ] **Fase 5**: Implementação da API e CLI (Pós-Toolkit) + +--- + +## 3. Visão Geral da Arquitetura + +A nossa arquitetura será baseada em **desacoplamento**. A API não executará o pesado processo de ETL diretamente. Em vez disso, ela atuará como um **controlador**, delegando a tarefa para um **trabalhador (worker)** em segundo plano. O módulo `AutoSINAPI` será o **toolkit** que o trabalhador utilizará. + +**Diagrama da Arquitetura:** + +``` ++-----------+ +----------------+ +----------------+ +---------------------+ +| | | | | | | API FastAPI | +| Usuário |---->| Kong Gateway |---->| (Controller) |---->| (Fila de Tarefas) | +| (Admin) | | (Auth & Proxy) | | POST /populate| | Ex: Redis | ++-----------+ +----------------+ +----------------+ +----------+----------+ + | + (Nova Tarefa) + | + v ++-------------------------------------------------+ +--------------------+----------+ +| AutoSINAPI Toolkit |<----| | +| (Biblioteca Python instalada via pip) | | Trabalhador (Celery Worker) | +| - Lógica de Download (em memória/disco) | | - Pega tarefa da fila | +| - Lógica de Processamento (pandas) | | - Executa a lógica do | +| - Lógica de Banco de Dados (SQLAlchemy) | | AutoSINAPI Toolkit | ++-------------------------------------------------+ +--------------------+----------+ + | + (Escreve os dados) + | + v + +--------------------+ + | | + | Banco de Dados | + | (PostgreSQL) | + +--------------------+ +``` + +----- + +## 4. O Contrato do Toolkit (Interface Pública) + +Para que o `AutoSINAPI` seja consumível por outras aplicações, ele deve expor uma interface clara e previsível. + +#### **Requisito 1: A Interface Pública do Módulo** + +O `AutoSINAPI` deverá expor, no mínimo, uma função principal, clara e bem definida. + +**Função Principal Exigida:** +`autosinapi.run_etl(db_config: dict, sinapi_config: dict, mode: str)` + + * **`db_config (dict)`**: Um dicionário contendo **toda** a informação de conexão com o banco de dados. A API irá montar este dicionário a partir das suas próprias variáveis de ambiente (`.env`). + ```python + # Exemplo de db_config que a API irá passar + db_config = { + "user": "admin", + "password": "senha_super_secreta", + "host": "db", + "port": 5432, + "dbname": "sinapi" + } + ``` + * **`sinapi_config (dict)`**: Um dicionário com os parâmetros da operação. A API também montará este dicionário. + ```python + # Exemplo de sinapi_config que a API irá passar + sinapi_config = { + "year": 2025, + "month": 8, + "workbook_type": "REFERENCIA", + "duplicate_policy": "substituir" + } + ``` + * **`mode (str)`**: O seletor de modo de operação. + * `'server'`: Ativa o modo de alta performance, com todas as operações em memória (bypass de disco). + * `'local'`: Usa o modo padrão, salvando arquivos em disco, para uso pela comunidade. + +#### **Requisito 2: Lógica de Configuração Inteligente (Sem Leitura de Arquivos)** + +Quando usado como biblioteca (`mode='server'`), o módulo `AutoSINAPI`: + + * **NÃO PODE** ler `sql_access.secrets` ou `CONFIG.json`. + * **DEVE** usar exclusivamente os dicionários `db_config` e `sinapi_config` passados como argumentos. + * Quando usado em modo `local`, ele pode manter a lógica de ler arquivos `CONFIG.json` para facilitar a vida do usuário final que o clona do GitHub. + +#### **Requisito 3: Retorno e Tratamento de Erros** + +A função `run_etl` deve retornar um dicionário com o status da operação e levantar exceções específicas para que a API possa tratar os erros de forma inteligente. + + * **Retorno em caso de sucesso:** + ```python + {"status": "success", "message": "Dados de 08/2025 populados.", "tables_updated": ["insumos_isd", "composicoes_csd"]} + ``` + * **Exceções:** O módulo deve definir e levantar exceções customizadas, como `autosinapi.exceptions.DownloadError` ou `autosinapi.exceptions.DatabaseError`. + +----- + +## 5. Roadmap de Desenvolvimento (Etapas Detalhadas) + +Este é o plano de ação detalhado, dividido em fases e tarefas. + +### Fase 1: Evolução do `AutoSINAPI` para um Toolkit + +Esta fase é sobre preparar o módulo para ser consumido pela nossa API. + + * **Etapa 1.1: Refatoração Estrutural:** Quebrar o `sinapi_utils.py` em módulos menores (`downloader.py`, `processor.py`, `database.py`) dentro de uma estrutura de pacote Python, como planejamos anteriormente. + * **Etapa 1.2: Implementar a Lógica de Configuração Centralizada:** Remover toda a leitura de arquivos de configuração de dentro das classes e fazer com que elas recebam suas configurações via construtor (`__init__`). + * **Etapa 1.3: Criar a Interface Pública:** Criar a função `run_etl(db_config, sinapi_config, mode)` que orquestra as chamadas para as classes internas. + + * **Etapa 1.3.1: Desacoplar as Classes (Injeção de Dependência):** Em vez de uma classe criar outra (ex: `self.downloader = SinapiDownloader()`), ela deve recebê-la como um parâmetro em seu construtor (`__init__(self, downloader)`). Isso torna o código muito mais flexível e testável. + * **Etapa 1.4: Implementar o Modo Duplo:** Dentro das classes `downloader` e `processor`, adicionar a lógica `if mode == 'server': ... else: ...` para lidar com operações em memória vs. em disco. + * **Etapa 1.5: Empacotamento:** Garantir que o módulo seja instalável via `pip` com um `setup.py` ou `pyproject.toml`. + + +### Fase 2: Criação e desenvolvimento dos testes unitários + +... + +### Fase 3: Documentação Profunda e Detalhada + +**Objetivo:** Realizar a documentação e registro de todos os elementos do módulo, adicionando "headers" de descrição em cada arquivo crucial para detalhar seu propósito, o fluxo de dados (como a informação é inserida, trabalhada e entregue) e como ele se integra aos objetivos gerais do AutoSINAPI. + +**Importância:** Contextualizar contribuidores, agentes de IA e ferramentas de automação para que possam utilizar e dar manutenção ao módulo da maneira mais eficiente possível. + +**Tarefas Principais:** + +- [ ] **Adicionar Cabeçalhos de Documentação:** Inserir um bloco de comentário no topo de cada arquivo `.py` do módulo `autosinapi` e `tools`, explicando o propósito do arquivo. +- [ ] **Revisar e Detalhar Docstrings:** Garantir que todas as classes e funções públicas tenham docstrings claras, explicando o que fazem, seus parâmetros e o que retornam. +- [ ] **Criar Documento de Fluxo de Dados:** Elaborar um novo documento no diretório `docs/` que mapeie o fluxo de dados de ponta a ponta, desde o download até a inserção no banco de dados. +- [ ] **Atualizar README.md:** Adicionar uma seção de "Arquitetura Detalhada" ao `README.md`, explicando como os componentes se conectam. + +--- + +## 6. Atualização e Correção dos Testes (Setembro 2025) + +**Objetivo:** Atualizar a suíte de testes para refletir a nova arquitetura do pipeline AutoSINAPI, garantindo que todos os testes passem e que a cobertura do código seja mantida ou ampliada. + +... \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index c53bbff..7223d44 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0"] +requires = ["setuptools>=61.0", "setuptools_scm"] build-backend = "setuptools.build_meta" [project] name = "autosinapi" -version = "0.1" +dynamic = ["version"] authors = [ {name = "Lucas Antonio M. Pereira", email = "contato@arqlamp.com"}, ] @@ -29,12 +29,16 @@ test = [ "pytest-cov>=4.0.0", ] +[tool.setuptools_scm] +# Habilita o uso do setuptools_scm + [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q --cov=autosinapi" +addopts = "-ra -q" testpaths = [ "tests", ] +pythonpath = ["."] [tool.coverage.run] source = ["autosinapi"] @@ -49,4 +53,4 @@ exclude_lines = [ "def __repr__", "if __name__ == .__main__.:", "raise NotImplementedError", -] +] \ No newline at end of file diff --git a/setup.py b/setup.py index a94bd10..1c22b87 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="autosinapi", - version="0.1", + # A versão agora é gerenciada pelo setuptools_scm packages=find_packages(where="."), package_dir={"": "."}, install_requires=[ @@ -12,6 +12,7 @@ 'requests', 'setuptools', 'sqlalchemy', + 'psycopg2-binary', # Driver para PostgreSQL 'tqdm', 'typing', 'pytest>=7.0.0', @@ -22,7 +23,7 @@ author="Lucas Antonio M. Pereira", author_email="contato@arqlamp.com", description="Toolkit para automação do SINAPI", - long_description=open('README.md').read(), + long_description=open('README.md', encoding='utf-8').read(), long_description_content_type="text/markdown", classifiers=[ "Development Status :: 4 - Beta", diff --git a/sinapi_utils.py b/sinapi_utils.py deleted file mode 100644 index b2348f5..0000000 --- a/sinapi_utils.py +++ /dev/null @@ -1,1662 +0,0 @@ -""" -Utilitários centralizados para o sistema SINAPI -Este módulo contém todas as funções e classes comuns utilizadas pelos outros módulos do sistema. -""" - -import logging -import unicodedata -import pandas as pd -import re -import os -import zipfile -import requests -import time -import numpy as np -from time import sleep -from datetime import datetime, timedelta -from typing import List, Dict, Optional, Any, Union -from pathlib import Path -import sqlalchemy -from sqlalchemy import create_engine, text, URL , make_url -from tqdm import tqdm -from openpyxl import load_workbook -import json -import random - -class SinapiLogger: - """Classe para gerenciar logs do sistema SINAPI""" - - def __init__(self, nome: str, level: str = 'info'): - """ - Inicializa o logger com nome e nível específicos - Args: - nome (str): Nome do logger - level (str): Nível de log ('debug', 'info', 'warning', 'error', 'critical') - """ - self.logger = logging.getLogger(nome) - self.configure(level) - - def configure(self, level: str = 'info') -> None: - """Configura o logger com o nível especificado""" - levels = { - 'debug': logging.DEBUG, - 'info': logging.INFO, - 'warning': logging.WARNING, - 'error': logging.ERROR, - 'critical': logging.CRITICAL - } - - self.logger.setLevel(levels.get(level.lower(), logging.INFO)) - - if self.logger.hasHandlers(): - self.logger.handlers.clear() - - handler = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - handler.setFormatter(formatter) - self.logger.addHandler(handler) - - def log(self, level: str, message: str) -> None: - """Registra uma mensagem no nível especificado""" - getattr(self.logger, level.lower())(message) - -class ExcelProcessor: - """Classe para processamento de arquivos Excel do SINAPI""" - - def __init__(self): - self.logger = SinapiLogger("ExcelProcessor") - - def scan_directory(self, diretorio: str = None, formato: str = 'xlsx', data: bool = False,sheet: dict=None,refuse_List: list = None) -> Dict: - """ - Escaneia um diretório em busca de arquivos Excel - Args: - diretorio (str): Caminho do diretório - formato (str): Formato dos arquivos ('xlsx', 'xls', etc) - data (bool): Se True, processa os dados das planilhas - sheet (dict): {sheet_name:sheet_path} Dicionário com as planilhas a terem os dados extraídos. - Returns: - Dict: Resultados do processamento - """ - if not diretorio: - diretorio = os.getcwd() - diretorio = Path(diretorio).resolve() - - self.logger.log('info', f'Escaneando o diretório: {diretorio}') - resultado = {} - - if refuse_List is None: - refuse_List = ['Analítico com Custo','ANALITICO_COM_CUSTO'] - - try: - for arquivo in os.listdir(diretorio): - if not arquivo.lower().endswith(formato.lower()): - continue - - caminho = diretorio / arquivo - self.logger.log('info', f'Verificando: {arquivo}') - - if data and sheet: - if data and isinstance(sheet, dict) and arquivo in list(sheet.keys()): - for sheetName in list(sheet.keys()): - if sheetName in refuse_List: - continue - else: - try: - self.logger.log('info', f' Processando: {arquivo}') - wb = load_workbook(caminho, read_only=True) - planilhas_info = [] - for nome_planilha in wb.sheetnames: - if nome_planilha in refuse_List: - continue - else: - ws = wb[nome_planilha] - dados = self.get_sheet_data(ws) - planilhas_info.append((nome_planilha, dados)) - resultado[arquivo] = planilhas_info - wb.close() - - except Exception as e: - self.logger.log('error', f'Erro ao processar {arquivo} no caminho "{sheet}" : {str(e)}\n {list(sheet.keys())}') - - elif data and not sheet: - try: - self.logger.log('info', f' Processando {arquivo}') - wb = load_workbook(caminho, read_only=True) - planilhas_info = [] - for nome_planilha in wb.sheetnames: - if nome_planilha in refuse_List: - continue - else: - ws = wb[nome_planilha] - dados = self.get_sheet_data(ws) - planilhas_info.append((nome_planilha, dados)) - resultado[arquivo] = planilhas_info - wb.close() - except Exception as e: - self.logger.log('error', f"Erro ao processar {arquivo}: {str(e)}") - - else: - self.logger.log('info', f'Coletando nome e caminho do arquivo: {arquivo}') - resultado[arquivo] = str(caminho) - - except Exception as e: - self.logger.log('error', f"Erro ao escanear diretório: {str(e)}") - - self.logger.log('info', f'Encontrados {len(resultado)} arquivos') - return resultado - - def get_sheet_data(self, ws) -> List[int]: - """ - Extrai informações básicas de uma planilha Excel - Args: - ws: Worksheet do openpyxl - Returns: - List[int]: [total_cells, n_rows, n_cols] - """ - if not any(ws.cell(row=1, column=col).value for col in [1,2]) and not ws.cell(row=2, column=1).value: - return [0, 0, 0] - - total_cells = 0 - for row in ws.iter_rows(min_row=ws.min_row, max_row=ws.max_row, - min_col=ws.min_column, max_col=ws.max_column, - values_only=True): - total_cells += sum(1 for cell in row if cell is not None and (not isinstance(cell, str) or cell.strip())) - - return [total_cells, ws.max_row - ws.min_row + 1, ws.max_column - ws.min_column + 1] - -class FileManager: - """Classe para gerenciamento de arquivos do SINAPI""" - - def __init__(self): - self.logger = SinapiLogger("FileManager") - - def normalize_text(self, text: str) -> str: - """ - Normaliza um texto removendo acentos e convertendo para maiúsculo - Args: - text (str): Texto a ser normalizado - Returns: - str: Texto normalizado - """ - if not text: - return '' - - text = str(text).strip().upper() - text = text.replace('\n', ' ') - text = unicodedata.normalize('NFKD', text).encode('ASCII', 'ignore').decode('utf-8') - return text.replace(' ', '_').replace('-', '_').replace(' ', ' ').strip() - - def normalize_files(self, path: Union[str, Path], extension: str = 'xlsx') -> List[str]: - """ - Normaliza nomes de arquivos em um diretório - Args: - path: Caminho do diretório - extension (str): Extensão dos arquivos - Returns: - List[str]: Lista de nomes normalizados - """ - path = Path(path) - extension = extension.strip().lower().lstrip('*.') - normalized_names = [] - - for file in path.glob(f'*.{extension}'): - self.logger.log('debug', f'Avaliando arquivo: {file}') - new_name = self.normalize_text(file.stem) + '.' + extension - new_path = file.parent / new_name - self.logger.log('debug', f'Novo nome para {file.name} normalizado: {new_name}') - - if file.resolve() != new_path.resolve(): - self.logger.log('debug', f'Caminho de origem e destino são diferentes. Tentando renomear.') - try: - file.rename(new_path) - self.logger.log('info', f'Arquivo renomeado: {file.name} -> {new_path.name}') - normalized_names.append(new_name) - except Exception as e: - self.logger.log('error', f'Erro ao renomear {file}: {str(e)}') - - except: - self.logger.log('warning', f'Não foi possível renomear {file.name} para {new_name} pois um arquivo com este nome já existe.') - normalized_names.append(new_path.name) - - else: - self.logger.log('debug', f'Arquivo {file.name} já está normalizado.') - normalized_names.append(file.name) - - self.logger.log('info', f'Nomes normalizados: {str(normalized_names).replace("[","").replace("]","").replace("\'","")}') - return normalized_names - - def read_sql_secrets(self, secrets_path: str) -> tuple: - """Lê credenciais SQL com logging""" - try: - with open(secrets_path, 'r') as f: - content = f.read() - - credentials = parse_secrets(content) - required_keys = {'DB_USER', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_NAME'} - - if not required_keys.issubset(credentials): - missing = required_keys - set(credentials.keys()) - raise ValueError(f"Credenciais incompletas. Faltando: {', '.join(missing)}") - - return ( - credentials['DB_USER'], - credentials['DB_PASSWORD'], - credentials['DB_HOST'], - credentials['DB_PORT'], - credentials['DB_NAME'], - credentials.get('DB_INITIAL_DB', 'postgres') - ) - except Exception as e: - self.logger.log('error', f"Erro ao ler secrets: {e}") - raise - -class DatabaseManager: - """Classe para gerenciar operações de banco de dados""" - - def __init__(self, connection_string: str, log_level: str = 'info'): - """ - Inicializa o gerenciador de banco de dados - Args: - connection_string: String de conexão SQLAlchemy - log_level: Nível de log - """ - self.engine = create_engine(connection_string) - self.logger = SinapiLogger("DatabaseManager", log_level) - - def log(self, level: str, message: str): - """Wrapper para padronização de logs""" - self.logger.log(level, f"[DB] {message}") - - @staticmethod - def create_database(admin_connection_string: str, db_name: str, logger=None) -> bool: - """ - Cria um banco de dados se não existir. - Args: - admin_connection_string (str): String de conexão com o banco de dados administrativo. - db_name (str): Nome do banco de dados a ser criado. - logger (optional): Objeto logger para registrar eventos. - Returns: - bool: True se o banco de dados foi criado com sucesso ou já existia, False se ocorreu um erro inesperado. - """ - if logger is None: - logger = SinapiLogger("DatabaseManager") - logger.log('info', f"Verificando/criando banco de dados '{db_name}' usando conexão administrativa...") - try: - admin_engine = create_engine(admin_connection_string) - with admin_engine.connect() as conn: - conn.execution_options(isolation_level="AUTOCOMMIT").execute( - text(f"CREATE DATABASE {db_name}") - ) - logger.log('info', f"Banco de dados '{db_name}' criado com sucesso.") - admin_engine.dispose() - return True - except sqlalchemy.exc.ProgrammingError as e: - if 'already exists' in str(e): - logger.log('info', f"Banco de dados '{db_name}' já existe.") - return True - logger.log('error', f"Erro ao criar banco de dados '{db_name}': {e}") - raise - except Exception as e: - logger.log('error', f"Erro inesperado ao criar banco de dados '{db_name}': {e}") - return False - - def create_schemas(self, schemas: List[str]) -> None: - """ - Cria esquemas se não existirem - Args: - schemas: Lista de esquemas - """ - self.log('info', "Verificando esquemas...") - for schema in schemas: - try: - with self.engine.connect() as conn: - conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}")) - conn.commit() - self.log('debug', f"Schema '{schema}' verificado") - except Exception as e: - self.log('error', f"Erro no schema '{schema}': {e}") - self.log('info', f"Esquemas verificados: {', '.join(schemas)}") - - def _infer_sql_types(self, df: pd.DataFrame) -> List[str]: - """Infere tipos SQL a partir dos dtypes do DataFrame""" - type_mapping = [] - for col_name, dtype in df.dtypes.items(): - if 'int' in str(dtype): - sql_type = "BIGINT" - elif 'float' in str(dtype): - sql_type = "NUMERIC(15,2)" - else: - sql_type = "TEXT" - type_mapping.append(sql_type) - return type_mapping - - def create_table(self, schema: str, table: str, df: pd.DataFrame = None, columns: List[str] = None, types: List[str] = None) -> None: - """ - Cria uma tabela se não existir. Pode usar inferência de tipos a partir de um DataFrame. - Args: - schema: Nome do esquema - table: Nome da tabela - df: DataFrame para inferir colunas e tipos (opcional) - columns: Lista de colunas (obrigatório se df não for fornecido) - types: Lista de tipos SQL (obrigatório se df não for fornecido) - """ - table_name = f"{schema}.{table}" - self.log('info', f"Verificando tabela '{table_name}'...") - - if df is not None: - # Modo inferência automática - sql_types = self._infer_sql_types(df) - col_defs = [f"{col} {typ}" for col, typ in zip(df.columns, sql_types)] - elif columns and types: - # Modo manual - col_defs = [f"{col} {typ}" for col, typ in zip(columns, types)] - if df is not None: - sql_types = self._infer_sql_types(df) - col_defs = [f'"{col}" {typ}' for col, typ in zip(df.columns, sql_types)] # Colunas com aspas - elif columns and types: - col_defs = [f'"{col}" {typ}' for col, typ in zip(columns, types)] # Colunas com aspas - else: - raise ValueError("Deve fornecer DataFrame ou colunas/tipos") - - ddl = f"CREATE TABLE IF NOT EXISTS {schema}.{table} ({', '.join(col_defs)})" - # USA A NOVA FUNÇÃO PARA COMANDOS DDL - self.execute_non_query(ddl) - self.log('info', f"Tabela '{table}' criada/verificada") - - def insert_data(self, schema: str, table: str, df: pd.DataFrame, batch_size: int = 1000) -> None: - """ - Insere dados em uma tabela com validação e progresso - Args: - schema: Nome do esquema - table: Nome da tabela - df: DataFrame com os dados - batch_size: Tamanho do lote - """ - table_name = f"{schema}.{table}" - total_rows = len(df) - start_time = time.time() - - self.log('info', f"Inserindo {total_rows:,} registros em {table_name}...") - # ADIÇÃO: Converte todos os tipos de nulos (np.nan, pd.NA) para None, que o SQL entende como NULL. - df = df.replace({pd.NA: None, np.nan: None}) - try: - with self.engine.connect() as conn: - with tqdm(total=total_rows, desc="Inserindo dados", unit="reg") as pbar: - for i in range(0, total_rows, batch_size): - batch_start = time.time() - batch_df = df.iloc[i:i + batch_size] - - try: - self._insert_batch(conn, table_name, batch_df) - conn.commit() - except Exception as e: - self.log('error', f"Erro no lote {i//batch_size + 1}: {e}") - #raise - break - - batch_time = time.time() - batch_start - records = len(batch_df) - elapsed = time.time() - start_time - rate = records / batch_time if batch_time > 0 else 0 - - pbar.update(records) - pbar.set_postfix({ - "Tempo lote": f"{batch_time:.1f}s", - "Total": f"{elapsed:.1f}s", - "Reg/s": f"{rate:.0f}" - }) - - except Exception as e: - self.log('error', f"Falha crítica durante a inserção de dados em {table_name}: {e}", exc_info=True) - raise - total_time = timedelta(seconds=int(time.time() - start_time)) - self.log('info', f"Inserção concluída em {total_time}") - - def _insert_batch(self, conn: sqlalchemy.engine.Connection, table_name: str, - batch_df: pd.DataFrame) -> None: - """ - Insere um lote de dados na tabela - Args: - conn: Conexão SQLAlchemy - table_name: Nome completo da tabela (schema.tabela) - batch_df: DataFrame com os dados do lote - """ - - data = batch_df.to_dict(orient='records') - if not data: - return - - columns = list(data[0].keys()) - placeholders = ', '.join([':' + col for col in columns]) - quote_columns = [f'"{col}"' for col in columns] #verificar se é interessante normalizar esses textos antes de inserir no banco de dados - - query = f""" - INSERT INTO {table_name} ({', '.join(quote_columns)}) - VALUES ({placeholders}) - """ - conn.execute(text(query), data) - - def _build_dynamic_query(self, base_query: str, filters: Dict[str, tuple]) -> list[str, Dict]: - """ - Constroi query com cláusulas dinâmicas IN para múltiplas colunas. - Args: - base_query: Consulta base (sem WHERE) - filters: Dicionário com {coluna: valores} para cláusulas IN - Returns: - Tuple: (query_completa, dicionário_de_parâmetros) - """ - conditions = [] - params = {} - - for col, values in filters.items(): - if values: - param_name = f"{col}_vals" - conditions.append(f"{col} IN :{param_name}") - params[param_name] = tuple(values) - - where_clause = " AND ".join(conditions) if conditions else "1=1" - - return f"{base_query} WHERE {where_clause}", params - - def _create_composite_key(self, df: pd.DataFrame, key_columns: List[str]) -> pd.Series: - """Cria chave composta para comparação de registros""" - # Preenche NA com string vazia para evitar NaN na chave - return df[key_columns].fillna('').astype(str).apply('_'.join, axis=1) - - def validate_data_table(self, full_table_name: str, df: pd.DataFrame, backup_dir: Optional[Path] = None,policy: str = 'S') -> Optional[pd.DataFrame]: - """ - Verifica dados existentes com política pré-definida - Args: - policy: Política para duplicatas (S=Substituir, A=Agregar, P=Pular) - """ - schema_name, table_name = full_table_name.split('.') - - # Se tabela não existe, cria e retorna todos os dados - if not self.table_exists(schema_name, table_name): - self.create_table(schema_name, table_name, df=df) - return df - - # Identificar colunas chave - cod_columns = [col for col in df.columns if 'COD' in col] - key_columns = cod_columns + ['ANO_REFERENCIA', 'MES_REFERENCIA'] if cod_columns else [] - - # Sem colunas chave, retorna todos os dados - if not key_columns: - self.log('info', f"Não foram encontradas colunas chave para {table_name}") - return df - - self.log('info', f"Consultar registros existentes: {table_name}") - # Consultar registros existentes - unique_values = {col: df[col].dropna().unique().tolist() for col in key_columns} - base_query = f"SELECT {', '.join(key_columns)} FROM {full_table_name}" - self.log('info', f"Realizando a dinamic query: {base_query} + {unique_values}") - query, params = self._build_dynamic_query(base_query, unique_values) - - # Consultar registros existentes (usando return_type='dataframe' explícito) - try: - self.log('info', f"Verificando dados existentes para {table_name}") - existing_df = self.execute_query(query, params, return_type='dataframe') - except Exception as e: - self.log('error', f"Erro ao verificar dados existentes: {e}") - raise - - if existing_df.empty: - self.log('info', f"Não foram encontradas duplicatas para {table_name}") - return df - - - - - # Verificar duplicatas - df['temp_key'] = self._create_composite_key(df, key_columns) - existing_df['temp_key'] = self._create_composite_key(existing_df, key_columns) - - df['is_duplicate'] = df['temp_key'].isin(existing_df['temp_key']) - duplicates_count = df['is_duplicate'].sum() - - self.log('info', f"\nTabela: {table_name}") - self.log('info', f"Total registros: {len(df)} | Duplicatas: {duplicates_count}") - - - # Aplica política pré-definida - choice = policy.upper() - - if choice == 'S': - if backup_dir: - self.backup_table(schema_name, table_name, Path(backup_dir)) - - delete_query, delete_params = self._build_dynamic_query( - f"DELETE FROM {full_table_name}", - unique_values - ) - - # Executar DELETE sem precisar de retorno - self.execute_non_query(delete_query, delete_params) - self.log('info', f"Registros duplicados removidos: {duplicates_count}") - df_to_insert = df.drop(columns=['temp_key', 'is_duplicate']) - elif choice == 'A': - df_to_insert = df[~df['is_duplicate']].drop(columns=['temp_key', 'is_duplicate']) - self.log('info', f"Registros a inserir: {len(df_to_insert)}") - else: - self.log('info', "Inserção pulada por política.") - return None - - return df_to_insert - - def execute_query(self, query: str, params: Dict = None, timeout: int = 30,return_type: str = 'dataframe') -> Union[pd.DataFrame, int, str, None]: - """ - Executa uma query SQL e retorna resultados conforme o tipo especificado - Args: - query: Query SQL - params: Parâmetros da query - timeout: Timeout em segundos (opcional) - return_type: Tipo de retorno desejado: - 'dataframe' - Retorna DataFrame com resultados (padrão) - 'rowcount' - Retorna número de linhas afetadas - 'query' - Retorna a query formatada como string - 'none' - Não retorna nada (apenas executa) - Returns: - Resultado conforme return_type especificado - Exemplos de uso: - # 1. Obter query formatada para depuração - query_str = db_manager.execute_query( - "SELECT * FROM tabela WHERE id = :id", - {'id': 10}, - return_type='query' - ) - - # 2. Executar DDL sem retorno - db_manager.execute_query( - "CREATE TABLE exemplo (id SERIAL PRIMARY KEY)", - return_type='none' - ) - - # 3. Obter número de linhas afetadas - deleted_rows = db_manager.execute_query( - "DELETE FROM temporario WHERE expirado = true", - return_type='rowcount' - ) - - # 4. Comportamento padrão (DataFrame) - df = db_manager.execute_query( - "SELECT * FROM produtos WHERE preco > :preco_min", - {'preco_min': 100} - ) - """ - # Validação adicional - valid_return_types = ['dataframe', 'rowcount', 'query', 'none'] - if return_type.lower() not in valid_return_types: - raise ValueError(f"Tipo de retorno inválido. Opções válidas: {', '.join(valid_return_types)}") - start_time = time.time() - try: - # Formata a query para visualização - formatted_query = query - if params: - for key, value in params.items(): - try: - formatted_query = str(formatted_query) - except: - self.logger.log('info',f'Erro ao tentar transformar a query em string: {formatted_query}') - pass - if isinstance(formatted_query, str): - formatted_query = formatted_query.replace(f":{str(value)}", f"'{str(value)}'") #Aqui que há o problema de tipo, chega 'TextClause', mas deveria ser uma string, tenho que tratar isso - - if return_type == 'query': - return formatted_query - - with self.engine.connect() as conn: - result = conn.execute(text(query).execution_options(timeout=timeout), params or {}) - - if return_type == 'rowcount': - return result.rowcount - - if return_type == 'none': - return None - - # Comportamento padrão (dataframe) - if result.returns_rows: - return pd.DataFrame(result.fetchall(), columns=result.keys()) - return pd.DataFrame() - - except Exception as e: - error_msg = f"Erro na query: {e}\nQuery: {formatted_query}" - self.log('error', error_msg) - raise RuntimeError(error_msg) - - execution_time = time.time() - start_time - if execution_time > timeout/2: # Se demorou mais da metade do timeout - self.log('warning', f"Query demorada: {execution_time:.2f}s / timeout esperado: {timeout/2:.2f}s\nQuery: {formatted_query[:500]}...") - - def execute_non_query(self, query: str, params: Dict = None, timeout: int = 30) -> None: - """ - Executa um comando SQL que não retorna resultados (ex: CREATE, INSERT, DELETE, UPDATE). - """ - try: - with self.engine.connect() as conn: - conn.execute(text(query), params or {}) - conn.commit() # Essencial para DDL e DML - except Exception as e: - self.log('error', f"Erro no comando non-query: {e}\nQuery: {query}") - raise - - def backup_table(self, schema: str, table: str, backup_dir: Path) -> None: - """ - Faz backup dos dados de uma tabela em CSV - Args: - schema: Nome do esquema - table: Nome da tabela - backup_dir: Diretório para salvar o backup - """ - table_name = f"{schema}.{table}" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - backup_file = backup_dir / f"{schema}_{table}_{timestamp}.csv" - - try: - # Cria diretório se não existir - backup_dir.mkdir(parents=True, exist_ok=True) - - # Recupera os dados - query = f"SELECT * FROM {table_name}" - df = self.execute_query(query) - - # Salva em CSV - df.to_csv(backup_file, index=False) - self.log('info', f"Backup salvo em {backup_file}") - - except Exception as e: - self.log('error', f"Erro no backup de {table_name}: {e}") - raise - - def get_connection_info(self) -> dict: - """Retorna informações de conexão""" - url = make_url(self.engine.url) - return { - 'user': url.username, - 'host': url.host, - 'port': url.port, - 'dbname': url.database - } - - def table_exists(self, schema: str, table: str,create: bool = False) -> bool: #inserir a criação de tabelas. - """Verifica se tabela existe""" - try: - self.logger.log('info', f"[DB] Verificando tabela '{schema}.{table}'...") - query = f""" - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = :schema - AND table_name = :table - ) - """ - result = self.execute_query(query, {'schema': schema, 'table': table}, return_type='dataframe') - self.logger.log('info', f'Resultado da query: {type(result)} / {result}') - return result.iloc[0, 0] if not result.empty else False - except Exception as e: - self.log('error', f"Erro ao verificar existência da tabela {schema}.{table}: {e}") - return False - - def optimize_inserts(self): - """Configura otimizações para inserções em massa""" - self.execute_query("SET session_replication_role = 'replica'") - - def restore_defaults(self): - """Restaura configurações padrão do banco""" - self.execute_query("SET session_replication_role = 'origin'") - -class DatabaseConnection: - """Gerenciador de contexto para conexão segura""" - - def __init__(self, secrets_path: str, log_level: str = 'info'): - - self.secrets_path = secrets_path - self.log_level = log_level - self.db_manager = None - - def __enter__(self) -> DatabaseManager: - self.db_manager = create_db_manager(self.secrets_path, self.log_level) - return self.db_manager - - def __exit__(self, exc_type, exc_val, exc_tb): - logger = SinapiLogger("DatabaseConnection") - if self.db_manager: - self.db_manager.engine.dispose() - if exc_type: - logger.log('error', f"Erro durante operação de banco: {exc_val}") - return False - - @staticmethod - def create_database_connection(params: dict) -> DatabaseManager: - """ - Cria conexão com o banco de dados de forma otimizada - - Args: - params: Dicionário de parâmetros contendo: - - sql_secrets: Caminho para o arquivo de secrets - - log_level: Nível de log - - Returns: - Instância configurada de DatabaseManager - """ - logger = SinapiLogger("DatabaseConnection") - file_manager = FileManager() - - try: - # Obtenção de credenciais - creds = file_manager.read_sql_secrets(params['sql_secrets']) - user, pwd, host, port, dbname, initial_db = creds - - # Teste de conexão - test_conn_str = f"postgresql://{user}:{pwd}@{host}:{port}/{initial_db}" - test_connection(test_conn_str) - - # Conexão principal - main_conn_str = f"postgresql://{user}:{pwd}@{host}:{port}/{dbname}" - db_manager = DatabaseManager(main_conn_str, params.get('log_level', 'info')) - - # Atualização de parâmetros - conn_info = db_manager.get_connection_info() - params.update({ - **conn_info, - # Segurança penas certifique-se de que params não será usado para logar ou expor informações sensíveis em outro ponto do sistema. - 'password': '********', - 'initial_db': initial_db - }) - - return db_manager - - except Exception as e: - logger.log('error', f"Falha crítica na conexão com o banco: {e}") - raise - -class SinapiDownloader: - """Classe para download de arquivos do SINAPI""" - - def __init__(self, cache_minutes: int = 10): - self.logger = SinapiLogger("SinapiDownloader") - self.cache_minutes = cache_minutes - self.log_file = "sinap_webscraping_download_log.json" - - def _zip_pathfinder(self, folderName: str, ano: str, mes: str, formato: str = 'xlsx') -> str: - folder_name = folderName - zip_path = Path(folder_name) / f'SINAPI-{ano}-{mes}-formato-{formato}.zip' - if zip_path.exists(): - self.logger.log('info', f'Arquivo já existe: {zip_path}') - return str(zip_path) - else: - self.logger.log('info', f'Arquivo não existe: {zip_path}') - return None - - def _zip_filefinder(self,folderName: str, ano: str, mes: str, formato: str = 'xlsx', dimiss: list = None, target: str = None) -> Union[str, tuple]: - """ - Finds and selects ZIP files within a specified folder based on year, month, and format. - Args: - folderName (str): The name of the folder to search within. - ano (str): The year to search for in the filename. - mes (str): The month to search for in the filename. - formato (str, optional): The file format to search for in the filename. Defaults to 'xlsx'. - dimiss (list, optional): A list of filenames to exclude from the selection. Defaults to None. - target (str, optional): The file name to search for in the files search result. Defaults to None - Returns: - tuple: A tuple containing two dictionaries: - - zipFiles (dict): A dictionary where keys are filenames ending with '.zip' and values are their full paths. - - selectFile (dict): A dictionary containing selected ZIP files based on the specified criteria, - excluding files present in the `dimiss` list (if provided). Keys are filenames and values are their full paths. - None: Returns None if an error occurs during the file search. - filepath: Return a filepath if file target is found. - Raises: - Exception: Logs any exceptions encountered during the file search process. - """ - - self.logger.log('info', f'Iniciando pesquisa do arquivo na pasta {folderName}') - - try: - zipFiles = {} - selectFile = {} - path = Path(folderName) - - for arquivo in path.glob('*.zip'): - zipFiles[arquivo.name] = str(arquivo) - - # Se target especificado, retorna caminho direto - if target: - return zipFiles.get(target) - - - # Filtra arquivos do SINAPI do mês/ano - target_file = f'SINAPI-{ano}-{mes}-formato-{formato}.zip' - selectFile = {k: v for k, v in zipFiles.items() if target_file in k} - - # Retorna consistente - caminho direto se apenas 1 arquivo, ou tupla se múltiplos - if len(selectFile) == 1: - return list(selectFile.values())[0] - return zipFiles, selectFile - - except Exception as e: - self.logger.log('error', f'Erro ao buscar arquivos: {str(e)}') - return None - - def download_file(self, ano: str, mes: str, formato: str = 'xlsx',sleeptime: int = 2, count: int = 4) -> Optional[str]: - """ - Baixa arquivo do SINAPI se necessário - Args: - ano (str): Ano de referência (YYYY) - mes (str): Mês de referência (MM) - formato (str): Formato do arquivo ('xlsx' ou 'pdf') - sleeptime (int): Tempo de espera entre tentativas de download - Returns: - Optional[str]: Caminho do arquivo baixado ou None se falhou - """ - if not self._validar_parametros(ano, mes, formato): - return None - - if not self._pode_baixar(ano, mes): - return None - - url = f'https://www.caixa.gov.br/Downloads/sinapi-relatorios-mensais/SINAPI-{ano}-{mes}-formato-{formato}.zip' - folder_name = f'./downloads/{ano}_{mes}' - - #print('iniciando pesquisa...') - zip_path = self._zip_pathfinder(folder_name,ano,mes,formato) - - if zip_path: - return str(zip_path) - - try: - os.makedirs(folder_name, exist_ok=True) - try: - try: - download = self._download_with_retry(url, zip_path,retry_delays = [10, 30, 60], timeout=120) - self.logger.log('info', f'Download concluído: {zip_path}') - if download is True: - self._registrar_download(ano, mes) - return str(zip_path) - else: - raise - except Exception as e: - self.logger.log('warning', f'Primeira tentativa de download falhou: {str(e)}') - self.logger.log('info', f'\nTentando {count} downloads com proxies...') - self._download_with_proxies(url, zip_path, str(ano), str(mes), int(sleeptime),count) - self._registrar_download(ano, mes) - return str(zip_path) - - except Exception as e: - self.logger.log('error', f'Erro no download: {str(e)}') - return None - - - except Exception as e: - self.logger.log('error', f'Erro no download: {str(e)}') - return None - - def _download_with_proxies(self, url: str, zip_path: Path, ano: str, mes: str,sleeptime: int,count: int = 0) -> None: - """ - Baixa um arquivo usando uma lista de proxies públicos. - - Args: - url (str): URL do arquivo a ser baixado. - zip_path (Path): Caminho local para salvar o arquivo. - ano (str): Ano de referência (YYYY) - mes (str): Mês de referência (MM) - - Raises: - Exception: Se o download falhar após tentar vários proxies. - """ - proxies_url = "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/all/data.json" - try: - proxies_resp = requests.get(proxies_url, timeout=30) - proxies_resp.raise_for_status() - proxies_list = proxies_resp.json() - random.shuffle(proxies_list) - except Exception as e: - raise Exception(f'Erro ao obter lista de proxies: {str(e)}') from e - - success = False - self.logger.log('info', f'Tentando baixar com {len(proxies_list)} proxies...') - if count == 0: - count = len(proxies_list) - else: - for i,attempt in enumerate(range(count)): - self.logger.log('info', f'\n=============================\n >>>>>>> Tentativa nª{i+1} / {len(proxies_list)} <<<<<<<\n') - proxy_info = random.choice(proxies_list) - proxy = proxy_info.get("proxy") - if not proxy: - self.logger.log('warning', 'Proxy não encontrado na lista, pulando.') - continue - - proxies = { - "http": f"http://{proxy}", - "https": f"http://{proxy}", - } - - try: - self.logger.log('info', f'Tentando download com proxy: {proxy}') - session = requests.Session() - adapter = requests.adapters.HTTPAdapter(max_retries=1) - session.mount('https://', adapter) - response = session.get(url, timeout=120, allow_redirects=True, proxies=proxies) - response.raise_for_status() - with open(zip_path, 'wb') as f: - f.write(response.content) - self.logger.log('info', f'Download concluído com proxy: {proxy}') - success = True - break # Encerra o loop assim que um proxy funciona - except Exception as e: - self.logger.log('warning', f'Falha com proxy {proxy}: {str(e)}\n') - time.sleep(sleeptime) # Adiciona um pequeno delay antes de tentar o próximo proxy - - if not success: - raise Exception('Não foi possível baixar o arquivo com nenhum proxy') - - self._registrar_download(ano, mes) - return str(zip_path) - - def unzip_file(self, zip_path: Union[str, Path]) -> Optional[str]: - """ - Extrai um arquivo ZIP do SINAPI - Args: - zip_path: Caminho do arquivo ZIP - Returns: - Optional[str]: Caminho da pasta extraída ou None se falhou - """ - zip_path = Path(zip_path) - if not zip_path.exists(): - self.logger.log('error', f'Arquivo não existe: {zip_path}') - return None - - extraction_path = zip_path.parent / zip_path.stem - - try: - with zipfile.ZipFile(zip_path, 'r') as zip_ref: - zip_ref.extractall(extraction_path) - self.logger.log('info', f'Arquivos extraídos em: {extraction_path}') - return str(extraction_path) - except Exception as e: - self.logger.log('error', f'Erro ao extrair: {str(e)}') - return None - - def _validar_parametros(self, ano: str, mes: str, formato: str) -> bool: - """Valida os parâmetros de entrada""" - try: - if len(ano) != 4 or len(mes) != 2: - raise ValueError("Ano deve ter 4 dígitos e mês deve ter 2 dígitos") - if int(mes) < 1 or int(mes) > 12: - raise ValueError("Mês deve estar entre 01 e 12") - if formato not in ['xlsx', 'pdf']: - raise ValueError("Formato deve ser 'xlsx' ou 'pdf'") - return True - except Exception as e: - self.logger.log('error', f'Parâmetros inválidos: {str(e)}') - return False - - def _pode_baixar(self, ano: str, mes: str) -> bool: - """Verifica se já passou o tempo mínimo desde o último download""" - chave = f"{ano}_{mes}" - agora = datetime.now() - - if not os.path.exists(self.log_file): - return True - - try: - with open(self.log_file, "r") as f: - log = json.load(f) - ultimo = log.get(chave) - if ultimo: - ultimo_dt = datetime.fromisoformat(ultimo) - if agora - ultimo_dt < timedelta(minutes=self.cache_minutes): - tempo_restante = timedelta(minutes=self.cache_minutes) - (agora - ultimo_dt) - self.logger.log('warning', - f"Download recente detectado. Aguarde {tempo_restante} antes de tentar novamente.") - return False - except Exception as e: - self.logger.log('error', f'Erro ao ler log: {str(e)}') - - return True - - def _registrar_download(self, ano: str, mes: str) -> None: - """Registra a data/hora do download no log""" - chave = f"{ano}_{mes}" - agora = datetime.now().isoformat() - log = {} - - if os.path.exists(self.log_file): - try: - with open(self.log_file, "r") as f: - log = json.load(f) - except Exception: - pass - - log[chave] = agora - - with open(self.log_file, "w") as f: - json.dump(log, f) - - def _download_with_retry(self, url: str, zip_path: Path, retry_delays: list = [10, 30, 60], timeout: int = 120) -> None: - """Faz o download com retry em caso de falha com delays configuráveis - Args: - url (str): URL do arquivo a ser baixado. - zip_path (Path): Caminho local para salvar o arquivo baixado. - retry_delays (list, optional): Lista de tempos de espera em segundos entre cada tentativa. Defaults to [10, 30, 60]. - timeout (int, optional): Tempo máximo em segundos para aguardar uma resposta do servidor. Defaults to 120. - Returns: - bool: True se o download for bem-sucedido. - Raises: - requests.exceptions.HTTPError: Se ocorrer um erro HTTP durante o download. - Exception: Se ocorrer qualquer outro erro durante o download. - """ - - session = requests.Session() - adapter = requests.adapters.HTTPAdapter(max_retries=len(retry_delays)) - session.mount('https://', adapter) - - for attempt, delay in enumerate(retry_delays): - try: - self.logger.log('info', f'Tentativa de download {attempt + 1} de {len(retry_delays)}, aguardando {delay} segundos...') - time.sleep(delay) - response = session.get(url, timeout=timeout, allow_redirects=True) - response.raise_for_status() - - with open(zip_path, 'wb') as f: - f.write(response.content) - self.logger.log('info', f'Download concluído: {zip_path}') - return True - - except requests.exceptions.HTTPError as e: - if e.response.status_code == 404: - self.logger.log('error', 'Arquivo não encontrado no servidor') - else: - self.logger.log('error', f'Erro HTTP na tentativa {attempt + 1}: {str(e)}\n') - - - except Exception as e: - self.logger.log('error', f'Erro no download na tentativa {attempt + 1}: {str(e)}\n') - if attempt == len(retry_delays) - 1: - raise # Relevanta a exceção na última tentativa - - return False # Retorna False se todas as tentativas falharem - -class SinapiProcessor: - """Classe para processamento específico das planilhas SINAPI""" - - def __init__(self): - self.logger = SinapiLogger("SinapiProcessor") - self.file_manager = FileManager() - - def process_excel(self, file_path: Union[str, Path], sheet_name: str, header_id: int, split_id: int = 0) -> pd.DataFrame: - """ - Processa uma planilha SINAPI, normalizando colunas e realizando transformações necessárias - Args: - file_path: Caminho do arquivo Excel - sheet_name: Nome da planilha - header_id: Índice da linha de cabeçalho - split_id: Índice para split de colunas (melt) - Returns: - DataFrame: Dados processados - """ - #iniciando - sheet_name = sheet_name.replace('/','\\') - self.logger.log('info', f'Processando planilha {sheet_name} do arquivo {file_path}') - try: - df = pd.read_excel(file_path, sheet_name=sheet_name, header=header_id) - #self.logger.log('info', f'{df.head()}') - - # Normaliza nomes das colunas - df.columns = [self.file_manager.normalize_text(col) for col in df.columns] - #self.logger.log('info', f'Colunas: {list(df.columns)}') - - # Se necessário fazer melt (unpivot) - if split_id > 0: - self.logger.log('debug', f'Realizando melt com split_id={split_id}') - df = pd.melt( - df, - id_vars=df.columns[:split_id], - value_vars=df.columns[split_id:], - var_name='ESTADO', - value_name='COEFICIENTE' - ) - #self.logger.log('info', f'{df.head()}') - return df - - except Exception as e: - self.logger.log('error', f'Erro ao processar planilha: {str(e)}') - raise - - def identify_sheet_type(self, sheet_name: str, table_names: List[str] = None) -> Dict[str, int]: - """ - Identifica o tipo de planilha SINAPI e retorna suas configurações - Args: - sheet_name: Nome da planilha - table_names: Lista de nomes de tabelas conhecidas - Returns: - Dict: Configurações da planilha (split_id, header_id) - """ - sheet_name = self.file_manager.normalize_text(sheet_name) - - # Configurações padrão por tipo de planilha - configs = { - 'ISD': {'split_id': 5, 'header_id': 9}, - 'CSD': {'split_id': 4, 'header_id': 9}, - 'ANALITICO': {'split_id': 0, 'header_id': 9}, - 'COEFICIENTES': {'split_id': 5, 'header_id': 5}, - 'MANUTENCOES': {'split_id': 0, 'header_id': 5}, - 'MAO_DE_OBRA': {'split_id': 4, 'header_id': 5} - } - - # Verifica correspondências diretas - for type_name, config in configs.items(): - if type_name in sheet_name: - self.logger.log('info', f'Planilha {sheet_name} identificada como {type_name}') - return config - - # Verifica correspondências com table_names se fornecido - if table_names: - for i, table in enumerate(table_names): - table = self.file_manager.normalize_text(table) - if table in sheet_name: - if i == 0: # Insumos Coeficiente - return {'split_id': 5, 'header_id': 5} - elif i == 1: # Códigos Manutenções - return {'split_id': 0, 'header_id': 5} - elif i == 2: # Mão de Obra - return {'split_id': 4, 'header_id': 5} - - self.logger.log('warning', f'Tipo de planilha não identificado: {sheet_name}') - return None - - def validate_data(self, df: pd.DataFrame, expected_columns: List[str] = None) -> bool: - """ - Valida os dados de uma planilha SINAPI - Args: - df: DataFrame a ser validado - expected_columns: Lista de colunas esperadas - Returns: - bool: True se válido, False caso contrário - """ - try: - # Verifica se há dados - if df.empty: - self.logger.log('error', 'DataFrame está vazio') - return False - - # Verifica colunas esperadas - if expected_columns: - missing = set(expected_columns) - set(df.columns) - if missing: - self.logger.log('error', f'Colunas ausentes: {missing}') - return False - - # Verifica valores nulos em colunas críticas - critical_cols = [col for col in df.columns if 'COD' in col or 'ID' in col] - for col in critical_cols: - null_count = df[col].isnull().sum() - if null_count > 0: - self.logger.log('warning', f'Coluna {col} tem {null_count} valores nulos') - - return True - - except Exception as e: - self.logger.log('error', f'Erro na validação: {str(e)}') - return False - - def clean_data(self, df: pd.DataFrame) -> pd.DataFrame: - """ - Limpa e padroniza os dados de uma planilha SINAPI - Args: - df: DataFrame a ser limpo - Returns: - DataFrame: Dados limpos e padronizados - """ - try: - df = df.copy() - - # Remove linhas totalmente vazias - df = df.dropna(how='all') - - # Limpa strings - str_columns = df.select_dtypes(include=['object']).columns - for col in str_columns: - df[col] = df[col].apply(lambda x: self.file_manager.normalize_text(str(x)) if pd.notnull(x) else x) - - # Converte colunas numéricas - num_columns = df.select_dtypes(include=['float64', 'int64']).columns - for col in num_columns: - df[col] = pd.to_numeric(df[col], errors='coerce') - - # Remove caracteres especiais de colunas específicas - cod_columns = [col for col in df.columns if 'CODIGO' in col] - for col in cod_columns: - self.logger.log('info',f'Coluna Código Encontrada: {col}') - df[col] = df[col].astype(str).str.replace(r'[^0-9]', '', regex=True) - - desc_columns = [col for col in df.columns if 'DESCRICAO' in col] - for col in desc_columns: - self.logger.log('info',f'Coluna Descrição Encontrada: {col}') - df[col] = df[col].astype(str).str.replace(' ',' ', regex=True).apply( - lambda x: self.file_manager.normalize_text(x) if x else x - ).str.replace('_',' ', regex=True) - #df[col] = df[col].astype(str).str.replace('_',' ', regex=True) - - return df - - except Exception as e: - self.logger.log('error', f'Erro na limpeza dos dados: {str(e)}') - raise - -class SinapiPipeline: - def __init__(self, config_path="CONFIG.json"): - self.config = self.load_config(config_path) - self.logger = SinapiLogger("SinapiPipeline", self.config.get('log_level', 'info')) - self.downloader = SinapiDownloader(cache_minutes=90) - self.file_manager = FileManager() - self.processor = SinapiProcessor() - self.excel_processor = ExcelProcessor() - self.db_manager = None - - def load_config(self, config_path): - """Carrega o arquivo de configuração""" - try: - with open(config_path, 'r') as f: - return json.load(f) - except Exception as e: - self.logger.log('error', f'Erro ao carregar configuração: {e}') - raise - - def get_parameters(self): - """Obtém parâmetros de execução do config ou do usuário""" - params = { - 'ano': self.config.get('default_year'), - 'mes': self.config.get('default_month'), - 'formato': self.config.get('default_format', 'xlsx'), - 'workbook': self.config.get('default_workbook', 'REFERENCIA'), - } - - # Preenche valores faltantes via input do usuário - if not params['ano']: - params['ano'] = input('Digite o ano (YYYY): ').strip() - if not params['mes']: - params['mes'] = input('Digite o mês (MM): ').strip() - - # Validação básica - if not params['ano'].isdigit() or len(params['ano']) != 4: - raise ValueError("Ano inválido!") - if not params['mes'].isdigit() or len(params['mes']) != 2: - raise ValueError("Mês inválido!") - - return params - - def setup_database(self): - """Configura a conexão com o banco de dados""" - secrets_path = self.config['secrets_path'] - - if not os.path.exists(secrets_path): - raise FileNotFoundError(f'Arquivo de secrets não encontrado: {secrets_path}') - - self.db_manager = create_db_manager( - secrets_path=secrets_path, - log_level=self.config.get('log_level', 'info'), - output='target' - ) - - # Garante existência dos schemas - self.db_manager.create_schemas(['public', 'sinapi']) - - def download_and_extract_files(self, params): - """Gerencia download e extração de arquivos""" - ano = params['ano'] - mes = params['mes'] - formato = params['formato'] - diretorio_referencia = f"./downloads/{ano}_{mes}" - - # Cria diretório se necessário - if not os.path.exists(diretorio_referencia): - os.makedirs(diretorio_referencia) - - # Verifica se o arquivo já existe - filefinder_result = self.downloader._zip_filefinder(diretorio_referencia, ano, mes, formato) - - # Trata diferentes tipos de retorno - if isinstance(filefinder_result, tuple) and len(filefinder_result) == 2: - # Caso retorne (zipFiles, selectFile) - zip_files, select_file = filefinder_result - if select_file: - zip_path = list(select_file.values())[0] - else: - zip_path = None - elif isinstance(filefinder_result, str): - # Caso retorne diretamente o caminho - zip_path = filefinder_result - else: - zip_path = None - - # Faz download se necessário - if not zip_path: - zip_path = self.downloader.download_file(ano, mes, formato, sleeptime=1, count=3) - if not zip_path: - raise RuntimeError("Falha no download do arquivo") - - # Extrai arquivos - extraction_path = self.downloader.unzip_file(zip_path) - return extraction_path - - def process_spreadsheets(self, extraction_path, planilha_name): - """Processa as planilhas usando padrão Strategy""" - # Normaliza nomes de arquivos - self.file_manager.normalize_files(extraction_path, extension='xlsx') - - # Identifica planilhas disponíveis - workbook = self.excel_processor.scan_directory( - diretorio=extraction_path, - formato='xlsx', - data=True, - sheet={planilha_name: extraction_path} - ) - - df_list = {} - for sheet_name in workbook.get(planilha_name, []): - sheet_config = self.config['sheet_processors'].get( - sheet_name[0], - self.processor.identify_sheet_type(sheet_name[0]) - ) - - if not sheet_config: - self.logger.log('warning', f'Configuração não encontrada para: {sheet_name[0]}') - continue - - # Processa a planilha - df = self.processor.process_excel( - f'{extraction_path}/{planilha_name}', - sheet_name[0], - sheet_config['header_id'], - sheet_config['split_id'] - ) - - # Limpa e normaliza dados - clean_df = self.processor.clean_data(df) - table_name = self.file_manager.normalize_text( - f'{planilha_name.split(".")[0]}_{sheet_name[0]}' - ) - df_list[table_name] = clean_df - - return df_list - - def handle_database_operations(self, df_list, table_types=None): - """Gerencia operações de banco de dados""" - - duplicate_policy = self.config.get('duplicate_policy', 'substituir').upper() - backup_dir = self.config.get('backup_dir', './backups') - - if table_types is None: - table_types = { - 'INSUMOS': ['ISD', ], - 'COMPOSICAO': ['CSD', ], - 'ANALITICO': ['ANALITICO'], - 'PRECOS': ['INSUMOS', 'COMPOSICAO'], - } - - refuseList = [] - table_types_name = {} - schema = "sinapi" - - for df_name, df in df_list.items(): - df_name_type = df_name.split('_')[-1].upper() - matched_type = None - for type_name, names in table_types.items(): - if df_name_type.startswith(tuple(n.upper() for n in names)): - table_types_name[type_name] = df_name - matched_type = type_name - break - - if not matched_type: - refuseList.append(df_name) - self.logger.log( - 'info', - f"Tabela {df_name.split('_')[-1]} não definida em table_types, será desconsiderada.\n" - f"Lista de tabelas desconsideradas: {refuseList}" - ) - continue - - table_name = matched_type - table_name_type = f"{table_name}_{df_name_type}" - full_table_name = f"{schema}.{table_name_type}" - - self.logger.log('info', f'lista de tabelas desconsideradas: {refuseList}') - self.logger.log('info', f'Lista de tabelas consideradas:\n{table_types_name}') - self.logger.log('info', f'Nome da tabela onde será inserido os dados:\n\n>>>>>>>>>> {table_name}\n') - self.logger.log('info', f'table_types_name DEFINIDO: {df_name} -> {full_table_name}\n') - - try: - self.logger.log('info', f'\n\nEsquema.tabela.tipo: {full_table_name.lower()}\n') - df_to_insert = self.db_manager.validate_data_table( - full_table_name=full_table_name, - df=df, - backup_dir=backup_dir, - policy=duplicate_policy # Novo parâmetro - ) - - if df_to_insert is not None and not df_to_insert.empty: - self.logger.log('info', f'INSERINDO DADOS DE {df_name}') - self.db_manager.insert_data('sinapi', f'{table_name}_{df_name_type}', df_to_insert) - elif df_to_insert is not None and df_to_insert.empty: - self.logger.log('info', f"Nenhum dado novo para inserir na tabela {table_name} após validação.") - except Exception as e: - self.logger.log('error', f'Erro crítico ao processar a tabela {table_name}: {e}', exc_info=True) - break - - return table_types_name - - def export_results(self, df_list, params): - """Exporta resultados para CSV""" - ano = params['ano'] - mes = params['mes'] - diretorio_referencia = f"./{ano}_{mes}" - - for table_name, df in df_list.items(): - csv_filename = f"{table_name}_{ano}_{mes}.csv" - csv_path = os.path.join(diretorio_referencia, csv_filename) - df.to_csv(csv_path, index=False, encoding='utf-8-sig') - self.logger.log('info', f"DataFrame exportado para: {csv_path}") - - def run(self): - """Executa o pipeline completo""" - try: - # Passo 1: Obter parâmetros - params = self.get_parameters() - - # Passo 2: Configurar banco de dados - self.setup_database() - - # Passo 3: Download e extração - extraction_path = self.download_and_extract_files(params) - - # Passo 4: Identificar planilha principal - planilha_base_name = self.file_manager.normalize_text(f'SINAPI_{params["workbook"]}_{params["ano"]}_{params["mes"]}') - planilha_name = f'{planilha_base_name}.xlsx' - - # Passo 5: Processar planilhas - df_list = self.process_spreadsheets(extraction_path, planilha_name) - #self.logger.log('info', f'df_list:\n\n{df_list}\n\n') - - # Passo 6: Operações de banco - if df_list: - self.handle_database_operations(df_list) - else: - self.logger.log('warning', "Nenhuma planilha foi processada, pulando operações de banco de dados.") - - # # Passo 7: Exportar resultados - # self.export_results(df_list, params) - - self.logger.log('info', 'Processo concluído com sucesso!') - - except Exception as e: - self.logger.log('error', f'Erro durante o processo: {e}', exc_info=True) - raise - -#Funções Auxiliares - -logger = SinapiLogger("test_connection") - -def create_db_manager(secrets_path: str, log_level: str = 'info',output: str = None) -> DatabaseManager: - """ - Cria e retorna um DatabaseManager configurado a partir de arquivo de secrets - - Args: - secrets_path: Caminho completo para o arquivo .secrets - log_level: Nível de log desejado - output: Tipo de saída desejada: - test: dbtest - target: dbtarget - - Returns: - Instância configurada de DatabaseManager - - Raises: - FileNotFoundError: Quando arquivo não existe - ConnectionError: Quando falha teste de conexão - ValueError: Quando credenciais incompletas - """ - logger = SinapiLogger("create_db_manager", log_level) - - # Validação inicial do arquivo - logger.log('info', f"Iniciando criação do DatabaseManager com arquivo de secrets: {secrets_path}") - if not os.path.isfile(secrets_path): - logger.log('error', f"Arquivo de secrets não encontrado: {secrets_path}") - raise FileNotFoundError(f"Arquivo de secrets não encontrado: {secrets_path}") - - # Leitura das credenciais - try: - logger.log('debug', f"Lendo credenciais do arquivo: {secrets_path}") - with open(secrets_path, 'r') as f: - content = f.read() - credentials = parse_secrets(content) - logger.log('debug', "Credenciais lidas com sucesso.") - except Exception as e: - logger.log('critical', f"Falha na leitura do arquivo secrets: {e}") - raise RuntimeError(f"Falha na leitura do arquivo secrets: {e}") from e - - # Validação mínima das credenciais - required_keys = {'DB_USER', 'DB_PASSWORD', 'DB_HOST', 'DB_PORT', 'DB_NAME'} - if not required_keys.issubset(credentials): - missing = required_keys - set(credentials.keys()) - logger.log('error', f"Credenciais incompletas. Faltando: {', '.join(missing)}") - raise ValueError(f"Credenciais incompletas. Faltando: {', '.join(missing)}") - - # Teste de conexão com banco inicial - user=credentials['DB_USER'] - pwd=credentials['DB_PASSWORD'] - host=credentials['DB_HOST'] - port=credentials['DB_PORT'] - initial_db=credentials.get('DB_INITIAL_DB', 'postgres') # Definir a base a ser utilizada - test_conn_str = f"postgresql://{user}:{pwd}@{host}:{port}/{initial_db}" - - try: - logger.log('info', f"Testando conexão com banco inicial: {host}:{port}/{initial_db}") - test_connection(test_conn_str) - logger.log('info', f"Teste de conexão com banco inicial ({initial_db}) bem-sucedido.\n") - except Exception as e: - logger.log('critical', f"Falha no teste de conexão com banco inicial: {e}") - raise ConnectionError(f"Falha na conexão com banco inicial: {e}") from e - - #verifica qual o tipo de saída desejada: - if output == 'test': - target_db = credentials['DB_INITIAL_DB'] - elif output == 'target': - target_db = credentials['DB_NAME'] - elif output == None: - target_db = 'postgres' - else: - logger.log('error', f"Tipo de saída inválido: {output}") - raise ValueError(f"Tipo de saída inválido: {output}") - - try: - conn_str = format_connection_string( - user=credentials['DB_USER'], - password=credentials['DB_PASSWORD'], - host=credentials['DB_HOST'], - port=credentials['DB_PORT'], - database=target_db - ) - test_connection(conn_str) - except Exception as e: - logger.log('warning', f"Falha ao conectar ao banco de dados '{target_db}': {e}") - - # Tenta criar o banco de dados se a conexão falhar - admin_conn_str = format_connection_string( - user=credentials['DB_USER'], - password=credentials['DB_PASSWORD'], - host=credentials['DB_HOST'], - port=credentials['DB_PORT'], - database=initial_db # Usa o banco de dados inicial para criar o novo - ) - - try: - logger.log('info', f"Tentando criar o banco de dados '{target_db}'...") - DatabaseManager.create_database(admin_conn_str, target_db, logger) - logger.log('info', f"Banco de dados '{target_db}' criado com sucesso.") - - # Recria a string de conexão após a criação do banco - conn_str = format_connection_string( - user=credentials['DB_USER'], - password=credentials['DB_PASSWORD'], - host=credentials['DB_HOST'], - port=credentials['DB_PORT'], - database=target_db - ) - test_connection(conn_str) - - except Exception as create_err: - logger.log('critical', f"Falha ao criar o banco de dados '{target_db}': {create_err}") - raise - - # Cria e retorna o DatabaseManager - logger.log('info', f"Criando DatabaseManager com conexão: {host}:{port}/{target_db}") - db_manager = DatabaseManager( - connection_string=conn_str, - log_level=log_level - ) - - logger.log('info', f"DatabaseManager criado com sucesso. ({conn_str})\n") - return db_manager - -def format_connection_string(user: str, password: str, host: str, port: str, database: str) -> str: - """Formata a string de conexão de forma segura (usa SQLAlchemy URL para evitar injeção)""" - return URL.create( - drivername="postgresql", - username=user, - password=password, - host=host, - port=port, - database=database - ).render_as_string(hide_password=False) - -def test_connection(conn_str: str, timeout: int = 5, retries: int = 3): - """Testa conexão com retentativas automáticas""" - logger.log('info', f"Testando conexão com timeout={timeout}s e {retries} retentativas.") - for attempt in range(retries): - try: - logger.log('debug', f"Tentativa de conexão {attempt + 1}/{retries}...") - engine = create_engine(conn_str, connect_args={'connect_timeout': timeout}) - with engine.connect() as conn: - conn.execute(text("SELECT 1")) - logger.log('info', "Conexão bem-sucedida!") - return - except Exception as e: - logger.log('warning', f"Falha na tentativa {attempt + 1}: {e}") - if attempt < retries - 1: - sleep(2 ** attempt) # Backoff exponencial - logger.log('debug', f"Aguardando {2 ** attempt} segundos antes da próxima tentativa...") - else: - logger.log('error', f"Falha na conexão após {retries} tentativas.") - raise ConnectionError(f"Falha na conexão após {retries} tentativas: {e}") - -def parse_secrets(content: str) -> dict: - """Extrai credenciais do conteúdo do arquivo secrets""" - credentials = {} - for line in content.splitlines(): - match = re.match(r"\s*([A-Z_]+)\s*=\s*'([^']*)'", line) - if match: - key, value = match.groups() - credentials[key] = value - return credentials - -def make_conn_str(info: dict, dbname: str = None): - """Gera uma connection string a partir do dicionário de informações e nome do banco.""" - return f"postgresql://{info['user']}:{info['password']}@{info['host']}:{info['port']}/{dbname or info['dbname']}" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9f2a229 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +# Ajuste de ambiente para execução dos testes pytest +# +# Este arquivo garante que o diretório raiz do projeto esteja no sys.path +# para que o pacote 'autosinapi' seja encontrado corretamente durante os testes. + +import os +import sys + +# Adiciona a raiz do projeto ao sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) diff --git a/tests/core/test_database.py b/tests/core/test_database.py new file mode 100644 index 0000000..0b0563f --- /dev/null +++ b/tests/core/test_database.py @@ -0,0 +1,88 @@ +""" +Testes unitários para o módulo database.py +""" + +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest +from sqlalchemy.exc import SQLAlchemyError + +from autosinapi.core.database import Database +from autosinapi.exceptions import DatabaseError + + +@pytest.fixture +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 database(db_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) + db._engine = mock_engine + yield db, mock_engine + + +@pytest.fixture +def sample_df(): + """Fixture que cria um DataFrame de exemplo.""" + return pd.DataFrame( + { + "CODIGO": ["1234", "5678"], + "DESCRICAO": ["Produto A", "Produto B"], + "PRECO": [100.0, 200.0], + } + ) + + +def test_connect_success(db_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) + assert db._engine is not None + mock_create_engine.assert_called_once() + + +def test_connect_failure(db_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) + + +def test_save_data_success(database, sample_df): + """Testa salvamento bem-sucedido de dados.""" + db, mock_engine = database + mock_conn = MagicMock() + mock_engine.connect.return_value.__enter__.return_value = mock_conn + + db.save_data(sample_df, "test_table", policy="append") + + assert mock_conn.execute.call_count > 0 + + +@pytest.mark.filterwarnings("ignore:pandas only supports SQLAlchemy") +def test_save_data_failure(database, sample_df): + """Testa falha no salvamento de dados.""" + db, mock_engine = database + mock_conn = MagicMock() + mock_conn.execute.side_effect = SQLAlchemyError("Insert failed") + 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") diff --git a/tests/core/test_downloader.py b/tests/core/test_downloader.py index 51b6d34..e304ea4 100644 --- a/tests/core/test_downloader.py +++ b/tests/core/test_downloader.py @@ -1,89 +1,132 @@ """ Testes unitários para o módulo de download. """ -import pytest -from unittest.mock import Mock, patch -from pathlib import Path + from io import BytesIO +from unittest.mock import Mock, patch + +import pytest import requests + from autosinapi.core.downloader import Downloader from autosinapi.exceptions import DownloadError + # Fixtures @pytest.fixture def sinapi_config(): - return { - 'state': 'SP', - 'month': '01', - 'year': '2023', - 'type': 'insumos' - } + return {"state": "SP", "month": "01", "year": "2023", "type": "REFERENCIA"} + @pytest.fixture def mock_response(): response = Mock() - response.content = b'test content' + response.content = b"test content" response.raise_for_status = Mock() return response + +# Testes de URL Building +def test_build_url_referencia(sinapi_config): + """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" + ) + + +def test_build_url_desonerado(): + """Testa construção de URL para planilha desonerada.""" + config = {"state": "SP", "month": "12", "year": "2023", "type": "DESONERADO"} + downloader = Downloader(config, "server") + url = downloader._build_url() + + assert "SINAPI_DESONERADO_12_2023.zip" in url + + +def test_build_url_invalid_type(): + """Testa erro ao construir URL com tipo inválido.""" + config = {"state": "SP", "month": "01", "year": "2023", "type": "INVALIDO"} + downloader = Downloader(config, "server") + + with pytest.raises(ValueError, match="Tipo de planilha inválido"): + downloader._build_url() + + +def test_build_url_zero_padding(): + """Testa padding com zeros nos números.""" + config = { + "state": "SP", + "month": 1, # Número sem zero + "year": 2023, + "type": "REFERENCIA", + } + downloader = Downloader(config, "server") + url = downloader._build_url() + + assert "SINAPI_REFERENCIA_01_2023.zip" in url + + # Testes -@patch('autosinapi.core.downloader.requests.Session') +@patch("autosinapi.core.downloader.requests.Session") def test_successful_download(mock_session, 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') - result = downloader.download() - - # Verifica o resultado + downloader = Downloader(sinapi_config, "server") + result = downloader.get_sinapi_data() assert isinstance(result, BytesIO) - assert result.getvalue() == b'test content' + assert result.getvalue() == b"test content" session.get.assert_called_once() -@patch('autosinapi.core.downloader.requests.Session') + +@patch("autosinapi.core.downloader.requests.Session") def test_download_network_error(mock_session, sinapi_config): """Deve tratar erro de rede corretamente.""" # Configura o mock para simular erro session = Mock() - session.get.side_effect = requests.ConnectionError('Network error') + 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') - downloader.download() - - assert 'Network error' in str(exc_info.value) + downloader = Downloader(sinapi_config, "server") + downloader.get_sinapi_data() + assert "Network error" in str(exc_info.value) + -@patch('autosinapi.core.downloader.requests.Session') +@patch("autosinapi.core.downloader.requests.Session") def test_local_mode_save(mock_session, 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' - + save_path = tmp_path / "test.xlsx" + # Executa o download em modo local - downloader = Downloader(sinapi_config, 'local') - result = downloader.download(save_path) - + downloader = Downloader(sinapi_config, "local") + 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' - + 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' + assert result.getvalue() == b"test content" + def test_context_manager(sinapi_config): """Deve funcionar corretamente como context manager.""" - with Downloader(sinapi_config, 'server') as downloader: + with Downloader(sinapi_config, "server") as downloader: assert isinstance(downloader, Downloader) # A sessão será fechada automaticamente ao sair do contexto diff --git a/tests/core/test_processor.py b/tests/core/test_processor.py new file mode 100644 index 0000000..3ea0a55 --- /dev/null +++ b/tests/core/test_processor.py @@ -0,0 +1,92 @@ +""" +Testes unitários para o módulo processor.py +""" + +import logging + +import pandas as pd +import pytest + +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"} + p = Processor(config) + p.logger.setLevel(logging.DEBUG) + return p + + +@pytest.fixture +def sample_insumos_df(): + """Fixture que cria um DataFrame de exemplo para insumos.""" + return pd.DataFrame( + { + "CODIGO": ["1234", "5678", "9012"], + "DESCRICAO": ["AREIA MEDIA", "CIMENTO PORTLAND", "TIJOLO CERAMICO"], + "UNIDADE": ["M3", "KG", "UN"], + "PRECO_MEDIANO": [120.50, 0.89, 1.25], + } + ) + + +@pytest.fixture +def sample_composicoes_df(): + """Fixture que cria um DataFrame de exemplo para composições.""" + return pd.DataFrame( + { + "CODIGO_COMPOSICAO": ["87453", "87522", "87890"], + "DESCRICAO_COMPOSICAO": [ + "ALVENARIA DE VEDACAO", + "REVESTIMENTO CERAMICO", + "CONTRAPISO", + ], + "UNIDADE": ["M2", "M2", "M2"], + "CUSTO_TOTAL": [89.90, 45.75, 32.80], + } + ) + + +def test_normalize_cols(processor): + """Testa a normalização dos nomes das colunas.""" + df = pd.DataFrame( + { + "Código do Item": [1, 2, 3], + "Descrição": ["a", "b", "c"], + "Preço Unitário": [10, 20, 30], + } + ) + result = processor._normalize_cols(df) + assert "CODIGO_DO_ITEM" in result.columns + assert "DESCRICAO" in result.columns + assert "PRECO_UNITARIO" in result.columns + + +def test_process_composicao_itens(processor, tmp_path): + """Testa o processamento da estrutura das composições.""" + # Cria um arquivo XLSX de teste + test_file = tmp_path / "test_sinapi.xlsx" + df = pd.DataFrame( + { + "CODIGO_DA_COMPOSICAO": ["87453", "87453"], + "TIPO_ITEM": ["INSUMO", "COMPOSICAO"], + "CODIGO_DO_ITEM": ["1234", "5678"], + "COEFICIENTE": ["1,0", "2,5"], + "DESCRICAO": ["INSUMO A", "COMPOSICAO B"], + "UNIDADE": ["UN", "M2"], + } + ) + # Adiciona linha de cabeçalho e outras linhas para simular o arquivo real + writer = pd.ExcelWriter(test_file, engine="xlsxwriter") + df.to_excel(writer, index=False, header=True, sheet_name="Analítico", startrow=9) + writer.close() + + result = processor.process_composicao_itens(str(test_file)) + + assert "composicao_insumos" in result + 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 diff --git a/tests/test_config.py b/tests/test_config.py index 6359b19..8064fa1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,62 +1,62 @@ """ Testes unitários para o módulo de configuração. """ + import pytest + from autosinapi.config import Config from autosinapi.exceptions import ConfigurationError + # Fixtures @pytest.fixture def valid_db_config(): return { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", } + @pytest.fixture def valid_sinapi_config(): - return { - 'state': 'SP', - 'month': '01', - 'year': '2023', - 'type': 'insumos' - } + return {"state": "SP", "month": "01", "year": "2023", "type": "insumos"} + # Testes def test_valid_config(valid_db_config, valid_sinapi_config): """Deve criar configuração válida com sucesso.""" - config = Config(valid_db_config, valid_sinapi_config, 'server') - assert config.mode == 'server' + config = Config(valid_db_config, valid_sinapi_config, "server") + assert config.mode == "server" assert config.db_config == valid_db_config assert config.sinapi_config == valid_sinapi_config + def test_invalid_mode(): """Deve levantar erro para modo inválido.""" with pytest.raises(ConfigurationError) as exc_info: - Config({}, {}, 'invalid') - assert 'Modo inválido' in str(exc_info.value) + Config({}, {}, "invalid") + assert "Modo inválido" in str(exc_info.value) + def test_missing_db_config(valid_sinapi_config): """Deve levantar erro para config de DB incompleta.""" with pytest.raises(ConfigurationError) as exc_info: - Config({'host': 'localhost'}, valid_sinapi_config, 'server') - assert 'Configurações de banco ausentes' in str(exc_info.value) + Config({"host": "localhost"}, valid_sinapi_config, "server") + assert "Configurações de banco ausentes" in str(exc_info.value) + def test_missing_sinapi_config(valid_db_config): """Deve levantar erro para config do SINAPI incompleta.""" with pytest.raises(ConfigurationError) as exc_info: - Config(valid_db_config, {'state': 'SP'}, 'server') - assert 'Configurações do SINAPI ausentes' in str(exc_info.value) + Config(valid_db_config, {"state": "SP"}, "server") + assert "Configurações do SINAPI ausentes" in str(exc_info.value) + -def test_mode_properties(): +def test_mode_properties(valid_db_config, valid_sinapi_config): """Deve retornar corretamente o modo de operação.""" - config = Config( - valid_db_config(), - valid_sinapi_config(), - 'server' - ) + config = Config(valid_db_config, valid_sinapi_config, "server") assert config.is_server_mode is True assert config.is_local_mode is False diff --git a/tests/test_file_input.py b/tests/test_file_input.py index dfcebdd..e377712 100644 --- a/tests/test_file_input.py +++ b/tests/test_file_input.py @@ -1,97 +1,188 @@ """ Testes do módulo de download com suporte a input direto de arquivo. """ -import pytest + from pathlib import Path +from unittest.mock import MagicMock, patch + import pandas as pd -from autosinapi import run_etl +import pytest + +from tools.autosinapi_pipeline import Pipeline + + +@pytest.fixture +def mock_pipeline(mocker, tmp_path): + """Fixture para mockar o pipeline e suas dependências.""" + mocker.patch("tools.autosinapi_pipeline.setup_logging") + + # Cria um diretório de extração falso + extraction_path = tmp_path / "extraction" + extraction_path.mkdir() + # Cria um arquivo de referência falso dentro do diretório + (extraction_path / "SINAPI_Referência_2023_01.xlsx").touch() + + with patch("tools.autosinapi_pipeline.Database") as mock_db: + mock_db_instance = MagicMock() + mock_db.return_value = mock_db_instance -def test_direct_file_input(tmp_path): + 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, + ) + + +def test_direct_file_input(tmp_path, mock_pipeline): """Testa o pipeline com input direto de arquivo.""" - # Cria um arquivo XLSX de teste + pipeline, mock_db, _, mock_processor = mock_pipeline + test_file = tmp_path / "test_sinapi.xlsx" - df = pd.DataFrame({ - 'codigo': [1234, 5678], - 'descricao': ['Item 1', 'Item 2'], - 'unidade': ['un', 'kg'], - 'preco': [10.5, 20.75] - }) + df = pd.DataFrame( + { + "codigo": [1234, 5678], + "descricao": ["Item 1", "Item 2"], + "unidade": ["un", "kg"], + "preco": [10.5, 20.75], + } + ) df.to_excel(test_file, index=False) - - # Configura o teste + db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + "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) # Usa arquivo local + "state": "SP", + "month": "01", + "year": "2023", + "type": "insumos", + "input_file": str(test_file), } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica o resultado - assert result['status'] == 'success' - assert result['details']['rows_processed'] == 2 - assert isinstance(result['details']['timestamp'], str) - -def test_fallback_to_download(mocker): + + 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() + + mock_processor.process_catalogo_e_precos.assert_called() + mock_db.save_data.assert_called() + + +def test_fallback_to_download(mock_pipeline): """Testa o fallback para download quando arquivo não é fornecido.""" - # Mock do downloader - mock_download = mocker.patch('autosinapi.core.downloader.Downloader._download_file') - mock_download.return_value = mocker.Mock() - + 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' - # Sem input_file, deve tentar download + "host": "localhost", + "port": 5432, + "database": "test_db", + "user": "test_user", + "password": "test_pass", } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica se o download foi tentado - mock_download.assert_called_once() - -def test_invalid_input_file(): + 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() + + mock_downloader.get_sinapi_data.assert_called_once() + + +def test_invalid_input_file(mock_pipeline, caplog): """Testa erro ao fornecer arquivo inválido.""" + pipeline, _, _, _ = mock_pipeline + db_config = { - 'host': 'localhost', - 'port': 5432, - 'database': 'test_db', - 'user': 'test_user', - 'password': 'test_pass' + "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' + "state": "SP", + "month": "01", + "year": "2023", + "type": "insumos", + "input_file": "arquivo_inexistente.xlsx", } - - # Executa o pipeline - result = run_etl(db_config, sinapi_config, mode='server') - - # Verifica se retornou erro - assert result['status'] == 'error' - assert 'Arquivo não encontrado' in result['message'] + + 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 new file mode 100644 index 0000000..c5807da --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,156 @@ +""" +Testes de integração para o pipeline principal do AutoSINAPI. +""" + +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest + +from autosinapi.exceptions import DatabaseError, DownloadError, ProcessingError +from tools.autosinapi_pipeline import Pipeline + + +@pytest.fixture +def db_config(): + """Fixture com configurações 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ções do SINAPI.""" + return { + "state": "SP", + "year": 2025, + "month": 8, + "type": "REFERENCIA", + "duplicate_policy": "substituir", + } + + +@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") + + # 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() + + with patch("tools.autosinapi_pipeline.Database") as mock_db, patch( + "tools.autosinapi_pipeline.Downloader" + ) as mock_downloader, patch( + "tools.autosinapi_pipeline.Processor" + ) as mock_processor: + + mock_db_instance = MagicMock() + mock_db.return_value = mock_db_instance + + mock_downloader_instance = MagicMock() + mock_downloader.return_value = mock_downloader_instance + + mock_processor_instance = MagicMock() + mock_processor.return_value = mock_processor_instance + + pipeline = Pipeline(config_path=None) + + 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", + return_value={ + "secrets_path": "dummy", + "default_year": sinapi_config["year"], + "default_month": sinapi_config["month"], + }, + ) + + mocker.patch.object( + 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") + mocker.patch.object(pipeline, "_sync_catalog_status") + + yield ( + pipeline, + mock_db_instance, + mock_downloader_instance, + mock_processor_instance, + ) + + +def test_run_etl_success(mock_pipeline): + """Testa o fluxo completo do ETL com sucesso.""" + pipeline, mock_db, _, mock_processor = mock_pipeline + + mock_processor.process_catalogo_e_precos.return_value = { + "insumos": pd.DataFrame( + {"codigo": ["1"], "descricao": ["a"], "unidade": ["un"]} + ), + "composicoes": pd.DataFrame( + {"codigo": ["c1"], "descricao": ["ca"], "unidade": ["un"]} + ), + } + mock_processor.process_composicao_itens.return_value = { + "composicao_insumos": pd.DataFrame({"insumo_filho_codigo": ["1"]}), + "composicao_subcomposicoes": pd.DataFrame(), + "parent_composicoes_details": pd.DataFrame( + {"codigo": ["c1"], "descricao": ["ca"], "unidade": ["un"]} + ), + "child_item_details": pd.DataFrame( + {"codigo": ["1"], "tipo": ["INSUMO"], "descricao": ["a"], "unidade": ["un"]} + ), + } + + pipeline.run() + + mock_db.create_tables.assert_called_once() + mock_processor.process_catalogo_e_precos.assert_called() + assert mock_db.save_data.call_count > 0 + + +def test_run_etl_download_error(mock_pipeline, caplog): + """Testa falha no download.""" + pipeline, _, mock_downloader, _ = mock_pipeline + + pipeline._find_and_normalize_zip.return_value = None + mock_downloader.get_sinapi_data.side_effect = DownloadError("Network error") + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Network error" in caplog.text + + +def test_run_etl_processing_error(mock_pipeline, caplog): + """Testa falha no processamento.""" + pipeline, _, _, mock_processor = mock_pipeline + + mock_processor.process_catalogo_e_precos.side_effect = ProcessingError( + "Invalid format" + ) + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Invalid format" in caplog.text + + +def test_run_etl_database_error(mock_pipeline, caplog): + """Testa falha no banco de dados.""" + pipeline, mock_db, _, _ = mock_pipeline + + mock_db.create_tables.side_effect = DatabaseError("Connection failed") + + pipeline.run() + + assert "Erro de negócio no pipeline AutoSINAPI: Connection failed" in caplog.text diff --git a/tools/CONFIG.example.json b/tools/CONFIG.example.json new file mode 100644 index 0000000..70b7aae --- /dev/null +++ b/tools/CONFIG.example.json @@ -0,0 +1,18 @@ +{ + "secrets_path": "tools/sql_access.secrets", + "default_year": "2025", + "default_month": "07", + "default_format": "xlsx", + "workbook_type_name": "REFERENCIA", + "duplicate_policy": "substituir", + "backup_dir": "./backups", + "log_level": "info", + "sheet_processors": { + "ISD": {"split_id": 5, "header_id": 9}, + "CSD": {"split_id": 4, "header_id": 9}, + "ANALITICO": {"split_id": 0, "header_id": 9}, + "COEFICIENTES": {"split_id": 5, "header_id": 5}, + "MANUTENCOES": {"split_id": 0, "header_id": 5}, + "MAO_DE_OBRA": {"split_id": 4, "header_id": 5} + } +} diff --git a/tools/autosinapi_pipeline.py b/tools/autosinapi_pipeline.py index 0efb6f3..3a6bf70 100644 --- a/tools/autosinapi_pipeline.py +++ b/tools/autosinapi_pipeline.py @@ -1,6 +1,391 @@ -# IMPORTAÇÕES -from sinapi_utils import SinapiPipeline +""" +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__": - pipeline = SinapiPipeline() - pipeline.run() \ No newline at end of file + main() diff --git a/tools/docker/.env.example b/tools/docker/.env.example new file mode 100644 index 0000000..f8369bf --- /dev/null +++ b/tools/docker/.env.example @@ -0,0 +1,10 @@ +# PostgreSQL Config +POSTGRES_DB=sinapi +POSTGRES_USER=sinapi_user +POSTGRES_PASSWORD=sinapi_pass + +# AutoSINAPI Pipeline Config +AUTOSINAPI_YEAR=2025 +AUTOSINAPI_MONTH=07 +AUTOSINAPI_TYPE=REFERENCIA +AUTOSINAPI_POLICY=substituir diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile new file mode 100644 index 0000000..cf637dc --- /dev/null +++ b/tools/docker/Dockerfile @@ -0,0 +1,21 @@ +# Use uma imagem base oficial do Python +FROM python:3.9-slim + +# Instala o Git +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 +COPY . /app + +# 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 . + +# Define o comando padrao para executar o pipeline +CMD ["python", "tools/autosinapi_pipeline.py"] \ No newline at end of file diff --git a/tools/docker/Makefile b/tools/docker/Makefile new file mode 100644 index 0000000..6195dbb --- /dev/null +++ b/tools/docker/Makefile @@ -0,0 +1,130 @@ +# 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 + +# Garante que as variaveis do .env sejam carregadas +include .env + +# Define o nome do projeto para evitar ambiguidades +COMPOSE_PROJECT_NAME=autosinapi + +help: + @echo "Comandos disponiveis:" + @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 down - Para e remove os conteineres." + @echo " make app-down - Para o container app" + @echo " make db-down - Para o container db" + @echo " make adminer-down - Para o container adminer" + @echo " make app-start - Inicia o container app" + @echo " make db-start - Inicia o container db" + @echo " make adminer-start - Inicia o container adminer" + @echo " make clean - Para tudo e apaga os volumes (DADOS DO DB SERAO PERDIDOS)." + @echo " make clean-app - Para o container app e remove os volumes." + @echo " make clean-db - Para o container db e remove os volumes." + @echo " make clean-adminer - Para o container adminer e remove os volumes." + @echo " make shell - Abre um terminal interativo dentro do conteiner 'app' que ja esta rodando." + @echo " " + @echo "Comandos de Log:" + @echo " make logs - Exibe os logs de TODOS os servicos em tempo real." + @echo " make logs-app - Exibe os logs apenas da aplicacao." + @echo " make logs-db - Exibe os logs apenas do banco de dados." + @echo " make logs-adminer - Exibe os logs apenas do adminer." + @echo " " + @echo "Utilitarios:" + @echo " Adminer (DB GUI): http://localhost:8080" + +# Constroi ou reconstroi a imagem da aplicacao se houver mudancas +build: + @echo "=> Construindo as imagens Docker..." + docker-compose build + +# Forca a reconstrucao da imagem sem usar o cache +build-no-cache: + @echo "=> Construindo as imagens Docker sem usar o cache..." + docker-compose build --no-cache + +# Sobe todos os servicos (db, app, adminer) em background +up: + @echo "=> Iniciando todos os servicos em background..." + docker-compose up -d + +# Executa o pipeline dentro do container 'app' que ja esta rodando +run: + @echo "=> Executando o pipeline do AutoSINAPI via 'exec'..." + docker-compose exec app python tools/autosinapi_pipeline.py + +# Para e remove os conteineres de todos os serviços +down: + @echo "=> Parando e removendo os conteineres..." + docker-compose down + +# Para containers individuais +app-down: + @echo "=> Parando o container 'app'..." + docker-compose stop app + +db-down: + @echo "=> Parando o container 'db'..." + docker-compose stop db + +adminer-down: + @echo "=> Parando o container 'adminer'..." + docker-compose stop adminer + +# Inicia containers individuais que já existem mas estão parados +app-start: + @echo "=> Iniciando o container 'app'..." + docker-compose start app + +db-start: + @echo "=> Iniciando o container 'db'..." + docker-compose start db + +adminer-start: + @echo "=> Iniciando o container 'adminer'..." + docker-compose start adminer + +# Limpa tudo: containers e volumes (cuidado!) +clean: + @echo "=> ATENCAO: Este comando ira apagar TUDO, incluindo o banco de dados." + @echo "=> Parando conteineres e removendo volumes..." + docker-compose down --volumes + +# Limpa containers e volumes individuais +clean-app: + @echo "=> ATENCAO: Parando e removendo o container 'app' e seus volumes..." + docker-compose rm -s -v app + +clean-db: + @echo "=> ATENCAO: Parando e removendo o container 'db' e seus volumes (DADOS SERAO PERDIDOS)..." + docker-compose rm -s -v db + +clean-adminer: + @echo "=> ATENCAO: Parando e removendo o container 'adminer' e seus volumes..." + docker-compose rm -s -v adminer + +# Comandos de Log +logs: + @echo "=> Exibindo logs de todos os servicos (Pressione Ctrl+C para sair)..." + docker-compose logs -f + +logs-app: + @echo "=> Exibindo logs da aplicacao (Pressione Ctrl+C para sair)..." + docker-compose logs -f app + +logs-db: + @echo "=> Exibindo logs do banco de dados (Pressione Ctrl+C para sair)..." + docker-compose logs -f db + +logs-adminer: + @echo "=> Exibindo logs do Adminer (Pressione Ctrl+C para sair)..." + docker-compose logs -f 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 diff --git a/tools/docker/docker-compose.yml b/tools/docker/docker-compose.yml new file mode 100644 index 0000000..391e821 --- /dev/null +++ b/tools/docker/docker-compose.yml @@ -0,0 +1,59 @@ +services: + db: + image: postgres:15 + container_name: autosinapi_db + env_file: + - ./.env + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - autosinapi_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + image: autosinapi-app + container_name: autosinapi_app + build: + context: ../.. + dockerfile: tools/docker/Dockerfile + # Mantém o container rodando em idle para que o 'exec' possa ser usado + command: ["tail", "-f", "/dev/null"] + env_file: + - ./.env + environment: + - DOCKER_ENV=true + - POSTGRES_HOST=db + depends_on: + db: + condition: service_healthy + volumes: + - ./downloads:/app/downloads + - ../../backups:/app/backups + - ../../logs:/app/logs + networks: + - autosinapi_net + + adminer: + image: adminer + container_name: autosinapi_adminer + restart: always + ports: + - 8080:8080 + depends_on: + db: + condition: service_healthy + networks: + - autosinapi_net + +volumes: + postgres_data: + +networks: + autosinapi_net: + driver: bridge \ No newline at end of file diff --git a/tools/pre_processador.py b/tools/pre_processador.py new file mode 100644 index 0000000..257789c --- /dev/null +++ b/tools/pre_processador.py @@ -0,0 +1,71 @@ +""" +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 diff --git a/workPlan.md b/workPlan.md deleted file mode 100644 index 62afcff..0000000 --- a/workPlan.md +++ /dev/null @@ -1,275 +0,0 @@ - -Como arquiteto da API, meu papel é definir o "contrato": como a API irá interagir com o módulo `AutoSINAPI`, quais as responsabilidades de cada um, e qual a arquitetura que garante a viabilidade econômica, segurança e performance que buscamos. - -Vamos organizar essa ideia em um plano de desenvolvimento lógico e estruturado. - ------ - -### **Visão Geral da Arquitetura: API, Toolkit e Tarefas em Segundo Plano** - -A nossa arquitetura será baseada em **desacoplamento**. A API não executará o pesado processo de ETL diretamente. Em vez disso, ela atuará como um **controlador**, delegando a tarefa para um **trabalhador (worker)** em segundo plano. O módulo `AutoSINAPI` será o **toolkit** que o trabalhador utilizará. - -**Diagrama da Arquitetura:** - -``` -+-----------+ +----------------+ +----------------+ +---------------------+ -| | | | | | | API FastAPI | -| Usuário |---->| Kong Gateway |---->| (Controller) |---->| (Fila de Tarefas) | -| (Admin) | | (Auth & Proxy) | | POST /populate| | Ex: Redis | -+-----------+ +----------------+ +----------------+ +----------+----------+ - | - (Nova Tarefa) - | - v -+-------------------------------------------------+ +--------------------+----------+ -| AutoSINAPI Toolkit |<----| | -| (Biblioteca Python instalada via pip) | | Trabalhador (Celery Worker) | -| - Lógica de Download (em memória/disco) | | - Pega tarefa da fila | -| - Lógica de Processamento (pandas) | | - Executa a lógica do | -| - Lógica de Banco de Dados (SQLAlchemy) | | AutoSINAPI Toolkit | -+-------------------------------------------------+ +--------------------+----------+ - | - (Escreve os dados) - | - v - +--------------------+ - | | - | Banco de Dados | - | (PostgreSQL) | - +--------------------+ -``` - ------ - -### **Parte 1: O Contrato de Serviço (As Diretrizes para o Módulo `AutoSINAPI`)** - -Para que a API possa usar o `AutoSINAPI` como um toolkit, o módulo precisa evoluir para uma biblioteca que exponha uma interface (API) clara. Como desenvolvedor da API, eu defino o que preciso que essa biblioteca me entregue. - -#### **Requisito 1: A Interface Pública do Módulo** - -O `AutoSINAPI` deverá expor, no mínimo, uma função principal, clara e bem definida. - -**Função Principal Exigida:** -`autosinapi.run_etl(db_config: dict, sinapi_config: dict, mode: str)` - - * **`db_config (dict)`**: Um dicionário contendo **toda** a informação de conexão com o banco de dados. A API irá montar este dicionário a partir das suas próprias variáveis de ambiente (`.env`). - ```python - # Exemplo de db_config que a API irá passar - db_config = { - "user": "admin", - "password": "senha_super_secreta", - "host": "db", - "port": 5432, - "dbname": "sinapi" - } - ``` - * **`sinapi_config (dict)`**: Um dicionário com os parâmetros da operação. A API também montará este dicionário. - ```python - # Exemplo de sinapi_config que a API irá passar - sinapi_config = { - "year": 2025, - "month": 8, - "workbook_type": "REFERENCIA", - "duplicate_policy": "substituir" - } - ``` - * **`mode (str)`**: O seletor de modo de operação. - * `'server'`: Ativa o modo de alta performance, com todas as operações em memória (bypass de disco). - * `'local'`: Usa o modo padrão, salvando arquivos em disco, para uso pela comunidade. - -#### **Requisito 2: Lógica de Configuração Inteligente (Sem Leitura de Arquivos)** - -Quando usado como biblioteca (`mode='server'`), o módulo `AutoSINAPI`: - - * **NÃO PODE** ler `sql_access.secrets` ou `CONFIG.json`. - * **DEVE** usar exclusivamente os dicionários `db_config` e `sinapi_config` passados como argumentos. - * Quando usado em modo `local`, ele pode manter a lógica de ler arquivos `CONFIG.json` para facilitar a vida do usuário final que o clona do GitHub. - -#### **Requisito 3: Retorno e Tratamento de Erros** - -A função `run_etl` deve retornar um dicionário com o status da operação e levantar exceções específicas para que a API possa tratar os erros de forma inteligente. - - * **Retorno em caso de sucesso:** - ```python - {"status": "success", "message": "Dados de 08/2025 populados.", "tables_updated": ["insumos_isd", "composicoes_csd"]} - ``` - * **Exceções:** O módulo deve definir e levantar exceções customizadas, como `autosinapi.exceptions.DownloadError` ou `autosinapi.exceptions.DatabaseError`. - ------ - -### **Parte 2: O Caminho do Desenvolvimento (Etapas Lógicas)** - -Este é o roadmap que seguiremos. - -#### **Fase 1: Evolução do `AutoSINAPI` para um Toolkit (Responsabilidade do Desenvolvedor do Módulo)** - -Esta fase é sobre preparar o módulo para ser consumido pela nossa API. - - * **Etapa 1.1: Refatoração Estrutural:** Quebrar o `sinapi_utils.py` em módulos menores (`downloader.py`, `processor.py`, `database.py`) dentro de uma estrutura de pacote Python, como planejamos anteriormente. - * **Etapa 1.2: Implementar a Lógica de Configuração Centralizada:** Remover toda a leitura de arquivos de configuração de dentro das classes e fazer com que elas recebam suas configurações via construtor (`__init__`). - * **Etapa 1.3: Criar a Interface Pública:** Criar a função `run_etl(db_config, sinapi_config, mode)` que orquestra as chamadas para as classes internas. - - * **Etapa1.3.1: Desacoplar as Classes (Injeção de Dependência):** Em vez de uma classe criar outra (ex: self.downloader = SinapiDownloader()), ela deve recebê-la como um parâmetro em seu construtor (__init__(self, downloader)). Isso torna o código muito mais flexível e testável. - * **Etapa 1.4: Implementar o Modo Duplo:** Dentro das classes `downloader` e `processor`, adicionar a lógica `if mode == 'server': ... else: ...` para lidar com operações em memória vs. em disco. - * **Etapa 1.5: Empacotamento:** Garantir que o módulo seja instalável via `pip` com um `setup.py` ou `pyproject.toml`. - -Nova Estrutura de Diretórios revista: - -``` -/AutoSINAPI/ -├── autosinapi/ # <--- NOVO: O código da biblioteca em si -│ ├── core/ # <--- Lógica de negócio principal -│ │ ├── database.py # (antiga classe DatabaseManager) -│ │ ├── downloader.py # (antiga classe SinapiDownloader) -│ │ ├── processor.py # (classes ExcelProcessor, SinapiProcessor) -│ │ └── file_manager.py # (antiga classe FileManager) -│ ├── pipeline.py # (antiga classe SinapiPipeline) -│ ├── config.py # (Nova lógica para carregar configs do .env) -│ ├── exceptions.py # (Definir exceções customizadas, ex: DownloadError) -│ └── __init__.py -├── tools/ # Ferramentas que USAM a biblioteca -│ ├── run_pipeline.py # (antigo autosinapi_pipeline.py, agora mais simples) -│ └── ... -├── tests/ # Diretório para testes unitários -├── pyproject.toml -├── setup.py -└── README.md - -``` - -#### **Fase 2: Criação e desenvolvimento dos testes unitários** - -Aqui está um planejamento completo para a criação e desenvolvimento dos testes unitários para o módulo AutoSINAPI. Este plano servirá como uma diretriz para o desenvolvedor do módulo, garantindo que o toolkit que receberemos seja de alta qualidade. - -A Filosofia: Por que Testar? -Antes de detalhar o plano, é crucial entender o valor que os testes trarão: - -Garantia de Qualidade: Encontrar e corrigir bugs antes que eles cheguem ao nosso ambiente de produção. - -Segurança para Refatorar: Permitir que o módulo AutoSINAPI evolua e seja otimizado no futuro. Se as mudanças não quebrarem os testes existentes, temos alta confiança de que o sistema continua funcionando. - -Documentação Viva: Os testes são a melhor forma de documentar como uma função ou classe deve se comportar em diferentes cenários. - -Design de Código Melhor: Escrever código testável naturalmente nos força a criar componentes menores, desacoplados e com responsabilidades claras. - -Ferramentas Recomendadas -O ecossistema Python tem ferramentas padrão e excelentes para testes. - -Framework de Teste: pytest - É o padrão da indústria. Simples de usar, poderoso e com um ecossistema de plugins fantástico. - -Simulação (Mocking): pytest-mock - Essencial. Os testes unitários devem ser rápidos e isolados. Isso significa que não podemos fazer chamadas reais à internet (site da Caixa) ou a um banco de dados real durante os testes. Usaremos "mocks" para simular o comportamento desses sistemas externos. - -Cobertura de Teste: pytest-cov - Mede qual porcentagem do nosso código está sendo executada pelos testes. Isso nos ajuda a identificar partes críticas que não foram testadas. - -O Plano de Testes Unitários por Módulo -A estratégia de testes seguirá a mesma estrutura modular que definimos para a refatoração do AutoSINAPI. - -Estrutura de Diretórios de Teste -/AutoSINAPI/ -├── autosinapi/ -│ ├── core/ -│ │ ├── downloader.py -│ │ └── ... -│ └── ... -├── tests/ # <--- Novo diretório para todos os testes -│ ├── core/ -│ │ ├── test_downloader.py -│ │ ├── test_processor.py -│ │ └── test_database.py -│ ├── test_pipeline.py -│ └── fixtures/ # <--- Para guardar arquivos de teste (ex: um .xlsx pequeno) -└── ... -1. Testes para core/downloader.py -Objetivo: Garantir que a lógica de download, retry e tratamento de erros de rede funcione corretamente, sem fazer nenhuma chamada real à internet. - -O que Simular (Mock): A função requests.get. - -Cenários de Teste: - -test_download_sucesso: Simular um requests.get que retorna um status 200 OK e um conteúdo de zip falso. Verificar se a função retorna o conteúdo esperado. - -test_download_falha_404: Simular um requests.get que levanta um erro HTTPError com status 404. Verificar se o downloader trata o erro corretamente, talvez levantando uma exceção customizada DownloadError. - -test_download_com_retry: Simular um requests.get que falha nas duas primeiras chamadas (ex: com Timeout) e funciona na terceira. Verificar se a lógica de retry é acionada. - -test_download_com_proxy: Verificar se, ao usar proxies, a chamada a requests.get é feita com o parâmetro proxies preenchido corretamente. - -2. Testes para core/processor.py -Objetivo: Garantir que o processamento dos dados do Excel (limpeza, normalização, transformação) está correto para diferentes cenários. - -O que Simular (Mock): Não há mocks externos, mas usaremos dados de teste. Criaremos pequenos DataFrames pandas ou até mesmo um arquivo .xlsx de exemplo no diretório tests/fixtures/ com dados "sujos". - -Cenários de Teste: - -test_normalizacao_texto: Testar a função de normalizar texto com strings contendo acentos, maiúsculas/minúsculas e espaços extras. Verificar se a saída está correta. - -test_limpeza_dataframe: Passar um DataFrame com valores nulos, colunas com nomes "sujos" e tipos de dados incorretos. Verificar se o DataFrame de saída está limpo e padronizado. - -test_processamento_melt: Para as planilhas que precisam de "unpivot", passar um DataFrame de exemplo e verificar se a transformação melt resulta na estrutura de colunas e linhas esperada. - -test_identificacao_tipo_planilha: Passar diferentes nomes de planilhas (ex: "SINAPI_CSD_...", "SINAPI_ISD_...") e verificar se a função retorna a configuração correta de header_id e split_id. - -3. Testes para core/database.py -Objetivo: Garantir que a lógica de interação com o banco de dados (criação de tabelas, inserção, deleção) gera os comandos SQL corretos, sem conectar a um banco de dados real. - -O que Simular (Mock): O objeto engine do SQLAlchemy e suas conexões. Vamos verificar quais comandos são enviados para o método .execute(). - -Cenários de Teste: - -test_create_table_com_inferencia: Passar um DataFrame e verificar se o comando CREATE TABLE ... gerado contém os nomes de coluna e os tipos SQL corretos. - -test_insert_data_em_lotes: Passar um DataFrame com mais de 1000 linhas (o tamanho do lote) e verificar se o método de inserção é chamado múltiplas vezes (uma para cada lote). - -test_logica_de_duplicatas_substituir: Simular que o banco já contém alguns registros. Chamar a função de validação com a política "substituir". Verificar se um comando DELETE FROM ... é executado antes do INSERT. - -test_logica_de_duplicatas_agregar: Fazer o mesmo que o anterior, mas com a política "agregar". Verificar se os dados inseridos são apenas os que não existiam no banco. - -4. Testes de Integração para pipeline.py e a Interface Pública -Objetivo: Garantir que a função principal run_etl orquestra as chamadas aos outros componentes na ordem correta. - -O que Simular (Mock): As classes Downloader, Processor e DatabaseManager inteiras. - -Cenários de Teste: - -test_run_etl_fluxo_ideal: Simular que cada componente funciona perfeitamente. Chamar run_etl(). Verificar se downloader.download() foi chamado, depois processor.process(), e por último database.insert(). - -test_run_etl_com_falha_no_download: Simular que o downloader.download() levanta uma exceção. Chamar run_etl(). Verificar se o processor e o database não foram chamados, provando que o pipeline parou corretamente. - -test_run_etl_passa_configs_corretamente: Chamar run_etl() com dicionários de configuração específicos. Verificar se os construtores ou métodos dos componentes mockados foram chamados com esses mesmos dicionários. - -Plano de Trabalho Sugerido -Configurar o Ambiente de Teste: - -Criar a estrutura de diretórios tests/. - -Adicionar pytest, pytest-mock e pytest-cov ao requirements.txt de desenvolvimento. - -Desenvolvimento Orientado a Testes (por módulo): - -Começar pelos módulos mais isolados e com menos dependências (ex: processor.py e file_manager.py). Escrever os testes primeiro, vê-los falhar, e depois implementar a lógica no módulo para fazê-los passar. - -Em seguida, testar o downloader.py, focando na simulação das chamadas de rede. - -Depois, o database.py, focando na simulação da conexão e na verificação das queries SQL geradas. - -Por último, escrever os testes de integração para o pipeline.py e a função pública run_etl, simulando as classes já testadas. - -Integração Contínua (CI): - -Após a criação dos testes, o passo final é configurar uma ferramenta como GitHub Actions para rodar todos os testes automaticamente a cada push ou pull request. Isso garante que nenhum código novo quebre a funcionalidade existente. - -Documentação dos Testes: - -Para garantir a manutenibilidade e a compreensão do código, é essencial documentar os testes de forma clara e concisa. A documentação deve incluir: - -1. **Descrição dos Testes**: Para cada teste, incluir uma breve descrição do que está sendo testado e qual é o comportamento esperado. - -2. **Pré-condições**: Listar quaisquer pré-condições que devem ser atendidas antes da execução do teste (ex: estado do banco de dados, arquivos de entrada, etc.). - -3. **Passos para Reproduzir**: Instruções detalhadas sobre como executar o teste, incluindo comandos específicos e configurações necessárias. - -4. **Resultados Esperados**: Descrever o que constitui um resultado bem-sucedido para o teste, incluindo saídas esperadas e efeitos colaterais. - -5. **Notas sobre Implementação**: Qualquer informação adicional que possa ser útil para entender a lógica do teste ou sua implementação. - -Essa documentação deve ser mantida atualizada à medida que os testes evoluem e novas funcionalidades são adicionadas ao sistema.